@bun-and-butter/migration
A small migration runner for ordered up/down migrations with pluggable stores, useful for schema changes, data backfills, file migrations, and other application upgrade steps.
Install from Git
Bun & Butter packages are installed directly from the GitHub repository over SSH. This is the recommended install path for the current version. See the Bun docs on Git dependencies for the exact `bun add` behavior.
bun add [email protected]:bun-and-butter/migration.git
README.md
A small Bun-first migration helper for running ordered up and down migrations with a pluggable persistence layer.
@bun-and-butter/migration is a small orchestrator for upgrade steps in your application. It does not care whether a migration changes a SQL schema, backfills data, rewrites files, updates an external system, rebuilds an index, or prepares some other piece of runtime state.
The important distinction is: a Store only remembers which migrations have already run. It does not define what those migrations are allowed to migrate. You can track migration state in SQL, JSON, or a custom store while the migration itself changes something completely different.
Quick Start
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { JSONStore, MigrationService, type Migration } from "@bun-and-butter/migration";
type AppContext = {
baseDir: string;
};
const migrations: Migration<AppContext>[] = [
{
name: "001_write_version_file",
async up({ baseDir }) {
await mkdir(baseDir, { recursive: true });
await writeFile(join(baseDir, "version.txt"), "v1\n", "utf8");
},
async down({ baseDir }) {
await rm(join(baseDir, "version.txt"), { force: true });
},
},
];
const ctx: AppContext = {
baseDir: "./tmp/demo-app",
};
// `JSONStore` is only used to track migration state.
// The migration itself can still change anything you want.
const store = new JSONStore("./tmp/demo-migrations.json");
const service = await MigrationService.build(store, migrations);
console.log("pending:", await service.getPending());
const applied = await service.up(ctx);
console.log("applied:", applied);
const rolledBack = await service.down(ctx);
console.log("rolled back:", rolledBack);Why Use It
When projects start small, migrations are often just ad-hoc scripts or one-off startup code. That works for a while, but it quickly gets harder to answer basic questions:
- which migrations already ran?
- which ones are still pending?
- how do we roll back just the latest change?
- how do we persist migration state somewhere reliable?
MigrationService keeps that flow small and explicit. You hand it an ordered list of migrations plus a Store, and it handles applying pending entries, tracking executed ones, and rolling them back in reverse order.
The Store is only state tracking. It is not the target of the migration itself.
What It Handles
The package currently focuses on the migration runner itself:
- defines a simple
Migration<Ctx>interface withname,up, anddown - runs all pending migrations in order with
up(ctx) - rolls back the latest migration, all migrations, or back to a named target
- returns structured execution results instead of relying on emitted events
- supports custom stores through the
Storeinterface - ships with
JSONStoreandSQLStoreas ready-to-use store implementations
What You Can Migrate
The runner itself is intentionally storage-agnostic. A migration can do anything your application needs, for example:
- create or alter database tables
- backfill or transform existing data
- move data between systems
- rewrite files or directories on disk
- rebuild search indexes or materialized views
- prepare application state before a new version starts serving traffic
The only requirement is that each migration exposes an up(ctx) and down(ctx) function and that you provide a Store that can remember which migrations already ran.
Core Pieces
There are two main pieces in the package:
MigrationServiceRuns your migrations and exposesgetPending(),getExecuted(),up(), anddown().StorePersists which migrations have already run. You can implement this yourself or use one of the included stores.
Included Stores
JSONStore(path)Persists migration state as an ordered JSON array in a file.SQLStore(sql)Persists migration state in a SQL table calledmigrations.
JSONStore is a good fit when you want a very small setup and a file on disk is enough for state tracking.
SQLStore is a good fit when you already have a SQL connection in your application or when you do not want to rely on a local file for migration state.
If your application needs something else entirely, you can provide your own Store implementation.
Store backend and migration target are independent from each other. For example, you can:
- use
JSONStorewhile migrations rewrite files or prepare local app state - use
SQLStorewhile migrations backfill data or call external systems - implement a custom
Storewhile migrations change a SQL schema
Rollback Behavior
down(ctx) supports three modes:
down(ctx)Rolls back only the most recently executed migration.down(ctx, 0)Rolls back all executed migrations in reverse order.down(ctx, "001_create_users")Rolls back everything executed after that migration and keeps the target migration applied.
If no migrations have been executed yet, down(...) simply returns an empty array.
Concurrency And Deployment Notes
This package does not implement a migration lock.
That means concurrent migration runners can interfere with each other. If two processes call up() at the same time, both may decide that the same migration is still pending and then race while applying or storing it. In other words: running migrations from multiple replicas at once is unsafe unless you provide your own coordination.
Recommended patterns:
- in Kubernetes, run migrations in a dedicated
Jobbefore rolling out the new application version - in a clustered deployment, run migrations on the leader or master process first and only spawn or unpause worker processes after migrations finished
- in process managers or custom boot flows, make sure exactly one instance is responsible for running migrations
- if you need stronger guarantees, wrap execution in infrastructure-level locking or implement a custom
Storewith explicit lock semantics
Examples
The repository currently includes three examples:
examples/demo.tsshows the smallest useful setup withMigrationService,SQLStore, applying migrations, and rolling back the latest changeexamples/json_store.tsshows generic file-oriented migrations tracked throughJSONStoreexamples/sql_store.tsshows schema-style migrations tracked throughSQLStore
Design Notes
- the package is intentionally small and keeps orchestration explicit
- migration ordering comes from your migration list plus the persisted store
- executed migrations are returned newest first for easier inspection and logging
- stores are pluggable, so the runner is not tied to a single database backend
Recommended Pattern
For most applications:
- define migrations with stable, ordered names such as
001_...,002_...,003_... - create a single
MigrationServiceduring startup - choose a store based on how and where you want to persist migration state
- run
up(ctx)from exactly one controlled place in your deployment - only start serving traffic after migrations completed successfully
- use
down(...)deliberately and usually only in operational or development workflows
Examples
import { SQL } from "bun";
import { MigrationService, SQLStore, type Migration } from "../src/migration";
// `MigrationService` runs your `up` and `down` functions and keeps track of
// which migrations have already been executed through a `Store`.
//
// In this example we use `SQLStore`, which persists the migration state in a
// regular SQL table called `migrations`.
type DemoContext = {
sql: SQL;
};
const migrations: Migration<DemoContext>[] = [
{
name: "001_create_users",
async up({ sql }) {
await sql`
CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL
)
`;
},
async down({ sql }) {
await sql`DROP TABLE IF EXISTS users`;
},
},
{
name: "002_add_email_to_users",
async up({ sql }) {
await sql`ALTER TABLE users ADD COLUMN email TEXT`;
},
async down({ sql }) {
await sql`
CREATE TABLE users_next (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL
)
`;
await sql`
INSERT INTO users_next (id, username)
SELECT id, username FROM users
`;
await sql`DROP TABLE users`;
await sql`ALTER TABLE users_next RENAME TO users`;
},
},
];
const sql = new SQL(":memory:");
try {
const ctx: DemoContext = { sql };
// The same SQL connection is used both by the application migrations and by
// the SQL-backed migration store.
const store = new SQLStore(sql);
// `build(...)` prepares the store before returning the ready-to-use service.
const service = await MigrationService.build(store, migrations);
// At the start all migrations are still pending.
console.log(
"pending before up:",
(await service.getPending()).map((migration) => migration.name),
);
// `up(...)` runs every pending migration in order and stores the result.
const applied = await service.up(ctx);
console.log("applied:", applied);
// Executed migrations are reported with the newest entry first.
console.log("executed after up:", await service.getExecuted());
// Calling `down(...)` without a target rolls back only the most recent
// migration.
const rolledBack = await service.down(ctx);
console.log("rolled back:", rolledBack);
console.log("executed after down:", await service.getExecuted());
// You can inspect the application schema directly with normal Bun SQL.
const columns = await sql<
{ name: string }[]
>`SELECT name FROM pragma_table_info('users') ORDER BY cid ASC;`;
console.log(
"users columns after rollback:",
columns.map((column) => column.name),
);
} finally {
await sql.close();
}import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { JSONStore, MigrationService, type Migration } from "../src/migration";
// `JSONStore` is useful when you want to keep migration state in a simple file
// while the migrations themselves can change anything else in your app.
type AppContext = {
appDir: string;
};
const rootDir = join(
tmpdir(),
`bun-and-butter-migration-json-${Bun.randomUUIDv7()}`,
);
const storePath = join(rootDir, "migration-state.json");
const migrations: Migration<AppContext>[] = [
{
name: "001_create_app_directory",
async up({ appDir }) {
await mkdir(appDir, { recursive: true });
},
async down({ appDir }) {
await rm(appDir, { recursive: true, force: true });
},
},
{
name: "002_write_version_file",
async up({ appDir }) {
await writeFile(join(appDir, "version.txt"), "v1\n", "utf8");
},
async down({ appDir }) {
await rm(join(appDir, "version.txt"), { force: true });
},
},
];
try {
const ctx: AppContext = {
appDir: join(rootDir, "app"),
};
const store = new JSONStore(storePath);
const service = await MigrationService.build(store, migrations);
console.log(
"pending before up:",
(await service.getPending()).map((migration) => migration.name),
);
const applied = await service.up(ctx);
console.log("applied:", applied);
const version = await readFile(join(ctx.appDir, "version.txt"), "utf8");
console.log("version file:", version.trim());
console.log("store file:", await readFile(storePath, "utf8"));
const rolledBack = await service.down(ctx, 0);
console.log("rolled back:", rolledBack);
} finally {
await rm(rootDir, { recursive: true, force: true });
}import { SQL } from "bun";
import { MigrationService, SQLStore, type Migration } from "../src/migration";
// `SQLStore` is useful when you already have a SQL connection and want the
// migration state to live in the database instead of a local file.
type AppContext = {
sql: SQL;
};
const migrations: Migration<AppContext>[] = [
{
name: "001_create_users",
async up({ sql }) {
await sql`
CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL
)
`;
},
async down({ sql }) {
await sql`DROP TABLE IF EXISTS users`;
},
},
{
name: "002_add_email_to_users",
async up({ sql }) {
await sql`ALTER TABLE users ADD COLUMN email TEXT`;
},
async down({ sql }) {
await sql`
CREATE TABLE users_next (
id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL
)
`;
await sql`
INSERT INTO users_next (id, username)
SELECT id, username FROM users
`;
await sql`DROP TABLE users`;
await sql`ALTER TABLE users_next RENAME TO users`;
},
},
];
const sql = new SQL(":memory:");
try {
const ctx: AppContext = { sql };
const store = new SQLStore(sql);
const service = await MigrationService.build(store, migrations);
console.log(
"pending before up:",
(await service.getPending()).map((migration) => migration.name),
);
const applied = await service.up(ctx);
console.log("applied:", applied);
const columnsAfterUp = await sql<
{ name: string }[]
>`SELECT name FROM pragma_table_info('users') ORDER BY cid ASC;`;
console.log(
"users columns after up:",
columnsAfterUp.map((column) => column.name),
);
const rolledBack = await service.down(ctx);
console.log("rolled back:", rolledBack);
const columnsAfterDown = await sql<
{ name: string }[]
>`SELECT name FROM pragma_table_info('users') ORDER BY cid ASC;`;
console.log(
"users columns after down:",
columnsAfterDown.map((column) => column.name),
);
} finally {
await sql.close();
}