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.
| Case | What happens | How you find out |
|---|---|---|
| A top-level config prop is a spread, shorthand, or computed key | Silent no-op — the feature(...) call reaches the parser intact | A downstream parse error with no mention of feature() |
| The table name is not a valid JS identifier | Throws, with an explicit "split into the two-export form" hint | The error names the table and suggests a rename |
| The name argument is not a string literal | Silent no-op | Downstream parse error |
columns is missing entirely | Silent no-op | Downstream 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-internalmarker
// @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 feature | feature() |
| Naming a table something that is not a JS identifier | q.table() + defineTable() |
| Needing another file to import the table identifier | q.table() + defineTable() |
| Bringing an existing Drizzle schema forward | sqliteTable()/pgTable() + defineTable() |
| Adding a junction, lookup, or pivot table with no API | raw sqliteTable()/pgTable(), // @quickback-internal, managed columns declared literally |
See Database Schema for the full column reference and feature() for the configuration surface.