/** * @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 /** 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 } /** * 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 /** * @description Listener registry + commit-ordered async dispatcher for * {@link BrainyChangeEvent}s. One instance per {@link Brainy}. */ export class ChangeFeed { private listeners = new Set() 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 } }