Quickback Docs

feature() — the canonical form

One function call per feature file — declare the table and its security contract together. This is the way to write Quickback features.

feature() is the way to write a Quickback feature: one file, one default export, the table and its security contract together. It expands at compile time into q.table() + defineTable(), which stays available for the cases that need it — see When to use which.

Quick reference

One object. Everything a table exposes is a key in it, and every key is optional except columns.

feature("name", {
  columns:     {},  // q.* column helpers — the Drizzle schema

  // Security — the five pillars
  firewall:    {},  // which ROWS (auto-derived from organizationId / ownerId / teamId)
  read:        {},  // who may read + `views` — named column projections
  create:      {},  // who may write — `action: "send"` maps the URL to an action file
  update:      {},
  delete:      {},  // + `mode: "soft" | "hard"`
  guards:      {},  // which FIELDS a caller may set
  masking:     {},  // which VALUES come back redacted
  rateLimit:   {},  // per-operation caps

  // Endpoints beyond CRUD — declared here, not written as action files
  transitions: {},  // guarded state changes → POST /:id/{name}
  triggers:    {},  // before/after hooks on every write
  owns:        {},  // composition boundary → atomic parent+children writes
  realtime:    {},  // broadcast insert/update/delete
  embeddings:  {},  // vectors generated on write

  // Placement + presentation
  path:        "",  // route base (defaults to the feature name)
  namespace:   {},  // hoisted route prefix + admission gate
  displayColumn: "", defaultSort: {}, inputHints: {}, layouts: {}, references: {},
});
You wantKeyPage
Tenant isolation, role gates, field and value controlfirewall, read/create/update/delete, guards, maskingSecurity
A narrower shape for a broader audienceread.viewsViews
publish / approve / checkIn — a state changetransitionsTransitions
A live CRUD URL that needs custom executecreate.action / update.action / delete.actionActions
A side-effect or data rule on every writetriggersTriggers
Parent plus its children written atomicallyownsChangesets
Clients updated liverealtimeRealtime
Semantic search over the tableembeddingsEmbeddings
The CMS to render it welldisplayColumn, defaultSort, inputHints, layoutsCMS

The rest of this page is the form itself; One object, not just CRUD shows the non-CRUD keys filled in.

The form

quickback/features/contacts.ts
import { feature, q } from "@quickback/compiler";

export default feature("contacts", {
  columns: {
    id:             q.id(),
    organizationId: q.scope("organization"),   // firewall auto-derives from this
    address:        q.text().required().index(),
    name:           q.text().optional(),
    ...q.audit(),
    ...q.softDelete(),
  },
  // "+" role expansion requires auth.roleHierarchy in quickback.config.ts
  read: { access: { roles: ["member+"] } },
  create: { access: { roles: ["member+"] } },
  update: { access: { roles: ["member+"] } },
  delete: { access: { roles: ["admin+"] }, mode: "soft" },
  guards: { createable: ["address", "name"] },
});

Type inference comes with it — typeof import("./contacts").default.$infer gives you the Contact row type.

One object, not just CRUD

The example above declares security and CRUD because that is the floor. The same config object carries every other per-table capability — a live feed, a state machine, a narrower projection, a write hook. They are keys next to read: and guards:, not separate files or services, and each generates a real endpoint on the same firewall → access → guards → masking pipeline.

export default feature("episodes", {
  columns: { /* … */ },
  firewall: [{ field: "organizationId", equals: "ctx.activeOrgId" }],
  read: {
    access: { roles: ["member+"] },
    views: { public: { fields: ["id", "title"], access: { roles: ["member+"] } } },
  },
  guards:      { updatable: ["title"], protected: { isPublished: ["publish"] } },
  masking:     { submitterEmail: { type: "email", show: { roles: ["admin"] } } },
  transitions: { publish: { field: "isPublished", from: false, to: true, access: { roles: ["admin+"] } } },
  realtime:    { enabled: true, onInsert: true, onUpdate: true },
  embeddings:  { fields: ["title", "summary"] },
});
KeyGives youReference
read.viewsNamed column projections, each with its own route and access ruleViews
transitionsGuarded state changes generated as real POST /:id/{name} actions — guards, stamps, undo, cascadesTransitions
triggersbefore/after hooks on write, lowered to a real SQL trigger or an application handlerTriggers
realtimePer-table broadcasts on insert/update/delete, audience-scopedRealtime
ownsThe composition boundary that turns on atomic parent-plus-children writesChangesets
embeddingsVector embeddings generated on insert/updateEmbeddings
masking / rateLimit / namespaceField redaction, per-operation caps, a hoisted route prefix + gateMasking, Rate limit, Areas
displayColumn / defaultSort / inputHints / layouts / referencesHow the CMS renders the table — no runtime effectCMS

Reach for these before writing an action file: an action is code you maintain, these are declarations the compiler owns. See the full option list in Definitions Overview and the "before you write one" routing table at the top of Actions.

How it works

feature(name, config) is a compile-time sugar, not a runtime abstraction. When the compiler sees export default feature(...), it rewrites the source to the canonical two-export form before any parsing runs. Every downstream layer — dialect pass, audit injection, security contracts, generated routes, OpenAPI, MCP tools, RLS emission — runs on the same Drizzle source it always did. Zero runtime cost; zero semantic difference from q.table() + defineTable().

That means:

  • Both forms remain supported indefinitely. Mix them freely — some files feature(...), some files q.table() + defineTable(), some files Drizzle sqliteTable() + defineTable(). The compiler dispatches per file.
  • Action files get the table from the generated helper. Each action lives in its own file under actions/ and imports both defineAction and the feature's table from ../.quickback/define-action — the helper re-exports the table typed with the generated schema, so record and the audit columns are visible without casts.
  • Diagnostics are identical. Missing firewall → same warning. Protected-field wiring → same check. Nothing about feature() weakens the secure-by-default posture.

Aliases

q is canonical (matches Zod's z., Superstruct's s.). qb is exported as an alias if you'd rather self-document the namespace:

import { feature, qb } from "@quickback/compiler";

export default feature("contacts", {
  columns: { id: qb.id(), name: qb.text().required(), ...qb.audit(), ...qb.softDelete() },
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  // …
});

Same object, two names — pick one.

When to use which

PatternWhen
feature(name, {...})Default. One table, one security contract, one file.
q.table() + defineTable()Multiple tables per file (rare), or you want to reference the table identifier before the default export.
sqliteTable() / pgTable() + defineTable()Interop with existing Drizzle code / migrations.
q.table(...) alone (no defineTable)Internal join/lookup/pivot tables with no public API. Compiler warns so you confirm the opt-out is intentional.

Feature directory layout

Feature directories normally sit flat under features/. To group several features under one authz concept (a shared route prefix + shared relationships/roles/scopes), put them inside an area folder carrying _area.ts — see Feature areas. Reserved sub-directories (actions/, lib/, pages/) are never child features, and a subdirectory of a NON-area feature containing defineTable(...)/defineAction(...) fails the compile (nested features load only under an area).

A feature directory holds tables, one file per action, and optional shared helpers:

features/podcast/
├── podcast.ts                  ← feature() — the primary table
├── shows.ts                    ← feature() (sibling table)
├── episode-tags.ts             ← q.table only (junction, no routes)
├── actions/
│   ├── publish.ts              ← defineAction, binds to the primary table
│   ├── report.ts               ← defineAction, standalone (has `path:`)
│   └── shows/
│       └── archive.ts          ← defineAction, binds to shows.ts
└── lib/
    └── shared.ts               ← feature-local helpers, copied verbatim

Two things to know:

  • actions/ walks recursively, keyed by relative path. A flat actions/<name>.ts binds to the feature's primary table; actions/<table>/<name>.ts binds to the sibling table file <table>.ts. An action with a path: is standalone and binds to nothing.
  • lib/ rides along — feature-local helper modules get copied verbatim into the generated output. Put shared Zod schemas and helpers here and import them from each action file.

For everything action-related — record vs standalone, conflict rules, the generated defineAction helper — see Actions.

Composition vs reference — owns

A schema-level FK (references: / .references()) says "this row points at that row." It does not say who owns whom. .references() may only target another feature table in the same database — never Better Auth users / user / organization / member (those live in AUTH_DB). When a set of child rows has no lifecycle of its own — junction memberships, normalized phones/emails, line items — declare the composition with owns on the root table:

export default feature("projects", {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    organizationId: q.scope("organization"),
    ...q.audit(),
    ...q.softDelete(),
  },
  owns: {
    tasks:   { table: "tasks",          fk: "projectId", inherit: ["organizationId"] },
    members: { table: "projectMembers", fk: "projectId", refs: { personId: "people" }, inherit: ["organizationId"] },
  },
});

That single fact generates the whole aggregate write path — one atomic changeset request over the root plus its owned relations, every op paying the child's own firewall/access/guards. Keep plain references for pointers at independent entities (those go in the relation's refs, existence-checked per-tenant). Contract v2 only. Full reference: changesets — including the per-op routes: false knob for widening a child's access for changeset use without mounting its raw route.

Action execute — one q namespace for everything

Action bodies need to run queries against the database. Quickback's compiled projects expose the Drizzle query-builder helpers on the same q namespace you use for schema authoring, so execute stays inside one DSL:

quickback/features/mail/actions/inbound.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { q } from "../../../lib/q";
import { mailboxes } from "../mailboxes";
import { messages } from "../messages";
import { threads } from "../threads";

export default defineAction({
  description: "Ingest an inbound email into the right thread.",
  path: "/mail/inbound",
  input: z.object({ mailboxAddress: z.string(), parsed: z.any() }),
  access: { roles: ["owner"] },
  unsafe: { reason: "Inbound webhook resolves the mailbox before a tenant is known" },
  async execute({ unsafeDb, input }) {
    // Find the mailbox this email was addressed to
    const mailbox = await unsafeDb.query.mailboxes.findFirst({
      where: q.eq(mailboxes.address, input.mailboxAddress),
    });
    if (!mailbox) return { error: "unknown mailbox" };

    // Look up the thread by in-reply-to + organization scope
    const thread = input.parsed.inReplyTo
      ? await unsafeDb.query.threads.findFirst({
          where: q.and(
            q.eq(threads.organizationId, mailbox.organizationId),
            q.eq(threads.lastMessageId, input.parsed.inReplyTo),
          ),
        })
      : null;

    // Insert the new message
    await unsafeDb.insert(messages).values({
      id: crypto.randomUUID(),
      threadId: thread?.id,
      organizationId: mailbox.organizationId,
      subject: input.parsed.subject,
    });

    return { ok: true };
  },
});

The q in action bodies is a runtime re-export of Drizzle's query builder — q.eq, q.and, q.or, q.not, q.ne, q.gt, q.gte, q.lt, q.lte, q.like, q.ilike, q.inArray, q.notInArray, q.isNull, q.isNotNull, q.between, q.desc, q.asc, q.sql. Pure re-export, zero wrapper cost.

The compiler generates src/lib/q.ts in every project so the runtime q is always available. You never need to import { eq } from "drizzle-orm" in an action — though you can if you want (Quickback doesn't hide drizzle, it just offers a consistent surface that matches the schema DSL).

Why one namespace

Quickback is an opinionated wrapper with opinionated security. One DSL for schema (q.table, q.text, q.id), one DSL for queries (q.eq, q.and, q.gte), one opt-in for routes (feature() / defineTable). You never need to learn the names of the underlying tools to write a feature — the same way Next.js users don't need to know they're using webpack, or Astro users don't need to know they're using Vite.

On this page