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((event) => {
for (const change of event) {
if (change.kind === 'entity') {
// change.op: 'add' | 'update' | 'remove'
// change.id, change.entity.type — enough to invalidate precisely
} else {
// change.kind === 'relation'
// change.op: 'relate' | 'unrelate' | 'update'
// change.relation.from / .to / .type
}
}
})
// later
off()TSWhat an event carries
Entity events (
kind: 'entity'): the operation, the id, and the entity's type (plus subtype when one exists) — enough to re-run exactly the reads that depend on that entity type, instead of invalidating a whole tenant.Relation events (
kind: 'relation'): the operation, the edge's id, and itsfrom/to/type— 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.
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((events) => {
for (const change of events) {
bus.publish(topicFor(change), change) // your bus: SSE broadcaster,
} // WebSocket room, queue — yours.
})TSSSE 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.