Troubleshooting

The most common "why is my voltro dev refusing to boot?" cases with copy-paste fixes. Each pattern maps a planner error message to the schema annotation that resolves it.

Every refuse-to-boot from the planner includes a structured fix hint. This page is the comprehensive catalog of those hints + the schema edit each one wants.

"NOT NULL column on a table whose row count is unknown"

Full error:

auto-migrate: REFUSED — 1 blocked operation(s):
  - add-column [users]: NOT NULL column on a table whose row count is unknown
    fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive the migration

What happened: you added a non-nullable column to a populated table without telling the planner how to populate it for existing rows.

Fix — pick ONE:

// Option A — constant default (planner emits ADD COLUMN ... NOT NULL DEFAULT value)
plan: text().default('free'),

// Option B — SQL expression (planner emits ADD nullable → UPDATE → SET NOT NULL)
email: text().backfill(sql`'unknown-' || id || '@local'`),

// Option C — JS function (slower, only when SQL can't express what you need)
embedding: text().backfill(async (row) => embed(row.title)),

// Option D — make it nullable
bio: text().nullable(),

Decision rubric in backfill.md.

"column missing from declared schema" (DROP COLUMN refused)

Full error:

✗ ALTER TABLE users DROP COLUMN legacyField
  ! fix: if intentional, add `legacyField: dropped()` to the schema. If a typo, restore the field

What happened: the live DB has a column the declared schema doesn't reference. Could be deliberate (you want to drop it) or accidental (someone deleted the field from the schema by mistake).

Fix — pick ONE:

// Option A — declare intent. Column goes away on next apply.
export const users = table('users', {
  id:           id(),
  legacyField:  dropped(),   // ← explicit. Planner allows the drop.
})

// Option B — typo, restore the field.
export const users = table('users', {
  id:           id(),
  legacyField:  text(),
})

// Option C — use VOLTRO_DESTRUCTIVE_OK for one-off applies (loud warning):
// VOLTRO_DESTRUCTIVE_OK=1 voltro db apply

"table missing from declared schema" (DROP TABLE refused)

✗ DROP TABLE oldUsersTable
  ! fix: if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration. If a typo, restore the table declaration

Fix:

// Option A — restore the table declaration (probably the right answer if surprised)
export const oldUsersTable = table('oldUsersTable', { id: id(), ... })

// Option B — file-based migration that moves data out + then drops.
// The file context is { sql, log, appliedAt } — no store; use sql:
// migrations/20260415_retire_old_users.ts
export default migration({
  id:   '20260415_retire_old_users',
  description: 'Move oldUsersTable rows into users, then drop it.',
  up:   async ({ sql }) => {
    await sql.unsafe(`INSERT INTO users (id, ...) SELECT id, ... FROM "oldUsersTable"`)
    await sql.unsafe(`DROP TABLE "oldUsersTable"`)
  },
  down: async ({ sql }) => { /* recreate + restore as far as possible */ },
})

// Option C — one-off destructive apply:
// VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring oldUsersTable per ticket #...'

Delete the entity and drop the table in the SAME change. The intuitive order — remove the code first, sort the schema out after — is the broken one: with VOLTRO_AUTO_MIGRATE=1 the very next boot sees an undeclared table, refuses, and the app crashloops until the drop is authorised. There is nothing to recover from, but the app is down while you work it out. Take the entity out together with the VOLTRO_DESTRUCTIVE_OK apply that removes its table, or leave the entity in place until you are ready to run both.

Three tables this never proposes dropping:

  • actors — the framework-provided audit subject. You don't declare an actors.entity.ts; db plan / db apply auto-include the built-in actors in the declared set, so it's never a DROP candidate. (Declare your own actors with extra columns and that takes precedence.)

  • Your own UNMANAGED infra tables — a table you keep outside the Voltro schema (a migration id-map, a legacy audit table). List them in VOLTRO_DB_IGNORE_TABLES (comma-separated) and the diff leaves them alone instead of planning a DROP:

    VOLTRO_DB_IGNORE_TABLES=_strapi_id_map,_legacy_audit voltro db apply

    The same env var is honoured by the voltro dev boot auto-migrate, not just the db plan / db apply CLI — set it in the app's environment and the boot diff leaves the listed tables alone too, so a fresh clone with a Strapi→Voltro _strapi_id_map sitting in the DB won't refuse-to-boot on a drop-table. (The framework already self-excludes its own _voltro_* / cluster_* runtime tables; this is the user list on top of that.)

  • Soft-drop snapshots — a <name>__dropped_<ts> left behind by VOLTRO_SOFT_DROP=1. The differ treats it as framework-managed until voltro db gc-snapshots reclaims it, so do NOT add one to VOLTRO_DB_IGNORE_TABLES.

A migrate / apply DDL statement failed — find which one

When voltro migrate / db apply hits a DDL error, the CLI names the failing statement plus the driver's fields, not just a stack:

═══ migrate: statement failed ═══
statement: CREATE INDEX "users_orgId_idx" ON "users" ("orgId")
db.message: column "orgId" does not exist
db.code: 42703

A column … does not exist on a CREATE INDEX usually means the column was never added to an EXISTING table: voltro migrate (auto-migrate) is CREATE TABLE IF NOT EXISTS — it does NOT ADD COLUMN to a table that already exists. To evolve an existing table's columns, use the declarative path (voltro db plandb apply), which orders ADD COLUMN before the index. Set VOLTRO_MIGRATE_DEBUG=1 to trace every statement as it executes.

"users.givenName looks like a new required column on a populated table"

Full error:

✗ ALTER TABLE users ADD COLUMN givenName text  # NOT NULL column on a table whose row count is unknown

What probably happened: you renamed firstNamegivenName in the schema, but didn't tell the planner it's a rename. The planner sees firstName gone + givenName new + classifies each separately.

Fix:

export const users = table('users', {
  id:        id(),
  givenName: text().renamedFrom('firstName'),
})

The planner folds the diff into one ALTER TABLE users RENAME COLUMN firstName TO givenName, classified safe. After the rename is applied in every env, the marker can be removed (covered in rename-and-drop.md).

"the migration did not converge" (apply refuses to record a fingerprint)

applyPlan: the migration did not converge. 31 operation(s) were executed without
error, but re-planning against the live schema still finds 31:
  - alter-column-default todos.attachments
  …
No fingerprint was recorded — recording one would make the next boot report
"schema up to date" for a schema that was never applied.

Every statement ran and the database accepted every one of them, and none of them changed anything. That is possible because DDL that changes nothing succeeds exactly as quietly as DDL that works — ALTER COLUMN x TYPE text on a column that is already text is a valid, successful no-op.

This message exists because the alternative is worse. Before the convergence check, such a plan reported applied 31 op(s), recorded a fingerprint, and every later boot short-circuited on "schema up to date" — for a schema that had never been applied. One app ran that way for two releases. The apply now proves it worked before it records anything: the same planner, run against the database as it now is, must have nothing left to do.

It is a framework bug, not a mistake in your schema. The named operations emit DDL that does not take effect. Report the operation kinds plus the column types involved.

Read the named operations before you trust the "no-op" wording, though — the message states a CAUSE, and a cause can be wrong. One release told users drop-table <name>__dropped_<ts> was a no-op when the DDL had worked perfectly: VOLTRO_SOFT_DROP=1 had renamed the table, and the planner then read its own snapshot as one more undeclared table and proposed dropping it again. Fixed, and worth knowing as the shape to look for: an operation naming an object that the PREVIOUS operation created or renamed is a planner blind spot, not dead DDL. In the meantime the schema is unchanged and safe — nothing was half-applied, and no fingerprint was written, so voltro db plan still shows you the truth.

If you need to move forward before a fix lands, apply the equivalent DDL by hand and re-run voltro db plan to confirm it converges.

"Schema fingerprint mismatch" (prod refuse)

Full error:

[voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
  declared = a8f2c9d10b3f4e62
  live     = 8f507ba1e1aadad5
Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
exit 1

What happened: the production runtime checked its declared schema's fingerprint against the latest _voltro_migration_plans.fingerprint and they don't match. The framework refuses to start serving because it doesn't know what to do — auto-apply on prod isn't allowed (see prod-pipeline.md).

Fix:

  1. Preview the plan against the prod DB shape (NOT prod credentials — use a staging-replica snapshot):
DB_URL=<staging-snapshot-url> voltro db plan
  1. Review the printed plan in the PR.

  2. Run apply from the CI/CD step (a one-shot job with migration credentials and NODE_ENV unset / stagingvoltro db apply re-diffs live and refuses on NODE_ENV=production):

voltro db apply --note 'PR #1234'
  1. Re-deploy. The new boot's fingerprint check passes.

--plan IS a real flag, and it is the better answer here. This note used to say it was not, and told you to run a plain voltro db apply instead — which recomputes the diff and therefore applies something nobody reviewed. The claim came from a defect, not from the design: the app root was resolved as "the first argument that does not start with -", so --plan plan.json handed the plan FILE to schema discovery (no schema files found, root: …/plan.json) while --plan=plan.json worked. Both spellings work now.

Prefer the reviewed form in a pipeline:

voltro db plan --json > plan.json          # review this in the PR
voltro db apply --plan plan.json           # apply exactly it, fingerprint-guarded

A plain voltro db apply stays correct for a developer machine, where the diff you would review is the one you just wrote.

If the cause is drift (someone DDL'd prod manually), see drift.md for reconciliation paths.

If the two processes are the same image: compare their env: blocks

A fingerprint mismatch does not always mean the schema changed. The declared table set is what gets hashed, and until 0.35.0 three RUNTIME flags could move it — so a pre-deploy migrate Job and the pods it feeds could disagree while running identical code against one database.

That was reported as a green migrate job followed by every pod in CrashLoopBackOff. The chart gave the Job its own env: list (NODE_ENV, DB_*) while CDC: "0" lived in the pods' block, because change data capture reads as a runtime concern. Measured on mariadb at NODE_ENV=production, each flag flipped alone:

flag effect on the DECLARED set
CDC=0 removes _voltro_cdc_offsets
VOLTRO_UNDO=on adds _voltro_undo_log
VOLTRO_TRACING_PERSIST=all adds _voltro_traces

From 0.35.0 none of them does. _voltro_cdc_offsets follows the DIALECT, so a mariadb/mssql app declares it either way; the other two are declared in app.config.ts and the env vars only choose what a process captures.

And from 0.48.0 NODE_ENV does not either. Until then, _voltro_traces and _voltro_undo_log defaulted to on outside production — safe for the fingerprint, which only ever compares processes inside ONE deployment, and unsafe for the reader that spans two. voltro data is that reader: a bundle exported from a development database carries the tables that database has, and a staged (non-destructive) replace needs every bundle table to exist in the target. One source tree therefore produced a bundle a production target could not stage, and the run fell back to truncating it — with nothing red anywhere.

Both are declared in every environment now. An unused declared table is an empty table; a schema that differs per environment is a class of failure. Declare them only to leave one OUT:

export default {
  // Declared in EVERY environment by default. Set `false` to keep a table out —
  // and then set it in every environment, or two deployments of one source tree
  // declare different schemas again. `VOLTRO_UNDO` / `VOLTRO_TRACING_PERSIST`
  // still decide what a process WRITES; they no longer decide what exists.
  schema: { undo: false, traces: false },
}

Setting a capture flag ON without the declaration now refuses the boot and names the field, rather than writing to a table nobody created.

The refusal also prints which of the three tables this process decided and from which input, so the comparison is a glance rather than a hash diff. Do compare the two env: blocks anyway if you use plugins: a plugin's extendSchema.tables is your code and may read anything, which is the one part the framework cannot guarantee for you.

"duplicate column name in plan" (logically invalid plan)

db apply: refusing — plan contains 2 ops targeting users.email (CREATE + DROP)
This usually means the planner couldn't determine the correct order.
Hand-edit the plan JSON or use a file-based migration to express the intent explicitly.

What happened: the diff produced both an ADD and DROP for the same column → ambiguous intent.

Fix: it's almost always a schema edit ordering problem. Either:

  • The schema was edited twice + both edits are in the diff (rebase the PR; squash the two commits)
  • A column was renamed + another column was added with the same name (use .renamedFrom() on the second)
  • A file-based migration is racing the planner (sequence them differently — file before planner)

"VOLTRO_DESTRUCTIVE_OK relaxes lossy ops only; plan also contains rename-without-marker"

auto-migrate: REFUSED — 2 blocked operation(s):
  - drop-column [users.legacy]: lossy
    fix: if intentional, add `legacy: dropped()` to the schema
  - rename-column [users.givenName from firstName]: rename without marker
    fix: declare `.renamedFrom('firstName')` on the new column

VOLTRO_DESTRUCTIVE_OK=1 was set but at least one blocked op is NOT lossy.
The flag only relaxes lossy ops; other refuse cases (rename, NOT-NULL-no-backfill, multi-step) stay firm.

What happened: you reached for VOLTRO_DESTRUCTIVE_OK=1 to bypass a refuse, but the plan has a non-lossy refuse too. The flag is intentionally narrow.

Fix: address the non-lossy refuse first (add the rename annotation in the example above). Then the flag relaxes the remaining lossy op.

"live introspection failed; cannot diff"

db plan: live introspection failed
  cause: Connection refused at localhost:5432

What happened: the framework can't reach the DB. The planner needs a live introspection to compute the diff.

Fix:

  • Check the DB is running (docker ps, systemctl status postgres)
  • Check the connection URL: echo $DB_URL matches what the DB expects
  • Check credentials: psql $DB_URL -c 'SELECT 1' should succeed
  • If using cloud, check the inspectToken: curl -H "Authorization: Bearer $TOKEN" "$APP_URL/_voltro/inspect/app" should return JSON

"advisory lock held; refusing to wait"

db apply: refusing — advisory lock 8732891 is held by another process (pid 4892)
This usually means another `voltro db apply` is running. Wait for it to finish or kill the holder.

Fix:

  • If a real apply is running elsewhere, wait
  • If the holder is stuck (pid 4892 died without releasing):
    • Postgres: SELECT pg_advisory_unlock(8732891); (run as the same user that acquired)
    • Or kill the postgres backend: SELECT pg_terminate_backend(<pid>)
    • For MySQL: SELECT RELEASE_LOCK('voltro_migration') from the same connection (different connection won't release)

The lock is per-database-cluster, not per-deploy. Two prod regions hitting the same DB cluster race; the second blocks until the first releases.

voltro dev boot hangs at "auto-migrate: planning schema" (0/1, no error)

The boot-time auto-migrate takes the same migration advisory lock as db apply. If a prior boot crashed while holding it (its DB connection still open) or a sibling pod holds it, the boot would otherwise wait on the lock — the pod sits at auto-migrate: planning schema, readiness never flips, and no error line prints.

The boot now fails fast instead of hanging: it polls the lock to a deadline (default 30s) and then aborts with a clear message rather than blocking forever.

could not acquire the postgres migration advisory lock within 30s.
Another migration is in progress, or a prior boot crashed while holding it.

Fix:

  • A crashed process's session-level advisory lock is released the moment its DB connection closes — so a truly dead holder frees the lock on its own; just restart.
  • If a live-but-stuck backend holds it, find + terminate it: SELECT pid, query FROM pg_stat_activity WHERE query LIKE '%advisory%'SELECT pg_terminate_backend(<pid>).
  • Long, legitimate migrations on a big schema can outlast 30s — raise the ceiling with VOLTRO_MIGRATION_LOCK_TIMEOUT_MS (milliseconds).

Related: if the boot instead REFUSES with a drop-table blocker for a table you want to keep (a _strapi_id_map-style leftover), that's the "table missing from declared schema" case — VOLTRO_DB_IGNORE_TABLES unfreezes it.

Variant: it hangs even with the lock free (large / FK-dense schema)

Same symptom, different cause. If nothing else holds the lock and the boot still sits at auto-migrate: planning schema, the schema introspection is the bottleneck — the step that reads the live database shape before diffing. It only runs on a real diff (a no-diff boot skips it via the schema fingerprint), which is why adding a single column can trigger it while an unchanged restart boots fine.

The cause is almost always a large, foreign-key-dense schema (hundreds of tables, thousands of FKs). Introspection reads foreign keys and primary keys directly from pg_catalog (index-backed, filter pushed down) rather than the information_schema constraint views — those can't push the per-batch table filter down, so each batch re-scans the whole catalog. On a 500-table / 2600-FK schema that is the difference between > 2 minutes (hangs) and well under a second.

If introspection ever degenerates again it fails fast instead of hanging: every introspection statement runs under a statement_timeout (default 30s), so a runaway query aborts with an actionable error rather than freezing the pod at 0/1.

schema introspection exceeded VOLTRO_INTROSPECT_TIMEOUT_MS (30000ms) — the schema is
very large / FK-dense or the database is slow.

Fix:

  • Raise the ceiling for a legitimately huge schema with VOLTRO_INTROSPECT_TIMEOUT_MS (milliseconds; 0 disables it entirely).
  • Prefer a direct (non-pooler) connection for migrations via DB_DIRECT_URL — so a large introspection response isn't mis-framed by a transaction-mode pooler.
  • VOLTRO_DB_IGNORE_TABLES does not help here — it filters the diff, which runs after introspection; the introspection cost is independent of it.

A MySQL/MariaDB apply failed midway

voltro db apply on postgres / mssql is ATOMIC: every op runs in ONE transaction, so a failure on op N rolls the WHOLE plan back — nothing is committed, no half-applied schema. (online-required CREATE INDEX CONCURRENTLY ops run after the commit — they can't be in a transaction — so a failure THERE can leave the index half-built; re-apply finishes it.)

Run migrations through a SESSION connection, not a transaction-mode pooler. Because the whole plan is one transaction, a large apply (many ops + big backfill UPDATEs + index builds) is ONE long-lived transaction. A transaction-mode pooler (Supabase Supavisor on :6543, PgBouncer in transaction mode) can't hold a multi-statement transaction reliably and will abort it — surfacing as an opaque Failed to execute statement (at sql.transaction). Point DB_URL at the direct / session connection (:5432, or a session-mode pooler) for db apply; raise statement_timeout for that session if a single index build is slow. This is the same constraint every migration tool has (Prisma/Drizzle/etc.) — the transaction pooler is for app traffic, the direct connection is for migrations. The failing statement itself is now logged with its SQL + db.code (e.g. 57014 statement timeout) so you can see which op stalled.

MySQL and MariaDB (and sqlite / turso) implicit-commit every DDL statement, so THERE a plan that fails on op N leaves ops 1..N-1 committed. The _voltro_migration_plans row is still written only on full success — that row means "this schema is live". What the apply DOES write as it goes is a per-operation resume ledger (_voltro_migration_ops): every op is recorded before any DDL runs, flipped to started before its statement and applied after, and the rows are deleted once the apply converges. There is still no --resume / --abort flag because there is nothing to choose.

Recovery is just to re-run the apply. It finds the ledger, logs migration resume: found an interrupted run …, reconciles any half-finished shadow-column swap or table rebuild, skips the ops that already took effect, and replays the rest — including a .backfill() that was only partly done, which a plain re-diff cannot express. Fix the cause of the failed op first (e.g. the ER_DUP_KEYNAME that stopped op N), then:

voltro db apply --note 'completing partial apply after fixing op N'

If a deploy reverted the code that referenced the half-applied schema, the re-diff naturally reflects the new declared shape — no separate abort step is needed; the next voltro db plan already shows the correct remaining work. See multi-dialect.

relation "..._uq" already exists (42P07) on re-apply

A composite (multi-column) .unique([a, b]) constraint that ALREADY exists in the DB is now introspected (postgres reads it back from pg_constraint), so db apply matches it against the declared schema and emits nothing. On older builds it wasn't read back, so the planner re-emitted ADD CONSTRAINT … UNIQUE for the existing one → 42P07 relation "<name>_uq" already exists, and the plan never reached "up to date". If you see this, update the framework. (Single-column .unique() was never affected — it round-trips via the column's unique flag.)

db plan keeps showing CREATE INDEX for indexes that already exist

If db plan always lists add-index for expressionIndex(...) / jsonIndex(...) indexes that demonstrably exist in the DB — and you never see a matching drop-index — that's an introspection gap (now fixed). On postgres an expression key carries a 0 in pg_index.indkey (it has no backing column), and the old introspect query INNER-joined pg_attribute on the column → the whole index disappeared from the live snapshot. The declared index then had nothing to match → re-emitted every run, but never converged to "up to date". (CREATE INDEX … IF NOT EXISTS made each re-emit a silent no-op, so it wasn't data-destructive — just a plan that never went empty.) The fix introspects expression indexes (with a NULL column + an expression flag) and matches them by NAME + uniqueness, since the DB normalises the expression text ((lower("email"))lower(email)) and it can't round-trip byte-for-byte. Plain-column indexes were never affected. If you see this, update the framework.

Second cause — the same table name in two schemas. If the phantom add-index is for PLAIN-column indexes (often camelCase like "<table>_tenantId_idx") and your DB has the SAME table names in more than one schema — classically a public legacy/migration copy alongside the app's own schema (e.g. voltro) — that was a separate introspection bug (now fixed). The table-list query joined pg_class by NAME, so a name present in both schemas fanned out to two rows → the table was listed twice → its columns and index-columns were accumulated twice in the BUILT snapshot (["tenantId"] became ["tenantId","tenantId"]) → the planner diffed ["tenantId"] != ["tenantId","tenantId"] and re-emitted forever. The raw pg_* catalog looks correct (the duplication is in introspect's built output, not the SQL) — to confirm it's THIS, call the inspect endpoint and look for doubled columns: curl "$API/_voltro/inspect/migrations" | jq '.drift.liveSnapshot.tables[] | select(.name=="<table>") | .indexes'. The fix scopes the table list to current_schema() by OID (+ defensive dedup), so each table is read once. Point DB_SCHEMA / the connection's search_path at your app schema and update the framework.

duplicate index name '<name>' across tables '<a>' and '<b>'

Index names are unique per schema, not per table, in every dialect. If you gave the SAME explicit name to indexes on two different tables (.index('byStatusStart', …) on both ab_tests and tournaments), boot / db plan now fails loud with this error instead of silently creating only one and re-emitting the rest forever. Fix: rename the collisions to distinct, table-scoped names (abTestsByStatusStart, tournamentsByStatusStart). Auto-named indexes (.index([col])<table>_<col>_idx) are table-prefixed and never collide — only hand-picked names can. (If you're updating from an older build that let these through, expect this error on first boot for every pre-existing collision — rename each one it names.)

auto-named index '<table>_<col>_idx' … exceeds the 63-byte … limit

The framework derives an FK auto-index name from the table + column name (<table>_<col>_idx). On a long junction table that can exceed 63 bytes — and the DB silently truncates index names (postgres → 63 bytes, dropping the _idx suffix), so the declared name (…_idx) never matches the live (truncated) one and db plan re-emits it forever. The explicit-index path was always length-validated; this closes the gap for the auto path — it now hard-fails at boot (same policy as every other identifier: no silent truncation). Two fixes, your choice:

  • Add an explicit short name for that FK column — .index('<short>', ['<col>']) — which replaces the auto-index, OR
  • Shorten the table / column name.

(Updating from an older build that truncated these? Expect the error on first boot for each one — apply one of the two fixes per index it names.)

db plan re-emits alter-column-default for a json().default({…}) column

A json() column with an OBJECT default (json<T>().default({ a: 1 })) had two problems on postgres (both now fixed): (1) the default was silently dropped — the DDL emitter only handled scalar defaults, so the column got no default at all (an omitted field inserted NULL, not the object); and (2) even once present, the comparison didn't match — postgres stores a jsonb default in canonical text ('{"a": 1}'::jsonb: spaces after :/, and keys reordered by length/bytes), which never equals the declared JS object's JSON.stringify, so db plan re-emitted alter-column-default every run. The fix emits object defaults as '<json>'::jsonb AND canonicalises both sides (key-sorted, space-free) before comparing. Update the framework.

Cross-dialect: object literal defaults are now emitted to DDL on every dialect, in each one's json idiom — postgres '<json>'::jsonb, mysql/mariadb (CONVERT('<json>' USING utf8mb4)) (a literal default is rejected on a JSON column), mssql/sqlite a '<json>' string literal — and the comparison canonicalises each engine's introspected form (postgres reorders + spaces; mysql wraps in cast(…); mssql wraps in ('…')). Arrays are unaffected (an array default stays as-is — its json[] vs native array() column is ambiguous). If you need a per-insert dynamic value instead of a fixed literal, use a factory .default(() => ({ … })) (the store applies it at insert).

CAST(… AS JSON) is MySQL-only, and shipping it for both cost a part-applied migration. MariaDB has no JSON type — the column is LONGTEXT with CHECK (json_valid(...)) — so AS JSON is not a cast target it accepts, and json().default([]) died mid-plan with ERROR 1064 … near 'JSON))'. The obvious MariaDB spelling DEFAULT ('[]') then fails on MySQL with ERROR 1101 BLOB, TEXT, GEOMETRY or JSON column can't have a default value, because a parenthesised literal is still a literal there. CONVERT(… USING utf8mb4) is the one expression both accept; measured against MariaDB 11.8.8 and MySQL 8.4.10, and covered by an integration test that boots both. Reported against 0.30.2, fixed in 0.31.0.

MySQL db plan / db apply crashes: Cannot read properties of undefined (reading 'toLowerCase')

MySQL 8 returns information_schema result columns in UPPERCASE (DATA_TYPE, COLUMN_NAME, …) where MariaDB returns lowercase. The introspector read the lowercase fields, so on MySQL the type mapper got an undefined data type and the whole introspect (every db plan / db apply) crashed. Fixed — the introspector now lowercases each information_schema row's keys (no-op on MariaDB). If you hit this on MySQL, update the framework. (MariaDB was never affected, which is why it went unnoticed — the introspect tests run on MariaDB.)

"no schema files found"

db plan: no schema files found
  hint: looked for *.entity.ts / *.schema.ts / schema.ts
        root: /home/me/myproject/apps/api

What happened: the discovery walker didn't find any schema files under the project root.

Fix:

  • Check you're running the command from the right directory (pwd)
  • Check your entity files match the convention (apps/api/database/*.entity.ts)
  • Run from the project dir, or pass the path as a POSITIONAL arg (voltro db plan ./apps/api) — there is no --root flag; the CLI resolves the root from the first non-flag argument, defaulting to the current working directory

"N reactive table(s) have NO change trigger in the database" (postgres)

auto-migrate: 500 reactive table(s) have NO change trigger in the database —
ab_test_results, ab_test_variants, ab_tests, … (+492). Writes to them will not
reach another instance's subscribers; a single instance is unaffected, which is
why this stays invisible until you scale out. Run `voltro db apply` to install them.

On postgres, reactivity is carried by DDL: a per-table framework_changes_<table> trigger that NOTIFYs the CDC channel. The declared schema and the database can disagree about which tables have one.

Run voltro db apply. It converges the triggers as its own step, and it does so even when the schema diff is empty — the usual case here, because a missing trigger is not a shape difference and db plan will correctly report 0 operations:

$ voltro db apply
schema diff: 0 operations, 0 blocked
  (schema is up to date)
db apply: installing change triggers on 500 table(s)
db apply: change triggers converged (1501 statement(s))

db apply --plan converges them too, so the pre-deploy Job pattern needs no extra step.

Why a table ends up without one. The trigger DDL is emitted by the full-schema path — a fresh database — so any table that arrived while your app was already running, or during a release that installed none, has no trigger. A restored dump can do it too (triggers travel with a full dump, but not with a schema-only or --no-triggers one), as can a hand-run DROP TRIGGER during an incident.

Why it stays invisible. A single instance's own writes reach its own subscribers through the in-process path. The trigger is what carries a write to the other instances, so the symptom only appears when you scale out — subscriptions that quietly stop updating, with nothing in the logs.

The mirror case is reported the same way: a .nonReactive() table that still carries a trigger keeps paying REPLICA IDENTITY FULL and a NOTIFY on every write for a subscription nobody receives. db apply removes both.

Boot converges them too, on both boot paths. voltro dev and voltro serve run the same check-and-repair at startup, so a schema-only restore or a CDC=0CDC=1 flip no longer waits for someone to notice:

reactive triggers: converged at boot — installed 500, removed 0 (1501 statement(s))

Three things about it are worth knowing before you deploy a fleet:

  • It does not queue. The repair takes the migration advisory lock with pg_try_advisory_lock and SKIPS if anything holds it, so N replicas booting together produce one repairing and N-1 logging another instance … is converging it. A concurrent voltro db apply holds the same lock, so the two can never run each other's DDL. The lock is scoped to your configured DB_SCHEMA, so another instance really means an instance of YOUR deployment — a second app sharing the database in a different schema takes a different key and neither defers the other.

  • It never fails a boot. A check that cannot run warns and the process continues; reactivity may be degraded, and that is still better than a diagnostic taking the app down.

  • It is a tunable, reactiveTriggers in app.config.ts, default 'repair':

    export default defineApiApp({
      store: 'postgres',
      reactiveTriggers: 'report',   // 'repair' (default) · 'report' · 'off'
    })

    VOLTRO_REACTIVE_TRIGGERS overrides the field. VOLTRO_AUTO_MIGRATE=0 downgrades 'repair' to 'report' — that variable means "this boot issues no DDL", and it is deliberately not read as "and say nothing".

When the fix hint doesn't match reality

The fix hints come from the planner's classification logic — they should always be actionable. If you see one that doesn't make sense given your code:

  1. Check git: were there uncommitted schema changes you forgot about? git status
  2. Check the introspect output: curl <app-url>/_voltro/inspect/migrations | jq '.pending' → see the raw plan
  3. File an issue with the schema + the inspect JSON + the message

Hint mismatches are bugs in the planner's classification — they're rare but always worth reporting because they're typically reproducible.

Where to learn more