Quickback Docs

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 } — data is the per-resource cache-seed contract; hasMore and nextCursor are additive.

  • Ordering is deterministic. orderBy keys apply in the order you list them. The primary key id is appended ascending as the final tie-break unless you already listed it, the same rule as the CRUD list. Without a unique final key, LIMIT picks among tied rows arbitrarily on Postgres.

  • Truncation is visible. With limit, the section reads one extra row: hasMore: true means more rows match than were returned. Without limit, hasMore is always false.

  • nextCursor continues on the table's CRUD list. When hasMore is true, nextCursor is the same keyset cursor the CRUD list emits. Fetch the next page with that list endpoint, passing the section's where as equality filters, its orderBy as ?sort=, and the cursor as ?starting_after=. For orderBy: [{ createdAt: "desc" }] and where: { eventId }, that is GET /api/v1/guests?eventId=<id>&sort=-createdAt&starting_after=<nextCursor>. The CRUD list appends the same id tie-break, so the cursor lines up. nextCursor is null when there is nothing more, when columns omits a sort key (or id), or when a sort key is masked for the caller. In those cases, raise limit or page the CRUD list with ?offset=.

  • sections filter: omitted/empty = all. An un-wanted list section returns { data: [], hasMore: false, nextCursor: null } (drop-in compatible with existing clients); un-wanted custom sections 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 reports cold=5 tx=5 in Server-Timing. Fewer sections is the lever when that dominates.

  • ETag: SHA-1 hex over { scopeSeed: <path params>, userId, payload }. An ifNoneMatch match returns { success: true, etag, notModified: true } with no sections. The digest is also exported as sha1Hex from lib/etag so remaining hand-written reads can delete their copies.

  • version probe — by default every section runs and the ETag is compared afterwards, so a match saves bytes but not time. Declare a cheap version: custom(...) and its value replaces the payload in the ETag seed (with the caller's roles, so a role change still invalidates). With ifNoneMatch the 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 on notModified. A probe that throws runs the sections and answers without an etag — a full read, never a stale notModified. Requires etag: 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 and openapi.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's Row — the same masked read projection the CRUD list returns — narrowed with Pick when columns is set. In openapi.json a columns section is an inline object with just those properties.
  • A custom() section with { output } is that schema; without it the section is unknown. Custom sections are optional in the type because an un-wanted one is absent from the response.
  • With etag: false there is no union: the envelope is { success: true, …sections }. With a version probe, etag is 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 / orderBy must name declared columns (compiler-injected audit columns like createdAt/modifiedAt are allowed).
  • list() options are literals, or a same-file top-level const MANY = 2000 — anything else fails with "use a literal".
  • where values are literals or the strict substitution allowlist ($ctx.userId, $ctx.activeOrgId, $ctx.<scope>.id, and $path.<param> for a param of the bundle's own path, e.g. path: "/projects/:projectId/overview" with where: { projectId: "$path.projectId" }) — always parameterized. A $path param the path does not declare is a compile error. Anything richer belongs in a custom() section.
  • Section names may not shadow the response envelope (success, etag, notModified, sections).
  • A bundle must never widen read access: its access.roles must be a subset of every list section table's read.access roles — a violation is a compile error, not a warning. Both sides are compared after lowering: admin+ through auth.roleHierarchy and declared authz.roles aliases through their members, so ["projectAccess"] and ["member", "admin", "owner"] are the same people. A declared role with a relationship or userRole arm is not a set; the check skips it with a warning.

On this page