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 anactors.entity.ts;db plan/db applyauto-include the built-inactorsin the declared set, so it's never a DROP candidate. (Declare your ownactorswith 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 applyThe same env var is honoured by the
voltro devboot auto-migrate, not just thedb plan/db applyCLI — 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_mapsitting in the DB won't refuse-to-boot on adrop-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 byVOLTRO_SOFT_DROP=1. The differ treats it as framework-managed untilvoltro db gc-snapshotsreclaims it, so do NOT add one toVOLTRO_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 plan → db 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 firstName → givenName 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:
- Preview the plan against the prod DB shape (NOT prod credentials — use a staging-replica snapshot):
DB_URL=<staging-snapshot-url> voltro db planReview the printed plan in the PR.
Run apply from the CI/CD step (a one-shot job with migration credentials and
NODE_ENVunset /staging—voltro db applyre-diffs live and refuses onNODE_ENV=production):
voltro db apply --note 'PR #1234'- 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-guardedA 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_URLmatches 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 4892died 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)
- Postgres:
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;0disables 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_TABLESdoes 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 intransactionmode) can't hold a multi-statement transaction reliably and will abort it — surfacing as an opaqueFailed to execute statement (at sql.transaction). PointDB_URLat the direct / session connection (:5432, or a session-mode pooler) fordb apply; raisestatement_timeoutfor 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.57014statement 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 isLONGTEXTwithCHECK (json_valid(...))— soAS JSONis not a cast target it accepts, andjson().default([])died mid-plan withERROR 1064 … near 'JSON))'. The obvious MariaDB spellingDEFAULT ('[]')then fails on MySQL withERROR 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--rootflag; 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=0 → CDC=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_lockand SKIPS if anything holds it, so N replicas booting together produce one repairing and N-1 logginganother instance … is converging it. A concurrentvoltro db applyholds the same lock, so the two can never run each other's DDL. The lock is scoped to your configuredDB_SCHEMA, soanother instancereally 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,
reactiveTriggersinapp.config.ts, default'repair':export default defineApiApp({ store: 'postgres', reactiveTriggers: 'report', // 'repair' (default) · 'report' · 'off' })VOLTRO_REACTIVE_TRIGGERSoverrides the field.VOLTRO_AUTO_MIGRATE=0downgrades'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:
- Check git: were there uncommitted schema changes you forgot about?
git status - Check the introspect output:
curl <app-url>/_voltro/inspect/migrations | jq '.pending'→ see the raw plan - 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
- Operation classes — the seven classes + per-class examples
- Backfill — SQL vs JS + the dry-run pattern
- Rename and drop — the
.renamedFrom()+dropped()lifecycle - Drift — when the live DB diverged
- Multi-dialect strategy — why MySQL + forward-roll
- Prod pipeline — the deploy-step apply pattern