172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
|
|
/**
|
||
|
|
* @module events/changeFeed
|
||
|
|
* @description The in-process change feed behind {@link Brainy.onChange} — the
|
||
|
|
* authoritative "something committed" signal for every canonical mutation,
|
||
|
|
* regardless of origin (direct API calls, batches, transactions, imports, the
|
||
|
|
* VFS, or an accelerated native deployment: all of them funnel through the
|
||
|
|
* same generation-store commit points this feed is emitted from).
|
||
|
|
*
|
||
|
|
* Design properties:
|
||
|
|
* - **Post-commit only.** Events are enqueued after the commit succeeds, so an
|
||
|
|
* aborted commit (a losing `ifRev` CAS, a failed transaction) never emits.
|
||
|
|
* - **Commit-ordered.** Events are enqueued in commit order and dispatched
|
||
|
|
* FIFO, so a subscriber observes mutations in the order they became durable.
|
||
|
|
* - **Never blocks the write path.** Dispatch happens in a microtask after the
|
||
|
|
* committing call returns; a slow or throwing listener cannot delay or fail
|
||
|
|
* a write. Listener errors are isolated per listener and per event.
|
||
|
|
* - **Zero cost when unused.** Callers consult {@link ChangeFeed.hasListeners}
|
||
|
|
* before constructing event payloads; with no subscribers the write path
|
||
|
|
* does no event work at all.
|
||
|
|
* - **Fire-and-forget.** No acknowledgement, backpressure, or replay. Events
|
||
|
|
* carry the committed `generation`, so a consumer that needs catch-up
|
||
|
|
* semantics can pair the live feed with `transactionLog()` / `asOf()`.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** The post-commit view of an entity carried by entity change events. */
|
||
|
|
export interface ChangeEventEntity {
|
||
|
|
/** The entity's canonical UUID. */
|
||
|
|
id: string
|
||
|
|
/** The entity's NounType string (e.g. `'person'`, `'document'`). */
|
||
|
|
type: string
|
||
|
|
/** The entity's subtype, when set. */
|
||
|
|
subtype?: string
|
||
|
|
/** The entity's custom (indexed) metadata fields. */
|
||
|
|
metadata: Record<string, unknown>
|
||
|
|
/** The writing service, when set. */
|
||
|
|
service?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The post-commit view of a relationship carried by relation change events. */
|
||
|
|
export interface ChangeEventRelation {
|
||
|
|
/** The relationship's canonical UUID. */
|
||
|
|
id: string
|
||
|
|
/** Source entity UUID. */
|
||
|
|
from: string
|
||
|
|
/** Target entity UUID. */
|
||
|
|
to: string
|
||
|
|
/** The relationship's VerbType string (e.g. `'contains'`). */
|
||
|
|
type: string
|
||
|
|
/** The relationship's custom metadata fields, when present. */
|
||
|
|
metadata?: Record<string, unknown>
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* One committed mutation, as delivered to {@link Brainy.onChange} listeners.
|
||
|
|
*
|
||
|
|
* Exactly one of `entity` / `relation` is populated for `kind: 'entity'` /
|
||
|
|
* `kind: 'relation'` events. `kind: 'store'` events (`clear` / `restore`)
|
||
|
|
* carry neither — they mean "the whole store changed; refetch what you care
|
||
|
|
* about".
|
||
|
|
*
|
||
|
|
* For `op: 'remove'` / `op: 'unrelate'` the payload is the record's LAST
|
||
|
|
* committed state (sourced from the commit's own before-image), so deletes are
|
||
|
|
* fully described rather than id-only.
|
||
|
|
*/
|
||
|
|
export interface BrainyChangeEvent {
|
||
|
|
/** What changed: one record, one relationship, or the whole store. */
|
||
|
|
kind: 'entity' | 'relation' | 'store'
|
||
|
|
/** The mutation, in Brainy's own API vocabulary. */
|
||
|
|
op:
|
||
|
|
| 'add'
|
||
|
|
| 'update'
|
||
|
|
| 'remove'
|
||
|
|
| 'relate'
|
||
|
|
| 'unrelate'
|
||
|
|
| 'updateRelation'
|
||
|
|
| 'clear'
|
||
|
|
| 'restore'
|
||
|
|
/** The mutated record's id (absent for store-level events). */
|
||
|
|
id?: string
|
||
|
|
/** Post-commit entity view (entity events only). */
|
||
|
|
entity?: ChangeEventEntity
|
||
|
|
/** Post-commit relation view (relation events only). */
|
||
|
|
relation?: ChangeEventRelation
|
||
|
|
/**
|
||
|
|
* The committed generation this mutation belongs to (Model B: every write
|
||
|
|
* is a generation; a transaction's items share one). Absent for store-level
|
||
|
|
* events and init-time bootstrap writes, which are not generation-stamped.
|
||
|
|
*/
|
||
|
|
generation?: number
|
||
|
|
/** Commit timestamp (ms since epoch). */
|
||
|
|
timestamp: number
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Listener signature for {@link Brainy.onChange}. */
|
||
|
|
export type ChangeListener = (event: BrainyChangeEvent) => void
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A change event as constructed by a mutation method BEFORE its commit: the
|
||
|
|
* commit seam stamps `generation`/`timestamp` after the write becomes
|
||
|
|
* durable. An entity `remove` descriptor may omit `entity` — the seam fills
|
||
|
|
* it from the commit's own before-image (the record's last committed state).
|
||
|
|
*/
|
||
|
|
export type PendingChangeEvent = Omit<BrainyChangeEvent, 'generation' | 'timestamp'>
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description Listener registry + commit-ordered async dispatcher for
|
||
|
|
* {@link BrainyChangeEvent}s. One instance per {@link Brainy}.
|
||
|
|
*/
|
||
|
|
export class ChangeFeed {
|
||
|
|
private listeners = new Set<ChangeListener>()
|
||
|
|
private queue: BrainyChangeEvent[] = []
|
||
|
|
private draining = false
|
||
|
|
|
||
|
|
/** Whether any listener is subscribed — the write path's zero-cost gate. */
|
||
|
|
get hasListeners(): boolean {
|
||
|
|
return this.listeners.size > 0
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description Subscribe to committed mutations.
|
||
|
|
* @param listener - Called once per committed mutation, in commit order.
|
||
|
|
* @returns An unsubscribe function.
|
||
|
|
*/
|
||
|
|
subscribe(listener: ChangeListener): () => void {
|
||
|
|
this.listeners.add(listener)
|
||
|
|
return () => {
|
||
|
|
this.listeners.delete(listener)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @description Enqueue committed events and schedule dispatch. Call ONLY
|
||
|
|
* after the commit has succeeded — this feed must never announce a write
|
||
|
|
* that did not become durable. Safe to call with an empty array.
|
||
|
|
* @param events - The committed mutations, in commit order.
|
||
|
|
*/
|
||
|
|
emit(events: BrainyChangeEvent[]): void {
|
||
|
|
if (events.length === 0 || this.listeners.size === 0) return
|
||
|
|
this.queue.push(...events)
|
||
|
|
if (!this.draining) {
|
||
|
|
this.draining = true
|
||
|
|
queueMicrotask(() => this.drain())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Deliver everything queued, FIFO, isolating listener errors. */
|
||
|
|
private drain(): void {
|
||
|
|
try {
|
||
|
|
while (this.queue.length > 0) {
|
||
|
|
const event = this.queue.shift()!
|
||
|
|
for (const listener of this.listeners) {
|
||
|
|
try {
|
||
|
|
listener(event)
|
||
|
|
} catch (err) {
|
||
|
|
// A subscriber's bug must never affect the write path or its
|
||
|
|
// sibling subscribers.
|
||
|
|
console.error('[Brainy] onChange listener threw:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
this.draining = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Drop all listeners (brain close/teardown). Queued events are discarded. */
|
||
|
|
close(): void {
|
||
|
|
this.listeners.clear()
|
||
|
|
this.queue.length = 0
|
||
|
|
}
|
||
|
|
}
|