Multi-dialect strategy
How the planner + applier behave across Postgres, MySQL, MariaDB, MSSQL, SQLite — the atomicity matrix, MySQL's implicit-commit landmine + re-diff-to-recover, SQLite's table-rewrite mechanic, plus the per-dialect DDL idioms the framework hides.
The planner produces ONE MigrationPlan regardless of dialect. The applier executes it per-dialect, dispatching through sql.onDialectOrElse for every emit + falling back to runtime probes when behaviour diverges. The same voltro db apply invocation against the same schema produces structurally identical results on every backend.
What ISN'T uniform: transactional DDL semantics.
| Dialect | Transactional DDL | Advisory-lock mechanism | Backfill in tx with DDL |
|---|---|---|---|
| Postgres | ✓ all DDL atomic | pg_advisory_lock(KEY) |
✓ |
| MSSQL | ✓ all DDL atomic | sp_getapplock |
✓ |
| SQLite | ✓ all DDL atomic | process-local mutex | ✓ |
| MySQL / MariaDB | ✗ implicit commit per DDL | GET_LOCK('voltro_migration', N) |
✓ but SEPARATE from DDL |
This is the operationally heaviest cross-dialect difference. The rest of the page covers what changes.
Postgres / MSSQL / SQLite — transactional happy path
A multi-step plan runs inside one BEGIN ... COMMIT. Mid-flight failure rolls EVERYTHING back; the next plan diff is identical to the pre-apply one. There's nothing to resume — re-running the apply re-runs the plan from scratch.
The advisory-lock variants serialise concurrent applies — two operators running voltro db apply against the same DB at the same time go through serially.
MySQL / MariaDB — re-diff-to-recover territory
Every DDL statement implicitly commits. A 5-op plan on MySQL is effectively 5 separate "atomic statements" with the prior ones already committed when a later one fails. If op 5 of 5 fails, ops 1–4 stay applied:
plan applying (mysql, env=prod):
1. create-table audit_logs ✓ 42ms
2. add-column users.email ✓ 18ms
3. backfill users.email ✓ 12s
4. alter-column users.email SET NOT NULL ✓ 8ms
5. add-index audit_logs(actorId) ✗ ER_DUP_KEYNAMEThere is NO --resume / --abort flag and NO per-op partial-status row — the applier records a _voltro_migration_plans row only on a fully successful apply. Recovery is to re-run the apply once the cause is fixed:
voltro db applyvoltro db apply re-introspects the live DB and diffs the declared schema against the (half-applied) live shape, so it emits ONLY the ops still missing — ops 1–4 are already in the DB and don't reappear in the diff. If someone finished op 5 out of band (psql, a corrective hot-fix), the re-diff sees it as present and skips it too. If the failed op is no longer the right answer because the schema was edited in response, the next voltro db plan already reflects the new declared shape — nothing to abort.
SQLite — table rewrite mechanic
SQLite lacks ALTER COLUMN. For type changes, the applier auto-emits the standard pattern:
CREATE TABLE <name>_new (<new column definitions>)INSERT INTO <name>_new SELECT (with cast) FROM <name>DROP TABLE <name>ALTER TABLE <name>_new RENAME TO <name>- Recreate every index + every FK the old table had
The plan output flags this as "rewrite table" so you know what's happening:
⊕ ALTER TABLE users ALTER COLUMN status TYPE varchar(20) # rewrite table (SQLite has no ALTER COLUMN)
→ recreates 3 indexes, 2 incoming FKsCaveats:
- Foreign keys referencing the rewritten table get dropped and recreated. If they declared
ON DELETE CASCADE, the recreate restores it; ordering matters internally. - Multi-rewrite plans on SQLite are brittle. A plan that rewrites 3 related tables in one apply may have intermediate states where a FK temporarily references a non-existent table. The framework orders them topologically; mixed rename + rewrite within one plan can hit edge cases. If a SQLite multi-rewrite refuses, file-based migrations let you control ordering manually.
Per-dialect DDL idioms hidden from you
The framework emits the right dialect-native idiom for every concept. You don't write these by hand:
| Concept | Postgres | MySQL / MariaDB | MSSQL | SQLite |
|---|---|---|---|---|
| Identifier quoting | "name" |
`name` |
[name] |
"name" |
CREATE TABLE IF NOT EXISTS |
native | native | IF NOT EXISTS (SELECT * FROM sys.tables...) EXEC(...) |
native |
| Auto-increment id | BIGSERIAL |
BIGINT AUTO_INCREMENT |
BIGINT IDENTITY(1,1) |
INTEGER PRIMARY KEY AUTOINCREMENT |
| Booleans | native boolean |
tinyint(1) (0/1) |
bit (0/1) |
integer (0/1) |
| JSON column | jsonb |
json |
nvarchar(max) |
text |
| Timestamp with tz | timestamptz |
datetime(6) |
datetime2 |
datetime |
now() default |
now() |
CURRENT_TIMESTAMP(6) |
SYSUTCDATETIME() |
current_timestamp |
RETURNING * on INSERT |
native | NOT available (separate SELECT) | OUTPUT INSERTED.* |
native |
LIMIT N OFFSET M |
native | native | OFFSET M ROWS FETCH NEXT N ROWS ONLY |
native |
| FK with cascade | native | native | native | native (must PRAGMA foreign_keys = ON) |
The DDL emitter under @voltro/database/src/migrate.ts is one of the densest cross-dialect dispatch files in the codebase. Bug reports for "X doesn't work on dialect Y" usually trace to a missing branch there.
Boot-log shape per dialect
voltro dev prints a one-line dialect summary during the auto-migrate phase:
[voltro:dev] auto-migrate: planning schema dialect=postgres env=dev tables=22
[voltro:dev] auto-migrate: applied 3 op(s) in 412ms [safe=3 needs-default=0 needs-backfill=0 rename=0 lossy=0] fingerprint=8f507ba1e1aadad5For MySQL the line notes the non-atomic-DDL constraint (a failed op leaves earlier ops committed; re-run apply to finish):
[voltro:dev] auto-migrate: planning schema dialect=mysql env=dev tables=22 (implicit-commit DDL — re-run apply after a mid-plan failure)For SQLite the line notes the single-process-only constraint:
[voltro:dev] auto-migrate: planning schema dialect=sqlite env=dev tables=22 (single-process — no concurrent appliers possible)Replication caveats during apply
If read-replicas are configured (DB_REPLICA_URLS set), the applier ALWAYS targets the primary. Replicas catch up via their normal replication stream. There's a window after apply where the replica fingerprint differs from primary — visible in voltro db drift if it's run against the replica URL during that window.
For multi-region deploys, time the apply against primary's region + accept the inter-region replication lag as the propagation time. Drift is measured against the primary fingerprint — the canonical schema authority that the applier always targets; replicas converge to it through their replication stream, so a transient post-apply mismatch on a replica is replication lag, not drift. To check a specific replica during that window, run voltro db drift against its URL.
When the dialect rejects something the planner emitted
This shouldn't happen — the framework's per-dialect DDL emitter is the test surface for every code path. If you see a dialect-side error during an apply that looks like the framework emitted invalid SQL:
- Capture the failing SQL from
voltro logs --trace <plan-id>. - File an issue with: the schema diff, the dialect + version, the exact error message.
- Workaround: drop down to a file-based migration with hand-written DDL for the affected op.
The framework can't auto-fix every dialect's pathological cases (MariaDB's RETURNING gap on UPDATE, MySQL's strict-mode rejection of TEXT defaults, MSSQL's optimizer quirks with FILTER + RAISERROR). Where the test suite has caught those, the emitter has the right branch. Where it hasn't, file the report — the matrix grows from real failures.