Quickback Docs

feature() — single-export sugar

One function call per feature file — declare the table and its security contract together. Equivalent to `q.table()` + `defineTable()` but half the boilerplate.

The q.table() + defineTable() pair has been co-equal with Drizzle since day one. It works, but it means two exports and two concepts for something most feature files treat as one unit: a table with a security contract.

feature() collapses both into a single default export.

Before

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

export const contacts = q.table("contacts", {
  id:             q.id(),
  organizationId: q.text().required().index(),
  address:        q.text().required().index(),
  name:           q.text().optional(),
});

export default defineTable(contacts, {
  firewall: { organization: {}, softDelete: {} },
  crud: {
    list:   { access: { roles: ["member+"] } },
    create: { access: { roles: ["member+"] } },
    update: { access: { roles: ["member+"] } },
    delete: { access: { roles: ["admin+"] }, mode: "soft" },
  },
  guards: { createable: ["address", "name"] },
});

export type Contact = typeof contacts.$infer;

After

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

export default feature("contacts", {
  columns: {
    id:             q.id(),
    organizationId: q.text().required().index(),
    address:        q.text().required().index(),
    name:           q.text().optional(),
  },
  firewall: { organization: {}, softDelete: {} },
  crud: {
    list:   { access: { roles: ["member+"] } },
    create: { access: { roles: ["member+"] } },
    update: { access: { roles: ["member+"] } },
    delete: { access: { roles: ["admin+"] }, mode: "soft" },
  },
  guards: { createable: ["address", "name"] },
});

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

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, CRUD 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 reference the table by default import. import contacts from "./contacts" gives you the same typed handle q.table("contacts", ...) would have returned, so defineActions(contacts, { … }) still works.
  • 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() },
  firewall: { organization: {} },
  // …
});

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.

Handlers — one q namespace for everything

Custom action handlers 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 handler code stays inside one DSL:

quickback/features/mail/handlers/inbound.ts
import { q } from "../../../lib/q";
import mailboxes from "../mailboxes";
import messages from "../messages";
import threads from "../threads";

export const execute = async ({ rawDb, input }) => {
  // Find the mailbox this email was addressed to
  const mailbox = await rawDb.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 rawDb.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 rawDb.insert(messages).values({
    id: crypto.randomUUID(),
    threadId: thread?.id,
    organizationId: mailbox.organizationId,
    subject: input.parsed.subject,
    createdAt: new Date().toISOString(),
  });

  return { ok: true };
};

The q in handlers 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 a handler — 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