Quickback Docs

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 realtime data.changed frame, payload { table }, sent to the organization's members after a write commits.
  • counter — a per-organization version in qb_change_counter, bumped by database triggers on every write. Read it with changeCounter().
quickback.config.ts
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 invoices in 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 through auth.roleHierarchy) becomes the frame's targetRoles, in the caller's active organization. A cron or queue write has no active organization, so the frame goes to the organizationId of 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 by quickback_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-d1 and the Postgres providers. Elsewhere the compile fails.

On this page