Bundles
Single-round-trip bootstrap reads — declarative slices with firewall, masking, and ETag built in.
Overview
Client bootstrap screens want many small lists in one request. Hand-written
"bundle" endpoints all repeat the same 150 lines: a Promise.all of selects
shaped byte-identically to the CRUD list endpoints they replace, a
sections: string[] subset filter, a copy-pasted SHA-1 ETag +
ifNoneMatch → notModified short-circuit, a Server-Timing header, and
hand-re-derived per-slice masking. defineBundle generates all of it.
// features/events/guests.ts
import { feature, q } from "@quickback/compiler";
export default feature("guests", {
columns: {
id: q.id(),
eventId: q.text().required().index(),
status: q.text().required(),
displayName: q.text().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
read: { access: { roles: ["member", "admin"] } },
});
// features/events/supportTickets.ts
import { feature, q } from "@quickback/compiler";
export default feature("supportTickets", {
columns: {
id: q.id(),
eventId: q.text().required().index(),
subject: q.text().required(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
read: { access: { roles: ["member", "admin"] } },
});
// features/events/actions/getOverviewBundle.ts
import { z } from "zod";
import { defineBundle, list, custom } from "../.quickback/define-bundle";
export default defineBundle({
path: "/event/:eventId/overview-bundle",
description: "Console Overview bootstrap",
access: { roles: ["member", "admin"] }, // a subset of every list table's read.access
etag: true, // default true
serverTiming: "overview", // optional
sections: {
guests: list("guests", {
columns: ["id", "status", "modifiedAt", "createdAt", "displayName"],
where: { eventId: "$path.eventId" },
orderBy: [{ createdAt: "desc" }],
limit: 500,
}),
tickets: list("supportTickets", { where: { eventId: "$path.eventId" }, orderBy: [{ modifiedAt: "desc" }], limit: 100 }),
counts: custom(async ({ db, ctx }) => ({ open: 0 }), { output: z.object({ open: z.number().int() }) }),
},
});Semantics
-
Generated POST action with input
{ sections?: string[], ifNoneMatch?: string }, mounted through the standard standalone pipeline — auth, access, org/team preconditions, rate limits, OpenAPI, MCP tools, security contracts all apply. -
list()sections run through the scoped db — the org firewall and soft-delete filter apply by construction — and the target table's REAL mask function is applied (the same read projection the CRUD list endpoint runs), so masked columns are safe to include. Every list slice lands as{ data: rows, hasMore, nextCursor }—datais the per-resource cache-seed contract;hasMoreandnextCursorare additive. -
Ordering is deterministic.
orderBykeys apply in the order you list them. The primary keyidis appended ascending as the final tie-break unless you already listed it, the same rule as the CRUD list. Without a unique final key,LIMITpicks among tied rows arbitrarily on Postgres. -
Truncation is visible. With
limit, the section reads one extra row:hasMore: truemeans more rows match than were returned. Withoutlimit,hasMoreis alwaysfalse. -
nextCursorcontinues on the table's CRUD list. WhenhasMoreis true,nextCursoris the same keyset cursor the CRUD list emits. Fetch the next page with that list endpoint, passing the section'swhereas equality filters, itsorderByas?sort=, and the cursor as?starting_after=. FororderBy: [{ createdAt: "desc" }]andwhere: { eventId }, that isGET /api/v1/guests?eventId=<id>&sort=-createdAt&starting_after=<nextCursor>. The CRUD list appends the sameidtie-break, so the cursor lines up.nextCursorisnullwhen there is nothing more, whencolumnsomits a sort key (orid), or when a sort key is masked for the caller. In those cases, raiselimitor page the CRUD list with?offset=. -
sectionsfilter: omitted/empty = all. An un-wanted list section returns{ data: [], hasMore: false, nextCursor: null }(drop-in compatible with existing clients); un-wantedcustomsections are absent. -
All sections run in one
Promise.all. On Postgres over Hyperdrive each concurrent section leases its own connection, opened cold for the request — a five-section bundle reportscold=5 tx=5inServer-Timing. Fewer sections is the lever when that dominates. -
ETag: SHA-1 hex over
{ scopeSeed: <path params>, userId, payload }. AnifNoneMatchmatch returns{ success: true, etag, notModified: true }with no sections. The digest is also exported assha1Hexfromlib/etagso remaining hand-written reads can delete their copies. -
versionprobe — by default every section runs and the ETag is compared afterwards, so a match saves bytes but not time. Declare a cheapversion: custom(...)and its value replaces the payload in the ETag seed (with the caller's roles, so a role change still invalidates). WithifNoneMatchthe probe runs first and a match skips every section; without it the probe joins the sections'Promise.all, so a first load pays nothing extra.version: custom(async ({ db }) => (await db.select({ v: max(projects.modifiedAt) }).from(projects))[0]?.v ?? "0", ),With
changes: { counter: true },changeCounter(db, ctx.activeOrgId)is a probe that every write in the organization moves — including hard deletes, queue, cron and raw SQL. See Table change signals.The contract you accept: the ETag changes only when the version changes. A probe that misses a mutation (a hard delete does not move
max(modifiedAt)) keeps clients onnotModified. A probe that throws runs the sections and answers without anetag— a full read, never a stalenotModified. Requiresetag: true. -
custom()sections receive the full action context and are embedded verbatim under the section name — the author owns masking there. An optional second argument{ output: <zod schema> }types the section in the generated client andopenapi.json; it is never validated at runtime.
Generated client types
quickback.gen.ts and openapi.json carry the bundle's response, so no
client hand-types the envelope. For the bundle above:
// Api.events.actions.getOverviewBundle
export type Input = {
sections?: Array<"guests" | "tickets" | "counts">;
ifNoneMatch?: string;
};
export type Output =
| { success: true; etag: string; notModified: true }
| {
success: true;
etag: string;
notModified: false;
guests: { data: Array<Pick<Api.events.guests.Row, "id" | "status" | "modifiedAt" | "createdAt" | "displayName">>; hasMore: boolean; nextCursor: string | null };
tickets: { data: Array<Api.events.supportTickets.Row>; hasMore: boolean; nextCursor: string | null };
counts?: { open: number };
};- A
list()section is the table'sRow— the same masked read projection the CRUD list returns — narrowed withPickwhencolumnsis set. Inopenapi.jsonacolumnssection is an inline object with just those properties. - A
custom()section with{ output }is that schema; without it the section isunknown. Custom sections are optional in the type because an un-wanted one is absent from the response. - With
etag: falsethere is no union: the envelope is{ success: true, …sections }. With aversionprobe,etagis optional in the full variant (a failed probe omits it).
Compile-time validation (fail-closed)
The CLI harvests the generated bundle input contract locally before contacting
the compiler. Keep the ../.quickback/define-bundle import shown above; the
generated helper does not need to exist in your authored source tree.
Harvesting does not query list tables or invoke custom() handlers, and bundles
must pass the same complete-input-schema check as other actions.
- Unknown section tables error, listing candidates.
columns/where/orderBymust name declared columns (compiler-injected audit columns likecreatedAt/modifiedAtare allowed).list()options are literals, or a same-file top-levelconst MANY = 2000— anything else fails with "use a literal".wherevalues are literals or the strict substitution allowlist ($ctx.userId,$ctx.activeOrgId,$ctx.<scope>.id, and$path.<param>for a param of the bundle's ownpath, e.g.path: "/projects/:projectId/overview"withwhere: { projectId: "$path.projectId" }) — always parameterized. A$pathparam the path does not declare is a compile error. Anything richer belongs in acustom()section.- Section names may not shadow the response envelope
(
success,etag,notModified,sections). - A bundle must never widen read access: its
access.rolesmust be a subset of every list section table'sread.accessroles — a violation is a compile error, not a warning. Both sides are compared after lowering:admin+throughauth.roleHierarchyand declaredauthz.rolesaliases through their members, so["projectAccess"]and["member", "admin", "owner"]are the same people. A declared role with a relationship oruserRolearm is not a set; the check skips it with a warning.
Views - Column Level Security
Named field projections with per-view access control. Views live under the read pipeline (read.views) and implement column-level security.
Guards - Field Modification Rules
Control which fields can be modified during CREATE vs UPDATE operations. Protect sensitive fields and enforce data integrity rules.