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

What 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 its from / 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.
})TS
  • 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.