Drift detection
How the framework detects schema drift (live DB ≠ last applied fingerprint), what causes drift, and how to reconcile it — with the planner's introspection, or via corrective plan, or by accepting + re-baselining.
Drift = the live database's schema doesn't match the fingerprint of the last applied voltro db apply plan. It's a passive detection — the framework only knows about drift after introspecting + comparing fingerprints. The detection itself is cheap (one COUNT + one fingerprint compare per check); the reconciliation path depends on cause.
How drift gets detected
Three trigger paths:
- Manual —
voltro db driftruns the check on demand, exits 4 on drift - On boot (dev) — every
voltro devboot runs the planner, which detects drift implicitly (the plan will be non-empty) - Periodic (cloud) — the cloud dashboard polls each app's
/_voltro/inspect/migrationsendpoint; drift state is in the response
All three paths produce the same DriftSnapshot shape:
{
isDrifted: boolean,
liveFingerprint: string, // current introspected user-schema fingerprint
lastAppliedFingerprint?: string, // newest _voltro_migration_plans row's fp
lastAppliedAt?: string, // when it was applied
lastAppliedId?: string, // plan id
}isDrifted: false ↔ liveFingerprint === lastAppliedFingerprint.
User-schema only: the fingerprint excludes _voltro_* framework tables. Bookkeeping tables grow rows + change row counts as the app runs; including them in the fingerprint would make every boot look drifted.
Common causes
Manual DDL
Someone ran ALTER TABLE ... or CREATE INDEX ... via psql / DataGrip / Adminer instead of the framework. The live DB has changes the planner's history doesn't reflect.
$ voltro db drift
db drift: live schema DIVERGED from last applied state
last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00 (plan_mig_5k78)
live now: a8f2c9d10b3f4e62
Probable causes:
- manual DDL ran out-of-band (psql session, another tool)
- someone applied a plan but the row never made it into _voltro_migration_plans
To reconcile, run `voltro db plan` to see what your code expects vs the live DB.Out-of-band auto-applier
Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through _voltro_migration_plans.
Truncated history table
_voltro_migration_plans was truncated, restored from a backup, or the DB was restored to a point-in-time before the latest applies. The live schema is post-apply but the table doesn't know it.
Replica fingerprinted instead of primary
The drift detector ran against a read-replica that's lagging. Wait for the replica to catch up + re-check. (The framework's drift detector targets primary by default; this only bites when the user explicitly points the check at a replica URL.)
Reconciliation paths
Path 1 — adopt the live state by declaring it in TS
When the live DB IS what you want (the manual DDL is correct, only
bypassing the planner was sloppy), bring the declared schema up to the
live shape: edit the *.entity.ts files so they describe exactly what
the live DB now has. The next voltro db plan then diffs empty, and a
voltro db apply records a fresh _voltro_migration_plans row at the
new fingerprint — re-baselining history without any DDL.
voltro db plan # confirm the diff is now empty (declared == live)
voltro db apply --note 'accepting manual DDL from 2026-06-15 — see ticket #789'voltro db apply with an empty plan writes no DDL; it just locks in
the current fingerprint with your note. The history shows it:
plan_mig_5k79 fp=a8f2c9d1 env=dev src=auto-diff 0 op(s) 12ms ... by=alice
[note: accepting manual DDL from 2026-06-15 — see ticket #789]There is no metadata-only --reconcile flag — re-baselining always
goes through the declare-then-apply loop, so the TS schema stays the
single source of truth.
Path 2 — corrective plan against drift
When the live DB has accumulated cruft + the declared schema is what you want:
voltro db plan # see the diff between code + live
voltro db apply # execute the diff, removing the driftThe plan diff will show the corrective ops:
schema diff: 2 operations, 0 blocked
✗ DROP INDEX manual_idx_we_forgot_to_remove # safe (no FK depends on it)
⊕ ALTER TABLE users ADD COLUMN missing_field text # backfill: sql`'default'`
fingerprint: a8f2c9d10b3f4e62 → 8f507ba1e1aadad5Apply lands the corrections + the new fingerprint matches the declared schema.
Path 3 — declared schema needs updates
The live DB has a column the declared schema doesn't reference, and you WANT to keep that column in the schema. Update the schema TS file to add it:
// users.entity.ts
export const users = table('users', {
id: id(),
email: text(),
extra_field: text().nullable(), // add to declared
})Now the live shape matches the declared shape after the next plan (which will be empty). The drift "fixed itself" through code changes.
Drift on prod
Production drift is the most important to catch quickly because it suggests an unauthorised change to the production DB. The cloud dashboard's drift detector runs every 5 minutes against each customer's prod app + surfaces the divergence as soon as it appears.
Surfaces:
- Dashboard banner on the affected app's Migrations tab
- Audit log row tagged
drift.detected
Out-of-band channels (Slack, email, PagerDuty) are intentionally NOT in the framework. Subscribe to the audit log via your existing observability stack — every drift event is a row your SIEM / monitoring already consumes, and your team's incident process kicks in from there.
The org's incident response process kicks in from there. Common immediate actions:
- Check the audit log for any non-CI DB access
- Run
voltro db driftagainst a snapshot to confirm the divergence (the check is a structural fingerprint compare — it tells you THAT the schema diverged, not which rows changed) - Decide: corrective plan or re-baseline?
What the dashboard shows
The Migrations tab's drift banner renders when isDrifted: true:
⚠ Schema drift detected
last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00
live now: a8f2c9d10b3f4e62
→ Run `voltro db plan` to see what your code expects vs the live DB.Click → expands to a comparison view showing the introspected live shape + the declared shape, highlighting the divergent tables. (Cloud dashboard only; local devtools shows just the banner without the comparison view.)
What about replicas?
Each replica has its own catch-up state. The framework's drift detector compares against PRIMARY by default; replicas catch up via the normal replication stream + reach the same fingerprint within their lag window.
If you specifically want to monitor replica drift (rare; mostly relevant during major maintenance windows), the cloud dashboard's Settings page allows enabling "Replica drift monitoring" which polls each replica URL separately + alerts on lag > N minutes.
Drift after rollback
voltro db rollback itself records a new row in _voltro_migration_plans, so the fingerprint of that row matches the post-rollback state. No drift gets reported as a side-effect of rollback.
If something else changed the live DB between the original apply + the rollback, that drift was already present + the rollback doesn't surface it differently. Run voltro db drift after rollback to confirm reconciliation if you're suspicious.
Detecting drift is the easy part
The hardest part of drift response is figuring out what changed + who did it. The framework can tell you that the fingerprints differ + show the structural diff. It can't tell you who ran the DDL or why. Pair the framework's drift detector with:
- Database audit logs (Postgres
pgaudit, MySQL audit plugin, MSSQL Audit, SQLite no-op) - Network access logs (who reached the DB during the drift window)
- Application logs filtered by trace id (if the drift happened during a request, trace shows the caller)
- The team's normal incident response (Slack channel for accidental changes, post-mortem cadence)
TL;DR
Detect: voltro db drift
Fix code: voltro db apply (apply corrective plan from current diff)
Adopt DB: edit the *.entity.ts to match live, then voltro db apply (empty plan re-baselines)
Backup: if data was lost, restore from your DB backup system — the framework can't help