Quickback Docs

Escape Hatches

When feature() can't represent your file — the four shapes the sugar refuses, and what to write instead.

feature() is the canonical way to author a table, and it is provably lossless: expandFeatureSugar is a source-to-source rewrite that runs before the schema is compiled to Drizzle. It turns

feature(name, { columns, indexes, unique, ...rest })

into q.table(name, columns, { indexes, unique }) plus defineTable(name, rest), passing every other key through verbatim. Both forms produce byte-identical output.

defineTable() is not deprecated. It is the layer underneath, and it is what you reach for in the cases the sugar cannot express.

The four shapes the sugar refuses

The rewriter is deliberately conservative: when it does not recognise the shape it returns your source unchanged rather than guessing. Three of the four cases below are silent, which is why they are worth knowing by name.

CaseWhat happensHow you find out
A top-level config prop is a spread, shorthand, or computed keySilent no-op — the feature(...) call reaches the parser intactA downstream parse error with no mention of feature()
The table name is not a valid JS identifierThrows, with an explicit "split into the two-export form" hintThe error names the table and suggests a rename
The name argument is not a string literalSilent no-opDownstream parse error
columns is missing entirelySilent no-opDownstream parse error

Case 1 is why this page exists

A spread in the top-level config is the one that costs real time:

// features/jobs/jobs.ts — DOES NOT WORK
import { feature, q } from "@quickback/compiler";

const sharedFirewall = { organization: true };

export default feature("jobs", {
  columns: { /* ... */ },
  ...sharedFirewall,          // ← spread in top-level config: rewriter bails
  read: { access: { roles: ["admin"] } },
});

The rewriter walks the config object's properties and bails the moment one is not a plain name: value assignment. Nothing warns you. The feature(...) call is handed to the parser as-is and you get a parse error further downstream with no connection to its cause.

Dropping to defineTable() does not rescue a spread. The resource-config parser reads the config as a static object literal, so a spread fails there too — just with a different message:

Failed to parse defineTable() resource config for feature "jobs" table "jobs.ts".
Ensure the source exports a static Quickback definition without dynamic expressions.

The top-level config of a table must be written out literally, in either form. Share configuration by repeating the keys, not by spreading a constant into them.

Write the keys out:

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

export default feature("jobs", {
  columns: {
    id:             q.id(),
    title:          q.text().required(),
    organizationId: q.scope("organization"),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewall: { organization: true },
  read:   { access: { roles: ["owner", "admin"] } },
  create: { access: { roles: ["owner", "admin"] } },
  update: { access: { roles: ["owner", "admin"] } },
  delete: { access: { roles: ["owner", "admin"] } },
});

Spreads inside columns are fine. ...q.audit() and ...q.softDelete() are not affected by any of this — the spread-bail only inspects top-level config props, and columns is taken as raw text and handed to the q-DSL lowering untouched.

Case 2 — the name argument is two things at once

The first argument to feature() is both the SQL table name and the emitted JS identifier. A hyphen is legal in SQL and illegal in JavaScript, so the rewriter throws rather than emitting export const vendor-checkins = ...:

feature("vendor-checkins", …): table name must be a valid JS identifier
(letters, digits, _ or $; not starting with a digit). Got "vendor-checkins".
Rename the table — e.g. "vendor_checkins" — or split into the two-export form
(export const X = q.table(…); export default defineTable(X, …)).

The convention that follows: folder kebab, table argument identifier. A vendor-checkins/ feature directory holds feature("vendorCheckins", …).

Raw Drizzle is a third thing, not a worse defineTable

A table file that exports a Drizzle table and no defineTable default export is an internal child table. It is not a downgrade of a resource — it is a different kind of object:

  • No CRUD routes, no security pillars — nothing to attach them to
  • Still migrated, still in the schema registry
  • Still receives the audit columns and the parent feature's scope column
  • Warns on every compile unless the file carries a // @quickback-internal marker

// @quickback-internal suppresses the warning. It does not exempt the table from the managed-column rules. Every table in a feature is validated, and a child table has no resource config to say otherwise — so it needs the audit quartet and the soft-delete pair, declared literally in Drizzle form. ...q.audit() is q-DSL only; it is never expanded inside sqliteTable({...}).

// features/interviews/interview_scores.ts
// @quickback-internal — child table, written from the `complete` action only.
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const interviewScores = sqliteTable("interview_scores", {
  id: text("id").primaryKey(),
  interviewId: text("interview_id").notNull(),
  scorerId: text("scorer_id").notNull(),
  rating: integer("rating").notNull(),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text("created_by"),
  modifiedBy: text("modified_by"),
  deletedAt: text("deleted_at"),
  deletedBy: text("deleted_by"),
});

Postgres targets must use pgTable

Raw interop tables skip the q-to-Drizzle normalization, so the mechanical SQLite-to-Postgres rewrite is not applied to them. A sqliteTable in a project whose database provider is Postgres is a hard compile error, not a warning — the alternative is subtly-broken output (ISO-string audit columns where the pg triggers stamp timestamptz, SQLite-only column options leaking into the pg schema).

Reference implementations, both ports of the same table:

  • examples/recruitment/quickback/features/interviews/interview_scores.ts (SQLite)
  • examples/recruitment-neon/quickback/features/interviews/interview_scores.ts (Postgres)

Postgres uses timestamp("…", { withTimezone: true }).notNull().defaultNow() for the timestamps, .$onUpdate(() => new Date()) on modifiedAt, plain text(…) for the actor columns, and timestamp("deleted_at", { withTimezone: true }) for deletedAt.

Attaching pillars to a raw Drizzle table

If you have an existing Drizzle table and you do want CRUD routes and security on it, that is exactly what defineTable() is for — it is the only form that can take a table object it did not create:

// features/jobs/jobs.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const jobs = sqliteTable("jobs", { /* your existing columns + managed columns */ });

export default defineTable(jobs, {
  firewall: { organization: true },
  read:     { access: { roles: ["owner", "admin"] } },
});

Which form to use

You are…Use
Writing a new featurefeature()
Naming a table something that is not a JS identifierq.table() + defineTable()
Needing another file to import the table identifierq.table() + defineTable()
Bringing an existing Drizzle schema forwardsqliteTable()/pgTable() + defineTable()
Adding a junction, lookup, or pivot table with no APIraw sqliteTable()/pgTable(), // @quickback-internal, managed columns declared literally

See Database Schema for the full column reference and feature() for the configuration surface.

On this page