# Escape Hatches

`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:

{/* doc-compile: skip — this fence is the broken shape the section names; it is supposed to fail. */}
```typescript
// 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:
>
> ```text
> 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:

```typescript
// 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 = ...`:

```text
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({...})`.


```typescript
// 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:

{/* doc-compile: skip — the columns are elided to a comment; the point is the defineTable(existingDrizzleTable, ...) call shape. */}
```typescript
// 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](/define/schema) for the full column reference and
[feature()](/define/feature) for the configuration surface.
