Brainy Brainy
Docs Brainy

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()TS

The 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 writing service when 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, its from / to / type, and each endpoint's tags custom field as fromTags / 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() with retention: 'latest-only' (the class vfs.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. See docs/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.TS

If 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-op transact is 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=head is "start from now" — no replay, a real cursor at attach time. A since past the real head is refused by name rather than silently held forever waiting for a commit that can never reach it — see docs/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 EventSource per 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.