Apps and handlers

Change stream

Edit source

Change stream

The change stream is how a Centraid app stays live without polling. It's a single SSE endpoint per app that publishes one event for every successful write.

Code
GET /centraid/<id>/_changes

Open a connection, get a stream of table-level invalidations whenever an action mutates the app's database. Close the connection when you're done.

What lands on the wire

Each event names the table(s) that changed. The subscriber's job is to invalidate any local cache keyed on those tables and re-fetch.

TODO(#120) — capture the exact event payload shape (event name, JSON body, whether it's one event per table or one event with a table list). The README documents the surface but not the wire format; I'd need to read packages/runtime-core/src/ for the exact serialization.

Why table-level, not row-level

Centraid's change bus deliberately publishes table invalidations rather than row diffs. The tradeoff:

  • Pro: trivial to implement, trivial to subscribe to, works for arbitrary handler logic (the dispatcher doesn't need to model your business rules).
  • Pro: subscribers don't need to merge diffs into client state — they just re-run the query.
  • Con: noisier than row diffs. Every change to a 10k-row table wakes every subscriber on that table.

For the kinds of apps Centraid targets — small, single-user, single-purpose — the simpler model wins. A "todos" table doesn't get 10k rows.

Where the invalidation comes from

Two paths produce change events:

  1. Action handlers. When centraid_write invokes an action, the dispatcher reads the writes: [] declaration from the action's manifest entry and fires invalidations for each table after the handler returns success.
  2. centraid_sql_write agent tool. When an OpenClaw-side agent uses the in-gateway SQL tool to write directly, the plugin's hook emits through runtime.changeBus, so the stream looks the same to subscribers.

Successful writes also emit through runtime.changeBus, so any subscriber on /centraid/<appId>/_changes learns about the mutation. — packages/openclaw-plugin/README.md

What you can't write through

The centraid_sql_* tools refuse DDL (CREATE, ALTER, DROP) and PRAGMA. Schema changes go through migrations/, not the runtime path. The dispatcher's write path only runs action handlers, which are user code — but those handlers run against the scoped DB proxy, which is a small surface (prepare, run, get, all, exec-with-caveats) and is what makes the invalidation tracking reliable.

Subscribing from an app iframe

The pattern is plain EventSource:

js
// inside app.jsconst es = new EventSource('_changes');es.addEventListener('change', (e) => {  const { tables } = JSON.parse(e.data);  if (tables.includes('hydrate_daily')) refresh();});

TODO(#120) — verify the event name (change vs message) and field name (tables vs tableNames) against runtime-core source. Pattern is correct; field names are placeholder.

The path is relative (_changes), so the subscription works whether the gateway is local or remote — the iframe loads from /centraid/<id>/ so _changes resolves to /centraid/<id>/_changes.

Why this matters for the read/write split

Queries are deliberately not tracked. They take a fast read path that doesn't go through SQLite's session-tracking machinery. Writes from a query handler succeed but never show up on _changes — see Queries and actions. The whole reason the read/write split is enforced by a governance directive is to keep the change stream honest.

Where to go next

Was this useful?