Back to catalog

@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.

Tags
v1.0.0
Install

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
				
Documentation

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 with name, up, and down
  • 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 Store interface
  • ships with JSONStore and SQLStore as 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:

  • MigrationService Runs your migrations and exposes getPending(), getExecuted(), up(), and down().
  • Store Persists 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 called migrations.

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 JSONStore while migrations rewrite files or prepare local app state
  • use SQLStore while migrations backfill data or call external systems
  • implement a custom Store while 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 Job before 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 Store with explicit lock semantics

Examples

The repository currently includes three examples:

  • examples/demo.ts shows the smallest useful setup with MigrationService, SQLStore, applying migrations, and rolling back the latest change
  • examples/json_store.ts shows generic file-oriented migrations tracked through JSONStore
  • examples/sql_store.ts shows schema-style migrations tracked through SQLStore

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

For most applications:

  1. define migrations with stable, ordered names such as 001_..., 002_..., 003_...
  2. create a single MigrationService during startup
  3. choose a store based on how and where you want to persist migration state
  4. run up(ctx) from exactly one controlled place in your deployment
  5. only start serving traffic after migrations completed successfully
  6. use down(...) deliberately and usually only in operational or development workflows
Usage Examples

Examples

examples/demo.ts
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();
}
examples/json_store.ts
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 });
}
examples/sql_store.ts
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();
}