Migrations
Manage database schema changes with VibORM migrations
VibORM provides two approaches for managing database schema changes:
- Push — history-free live schema synchronization, ideal for development
- Migrate — an authenticated, version-controlled estate, intended for production
Push compares your VibORM models with the live database. Migrate compares the desired schema snapshot with an authenticated parent state, publishes that transition, and separately verifies the live database when it applies it.
Choose Your Approach
| Push | Migrate | |
|---|---|---|
| Use case | Development, prototyping | Production, team collaboration |
| Version control | No history artifacts | Yes, state manifests, snapshots, and reviewed SQL blobs committed to Git |
| Rollback | Manual | down() executes the stored down artifact, honouring each migration’s persisted rollback policy |
| Production CI/CD | Not recommended | Fully supported |
CLI vs API
VibORM migrations can be used via the CLI or programmatically via the Migration Client API.
CLI
The CLI is the recommended way to manage migrations during development:
# Push schema directly to database
npx viborm push
# Publish a new migration state
npx viborm migrate generate --name add-users
# Apply pending migrations
npx viborm migrate apply
# Check migration status
npx viborm migrate status
Migration Client API
For programmatic control (scripts, CI/CD, custom tooling), use the migration client:
import { createFsStorageWriter, createMigrationClient } from "viborm/migrations";
import { client } from "./client";
const migrations = createMigrationClient(client, {
storage: createFsStorageWriter("./migrations"),
});
await migrations.generate({ name: "add-users" });
await migrations.apply();
await migrations.status();
const preview = await migrations.push({
dryRun: true,
resolve: async (change) => {
if (change.type === "destructive") {
return change.proceed();
}
if (change.type === "ambiguous") {
return change.rename();
}
if (change.isNullable) {
return change.useNull();
}
return change.reject();
},
});
await migrations.push({ consent: preview.consent });
Capability-Typed Clients
The returned client exposes only operations that its storage can support:
| Construction | Surface |
|---|---|
createMigrationClient(client) |
push, log |
createMigrationClient(client, { storage: reader }) |
Adds estate inspection and application operations |
createMigrationClient(client, { storage: writer }) |
Also adds generate and reset |
This keeps a storage-less deployment script from compiling a call that can only fail with “storage required” at runtime.
How It Works
Both push and migrate follow the same core process:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ VibORM Schema │ ──▶ │ Schema Diff │ ──▶ │ DDL Generation │
│ (your code) │ │ (detect changes)│ │ (database SQL) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
- Serialize your VibORM models into a schema snapshot
- Compare against the current database state (push) or previous snapshot (migrate)
- Generate database-specific DDL using the appropriate migration driver
- Execute the DDL (push) or publish authenticated estate artifacts (migrate)
Atomicity
Migration atomicity is a DDL-specific guarantee and is not implied by support for client transactions. VibORM admits effectful work only when the concrete provider proves its lock, marker compare-and-swap, and execution boundary. MySQL DDL is the documented stepwise exception because each statement commits:
| Driver | push |
migrate apply |
|---|---|---|
| PGlite, pg, postgres.js, Bun SQL | Transaction when every statement permits it | Same guarantee |
| SQLite3, Bun SQLite | One writer boundary; transactional where supported | Same guarantee |
| MySQL2 | Stepwise — a failed run can leave earlier statements applied | Same limitation, with durable progress evidence |
| Neon HTTP, PlanetScale, D1, D1 HTTP, libSQL | Effectful work refused; dry-run and read-only paths remain | Effectful work refused until the provider boundary is proven |
On the atomic paths, a successful migration applies every statement and a failed one applies none. The portable API exposes no transaction isolation options.