Table Change Signals
A built-in per-table "changed" broadcast and a per-organization change counter, for screens that refetch by table and bundle version probes.
changes gives every organization-scoped table two signals without per-table hooks:
broadcast— a realtimedata.changedframe, payload{ table }, sent to the organization's members after a write commits.counter— a per-organization version inqb_change_counter, bumped by database triggers on every write. Read it withchangeCounter().
export default defineConfig({
// ...
changes: {
broadcast: { access: { roles: ['member+'] } },
counter: true,
},
});Both cover tables with an organizationId column. Tables without one get neither.
broadcast — refetch by table
The write chokepoint (the audit-wrapped db) sends one frame per (organization, table) per request:
{ "type": "broadcast", "event": "data.changed", "payload": { "table": "invoices" } }- Once per table per request. Ten writes to
invoicesin one request send one frame. - After commit. Inside
db.transaction()the frames wait for the commit and are dropped on rollback. Outside a transaction each statement has already committed when its frame is sent. - Audience.
access.roles(expanded throughauth.roleHierarchy) becomes the frame'stargetRoles, in the caller's active organization. A cron or queue write has no active organization, so the frame goes to theorganizationIdof the written rows — which requires.returning(); without it there are no rows to read and no frame. - Coverage. Every write through the wrapped
db: CRUD routes, actions, cron, queue, and email handlers. Raw SQL and writes through an unwrapped handle send nothing — use the counter to catch those. - Best-effort. A failed send never fails the write.
table is the SQL table name — the same name trigger handlers receive.
Requires realtime (realtime: true on the database provider) and audit fields (on by default). Otherwise the compile fails.
counter — a safe bundle version probe
Compiler-owned AFTER INSERT / UPDATE / DELETE triggers on each organization-scoped table bump that organization's row in qb_change_counter. Because they are database triggers, queue, cron and raw-SQL writes count too. A soft delete counts once.
Use it as a bundle's version probe. A probe built on max(modifiedAt) misses hard deletes and every table the bundle reads indirectly. The counter misses neither:
import { changeCounter } from "../../../lib/change-counter";
version: custom(({ db, ctx }) => changeCounter(db, ctx.activeOrgId)),The trade-off: any write in the organization changes the version, so unrelated writes cost a full read. That is never a stale notModified. Clients can also compare the counter after a reconnect to decide whether to refetch.
- Postgres: the trigger functions are
SECURITY DEFINER, owned byquickback_owner. The Worker role reads only its active organization's row, or any row on the service lane, and cannot write the table. - D1: plain SQLite triggers in the features database.
- Contention: one row per organization serializes concurrent writes within that organization until each commits.
- Providers:
cloudflare-d1and the Postgres providers. Elsewhere the compile fails.
Related
- Named invalidations — typed, action-fired signals
- Bundles —
versionprobes and ETags - Triggers — the same journaled SQL-trigger lane
Named Invalidations
Typed, targeted "this changed — refresh" signals an action fires after commit, delivered to exactly the right subscribers over the broadcast channel.
Live Views
Keep an open view's full join-surface live in realtime — root row plus related child collections — with hybrid-delta updates and resync-on-reconnect.