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
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"] },
},
},
},
});| Key | Meaning |
|---|---|
table | The owned child — must be a defineTable/feature() in the project. String literal, never an identifier. Auth tables can never be owned. |
fk | The spine: the child column pointing at this root. Compiler-stamped on inserts; a client-supplied value is rejected. |
refs | Columns 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. |
inherit | Tenant/denorm columns copied verbatim from the (firewall-verified) parent row onto every inserted child. Compiler-stamped; client values rejected. |
owns | Nested 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.
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>/:idwithContent-Type: application/vnd.quickback.changeset+json— apply a changeset to an existing root. Plainapplication/jsonkeeps 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.
lidcorrelates a minted id increated; entries without alidkey 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=minimalomitsrootfrom the response. v1 representation is root-row-only (re-read through the root's masking) — nested aggregate reads are the phase-2?include=reader; usedefineViewincludes today.
Headers
| Header | Behavior |
|---|---|
If-Match | Optimistic 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-Key | The standard write-retry key. Claims are principal-scoped — a replayed key from a different principal is a 422, never a cached-body leak. |
Prefer | return=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:
- Admission — declared relation, declared op kind (unknown keys are 400s).
- 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. - systemManaged rejection — client data carrying
fk, anyinheritcolumn, 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.) - 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.
- The child's guards —
createable/updatablevalidation. - 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. - Tenant-scoped
refschecks — 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). - 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 byfk/inherit. Covering one arm of an OR is not coverage. This runs over the post-expansion firewall, so lanes spliced fromauthz.rulesgates 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. refskeys 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/createaccess 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, andlidare 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).
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. deliveryis required and fail-closed. The audience lives on a named delivery profile; it must appear in the referenced event'sdeliveriesallowlist. 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 onwaitUntil). - Root-row frame:
afterCommit: { realtime: true }additionally emits the root row (postgres_changes-style) on the table's ownrealtimeroom. The row rides unmasked with the table'sMASKING_CONFIGso 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/changedPathscarry 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 withdisclosesChildIds: 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 }) | |
|---|---|---|
| Purpose | tenant isolation column | proven-principal identity column |
| Firewall arm? | yes — it is the tenant WHERE | never |
| Verified how? | firewall re-verifies on read/patch/delete | token provenance (un-spoofable) |
| Phrase | "stamp AND verify" | "stamp AND prove" |
| Safe to broadcast? | to the matching scope-keyed room | only 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 liftingafterCommitonto plain tables/actions. The compiler still warns when anownsroot/child declaresrealtimewithoutafterCommit, or declaresembeddings, 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):
| Provider | transactional | Behavior |
|---|---|---|
| postgres / supabase / neon (websocket / hyperdrive) / libsql / better-sqlite3 / bun-sqlite | true | One transaction; any op failure rolls back the root op and every child op |
| cloudflare-d1, neon over http (the Cloudflare default) | false | Deterministic 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:
// 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 concept | Grounded in |
|---|---|
| Media-type dispatch on PATCH | RFC 5789 (the media type selects patch semantics) |
lid correlation + op list | JMAP /set creation ids, JSON:API lid |
Root-revision If-Match | JMAP state tokens, RFC 9110 preconditions |
| Pointer-carrying errors | RFC 9457 Problem Details + RFC 6901 JSON Pointer |
| Nested relation ops | SCIM 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).