The change feed
In this section
Brainy announces its own writes. brain.onChange(listener) subscribes to an in-process feed that fires after a write commits, carrying a fully-described event for every mutation — there is no polling door in this engine, and there never will be: readers react to announcements, they do not ask on a timer.
const off = brain.onChange((change) => {
if (change.kind === 'entity') {
// change.op: 'add' | 'update' | 'remove'
// change.id, change.entity.type/.subtype, change.entity.metadata (the
// full custom bag), change.entity.service — a room can re-project
// LOCALLY from this, never a fetch per event.
} else {
// change.kind === 'relation'
// change.op: 'relate' | 'unrelate' | 'update'
// change.relation.id / .from / .to / .type, plus .fromTags / .toTags —
// each endpoint's `tags` custom field, read at event-build time; absent
// when an endpoint has no tags or (a cascade delete) no longer exists.
}
// change.generation — the commit this mutation belongs to. A transact's
// mutations all carry the SAME generation: one commit, one generation.
})
// later
off()TSThe listener is called once per committed mutation, in commit order — not once per batch with an array. A transact() of four operations calls it four times, after the whole batch commits, and all four carry the same generation.
What an event carries
Entity events (
kind: 'entity'): the operation, the id, the entity's type (plus subtype when one exists), the writingservicewhen one was set, and the entity's FULL custom metadata bag — enough to re-project the row itself, not just invalidate it, without a follow-up read.Relation events (
kind: 'relation'): the operation, the edge's own id, itsfrom/to/type, and each endpoint'stagscustom field asfromTags/toTags(when the endpoint has one and still exists — a cascade delete's own endpoint carries no tags, since it is gone in the same commit) — remove and unrelate events are fully described, so a subscriber never has to re-read a record that no longer exists to learn what it was.Batches announce as batches: a
transact()commit emits its events together, after the whole batch commits — a subscriber never observes a half-applied batch.A working-state write emits no event.
add()/update()withretention: 'latest-only'(the classvfs.writeFile(path, data, { retention: 'latest-only' })uses) commits and advances the generation exactly like any other write, but this feed never hears about it — built for a row, like a co-editing CRDT snapshot, that would otherwise put a fresh event in front of every subscriber on every keystroke's debounce interval. Seedocs/api-contract.md§9's "Working-state files".
The scope, honestly
The feed is in-process: it fires in the process that performed the write. Under the engine's single-writer law that is exactly one process per store, so a subscriber in the writer process observes 100% of writes by construction — nothing can sneak past it. Cross-process delivery (websockets, server push, fleet buses) is deliberately not the engine's job: the feed is the engine-owned truth at the boundary, and your realtime layer fans it out.
Cost
Event assembly is skipped entirely when nobody listens — the feed checks for listeners before it builds a single event object. Subscribing costs you the events you consume, not the writes you don't.
The realtime pattern: feed in, SSE or WebSocket out
The feed is the engine-owned half of a realtime plane; the transport is the application's half. The shape that composes them:
// The writer process — the only process that writes this store, so the one
// place the feed sees everything.
brain.onChange((change) => {
bus.publish(topicFor(change), change) // your bus: SSE broadcaster,
}) // WebSocket room, queue — yours.TSIf a server already fans it out for you
A server that holds the store can serve the same announcements over SSE without you writing the fan-out, replayable from a cursor because it reads them back out of the engine's own commit log rather than out of a listener's memory. Two things about that feed are worth knowing before you build a consumer on it, and they follow from the engine's own model rather than from the transport:
A commit is one generation, however many mutations it carries, so each frame is addressed by
(generation, index)— the generation is the commit, the index is that mutation's position inside it, counting from 0. A four-optransactis four frames under one generation.Resuming is per generation, so delivery is at-least-once per generation. A consumer holding part of a commit holds part of a transaction it can never complete, so a resume lands on a commit boundary and never inside one: a client that dropped mid-commit receives that whole commit again. Dedup on
(generation, index)and the repeat costs you nothing — the pair is stable, so the second copy is free to discard.?since=headis "start from now" — no replay, a real cursor at attach time. Asincepast the real head is refused by name rather than silently held forever waiting for a commit that can never reach it — seedocs/host.md's resume table for both.Frame parity:
brainy host's own change feed (docs/host.md's "The change feed" section) carries the SAME entity/relation views as the in-process event above — built with the same reserved/custom split, so a room holding the graph in memory can apply a frame directly, never fetch a row per frame.brainy-serve's change feed does not carry these fields yet.
The in-process feed above is the same truth without the transport: the listener fires once per mutation, and a transact's mutations share one generation.
SSE fits read-mostly surfaces: one
EventSourceper client, the server replays each change onto the stream, reconnects are the browser's problem.WebSockets fit surfaces that also talk back (presence, live editing).
Either way, never poll the store: a reader that asks on a timer is reading stale answers between asks and warming caches with phantom traffic. Subscribe once, invalidate precisely — the event's entity type and id are carried so you can re-run exactly the reads that depend on what changed.