Migrate
Authenticated state-graph migrations — review exact SQL, publish immutable estate states, and apply them with a marker and ledger
When to Use Migrate
- Production deployments — the SQL you review is the SQL production executes
- Team collaboration — Git branches can diverge and merge without renaming files
- CI/CD pipelines —
check,status, andapplyhave stable--jsonoutput - Recoverable apply — interrupted work has one detectable state, not a guessed final status
push is a separate, history-free live sync. It never writes estate storage or the current-state marker. Use Push for local prototyping; use migrate when the change must be history.
What an Estate Is
One migration state is one graph node. It records a target schema snapshot and one authenticated transition from each parent. A linear state has one parent. A branch merge has one complete transition from every parent into the same target. Human names are labels only — they never decide order.
immutable estate descriptor
+
content-addressed schema snapshots and SQL blobs
+
immutable migration-state manifests
+
database current-state marker
+
append-only execution ledger
Production never evaluates migration TypeScript. It slices authenticated UTF-8 ranges from an authenticated SQL blob and binds the exact typed parameters sealed in each dispatch.
Workflow Overview
1. Change your VibORM models
2. Generate a new estate state (review the SQL blob)
3. Apply that state to the database
4. status / verify / log explain the marker and ledger
Composition Root
One migration client binds the database client to one optional estate storage.
There is no $migrations helper, migration manager, or
createFsStorageDriver.
# Default estate directory is ./migrations
npx viborm migrate generate --name add-users
npx viborm migrate apply
npx viborm migrate status --jsonimport { createMigrationClient, createFsStorageWriter } from "viborm/migrations";
import { client } from "./client";
const migrations = createMigrationClient(client, {
storage: createFsStorageWriter("./migrations"),
});
const generated = await migrations.generate({ name: "add-users" });
if (generated.outcome === "published") {
console.log(generated.stateId, generated.sql);
}
await migrations.apply();createMigrationClient is the only execution composition root. Its methods
reflect the supplied storage capability:
| Client construction | Available operations |
|---|---|
| No storage | push, log |
MigrationStorageReader |
The above plus check, list, show, graph, status, verify, apply, down, baseline, resolve |
MigrationStorageWriter |
Every operation, including generate and reset |
log reads the target database’s append-only ledger. push is history-free.
Neither operation reads estate storage.
Every viborm migrate subcommand accepts -d, --dir <dir> and --json.
There is no migration-wide --verbose or --force flag. The operation
sections below list the remaining command-specific flags.
Named Client and Result Types
The capability surfaces and operation results are exported for scripts and tooling that keep their types in a central module:
import type {
ApplyResult,
GenerateResult,
GraphResult,
LiveMigrations,
LogResult,
ReadableMigrations,
StatusResult,
WritableMigrations,
} from "viborm/migrations";
Every operation has a named result: GenerateResult, CheckResult,
ListResult, ShowResult, GraphResult, StatusResult, VerifyResult,
LogResult, ApplyResult, DownResult, BaselineResult, ResolveResult,
ResetResult, PushPreview, and PushApplyResult.
Generate a State
Generate compares the current models to the selected parent snapshot and publishes one new state. The first generate uses the virtual empty root. Later generates use the unique leaf, or every leaf when you are converging branches.
npx viborm migrate generate
npx viborm migrate generate --name add-users-table
npx viborm migrate generate --from empty
npx viborm migrate generate --dry-run --jsonconst result = await migrations.generate({
name: "add-users-table",
});
if (result.outcome === "published") {
console.log(result.stateId);
console.log(result.sql);
} else if (result.outcome === "noop") {
console.log("Schema already matches the selected parent");
}
const preview = await migrations.generate({ dryRun: true });
// preview.outcome === "preview" — nothing is publishedGenerate Options
| Option | CLI | API | Description |
|---|---|---|---|
| Name | --name <name> |
name: string |
Human label. Metadata only; identity is the state hash. |
| Parent | --from <stateId|empty> |
from: Sha256 | null |
Full parent state id, or null / empty for the virtual root. |
| Estate directory | --dir <dir> |
storage factory path | Estate root (default ./migrations). |
| Dry run | --dry-run |
dryRun: boolean |
Return the complete candidate without publishing. |
| Resolver | — | resolve |
Handle ambiguous structural changes. |
| Manual artifact | — | manualMigration |
Caller-owned transitions. No CLI flag. |
--from empty and from: null mean the virtual empty root. A second virtual-root transition is refused. Numeric file indexes do not exist.
Manual Transitions
Some work moves data, not just structure. VibORM refuses to invent that movement. You supply complete parent transitions before generation. The estate stores the final SQL and closed manifest — not a durable TypeScript migration language.
import { sql } from "viborm";
const currentStateId = (
await migrations.show({ name: "legacy-subject" })
).stateId;
await migrations.generate({
name: "subject-to-many",
manualMigration: {
transitions: [
{
from: currentStateId,
execution: "stepwise",
originChecks: [
{
kind: "trusted-read",
query: sql`SELECT NOT EXISTS (
SELECT 1 FROM "content"
WHERE "subject_type" <> 'content.post.v1'
)`,
equals: true,
},
],
up: [
sql`CREATE TABLE "content_subject_post" (
"contentId" text NOT NULL,
"postId" text NOT NULL,
PRIMARY KEY ("contentId", "postId")
);`,
sql`INSERT INTO "content_subject_post" ("contentId", "postId")
SELECT "id", "subject_id" FROM "content"
WHERE "subject_type" = 'content.post.v1';`,
],
rollback: {
kind: "manual",
execution: "stepwise",
sql: [sql`DROP TABLE "content_subject_post";`],
},
},
],
destinationChecks: [
{
kind: "trusted-read",
query: sql`SELECT NOT EXISTS (
SELECT 1 FROM "content" AS c
WHERE c."subject_type" = 'content.post.v1'
AND NOT EXISTS (
SELECT 1 FROM "content_subject_post" AS j
WHERE j."contentId" = c."id"
AND j."postId" = c."subject_id"
)
)`,
equals: true,
},
],
},
});
This example uses PostgreSQL quoting. Adapt the SQL and physical scalar types to the target dialect. Each check must return exactly one row and one boolean-like column. An origin check proves the pre-transition fact; a destination check proves the post-transition fact.
Rules, each a refusal rather than a warning:
- A state is wholly generated or wholly manual. Custom text is not spliced into generated DDL.
- Each
upvalue is one opaque provider dispatch — VibORM does not split it on semicolons. rollbackis{ kind: "manual", execution, sql }or{ kind: "irreversible", reason }. There is noautomaticinput arm.- Stepwise manual work without complete origin and destination checks is refused before dispatch.
- A requested
transactionalboundary on a provider that cannot honor it is refused before effects. - A blank irreversible reason is refused.
A data-only transition can create a new state while keeping the same schema snapshot. Equal snapshots never prove equal migration state.
Inspect the Graph
These commands do not apply SQL. check is offline. list, show, and graph read the estate.
npx viborm migrate check --json
npx viborm migrate list --json
npx viborm migrate show add-users --json
npx viborm migrate graph --jsonconst check = await migrations.check();
if (!check.ok) {
for (const finding of check.findings) {
console.error(finding.code, finding.message);
}
}
for (const state of await migrations.list()) {
console.log(state.stateId, state.name);
}
const shown = await migrations.show({ name: "add-users" });
console.log(shown.snapshotHash, shown.sqlHash);
console.log(shown.root, shown.leaf);
console.log(shown.incoming, shown.outgoing);
const graph = await migrations.graph();
console.log(graph.estateHash, graph.target);
console.log(graph.roots, graph.leaves);
console.log(graph.states, graph.edges);show and apply({ to }) accept a full state id, an unambiguous prefix, or an unambiguous name. Ambiguous names or prefixes refuse. Numeric indexes are not selectors.
list is the compact state-id/name view. show adds the snapshot and SQL
identities, root/leaf flags, and incoming/outgoing edge metadata. graph
returns the estate target, every state, and every edge. Edge metadata exposes
the parent and child ids, transition hash, requested execution boundary,
operation and step counts, origins, risks, and rollback kind. These are frozen
descriptions, not executable plans or mutable manifest objects.
Status, Verify, and Log
status, verify, and log are read-only. They never create control tables and never translate a provider failure into “no migrations applied.”
npx viborm migrate status --json
npx viborm migrate verify --json
npx viborm migrate log --limit 20 --jsonconst status = await migrations.status();
// status.control === "absent" | "present"
// status.marker — last confirmed state, or null
// status.pending — unique-leaf path from the marker, when one leaf exists
// status.unfinished — an attempt is still open on the ledger
const verify = await migrations.verify();
const events = await migrations.log();| Command | Lock | What it answers |
|---|---|---|
status |
no | Marker, pending path to the unique leaf, unfinished attempts. |
verify |
yes | Live managed schema matches the marker’s authenticated snapshot. |
log |
no | Authenticated database-ledger events, sorted by start time and event id. It does not inspect estate artifacts. |
CLI log --limit <n> keeps the last n events after that stable ordering. The
programmatic log() call returns the complete ledger so callers can slice or
filter it themselves.
There is no pending() method. Pending work is a field on status().
log() requires an existing, authentic control-table pair. When it is absent,
log() throws MIGRATION_NOT_FOUND; it does not return an empty successful
log.
Apply
Apply authenticates the selected path, refuses live drift, executes exact SQL slices, then compare-and-swaps the marker. The default target is the unique leaf. Multiple leaves require --to / { to }. Multiple routes require --via / { via }.
npx viborm migrate apply
npx viborm migrate apply --to add-users
npx viborm migrate apply --to a1b2c3d4
npx viborm migrate apply --via <stateId> --to <stateId>
npx viborm migrate apply --dry-run --jsonconst applied = await migrations.apply();
console.log(applied.outcome, applied.path);
await migrations.apply({ to: { name: "add-users" } });
await migrations.apply({ to: { prefix: "a1b2c3d4" } });
await migrations.apply({ to: { id: fullStateId } });
await migrations.apply({ dryRun: true });Apply Options
| Option | CLI | API | Description |
|---|---|---|---|
| Target | --to <selector> |
to: StateSelector |
Full id, unambiguous prefix, or unambiguous name. |
| Via | --via <stateId...> |
via: Sha256[] |
Force a path through these full state ids. |
| Estate directory | --dir <dir> |
storage factory path | Estate root. |
| Dry run | --dry-run |
dryRun: boolean |
Plan without executing. Zero database effects. |
There is no --force, no numeric --to 5, and no tracking-table name flag. Control tables are _viborm_migration_state and _viborm_migration_log.
If the marker is absent, ordinary apply requires the managed target to be empty apart from control objects. Adopting an existing schema is baseline, not an implicit first apply.
Roll Back
down follows the marker’s actual arrival path. It cannot invent a parent this database never used, and it cannot cross a baseline boundary.
Rollback does not restore discarded data. Generated destructive changes restore managed schema shape only.
| Stored rollback | Meaning |
|---|---|
schema |
Invert the generated operations. Structure returns; rows created after the forward migration may be gone. |
manual |
Author-owned reverse dispatches and checks. |
irreversible |
No reverse transition. down refuses and quotes the reason. |
npx viborm migrate down
npx viborm migrate down --steps 1
npx viborm migrate down --to add-users
npx viborm migrate down --dry-run --jsonawait migrations.down({ steps: 1 });
await migrations.down({ to: { name: "add-users" } });
await migrations.down({ steps: 1, dryRun: true });down is the only rollback verb. There is no tracking-only rollback(), drop, or pending() untrack. Removing a marker row while the schema stays live is not a supported operation.
Baseline an Existing Database
baseline({ to }) adopts a live database that already matches an estate state. It executes no migration SQL.
Under the target lock it requires:
- no existing marker or ledger history
- exact live physical equality with that state’s snapshot
- a complete structural root path to that state
- no manual, opaque, or data-only transition on that path
npx viborm migrate baseline --to add-users --jsonawait migrations.baseline({ to: { name: "add-users" } });The marker records a baseline path boundary so later down cannot invent an arrival parent.
Recover an Unfinished Attempt
An unfinished ledger attempt means the live schema may be between states. Ordinary apply and down refuse until you recover.
resolve is not checksum repair. It changes the marker only when live proof permits:
| Outcome | CLI | Required proof |
|---|---|---|
| Complete | --complete |
Remaining postchecks, destination checks, and the target fingerprint hold. |
| Rolled back | --rolled-back |
Origin checks and the origin fingerprint hold. |
| Retry | --retry |
Resume from the first step whose postcheck is false and precheck is true. |
npx viborm migrate resolve --complete --json
npx viborm migrate resolve --rolled-back --json
npx viborm migrate resolve --retry --json
Equal physical fingerprints are not proof for a data-only or opaque transition. If that transition omitted the checks that distinguish its two states, resolve refuses.
Reset
reset preloads and authenticates the complete clear-and-replay program before the first drop. It keeps the estate, both control tables, every ledger event, and the old marker until final success.
npx viborm migrate reset --confirm --dry-run --json
npx viborm migrate reset --confirm --to add-usersawait migrations.reset({ dryRun: true });
await migrations.reset({ to: { name: "add-users" } });There is no squash or compaction command. A future compaction feature would be an explicitly authored, fully checked direct transition — never inferred from a final snapshot.
Push Does Not Create History
const live = createMigrationClient(client);
const preview = await live.push({ dryRun: true });
await live.push({ consent: preview.consent });
push plans from the live catalog, replans under the target lock, and executes the accepted in-memory program. It never reads or writes estate storage and never changes the marker or ledger.
A non-empty push against a valid migration marker is refused. A no-op is allowed only when the marker, unfinished-attempt state, desired snapshot, live fingerprint, and empty diff all agree. That is not verify.
See Push for consent, force-reset, and dry-run.
Estate Layout
migrations/
├── estate.json
├── snapshots/
│ └── <snapshot-hash>.json
├── sql/
│ └── <sql-hash>.sql
└── states/
└── <state-id>.json
estate.json is created once and is immutable: format "1", hash "sha256", and the exact MigrationTarget. PostgreSQL estates are schema-bound. MySQL artifacts stay database-relative. SQLite keeps its dialect target.
Snapshots, SQL blobs, and state manifests are content-addressed. A state becomes visible only after its snapshot and SQL bytes are durable and the manifest is published through an atomic no-replace boundary. Filenames that look like hashes are not proof — every read recomputes the chain.
There are no numbered 0001_*.sql files, no meta/_journal.json, no latest snapshot, and no meta/_down/ tree. Forward SQL, rollback SQL, and checks live in one SQL blob; the state manifest points at exact byte ranges.
Storage Requirements
- Reads and inventories are strongly consistent.
- Publishing the same bytes at an existing identity is idempotent.
- Different bytes at an existing identity are corruption.
- The filesystem writer uses write-temp → fsync → hard-link no-replace. Check-then-rename is not a V1 publication.
- Eventually-consistent or last-writer-wins stores (including Workers KV) are not writable estate backends. Object stores need conditional writes and strong listing.
Provider Limits
No provider inherits a guarantee from its dialect name. Effectful work requires a proven lock, marker compare-and-swap, and execution boundary.
| Provider | Effectful migrate / push | Notes |
|---|---|---|
PGlite, pg, postgres.js, Bun SQL |
Yes, when lock/CAS is proven | Transactional when every statement permits it. |
| SQLite3, Bun SQLite | Yes, when lock/CAS is proven | One writer boundary; BEGIN IMMEDIATE where the family allows it. |
| MySQL2 | Stepwise | DDL implicitly commits. Nominal transactions do not create rollback. Progress is recorded per dispatch. |
| Neon HTTP | Read-only / offline | No interactive session to hold a lock. Generation, check, status, log, and push dry-run stay available. Effectful apply/down/reset/verify/push refuse. |
| PlanetScale | Read-only / offline | VTGate routing does not prove containment. Same offline/read-only split. |
| D1, D1 HTTP | Effectful refused until proven | Table recreation, foreign keys, native-batch atomicity, and marker CAS are not proven together. |
| LibSQL | Effectful refused until proven | Existing-row constraint validation and safe table reconstruction are not proven. |
CREATE INDEX CONCURRENTLY and other forbidden-in-transaction PostgreSQL operations are classified stepwise or refused before effect.
Interrupted stepwise work is explainable from the ledger. The tool never reports rollback that the provider could not perform.
Expand / Contract Deployments
A migration tool does not provide zero-downtime deploys by itself. Use expand/contract:
- Expand — add the new column, table, or index. Deploy application code that writes both shapes and reads the old one.
- Migrate data — backfill with a manual transition that carries origin and destination checks.
- Contract — deploy readers that only use the new shape, then generate a second state that drops the old objects.
Do not squash those states. Each published state stays in the graph so every environment can apply the exact transition it needs.
Error Families
Automation should switch on stable families, not message text. CLI --json is a view of the same result. Exit codes derive from these families:
| Family | Typical meaning |
|---|---|
| Invalid estate / artifact | Hostile, truncated, or non-canonical bytes. |
| Ambiguous graph or path | Multiple leaves, names, prefixes, or routes. |
| Live drift | Managed schema does not match the marker snapshot. |
| Lock timeout | Another runner holds the target lock. |
| Marker conflict | Compare-and-swap lost. |
| Unfinished attempt | Ledger has an open attempt. |
| Consent required / mismatch | Push plan changed or destructive work lacks that preview’s consent. |
| Unsupported provider | Effectful work is not admitted on this driver. |
| Partial effect | Stepwise progress is confirmed; later steps did not run. |
| Ambiguous commit | An opaque dispatch may have committed. |
import { isMigrationError } from "viborm/migrations";
try {
await migrations.apply();
} catch (error) {
if (isMigrationError(error)) {
console.error(error.code, error.message);
}
}
CI/CD
- name: Check estate
run: npx viborm migrate check --json
- name: Apply
run: npx viborm migrate apply --json
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Verify
run: npx viborm migrate verify --json
import { createMigrationClient, createFsStorageWriter } from "viborm/migrations";
import { client } from "./db/client";
const migrations = createMigrationClient(client, {
storage: createFsStorageWriter("./migrations"),
});
const status = await migrations.status();
if (status.unfinished) {
throw new Error("Resolve the unfinished attempt before apply");
}
if (status.pending.length > 0) {
await migrations.apply();
}