L12 - Migrations
Authenticated estate graph and history-free push — one differ, one compiler, dialect SQL in the bound migration driver
Location: src/migrations/
Why This Layer Exists
Your TypeScript schema is the source of truth:
const user = s.model({
id: s.string().id().ulid(),
email: s.string().unique(),
name: s.string().nullable(), // Add this scalar
});
The migrations layer serializes that schema, diffs it, and compiles dialect SQL. File history and live sync share that path. They do not share storage.
Domain Model
| Term | Meaning |
|---|---|
| Estate | One storage root: immutable target descriptor, snapshots, SQL blobs, state manifests. |
| Snapshot | Canonical description of the VibORM-managed schema. |
| SQL blob | Plain review SQL. Production slices authenticated byte ranges from it. |
| State | One graph node. Identity is the manifest hash, not a filename or index. |
| Marker | Database row for the last confirmed state and arrival path. |
| Ledger | Append-only attempt and outcome evidence. Not reconstructed from the marker. |
| Push plan | Ephemeral live program. Never inserted into the estate. |
Two declared facts at generation time: the desired snapshot and the parent transitions. Pairing, apply order, and “latest file” are not stored.
How It Works
1. Schema snapshot
The current TypeScript schema is serialized with the exact resolved relation index:
{
tables: [{
name: "user",
columns: [{
name: "email",
type: "varchar",
nullable: false,
unique: true
}],
indexes: [...],
foreignKeys: [...]
}],
enums: []
}
2. Comparison baseline
push introspects the live target through the bound migration driver.
migrate generate compares against the selected parent state’s snapshot. No live database is required. The first state uses the virtual empty root.
3. Diffing
The one differ emits operations. The one resolver settles destructive and ambiguous changes.
[
{ type: "addColumn", table: "user", column: { name: "name", ... } },
{ type: "dropIndex", table: "user", indexName: "email_idx" }
]
4. Compilation
compile.ts turns resolved operations or complete manual Sql values into ordered operations, checks, typed parameters, and one SQL blob. Dialect text, probes, and transactional classification stay on the bound MigrationDriver. Query-engine SQL is not used here.
Production never reparses display SQL and never splits on ; or comments. One manual Sql value is one opaque provider dispatch.
Estate on Disk
migrations/
├── estate.json
├── snapshots/<snapshot-hash>.json
├── sql/<sql-hash>.sql
└── states/<state-id>.json
There is no journal, no numbered 0001_*.sql, no latest snapshot, and no $migrations table. Control tables are _viborm_migration_state (marker) and _viborm_migration_log (ledger).
A state becomes visible only after snapshot and SQL bytes are durable and the manifest is published through an atomic no-replace write.
Public Surface
createMigrationClient is the one composition root. createFsStorageWriter is the filesystem estate factory.
generate
check / list / show / graph
status / verify / log
apply / down / baseline / resolve / reset
push
apply({ to }) targets a full state id, unambiguous prefix, or unambiguous name. Numeric indexes have no meaning.
push never receives estate storage and never writes the marker. A non-empty push against a valid marker is refused.
Removed and not aliased: MigrationContext, journal(), pending(), squash(), parseStatements, createFsStorageDriver, path-level get/put/delete.
Operation Types
The DiffOperation union (see src/migrations/types.ts):
| Operation | Description |
|---|---|
createTable |
New table |
dropTable |
Remove table |
renameTable |
Rename table |
addColumn |
Add column to table |
dropColumn |
Remove column |
renameColumn |
Rename column |
alterColumn |
Change column type/constraints |
createIndex |
Add index |
dropIndex |
Remove index |
addForeignKey |
Add foreign key constraint |
dropForeignKey |
Remove foreign key constraint |
addUniqueConstraint |
Add unique constraint |
dropUniqueConstraint |
Remove unique constraint |
addPrimaryKey |
Add primary key |
dropPrimaryKey |
Remove primary key |
createEnum |
Create enum type |
dropEnum |
Remove enum type |
alterEnum |
Add/remove enum values |
Ambiguous Changes
Some diffs cannot be decided from names alone:
// Did you rename "name" to "fullName"?
// Or delete "name" and create "fullName"?
Generation and push take a resolve callback. The CLI prompts during generate when a structural rename is ambiguous. Those decisions are closed into the published state or into push consent. They are not replayed from a journal.
Push vs Migrate
viborm push
History-free live sync. Preview is inert. Effectful apply requires the consent from that preview for destructive or force-reset work. Dry-run has zero database and storage effects. Push never writes estate storage or the marker.
Best for: local development, disposable databases.
viborm migrate
Authenticated state graph. Generate publishes immutable artifacts. Apply executes authenticated slices and advances the marker. down follows the recorded arrival path. Rollback restores managed schema shape; it does not restore discarded data.
Best for: production, review, branches that must converge.
See src/cli/commands/migrate.ts and viborm/migrations (createMigrationClient, createFsStorageWriter, previewPush).
Provider Limits
Effectful work is admitted only when lock and marker CAS are proven.
| Provider | Effectful migrate / push |
|---|---|
PGlite, pg, postgres.js, Bun SQL |
Effectful when lock/CAS is proven. Transactional when every statement permits it. |
| SQLite3, Bun SQLite | Effectful when lock/CAS is proven. |
| MySQL2 | Stepwise. DDL implicitly commits; nominal transactions do not roll back. |
| Neon HTTP, PlanetScale | Read-only / offline for effectful work. Generation, check, status, and push dry-run remain. |
| D1, D1 HTTP, LibSQL | Effectful refused until proven. |
No provider inherits a guarantee from its dialect name.
Connection to Other Layers
- L2 (Scalars): Scalar state determines column definitions
- L4 (Relations): The resolved relation index determines foreign keys and junctions
- L5 (Schema validation): Definition-time topology; migrations do not rescan inverses
- L7 (Adapters): Not the DDL owner. Dialect migration SQL lives on
MigrationDriver - L8 (Drivers): Connection, pinned session, execution. Neon HTTP and PlanetScale have no effectful lock