brainy/docs/guides/reacting-to-changes.md
David Snelling a3467e1f9b feat: temporal VFS — file content joins the Model-B immutability model
The temporal model had a hole exactly where files were concerned: every
entity write is an immutable generation with before-images, but VFS content
BYTES lived under an eager refCount GC left over from the pre-8.0 design —
unlink could physically destroy bytes that in-window history still
referenced, and overwrite never released the old hash at all (an unbounded
silent leak whose accidental byproduct was the only thing "preserving"
history). Reading the past could therefore return a stale field, a dangling
hash, or nothing, depending on luck.

Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS
decision. Each blob's metadata now carries historyRefCount alongside the
live refCount:

- The commit seam counts one history reference per persisted before-image
  record carrying a content hash (commitTransaction staging and the
  group-commit flush), recorded BEFORE the record-set persists and carried
  in the generation delta (blobHashes — always present on new deltas, so
  compaction only falls back to reading records for pre-contract
  generations). An aborted transaction compensates best-effort.
- unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete →
  release; overwrite finally releases the superseded hash — cancelling the
  dedup increment on same-content rewrites and closing the leak), and only
  AFTER the canonical mutation commits, so a failed delete can never leave a
  live file whose bytes compaction might reclaim.
- History compaction is the ONE reclamation point: after deleting a
  generation's record-set it releases that set's references and physically
  reclaims any hash at zero live AND zero history references. Pins are
  exempt automatically. Crash ordering is over-count-only in every path
  (record before persist, release after delete), so a crash can leak until
  the scrub recounts but can never reclaim bytes a retained generation
  needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get
  a one-time marker-gated backfill on open, failing into leak-safe mode
  (reclamation disabled) rather than guessing.

On top of the protected history, the temporal API the generational model
always implied:

- vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date,
  materialized from the history (pinned view released so compaction is
  never blocked by a read).
- vfs.history(path) — FileVersion[] ascending ({ generation, timestamp,
  hash, size, mimeType? }), the newest entry being the live state.
- Overwrites now refresh the file entity's data/embedding text — semantic
  search and the data field previously served the FIRST version's text
  forever (the stale-field defect a consumer's incident recovery depended
  on by luck).

Integration suite (temporal-vfs.test.ts): per-version exact reads +
history listing, leak-fix + history protection on overwrite, rm keeps bytes
readable, compaction reclaims past-window bytes and preserves in-window
(including the cross-file dedup case where an old file's history and a
newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00

4.4 KiB

title slug public category template order description next
Reacting to Changes guides/reacting-to-changes true guides guide 11 Subscribe to every committed mutation with `brain.onChange` — the in-process change feed behind live UIs, cache invalidation, and realtime sync. Covers the event shape, delivery guarantees, and catch-up patterns.
guides/optimistic-concurrency
guides/snapshots-and-time-travel

Reacting to Changes

brain.onChange(cb) is Brainy's in-process change feed: subscribe once and receive one event per committed mutation — every mutation, regardless of how it happened. Direct calls, batch methods, transact(), imports, and Virtual Filesystem writes all funnel through the same commit point the feed is emitted from, so nothing slips past it.

const off = brain.onChange((e) => {
  if (e.kind === 'entity') {
    console.log(`${e.op} ${e.entity?.type} ${e.id} @ generation ${e.generation}`)
  }
})

await brain.add({ data: 'Ada Lovelace', type: 'person' })
// → "add person 0198... @ generation 42"

off() // unsubscribe when done

The event

interface BrainyChangeEvent {
  kind: 'entity' | 'relation' | 'store'
  op: 'add' | 'update' | 'remove' | 'relate' | 'unrelate' | 'updateRelation'
    | 'clear' | 'restore'
  id?: string
  entity?:   { id: string; type: string; subtype?: string;
               metadata: Record<string, unknown>; service?: string }
  relation?: { id: string; from: string; to: string; type: string;
               metadata?: Record<string, unknown> }
  generation?: number
  timestamp: number
}
  • Entity events (add / update / remove) carry the post-commit indexed view — type, subtype, and the full custom metadata, so you can match your own where-style filters against events without a read.
  • Deletes are fully described. A remove or unrelate event carries the record's last committed state (sourced from the commit's own history record), not just an id.
  • Batches emit per item. addMany / updateMany / relateMany / removeMany emit one event per affected record; a transact() batch emits one event per item, all sharing the batch's single generation.
  • Cascades are visible. Removing an entity also emits unrelate for each relationship the delete cascaded to.
  • Store-level events (kind: 'store') fire for the two wholesale operations — clear() and restore() — and mean "everything may have changed; refetch what you care about."

Delivery guarantees

  • Post-commit only. An aborted write — a losing ifRev compare-and-swap, a rejected transaction — never emits. If you received the event, the write is durable.
  • Commit-ordered. Events arrive in the order writes committed; generation is monotonic.
  • Asynchronous, never blocking. Delivery happens in a microtask after the write completes. A slow listener cannot delay a write; a throwing listener is logged and isolated from other listeners.
  • Zero overhead when unused. With no subscribers, the write path does no event work at all.
  • Fire-and-forget. There is no replay or backpressure. For catch-up after a disconnect, use the generation on each event together with asOf() / the transaction log: record the last generation you processed, and on reconnect diff from there. For file content specifically, vfs.readFile(path, { asOf }) and vfs.history(path) are the temporal read — see Snapshots & Time Travel.

Patterns

Cache invalidation — drop cached reads for whatever changed:

brain.onChange((e) => {
  if (e.kind === 'store') return cache.clear()
  if (e.id) cache.delete(e.id)
})

Live queries (notify-and-refetch) — re-run a query when a relevant change lands, rather than diffing incrementally:

brain.onChange((e) => {
  if (e.kind === 'entity' && e.entity?.type === 'order') {
    refreshOpenOrdersView() // debounce as needed
  }
})

Forwarding to other processes — the feed is in-process by design. To push changes to browsers or other services, forward events through your own transport (WebSocket, SSE) from the process that owns the brain.

Lifecycle

onChange returns an unsubscribe function — call it when tearing down a subscriber (for example, when evicting a pooled instance). brain.close() drops all listeners; no events are delivered for or after close().