Quickback Docs

Troubleshooting

Common issues and solutions when working with the Quickback compiler and generated APIs.

Common issues and their solutions. For cloud compiler specific issues, see Cloud Compiler Troubleshooting.

Compilation Issues

"Feature must use defineTable()"

Your table file is missing the default export:

// Wrong — no default export
export const applications = sqliteTable("applications", { /* columns */ });

// Correct — add defineTable default export
export const applications = sqliteTable("applications", { /* columns */ });
export default defineTable(applications, { /* firewall, guards, crud … */ });

Adding the default export also makes this a feature table, which means the managed audit columns must be declared in the columns object. A raw Drizzle table spells them out literally — see Schema for the canonical lines, or run quickback migrate visible-columns to write them for you.

"File exports multiple tables with defineTable"

A single file can export multiple Drizzle tables, but only ONE can have a defineTable() default export. Split tables into separate files:

quickback/features/applications/
├── applications.ts           # defineTable(applications, { ... })
└── candidates.ts             # defineTable(candidates, { ... })

Missing fields in generated API

If a field exists in your schema but doesn't appear in API responses:

  1. Guards: Check that the field is in createable (for POST) or updatable (for PATCH). Fields not listed are silently stripped.
  2. Views: If using views, the field must be in the view's fields array.
  3. Masking: The field may be present but masked (showing [REDACTED] or ***). Check your masking config.

Scope column not auto-detected

The firewall auto-detects the org column across six accepted spellings — organizationId, organisationId, orgId, organization, organisation, org — and ownerId as the owner column. If your column uses any other name, declare the firewall explicitly:

firewall: [
  { field: 'tenantId', equals: 'ctx.activeOrgId' },
]

Or use q.scope() and override the SQL name:

columns: {
  tenantId: q.scope('organization', { sqlName: 'tenant_id' }),
}

"userId" is not the owner column

ownerId is the owner column: the compiler firewalls reads with WHERE ownerId = ctx.userId and auto-stamps the column on insert. userId is not auto-detected on feature tables — it is a Better Auth convention on Better Auth's own tables (session, account, member, passkey), and on a feature table it usually means a user this row references (member, contact, invitee) rather than the row's owner. If userId is the only candidate column on the table the compiler raises an error; alongside a detected isolation column it raises a warning.

Two escapes: rename the column to ownerId, or declare it explicitly.

firewall: { owner: { column: "userId" } }

See Firewall for the full rule.

Migration Issues

Columns persist after removal from schema

Quickback (via Drizzle) does not generate DROP COLUMN migrations. If you remove a column from your schema, the database column will remain as an orphaned column. SQLite handles this gracefully — the column won't appear in API responses since Drizzle only selects defined columns.

To clean up, you'd need to manually create a migration.

Drizzle rename prompts in CI

Use the updated Quickback CLI for automatic SQLite create prompts. For an actual rename, add explicit rename hints in your config. If generation fails after the CLI answered a prompt, inspect the final Drizzle output in the error. See Drizzle rename prompts.

Cloudflare D1 destructive migrations

Quickback now adds a D1-specific safety pass around Drizzle-generated migrations for the cloudflare-d1 provider:

  1. Better Auth cleanup for retired managed tables is stripped instead of shipped as raw DROP TABLE SQL.
  2. Drop-only cleanup migrations are reordered child-first when foreign keys require it.
  3. All D1 migration directories generated by the compiler are replayed against SQLite during compile, so a D1-breaking migration fails before deploy.

If quickback build now fails with a D1 replay error, treat that as a real migration problem instead of a deploy-time surprise. The generated SQL needs manual intervention or a schema change that Drizzle can express more safely.

Postgres: column "…" cannot be cast automatically

On Postgres providers (neon, planetscale-postgres), Drizzle emits a column type change as a bare ALTER COLUMN … SET DATA TYPE, which Postgres refuses when no implicit cast exists — for example when the text audit columns of a pre-0.68 journal become timestamptz. In a newly generated migration, Quickback rewrites a change to a timestamp, date/time, numeric, boolean, uuid, or json/jsonb type as SET DATA TYPE <type> USING "<col>"::<type>, so existing rows convert in place. It drops the column's old default first when the same migration sets or drops it afterwards; a default is never removed otherwise.

Changes to length-limited types (varchar(n), char(n), bit(n)) and arrays stay bare: an explicit cast would silently truncate values the bare statement rejects. Add USING by hand if you need one there.

Existing migrations are never rewritten. If an earlier compiler generated a type-change migration that fails with this error, it never applied — npm run db:migrate runs pending migrations in one transaction, and a failure records nothing in drizzle.__drizzle_migrations. Fix it in one of two ways:

  • It is the newest migration: delete that .sql file, its meta/NNNN_snapshot.json, and its meta/_journal.json entry, then rebuild to regenerate it.

  • Later migrations follow it: edit the failing statement in place before applying it. Add USING "<col>"::<type> and, if the column has a default that cannot be cast, a DROP DEFAULT statement before it:

    ALTER TABLE "app"."events" ALTER COLUMN "created_at" DROP DEFAULT;
    --> statement-breakpoint
    ALTER TABLE "app"."events" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING "created_at"::timestamp with time zone;

    The drizzle migrator does not compare hashes of applied migrations, and a recompile hands the edited file back unchanged.

Recompile changed the comments at the top of an old migration

A recompile hands every existing migration back byte-for-byte, including the generated header comment in whatever wording it was first stamped with (quickback compile or quickback build). Earlier compilers did not:

  • a header stamped as quickback compile was restamped as quickback build;
  • the compiler's own comment banner directly under the header — on the Postgres RLS migration (NNNN_quickback_rls_*.sql) and the table-triggers migration (NNNN_quickback_triggers_*.sql) — was deleted, and a later recompile deleted the comment block after it too.

Only comments changed; no SQL statement was touched. What that means depends on how you deploy:

  • Browser deploys (Cloudflare connection). The deploy ledger hashes each applied migration, so the rewritten file refused the redeploy with migration "…" already ran against this database with different content. Nothing was applied. Redeploy — Start keeps the bytes that were actually applied, and the compiler now returns them unchanged.
  • quickback deploy, wrangler d1 migrations apply, npm run db:migrate. These record migrations by name or timestamp, not content, so nothing was refused and the database is unaffected. Leave the file as it is on disk: whatever shape it has now stays stable across recompiles. If you want the original comments back, restore the file from git — it is a comment-only change.

Runtime Issues

401 on all requests

  1. Missing auth: Ensure your request includes Authorization: Bearer <token> or the session cookie
  2. Expired session: Sessions last 7 days by default. Re-authenticate.
  3. Wrong auth URL: Check that BETTER_AUTH_URL matches your deployment URL

403 Forbidden

  1. Wrong role: Check the access config for the endpoint — your role may not be listed
  2. Wrong org: You may be in a different organization than the record's organizationId
  3. Firewall miss: A firewall miss returns 403 by default (firewallErrorMode: 'reveal'). Set firewallErrorMode: 'hide' on the resource to return an opaque 404 instead, so cross-tenant probes can't confirm a row exists.

400 Bad Request on create/update

  1. Guard violation: You're sending a field not in createable or updatable. Check the error's details.fields.
  2. Missing required field: A .notNull() column without a default is missing from your request body.
  3. Immutable field: You're trying to update a field marked as immutable.

Records missing from list

Records may be filtered by:

  1. Firewall: Only records matching your organization (and optionally owner) are returned
  2. Soft delete: Soft-deleted records are automatically excluded
  3. Access conditions: Record-level conditions may filter results

Recovering a previous src/ tree

The CLI archives the previous src/ tree to quickback/.archives/src-<ISO-timestamp>/ before each compile (most recent 3 retained). If a generator regression silently dropped a file you depended on, restore it from the latest archive:

ls quickback/.archives/
# src-2026-04-27T16-31-02-014/   ← latest
# src-2026-04-27T16-29-44-882/
# src-2026-04-27T16-12-19-301/

# Restore the whole tree (back-out a bad compile):
rm -rf src
cp -R quickback/.archives/src-2026-04-27T16-31-02-014 src

# …or grep for what disappeared:
grep -r "missingExportName" quickback/.archives/src-2026-04-27T16-31-02-014/

Anything in src/ not produced by the latest compile is removed on every compile — see CLI → Compile Definitions for the full contract.

Data Issues

Every compile that has something to check emits reports/data-remediation.sql in the project root. It is read-only as emitted: every statement is a SELECT, the repair UPDATEs are commented out, and neither the compiler nor the CLI ever runs it. Run it deliberately, read the results, then decide.

On D1, pass the file to --command so the results print (run it from the project root; DB is the features database binding — the report's header names yours):

npx wrangler d1 execute DB --remote --command "$(cat reports/data-remediation.sql)"
# the wrangler dev database instead:
npx wrangler d1 execute DB --local --command "$(cat reports/data-remediation.sql)"

Don't use --file with --remote here: that path is D1's bulk import, which takes the database offline while it runs and prints no result rows. On Postgres: psql "$DATABASE_URL" -f reports/data-remediation.sql.

Timestamps read 1970-01-01 (SQLite / D1)

From compiler v0.33.2 (2026-05-21) until the fix, a Drizzle insert on SQLite / D1 that omitted a timestamp column persisted 1970-01-01T00:00:00.000Z instead of the insert time. The column carries that constant as its DDL default so ALTER TABLE ADD COLUMN works, and Drizzle preferred it over the column's $defaultFn. Affected:

  • Every .defaultNow() column (q or raw Drizzle), on every insert that omitted it — including ordinary POST / batch / upsert requests.
  • createdAt / modifiedAt, only on inserts that did not go through the request db — queue and cron handlers' db, createDb() handles, and your own Drizzle instances. Requests and actions stamp both explicitly, so their rows are correct.

Postgres is not affected: .defaultNow() there is a real DEFAULT now().

Recompiling fixes new writes. Existing rows keep the epoch — the real times were never recorded, so nothing can recover them. The report counts sentinel rows per column. Where the table also has a trustworthy created_at, it carries a commented repair like:

-- UPDATE "applications" SET "applied_at" = "created_at" WHERE "applied_at" = '1970-01-01T00:00:00.000Z' AND "created_at" <> '1970-01-01T00:00:00.000Z';

The repair is an approximation: it writes the row's creation time, not the value the column should have held. That is right for "created" or "applied" style columns written at insert, and wrong for anything that means something else. Run a repair only for columns where that trade is acceptable. Take a backup first (npx wrangler d1 export <features-database-name> --remote --output backup.sql — the database_name of the DB binding in wrangler.toml), then run the one statement, uncommented, with --command. created_at itself has no proxy on the row; the report says so instead of offering a repair.

Raw SQL still reaches the DDL default. If you insert with db.run(sql`INSERT …`), set the timestamp columns yourself.

Stored javascript: / data: URLs

Until the fix, q.url() accepted any scheme the WHATWG URL parser does, including javascript: and data:. A UI that renders such a value as a link is a stored-XSS hole. q.url() now accepts only absolute http(s) URLs. The bundled CMS never renders url values as links, and the Account consent page links only http(s) values.

For each q.url() column, the report lists the rows whose value is not http(s). There is no automatic repair. Fix, clear, or delete each row yourself. Until you have, make sure your own UI only links http(s) values — for example, check new URL(value).protocol.

See Also

On this page