Quickback Docs

TypeScript Type Tree

Lightweight, branded table and action types generated directly from Quickback's compiler model.

Every compile emits quickback.gen.ts: a compact, type-only tree designed for application code. It is generated directly from the same compiler model that drives routes and OpenAPI, so there is no runtime package and no code to initialize.

import type { Api, Qb } from "../quickback.gen";

type Application = Api.applications.Row;
type NewApplication = Api.applications.Insert;
type ApplicationPatch = Api.applications.Update;
type ApplicationId = Api.applications.Id;

type AdvanceInput = Api.applications.actions.advance.Input;
type AdvanceOutput = Api.applications.actions.advance.Output;
type PipelineItem = Api.applications.views.pipeline.Item;
type PipelineResponse = Api.applications.views.pipeline.Response;

type Page = Qb.Page<Application>;
type ApiProblem = Qb.Problem;

Feature areas preserve their authored hierarchy:

type Booking = Api.events.travel.bookings.Row;

Table types

Each resource table exposes:

MemberMeaning
IdNominal table-specific primary-key type
RowComplete selected wire shape
InsertCreateable fields, with defaults and nullable fields optional
UpdateUpdatable fields, all optional
ListResponseQb.Page<Row>
columns.<name>Type of one row column. A column named like a TypeScript keyword or built-in type (number, string, delete, …) is prefixed: columns._number
views.<name>.ItemA view's projected row
views.<name>.ResponsePage plus typed aggregations where declared
actions.<name>.InputThe action's Zod input contract
actions.<name>.OutputThe declared Zod output contract, or unknown

Column types follow the wire, not the storage: q.bool() and integer(..., { mode: "boolean" }) are boolean, integer(..., { mode: "timestamp" }) and q.timestamp() are ISO string, q.json() is unknown unless it is given a value schema, enum columns are literal unions, and nullable columns add | null.

Primary and foreign keys are branded by default. That makes accidentally passing a candidate ID where a job ID is required a TypeScript error, while both values remain ordinary strings or numbers at runtime.

A column is a foreign key here by the same rule the CMS and ?include= use: a Drizzle .references(), an entry in the resource's references: map ({ vendorId: 'contact' }), or the <table>Id naming convention when a table of that name exists.

function loadJob(id: Api.jobs.Id) {}

declare const candidateId: Api.candidates.Id;
loadJob(candidateId); // TypeScript error

At an untyped boundary, validate the value and cast it once:

const jobId = routeParams.id as Api.jobs.Id;

Auth references

Columns that point at a Better Auth principal are branded too, even though those tables are not part of your project: tenant scopes (q.scope('organization'), q.scope('owner'), q.scope('team'), or a raw Drizzle column bound by the firewall — branded by the arm it binds, so an owner firewall on userId makes it a Qb.UserId whatever the column is called), the audit stamps (createdBy, modifiedBy, deletedBy), and any .references('user' | 'organization' | 'team') column.

declare const job: Api.jobs.Row;

job.organizationId; // Qb.OrganizationId
job.createdBy;      // Qb.UserId | null

function notify(userId: Qb.UserId) {}
notify(job.organizationId); // TypeScript error — an org id is not a user id

Masked columns

Masking rewrites a column's value for callers outside its show.roles. A plain string type would promise a value the runtime does not deliver, so tables with masking rules take a Role type parameter on their read shapes: Row<Role>, ListResponse<Role>, columns.<name><Role>, and views.<name>.Item<Role> / Response<Role>. Writes (Insert, Update) are unchanged — clients send real values.

// jobs.ts: masking: { internalNotes: { type: 'redact', show: { roles: ['admin'] } } }
declare const anyone: Api.jobs.Row;          // Role defaults to never
declare const admin: Api.jobs.Row<"admin">;
declare const member: Api.jobs.Row<"member">;

anyone.internalNotes; // Qb.Masked<string> | null — may be redacted
admin.internalNotes;  // string | null — revealed
member.internalNotes; // Qb.Masked<string> | null

Qb.Masked<T> is a brand on the wire primitive: it is still assignable to T (a masked email is meant to be rendered), but it is not T, so a function that requires the real value cannot silently accept the default row. Name the role you fetched with to get the revealed type; a union role (Row<"member" | "admin">) yields T | Qb.Masked<T>.

A masked value narrower than a free string — a number, boolean, array, JSON value or enum literal — cannot keep its type once masked: the redact mask replaces it with the string "[REDACTED]". Qb.Masked<T> admits that marker, typed Qb.Redacted, so narrow before using the value:

// masking: { salary: { type: 'redact', show: { roles: ['admin'] } } }  — q.int()
declare const row: Api.jobs.Row;
row.salary;                        // Qb.Masked<number> | null  (includes Qb.Redacted)
if (typeof row.salary === "number") console.log(row.salary * 12);

OpenAPI documents the same thing: the column's schema becomes anyOf: [<schema>, { type: "string", enum: ["[REDACTED]"] }]. String-shaped masks (email, phone, …) can't mask such columns at all — see Masking.

show: { or: 'owner' } reveals by record ownership, which no type parameter can see, so those columns stay Masked for every role outside show.roles.

Reading collections

List and view responses share one envelope: rows under data, pagination metadata under pagination, and view naming the projection behind the response (null for the default collection read).

declare const res: Api.applications.ListResponse; // Qb.Page<Api.applications.Row>

res.data;                  // Api.applications.Row[]
res.view;                  // string | null
res.pagination.count;      // rows in this page
res.pagination.hasMore;
res.pagination.nextCursor; // pass back as ?starting_after=
res.pagination.total;      // number | undefined — only when ?total=true

total and totalPages are counted only when the request passes ?total=true, so both are optional; every other member is always present. A view that declares aggregations intersects this shape with a typed aggregations object, and an aggregate-only view returns { view, aggregations } with no rows or pagination.

Declaring action outputs

Add an optional output Zod schema to make an action end-to-end typed. An action that returns a row of a resource table declares it with row(<table>) from the feature's helper — its Output is that table's Row, and OpenAPI references the table's component:

import { defineAction, row, applications } from "../.quickback/define-action";

// Output = Api.applications.Row
output: row(applications)

// Nested works too: Output = { application: Api.applications.Row; created: boolean }
output: z.object({ application: row(applications), created: z.boolean() })

The table can also come from its own table module, a sibling's, or another feature's — row(payments) from payments/payments types as Api.payments.Row and references the Payments component. See Declaring action outputs.

For any other shape, write the schema out:

quickback/features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Advance an application",
  input: z.object({
    nextStatus: z.enum(["screening", "interview", "offer"]),
  }),
  output: z.object({
    id: z.string(),
    status: z.enum(["screening", "interview", "offer"]),
  }),
  access: { roles: ["recruiter+"] },
  async execute({ record, input }) {
    return { id: record.id, status: input.nextStatus };
  },
});

The generated feature helper checks execute against the declared output at compile time, and the generated route parses the result through it at runtime (output.parse(result)) before responding. The declared type is therefore the wire truth, not an annotation: keys the schema does not declare are stripped, and a result that does not satisfy the schema is a 500 rather than a silently different payload. This holds for allowRawSql actions too — the raw query is unchecked, but what leaves the action is not. Actions with responseType: 'stream' or 'file' return their result untouched.

The CLI harvests both schemas locally without invoking execute; the resulting contract feeds quickback.gen.ts, OpenAPI, and api-types.gen.ts.

Types are not transport

quickback.gen.ts is import type only: it describes the API, it does not call it. A hand-rolled fetch still returns any, and nothing checks that the URL you typed exists. For a client whose route table is the contract, use api-types.gen.ts with openapi-fetch: paths, methods, parameters, and response bodies are checked at the call site, and both artifacts come from the same compiler model, so they cannot drift from each other.

import createClient from "openapi-fetch";
import type { paths } from "../api-types.gen";

const api = createClient<paths>({ baseUrl: "https://api.example.com" });
const { data } = await api.GET("/api/v1/jobs/{id}", { params: { path: { id } } });

Fetch client

Set client: true and every compile also emits quickback.client.ts: a zero-dependency fetch client with one method per generated route, typed through quickback.gen.ts. It has no React and no runtime package, and it is the only generated code that knows the wire format (list envelopes, filter syntax, Problem Details), so UI code built on it never builds a URL or parses a response.

export default defineConfig({
  client: true,                              // quickback.client.ts
  // client: "apps/web/src/lib/api.ts",      // or one custom path
  // client: ["apps/web/src/api.ts", "apps/admin/src/api.ts"],
});

client requires types — the client imports Api and Qb from the first types output, and the compile fails if types is false. It is off by default.

A CRUD verb mapped to an action (create: { action: "send" }) keeps its method name but takes that action's contract: create(body: Api.messages.actions.send.Input) returning actions.send.Output, not Insert / Row. The action gets no second method.

TanStack Start denies `*.client.*` on the server

TanStack Start's import protection rejects any **/*.client.* module in the server environment, which includes the SPA prerender, so importing the default quickback.client.ts fails that build with Import denied in server environment. Give the client a name outside that pattern: client: "src/lib/quickback-client.ts".

import { createClient, QuickbackError } from "./quickback.client";

const api = createClient({
  baseUrl: "https://api.example.com",   // paths already include /api/v1
  // credentials: "include" by default — the Better Auth session cookie
  // headers: () => ({ authorization: `Bearer ${token}` }),
});

// GET /api/v1/applications?stage.in=screen,offer&sort=-createdAt&total=true
const page = await api.applications.list({
  filter: { "stage.in": ["screen", "offer"] },
  sort: "-createdAt",
  total: true,
});
page.data;              // Api.applications.Row[]
page.pagination.total;  // present because total: true

const app = await api.applications.get(id);          // bare record
await api.applications.update(id, { notes: "…" });   // bare record
await api.applications.delete(id);                   // void ({ success: true } on the wire)
await api.applications.views.pipeline({ limit: 25 });
await api.applications.actions.advance(id, { notes: "Strong interview" });

try {
  await api.applications.create({ candidateId, jobId });
} catch (err) {
  if (err instanceof QuickbackError) {
    err.status;    // HTTP status
    err.problem;   // Qb.Problem when the body was application/problem+json, else null
    err.fields;    // { [field]: message } from details.fields — guards and validation alike — else null
  }
}

Batch, upsert and pagination

Where the batch routes exist, api.<x>.batch sends the documented batch bodies; 207 partial success resolves (read errors and meta.transactional), a fail-fast stop throws. upsert(id, body) is PUT /:id and exists only on tables that mount it (guards: false + generateId: false).

const result = await api.applications.batch.create(records, { failFast: true });
result.success;   // Api.applications.Row[]
result.errors;    // [{ index, record, error: { code, details: { fields } } }]
await api.applications.batch.update([{ id, stage: "offer" }]);
await api.applications.batch.delete([id1, id2]);

// Every page, following pagination.nextCursor (?starting_after=). When the
// cursor is null — sorting by a column masked for you — it pages by offset.
import { paginate } from "./quickback.client";
for await (const page of paginate(api.applications.list, { limit: 100 })) {
  page.data;
}

Bearer lane (React Native, native clients)

By default the client authenticates with the Better Auth session cookie (credentials: "include"). React Native and other non-browser clients send no Origin header, so every cookie-authenticated write is refused with 403 CSRF_ORIGIN_REJECTED — the CSRF guard only admits cookie mutations from a trusted origin. Use the bearer lane instead:

import * as SecureStore from "expo-secure-store";

const api = createClient({
  baseUrl: "https://api.example.com",
  auth: "bearer",                 // no cookies; credentials default to "omit"
  sessionStore: {                 // default: memory, lost on reload
    get: () => SecureStore.getItemAsync("qb.session"),
    set: (token) => (token
      ? SecureStore.setItemAsync("qb.session", token)
      : SecureStore.deleteItemAsync("qb.session")),
  },
});

// Sign in through the client: the session token arrives in set-auth-token
// on /auth/v1/* and is saved to sessionStore.
await api.request("POST", "/auth/v1/sign-in/email", { body: { email, password } });
await api.applications.list();

Every request carries Authorization: Bearer <token>:

  1. the JWT the API returned last in set-auth-token (kept in memory — with auth.jwt every session-authenticated call returns one), else
  2. the session token from sessionStore.

set-auth-token means two things by path: under /auth/v1 it is Better Auth's session token (saved to sessionStore); anywhere else it is a short-lived JWT (cached in memory). When the API answers 401 to a JWT — expired, key rotated, revoked — the client drops it and retries the request once on the session token; the session path re-validates and returns a fresh JWT. A 401 on the session token itself is final. An authorization header you pass in headers wins over the lane.

Realtime

With realtime enabled, api.realtime.subscribe(handler, { ticket? }) opens the WebSocket for you: it mints a ws-ticket through the same auth lane (a fresh one for every reconnect — tickets live 60 seconds), shares one socket between subscribers with the same ticket body, reconnects two seconds after a drop, and closes when the last subscriber unsubscribes. A ticket refused with 401, 403 or 429 (a session mid-refresh, a rate limit) is retried with backoff from two seconds up to thirty; any other 4xx is final. Frames are CloudEvents envelopes — switch on qbframe.

const unsubscribe = api.realtime.subscribe((frame) => {
  if (frame.qbframe === "postgres_changes") refetch(frame.data.table);
});

The client mirrors the type tree: api.<x> exists wherever Api.<x> is a table with routes, and a method exists only if its route does — a table without delete access has no delete. Record actions take the record id first; standalone actions with path parameters take a params object; GET actions send their input as query parameters. Actions declared responseType: 'stream' or 'file' resolve to the raw Response. List-shaped methods accept limit, offset, sort, search, total, fields, include, startingAfter / endingBefore and filter — see Query Parameters; array filter values are comma-joined for .in, and an unknown filter field is a 400, not an ignored key. Changeset (application/vnd.quickback.changeset+json) writes are not in the client; send them with api.request.

Runtime extras for generic UI and native clients:

  • resources — keyed by schema-registry table name (the key of SchemaRegistry.tables, the table's JS name), so UI holding a registry table can reach its routes without building a URL: { client, table, ops, views, actions, recordActions, routes }. client is the dotted path on the client ("applications", "crm.contacts"); recordActions is the subset of actions that take the record id; routes maps list / get / create / update / delete / upsert, batchCreate / batchUpdate / batchDelete / batchUpsert, views.<name> and actions.<name> to { method, path } (path templates with {id}) for clients that build requests themselves (Swift, Kotlin).
  • api.schema() — GET /api/v1/schema, typed as SchemaRegistry (see Schema Registry). The registry is fetched, never embedded: it describes columns, roles and masking, so it stays behind that route's auth gate instead of shipping in every frontend bundle. The gate defaults to platform sysadmins; open it to your frontend's users with schemaRegistry.access.
  • api.request(method, path, { query, body }) — any other route (Better Auth, changesets, custom paths) through the same auth lane and error handling.

The emitted file is plain TypeScript that passes strict, erasableSyntaxOnly, verbatimModuleSyntax and noUnused* — it drops into a Vite / shadcn template as-is.

Configuration

The artifact is on by default:

export default defineConfig({
  // Default: quickback.gen.ts
  types: true,
});

Use a custom path, fan out identical copies to colocated apps, disable branding, or turn the artifact off. Paths resolve inside the project root: .. segments and absolute paths fail the compile (Path traversal is not allowed). A sibling package consumes the file through a path alias ("@api/*": ["../api/client/*"] in its tsconfig.json), not by emitting into it:

export default defineConfig({
  types: {
    output: [
      "quickback.gen.ts",
      "apps/web/src/lib/quickback.gen.ts",
    ],
    brandIds: true,
  },
});
types valueBehavior
true or omittedWrite quickback.gen.ts
falseDo not emit the first-party type tree
"src/lib/quickback.gen.ts"Write to one custom path
["quickback.gen.ts", "apps/web/src/lib/quickback.gen.ts"]Fan out identical copies inside the project
{ output, brandIds: false }Configure paths and use structural IDs

api-types.gen.ts remains available through openapi.types for openapi-fetch and third-party OpenAPI tools. The two artifacts are independent: disabling OpenAPI types does not disable the first-party tree.

The compiler also emits reports/types.parity.gen.ts when both artifacts exist. Quickback's output-matrix CI compiles that proof separately to require mutual assignability between direct table/action types and the OpenAPI wire types; it is outside the generated application's src/**/* build.

On this page