brainy/docs/guides/reacting-to-changes.md

118 lines
4.4 KiB
Markdown
Raw Normal View History

feat: brain.onChange — the in-process change feed for every committed mutation Subscribe once and receive one post-commit event per affected record for EVERY canonical write, regardless of origin: direct calls, batch methods, transact(), imports, and Virtual Filesystem writes all funnel through the same commit points the feed is emitted from. This is the authoritative in-process signal for live UIs, cache invalidation, and realtime sync layers that forward it over their own transports. Architecture — the post-commit dual of the ifRev precommit hook: mutation methods hand lightweight event descriptors to the commit seam (persistSingleOp / transact's plan), which stamps the committed {generation, timestamp}, enriches entity deletes with the record's LAST committed state from the commit's own before-images (free — Model B reads them anyway; removeMany's id-only deletes gain full payloads this way), and emits only after the commit succeeds — a losing CAS or rejected batch never announces anything. Dispatch is a microtask FIFO after the mutex releases: commit-ordered, a slow listener never delays a write, a throwing listener is logged and isolated, and with no subscribers the write path constructs no events at all. Single-point emission was chosen over per-method hooks because the aggregation-hook pattern demonstrably drifted (relation ops and removeMany were silently missing from it). Coverage: add/update/remove (+ cascade unrelate per deleted relationship), relate (both edges when bidirectional)/unrelate/updateRelation, per-item events for addMany/updateMany/relateMany/removeMany, per-item events sharing one generation for transact(), and transitively imports + VFS. clear() and restore() — wholesale raw-state operations outside the per-record commit path — emit a single store-level event meaning "refetch everything". brain.close() drops all listeners. New module src/events/changeFeed.ts (BrainyChangeEvent + ChangeFeed, exported from the package root); guide docs/guides/reacting-to-changes.md. Integration suite pins the contract: per-op payload fidelity, delete last-state payloads, batch per-item emission, one-generation transact batches, VFS-origin events, CAS-loser silence, ordering, listener isolation, unsubscribe, and store-level events.
2026-07-10 11:24:31 -07:00
---
title: Reacting to Changes
slug: guides/reacting-to-changes
public: true
category: guides
template: guide
order: 11
description: 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.
next:
- 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.
```ts
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
```ts
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](optimistic-concurrency.md), 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](snapshots-and-time-travel.md): record the
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
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](snapshots-and-time-travel.md).
feat: brain.onChange — the in-process change feed for every committed mutation Subscribe once and receive one post-commit event per affected record for EVERY canonical write, regardless of origin: direct calls, batch methods, transact(), imports, and Virtual Filesystem writes all funnel through the same commit points the feed is emitted from. This is the authoritative in-process signal for live UIs, cache invalidation, and realtime sync layers that forward it over their own transports. Architecture — the post-commit dual of the ifRev precommit hook: mutation methods hand lightweight event descriptors to the commit seam (persistSingleOp / transact's plan), which stamps the committed {generation, timestamp}, enriches entity deletes with the record's LAST committed state from the commit's own before-images (free — Model B reads them anyway; removeMany's id-only deletes gain full payloads this way), and emits only after the commit succeeds — a losing CAS or rejected batch never announces anything. Dispatch is a microtask FIFO after the mutex releases: commit-ordered, a slow listener never delays a write, a throwing listener is logged and isolated, and with no subscribers the write path constructs no events at all. Single-point emission was chosen over per-method hooks because the aggregation-hook pattern demonstrably drifted (relation ops and removeMany were silently missing from it). Coverage: add/update/remove (+ cascade unrelate per deleted relationship), relate (both edges when bidirectional)/unrelate/updateRelation, per-item events for addMany/updateMany/relateMany/removeMany, per-item events sharing one generation for transact(), and transitively imports + VFS. clear() and restore() — wholesale raw-state operations outside the per-record commit path — emit a single store-level event meaning "refetch everything". brain.close() drops all listeners. New module src/events/changeFeed.ts (BrainyChangeEvent + ChangeFeed, exported from the package root); guide docs/guides/reacting-to-changes.md. Integration suite pins the contract: per-op payload fidelity, delete last-state payloads, batch per-item emission, one-generation transact batches, VFS-origin events, CAS-loser silence, ordering, listener isolation, unsubscribe, and store-level events.
2026-07-10 11:24:31 -07:00
## Patterns
**Cache invalidation** — drop cached reads for whatever changed:
```ts
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:
```ts
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()`.