Quickback Docs

Changesets — aggregate writes

Declare an owns boundary on a root table and write the parent plus its owned relations in one atomic request — every op paying the child's own firewall, access, and guards.

A changeset writes a parent row and its owned relations — junction memberships, normalized phones/emails, line items — in one request, replacing the hand-rolled multi-table action. You declare the one fact the compiler was missing — which relations are compositions (owned by this root) vs references (independent entities) — and the compiler generates the whole write path.

Changesets are a contract v2 capability (contract: { version: "v2" }). Declaring owns on a v1-pinned project is a compile error.

Declaring the boundary — owns

quickback/features/projects/projects.ts
export default feature("projects", {
  // ...columns, firewall, guards, masking, read...
  create: { access: { roles: ["admin", "owner"] } },
  update: { access: { roles: ["admin", "owner"] } },

  owns: {
    tasks: {
      table: "tasks",            // camelCase table name — a defineTable in this project
      fk: "projectId",           // FK on the CHILD pointing at this root's PK
      refs: {                    // client-supplied pointers at INDEPENDENT entities
        assigneeId: "people",
        reviewerId: "people",    // nullable refs may be omitted or null
      },
      inherit: ["organizationId"], // parent columns copied onto every inserted child
      owns: {                    // grandchildren — structural nesting, depth-capped
        checklist: { table: "taskChecklistItems", fk: "taskId", inherit: ["organizationId"] },
      },
    },
  },
});
KeyMeaning
tableThe owned child — must be a defineTable/feature() in the project. String literal, never an identifier. Auth tables can never be owned.
fkThe spine: the child column pointing at this root. Compiler-stamped on inserts; a client-supplied value is rejected.
refsColumns pointing at independent entities. Client-supplied, existence-checked inside the caller's tenant (the referenced table's firewall is ANDed) — on inserts and patches. The referenced row is never written through.
inheritTenant/denorm columns copied verbatim from the (firewall-verified) parent row onto every inserted child. Compiler-stamped; client values rejected.
ownsNested owned relations. Max 3 levels of nesting (the aggregate spans at most 4 tables).

A child's q.stamp identity columns are a fourth, disjoint source, sitting alongside fk/inherit/refs. On a changeset insert the engine stamps them from the caller's proven scope claim (ctx.scope.<kind>.<claim>) — not from the parent row — but only when the caller is a scope-only principal; an org-admitted caller keeps their client value (org-firewall-bounded). The contrast is exact: inherit copies tenant columns from the firewall-verified parent, q.stamp copies identity from the proven token. A required claim absent on a scope principal fails the op with 403 — emitted before the ref-existence checks, so a claim-less caller cannot probe which rows exist. A refs key may not alias a q.stamp column (a column is compiler-stamped or a client ref, never both). On a changeset patch op the stamp column is immutable — a client value for it is rejected (400) like any systemManaged column, matching the plain update path. Because the stamp lives inside the changeset handler, a keyed (Idempotency-Key) replay neither re-writes nor re-stamps; a keyless retry re-writes and re-stamps.

Owning a relation is not a write grant. Every changeset op is admitted by the child table's own crud declaration: insert requires create: on the child, patch requires update:, delete requires delete: (soft mode only in v1) — each with its own access. There is no per-relation access override: access is declared exactly once, on the child.

routes: false — changeset admission without a raw route

Widening a child's access for changeset use must not silently open its raw CRUD surface. The create, update, and delete op configs accept routes: false (upsert/put does not): the access stays declared (it is the changeset admission gate) while the raw HTTP route — and its auto-promoted batch variant — is never mounted, documented, or counted in the operations manifest.

quickback/features/projects/tasks.ts
update: { access: { roles: ["member", "admin", "owner"] }, routes: false },

The wire contract

One blessed path — RFC 5789 media-type dispatch on the routes you already have. No subpath.

  • PATCH /api/v2/<resource>/:id with Content-Type: application/vnd.quickback.changeset+json — apply a changeset to an existing root. Plain application/json keeps the flat-column PATCH.
  • POST /api/v2/<resource> with the same media type — create the root and its children in one changeset.
// PATCH /api/v2/projects/prj_1   (Content-Type: application/vnd.quickback.changeset+json)
{
  "patch": { "name": "Renamed" },              // sparse ROOT patch (optional)
  "tasks": {
    "insert": [
      {
        "lid": "t1",                            // client correlation id
        "title": "Ship it",
        "assigneeId": "per_9",                  // ref — tenant-checked
        "checklist": { "insert": [{ "label": "QA pass" }] }  // grandchild, FKs onto t1's minted id
      }
    ],
    "patch":  [{ "id": "tsk_2", "state": "done" }],
    "delete": ["tsk_3"]
  }
}
// Response
{
  "success": true,
  "root": { "id": "prj_1", "name": "Renamed", /* masked through the root's masking */ },
  "created": { "t1": "tsk_9a…", "/tasks/insert/0/checklist/insert/0": "tci_44…" },
  "meta": { "ops": 4, "transactional": false }
}
  • Inserts/patches apply parent-first (document order); deletes apply children-first.
  • lid correlates a minted id in created; entries without a lid key on their JSON Pointer. A nested insert's FK comes from its parent op's minted id — an unresolvable parent lid is a pointer-carrying 400, never a silent re-parent onto the root.
  • Prefer: return=minimal omits root from the response. v1 representation is root-row-only (re-read through the root's masking) — nested aggregate reads are the phase-2 ?include= reader; use defineView includes today.

Headers

HeaderBehavior
If-MatchOptimistic concurrency against the root revision — the root's updatedAt (or the injected modifiedAt audit column). Accepted value forms: W/"<revision>", "<revision>", or the bare revision value, where <revision> is the raw value of the revision column as returned in the root row. Do not echo GET /:id's ETag header into If-Match — that ETag is the contract layer's body-hash ETag, not this revision, and will always 412. Compared inside the transaction; mismatch → 412. Every accepted changeset touches the revision. A root with no revision column answers If-Match with 428 PRECONDITION_UNSUPPORTED — never silently ignored; the POST variant always answers 428 (no pre-existing revision to match).
Idempotency-KeyThe standard write-retry key. Claims are principal-scoped — a replayed key from a different principal is a 422, never a cached-body leak.
Preferreturn=representation (default) or return=minimal.

Errors

RFC 9457 application/problem+json. The failing op is identified by a JSON Pointer in the pointer extension (/tasks/insert/0/assigneeId). On non-transactional providers the error also carries an ops extension listing the outcomes of ops applied before the failure — the honest committed prefix. On transactional providers the rollback erased every applied op, so the error carries no ops extension (advertising rolled-back ids would point clients at rows that no longer exist). Outcome entries carry only { pointer, status, id? } — never child field values (v1 deliberately withholds nested child representation, so errors cannot become an unmasked-child side channel).

The security model — "owning a relation is not a backdoor"

The root op runs the root table's full single-record gate stack, in order: auth → cross-tenant guard → the root's own update (PATCH) / create (POST) access — always, including child-only bodies; there is no child-only bypass lane → firewall fetch (reveal → 403 / hide → 404, byte-consistent with the plain handler) → requireSelf → root body schema + ownership guard + guards. The POST variant runs the root's full create pipeline (org validation, FK existence checks, defaults/computed, ownership stamping).

Every child op then pays:

  1. Admission — declared relation, declared op kind (unknown keys are 400s).
  2. The child's own access — the pre-record role gate runs before any row fetch (no 404-vs-403 ID probing under the aggregate); record: predicates are evaluated against the fetched row on patch/delete and against the submitted input on insert.
  3. systemManaged rejection — client data carrying fk, any inherit column, or an ownership column is rejected with the op's pointer, never stripped or stamped-over. (The child's plain-CRUD guards are unaffected — a junction may keep its FK createable for its raw route; the same field in a changeset op is rejected because the changeset supplies it structurally.)
  4. The child's column body schema — q-authored children pay the same Zod types/enums/lengths backstop their plain POST/PATCH enforces (strict, guards-aware, with the compiler-stamped columns omitted). Drizzle-authored children have no Zod on their plain routes either, so none applies here — parity, not an omission.
  5. The child's guardscreateable/updatable validation.
  6. The child's firewall — every patch/delete WHERE is spine FK = parent id AND child firewall AND id, same-feature or cross-feature alike. Inserts derive tenant columns only from the firewall-verified parent row.
  7. Tenant-scoped refs checks — each non-null ref must exist inside the referenced table's firewall, on inserts and patches alike (re-pointing a ref pays the same check; only columns present in the op's data are checked).
  8. Server-minted child ids — a client-supplied child primary key on an insert is rejected (like the plain POST's strict body schema), unless the project is configured for client-provided ids (generateId: false).

Child delete ops stamp the fixed deletedAt audit column — exactly what the plain DELETE route stamps (the audit wrapper then auto-stamps deletedBy). An isNull firewall arm on any other column (e.g. archivedAt) is a read-scoping predicate, never the soft-delete stamp.

Handler triggers fire — the changeset is a first-party write

Every root and owned-child op writes through the same audit-wrapped db chokepoint the plain CRUD routes and actions use — not a raw Drizzle handle. So a child's declared handler: triggers fire on the changeset exactly as they do on the child's own route: beforeInsert / beforeUpdate / beforeDelete run at the write (a throw becomes the same TRIGGER_REJECTED and — on a transactional provider — rolls the whole aggregate back), and afterInsert / afterUpdate run once per affected row. Audit stamping (createdBy / modifiedBy) rides the same chokepoint. The only gap is afterDelete, which the soft-delete stamp can't see — identical to the plain DELETE route's own limitation. (sql: triggers fire on every path regardless, as always.) This is the parity the trigger docs promise: a generated contract-v2 changeset is a compiler write surface, not one of the queue/cron/raw-SQL bypasses that skip handler hooks.

Compile-time, fail-closed (everything throws, nothing normalizes silently):

  • Per-arm tenant coverage: every column arm of the child's resolved firewall — including every branch of an any: OR — must be satisfiable by fk/inherit. Covering one arm of an OR is not coverage. This runs over the post-expansion firewall, so lanes spliced from authz.rules gates and { tenant } anchors are checked too.
  • Children firewalled through a subquery arm (via:, { permission }, arrows) cannot be owned in v1 — even when the arm arrives through a gate splice.
  • refs keys must be disjoint from {fk} ∪ inherit ∪ ownership columns — a column is either compiler-stamped or a client ref, never both (blocks ownership-column injection through a ref).
  • Relationship / FGA access arms are compile errors on the changeset lane — on the root's update/create access and on every admitted child op's access. Those arms are decided by inline route code the changeset engine does not emit in v1; rather than silently widen (or silently 403) the gate, the compiler refuses. Use roles/record predicates, or keep the write in an authored action. (Function-form access is refused for the same reason.)
  • Envelope-encrypted roots cannot ride the changeset in v1 — the response path does not run the decrypt-then-mask prepare helper, so the returned root would carry sealed ciphertext. The compiler refuses rather than degrade silently. (Sealed-mode columns are unaffected — plain routes return those as-is too.)
  • Relation names insert, patch, delete, and lid are rejected — the wire parser reserves those payload keys, so such a relation would be silently unreachable on the wire.
  • One aggregate root per table, acyclic graph, bounded depth, declared child ops only, soft-delete children only, contract v2 only.

afterCommit — emit a realtime signal after the changeset commits

A changeset that must notify live views declares afterCommit on the aggregate root — a declarative broadcast, not a hand-rolled try { realtime.* } catch {} tail. It fires one aggregate-level CloudEvent strictly after the transaction commits, riding the existing named-invalidation / Broadcaster egress (so it inherits the CloudEvents envelope + AsyncAPI description for free).

quickback/features/travel/lodging-reservations.ts
export default defineTable(lodgingReservations, {
  // ...firewall / read / crud ...
  owns: { occupants: { table: "lodgingReservationGuests", fk: "reservationId",
                       inherit: ["eventId", "organizationId"] } },
  afterCommit: {
    broadcast: {
      travelChanged: {
        event: "attendRealtime.mobileBundleChanged.travelUpdated", // registry.event.variant
        delivery: "thisEvent",          // MUST be in the event's `deliveries` allowlist
        payload: {
          eventId: "root.eventId",       // a committed root column
          reservationId: "rootId",       // the root pk
          occupantIds: "affectedIds",    // ids of child rows touched this changeset
        },
      },
    },
  },
});
  • One event per aggregate, not one per touched child. The payload carries an affected-ids / changed-paths roll-up derived from the applied ops (affectedIds = child ids, changedPaths = JSON pointers) — ids and pointers only, never child field values.
  • delivery is required and fail-closed. The audience lives on a named delivery profile; it must appear in the referenced event's deliveries allowlist. There is no default selection, so an event permitting a narrow AND a wide room can never silently fan a changeset frame to the wider one.
  • Payload sources: root.<col> (a committed root column), rootId, affectedIds, changedPaths. Every payload key the variant declares must be mapped; unknown sources / undeclared keys are compile errors.
  • Best-effort: a broadcast failure never rolls back the committed changeset (console.warn, dispatched on waitUntil).
  • Root-row frame: afterCommit: { realtime: true } additionally emits the root row (postgres_changes-style) on the table's own realtime room. The row rides unmasked with the table's MASKING_CONFIG so the Broadcaster masks per-subscriber-role at fanout — the same discipline plain CRUD realtime uses.

Fail-closed disclosure gates

Because a broadcast payload is not masked at fanout (only the root-row frame is), afterCommit fails the build rather than leak:

  • A masked root.<col> may ride a broadcast only to a role-only delivery whose roles ⊆ the column's unmask roles (same app-role namespace). A scope-keyed delivery or an empty unmask set (show: { or: 'owner' }) always throws.
  • affectedIds / rootId / changedPaths carry firewalled row IDs (existence/enumeration). They may ride a scope-keyed delivery (bounded to one scope-instance room); a role-only / org-wide delivery throws unless you acknowledge with disclosesChildIds: true.

q.scope verified vs q.stamp proven — the "stamp then emit" rule

When a changeset stamps a proven identity and then emits it, the two features compose on one write path:

q.scope('<kind>')q.stamp({ fromScope, claim })
Purposetenant isolation columnproven-principal identity column
Firewall arm?yes — it is the tenant WHEREnever
Verified how?firewall re-verifies on read/patch/deletetoken provenance (un-spoofable)
Phrase"stamp AND verify""stamp AND prove"
Safe to broadcast?to the matching scope-keyed roomonly to an audience ⊆ the stamping scope kind

A q.stamp identity column (e.g. guestId) sourced via root.<col> is audience-gated like affectedIds (05×06 integration): the delivery must be scope-keyed to the same scope kind that stamped it, else the build fails — unless you acknowledge with disclosesStampedIdentity: true. A stamp is un-spoofable, not un-sensitive: provenance is not audience clearance.

Idempotency — at-least-once

afterCommit is at-least-once, exactly as idempotent as the write it rides. A retry carrying the same Idempotency-Key neither re-writes nor re-emits (the middleware returns the cached response before the handler runs). A keyless retry re-runs the whole handler → re-writes and re-emits. Callers that need single-delivery must send an Idempotency-Key.

No-tx: "row written ⇏ event fired"

On a no-tx provider (D1 / neon-http), a mid-changeset failure throws before the success path, so no frames emit for the partially-applied prefix — meta.transactional: false is the only guarantee signal. A fully-successful no-tx changeset does emit, still with transactional: false. Never assume "row written ⇒ event fired"; meta.transactional is the contract.

Deferred out of v1: embedding reindex on the changeset lane, notify (device push — a WS broadcast is not an APNs/web-push delivery), and lifting afterCommit onto plain tables/actions. The compiler still warns when an owns root/child declares realtime without afterCommit, or declares embeddings, so a silent asymmetry can't be enabled unknowingly.

Atomicity — honest, per provider

The whole changeset runs inside db.transaction() when the provider supports it; meta.transactional reports the actual guarantee (same contract as batch operations):

ProvidertransactionalBehavior
postgres / supabase / neon (websocket / hyperdrive) / libsql / better-sqlite3 / bun-sqlitetrueOne transaction; any op failure rolls back the root op and every child op
cloudflare-d1, neon over http (the Cloudflare default)falseDeterministic parent-first fail-fast: the first failure stops the changeset; ops already applied stay committed (a consistent prefix — each was independently authorized)

Calling it from authored code

The engine is exported per root as apply<Singular>Changeset — the seam authored actions delegate through when they still need residue logic (preconditions, server-side transforms, notifications) around the aggregate write:

quickback/features/projects/actions/importBoard.ts
// Action files are compiled into the feature directory, next to the
// generated module — the relative import resolves at runtime.
import { applyProjectChangeset } from "../projects.changeset";

export default defineAction({
  // ...description, input, access...
  execute: async ({ db, ctx, input }) => {
    // precondition / transform residue …
    const result = await applyProjectChangeset(db, ctx, input.projectId, input.changes);
    // notify / respond …
    return result;
  },
});

There is no trusted mode. The auth assertion, cross-tenant guard, and the root's own pre-record access gate are the helper's first statements — an authored action whose action-level access is looser than the root's crud access still pays the root's declared authorization when it delegates. The HTTP handler is a thin wrapper over the same helper (parse + Problem-Details rendering only).

Standards mapping

Changeset conceptGrounded in
Media-type dispatch on PATCHRFC 5789 (the media type selects patch semantics)
lid correlation + op listJMAP /set creation ids, JSON:API lid
Root-revision If-MatchJMAP state tokens, RFC 9110 preconditions
Pointer-carrying errorsRFC 9457 Problem Details + RFC 6901 JSON Pointer
Nested relation opsSCIM PATCH (typed sub-resources)

What stays phase 2

Full-document replace diff sugar (and its key: reconciliation field), the application/json-patch+json representation, cross-branch $lid references, nested Prefer: return=representation, ?include= aggregate reads, and find-or-create roots by business key (conflictTarget).

On this page