Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/migration",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Database-agnostic migration runner with ledger tracking, dry-run, checksums, lock adapters, and OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors/migration-error": {
"import": {
"types": "./dist/errors/migration-error.d.ts",
"default": "./dist/errors/migration-error.js"
},
"require": {
"types": "./dist/errors/migration-error.d.ts",
"default": "./dist/errors/migration-error.js"
}
},
"./planner/migration-planner": {
"import": {
"types": "./dist/planner/migration-planner/migration-planner.d.ts",
"default": "./dist/planner/migration-planner/migration-planner.js"
},
"require": {
"types": "./dist/planner/migration-planner/migration-planner.d.ts",
"default": "./dist/planner/migration-planner/migration-planner.js"
}
},
"./runner/migration-runner": {
"import": {
"types": "./dist/runner/migration-runner/migration-runner.d.ts",
"default": "./dist/runner/migration-runner/migration-runner.js"
},
"require": {
"types": "./dist/runner/migration-runner/migration-runner.d.ts",
"default": "./dist/runner/migration-runner/migration-runner.js"
}
},
"./store/memory-store": {
"import": {
"types": "./dist/store/memory-store/memory-store.d.ts",
"default": "./dist/store/memory-store/memory-store.js"
},
"require": {
"types": "./dist/store/memory-store/memory-store.d.ts",
"default": "./dist/store/memory-store/memory-store.js"
}
},
"./store/database-store": {
"import": {
"types": "./dist/store/database-store/database-store.d.ts",
"default": "./dist/store/database-store/database-store.js"
},
"require": {
"types": "./dist/store/database-store/database-store.d.ts",
"default": "./dist/store/database-store/database-store.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/db": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,5 @@
export {
SurrealDbAdapter,
type SurrealDbAdapterConfig,
type SurrealInstance,
} from "./surrealdb.js";

View File

@@ -0,0 +1,60 @@
# surrealdb-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SurrealDbAdapter` adapts a SurrealDB instance to the minimal `execute`/`query` interface required by `DatabaseMigrationStore` and migration context objects. Because SurrealDB uses named query variables rather than positional parameters, this adapter translates positional `params` arrays to `{ p0, p1, … }` named variable maps automatically. This allows the same migration SQL templates to work across SQLite (positional `?`) and SurrealDB (named `$p0`) by using a consistent calling convention.
## Features
- `query<T>(sql, params?)` — executes a SurrealDB query and returns typed results.
- `execute(sql, params?)` — executes a non-query statement; always returns `{ rowsAffected: 0 }` (SurrealDB does not expose row counts).
- Translates positional params array to named vars (`p0`, `p1`, …).
- Wraps errors in `MigrationError(MIGRATION.STORE_ERROR)` or `MIGRATION.FAILED`.
## Usage: Basic Example
```ts
import { SurrealDbAdapter } from "@suaveplan/migration";
import { DatabaseMigrationStore } from "@suaveplan/migration";
import Surreal from "surrealdb";
const db = new Surreal();
await db.connect("http://localhost:8000");
const adapter = new SurrealDbAdapter({ db });
const store = new DatabaseMigrationStore({ adapter });
```
## Usage: Advanced Example
```ts
import { SurrealDbAdapter } from "@suaveplan/migration";
import { MigrationRunner, MemoryMigrationStore } from "@suaveplan/migration";
const adapter = new SurrealDbAdapter({ db: surrealInstance });
const store = new MemoryMigrationStore(); // or DatabaseMigrationStore
const runner = new MigrationRunner({ migrations, store });
```
## API Reference
### `class SurrealDbAdapter`
**Constructor**: `(config: SurrealDbAdapterConfig)`
- `config.db` — object implementing `query<T>(sql, vars?): Promise<T[]>`.
### `query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>`
Translates params to named vars and delegates to `db.query`. Returns an empty array if the result is not an array.
### `execute(sql: string, params?: readonly unknown[]): Promise<{ rowsAffected: number }>`
Executes SQL via `db.query`. Always returns `{ rowsAffected: 0 }`.
## Implementation Notes
SurrealDB's `query` method returns an array of result sets per SQL statement. This adapter flattens the outer array and returns the first result set. For multi-statement SQL, callers should split statements and call `query` for each.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MigrationError } from "../../errors/migration-error.js";
import { SurrealDbAdapter, type SurrealInstance } from "./surrealdb.js";
class FakeSurreal implements SurrealInstance {
protected results: unknown[] = [];
protected shouldThrow = false;
protected lastSql = "";
protected lastVars: Record<string, unknown> = {};
setResults(r: unknown[]) {
this.results = r;
}
setThrow(t: boolean) {
this.shouldThrow = t;
}
getLastSql() {
return this.lastSql;
}
getLastVars() {
return this.lastVars;
}
async query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]> {
if (this.shouldThrow) throw new Error("surreal error");
this.lastSql = sql;
this.lastVars = vars ?? {};
return this.results as T[];
}
}
describe("SurrealDbAdapter", () => {
it("query returns results", { timeout: 5000 }, async () => {
const surreal = new FakeSurreal();
surreal.setResults([{ id: "1" }]);
const adapter = new SurrealDbAdapter({ db: surreal });
const results = await adapter.query("SELECT * FROM users");
expect(results).toHaveLength(1);
});
it("query translates positional params to named vars", {
timeout: 5000,
}, async () => {
const surreal = new FakeSurreal();
surreal.setResults([]);
const adapter = new SurrealDbAdapter({ db: surreal });
await adapter.query("SELECT * FROM users WHERE id = $p0", ["abc"]);
expect(surreal.getLastVars()).toEqual({ p0: "abc" });
});
it("query returns empty array when result is not array", {
timeout: 5000,
}, async () => {
const surreal = new FakeSurreal();
surreal.setResults([]);
const adapter = new SurrealDbAdapter({ db: surreal });
const results = await adapter.query("SELECT 1");
expect(Array.isArray(results)).toBe(true);
});
it("query wraps errors in MigrationError", { timeout: 5000 }, async () => {
const surreal = new FakeSurreal();
surreal.setThrow(true);
const adapter = new SurrealDbAdapter({ db: surreal });
await expect(adapter.query("SELECT 1")).rejects.toBeInstanceOf(
MigrationError
);
});
it("execute returns rowsAffected 0", { timeout: 5000 }, async () => {
const surreal = new FakeSurreal();
surreal.setResults([]);
const adapter = new SurrealDbAdapter({ db: surreal });
const result = await adapter.execute("CREATE TABLE users");
expect(result.rowsAffected).toBe(0);
});
it("execute wraps errors in MigrationError", {
timeout: 5000,
}, async () => {
const surreal = new FakeSurreal();
surreal.setThrow(true);
const adapter = new SurrealDbAdapter({ db: surreal });
await expect(
adapter.execute("CREATE TABLE users")
).rejects.toBeInstanceOf(MigrationError);
});
it("execute with no params works", { timeout: 5000 }, async () => {
const surreal = new FakeSurreal();
surreal.setResults([]);
const adapter = new SurrealDbAdapter({ db: surreal });
const result = await adapter.execute("CREATE TABLE users");
expect(result.rowsAffected).toBe(0);
expect(surreal.getLastVars()).toEqual({});
});
it("query returns empty array for non-array result", {
timeout: 5000,
}, async () => {
// Simulate a SurrealDB that returns a non-array
const adapter = new SurrealDbAdapter({
db: {
async query<T>(): Promise<T[]> {
return null as unknown as T[];
},
},
});
const result = await adapter.query("SELECT 1");
expect(result).toEqual([]);
});
it("query wraps non-Error throws in MigrationError", {
timeout: 5000,
}, async () => {
const adapter = new SurrealDbAdapter({
db: {
async query<T>(): Promise<T[]> {
throw "string error";
},
},
});
await expect(adapter.query("SELECT 1")).rejects.toBeInstanceOf(
MigrationError
);
});
it("execute wraps non-Error throws in MigrationError", {
timeout: 5000,
}, async () => {
const adapter = new SurrealDbAdapter({
db: {
async query<T>(): Promise<T[]> {
throw "string error";
},
},
});
await expect(adapter.execute("CREATE TABLE t")).rejects.toBeInstanceOf(
MigrationError
);
});
});

View File

@@ -0,0 +1,71 @@
import {
MIGRATION_CODES,
MigrationError,
} from "../../errors/migration-error.js";
/** Minimal SurrealDB instance interface required by the adapter. */
export interface SurrealInstance {
query<T = unknown>(
sql: string,
vars?: Record<string, unknown>
): Promise<T[]>;
}
export interface SurrealDbAdapterConfig {
db: SurrealInstance;
}
/**
* SurrealDB adapter that implements the minimal `execute`/`query` interface
* expected by `DatabaseMigrationStore` and `MigrationExecutor` context.
*
* SurrealDB does not support positional parameters in the same way as SQL,
* so params are passed as named vars (`$p0`, `$p1`, …).
*/
export class SurrealDbAdapter {
protected readonly db: SurrealInstance;
constructor(config: SurrealDbAdapterConfig) {
this.db = config.db;
}
async query<T>(sql: string, params?: readonly unknown[]): Promise<T[]> {
try {
const vars = paramsToVars(params);
const results = await this.db.query<T>(sql, vars);
return Array.isArray(results) ? results : [];
} catch (err) {
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
`SurrealDB query failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
async execute(
sql: string,
params?: readonly unknown[]
): Promise<{ rowsAffected: number }> {
try {
const vars = paramsToVars(params);
await this.db.query(sql, vars);
return { rowsAffected: 0 };
} catch (err) {
throw new MigrationError(
MIGRATION_CODES.FAILED,
`SurrealDB execute failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
}
function paramsToVars(params?: readonly unknown[]): Record<string, unknown> {
if (!params || params.length === 0) return {};
const vars: Record<string, unknown> = {};
for (let i = 0; i < params.length; i++) {
vars[`p${i}`] = params[i];
}
return vars;
}

View File

@@ -0,0 +1,64 @@
# batch-migrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BatchMigrator` orchestrates migrations across multiple database targets simultaneously. It is useful in multi-tenant architectures where each tenant has a separate database, or in microservice deployments where several service databases must be migrated as part of a single release. The migrator processes targets in configurable concurrency batches using `Promise.allSettled`, so a single target failure does not block others.
## Features
- Configurable `concurrency` (default 5) — processes this many targets in parallel per batch.
- `Promise.allSettled` semantics — partial failures are collected rather than aborting the run.
- Returns `{ results, failed, succeeded }` with a map from target name to `MigrationResult`.
- AbortSignal forwarding to each runner.
## Usage: Basic Example
```ts
import { BatchMigrator } from "@suaveplan/migration";
const migrator = new BatchMigrator({ targets: [
{ name: "tenant-a", runner: runnerA },
{ name: "tenant-b", runner: runnerB },
] });
const result = await migrator.run();
// result.succeeded = ["tenant-a", "tenant-b"]
// result.failed = []
```
## Usage: Advanced Example
```ts
import { BatchMigrator } from "@suaveplan/migration";
const migrator = new BatchMigrator({ targets: tenantRunners, concurrency: 10 });
const result = await migrator.run({ signal: controller.signal });
if (result.failed.length > 0) {
console.error(`Failed targets: ${result.failed.join(", ")}`);
}
```
## API Reference
### `class BatchMigrator`
**Constructor**: `(config: BatchMigratorConfig)`
- `config.targets` — array of `{ name: string; runner: MigrationRunnerLike }`.
- `config.concurrency?` — parallel target count per batch (default 5).
### `run(options?: RunOptions): Promise<BatchMigrationResult>`
Returns:
- `results: Map<string, MigrationResult>` — per-target outcome.
- `failed: string[]` — names of targets that threw.
- `succeeded: string[]` — names of targets that resolved.
## Implementation Notes
The concurrency loop slices `targets` into chunks of `concurrency` and awaits each chunk before starting the next. This provides bounded parallelism without spinning up all targets at once. Failed targets receive a stub `MigrationResult` with `failed: [name]` to ensure the results map is always complete.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
MigrationResult,
MigrationRunnerLike,
RunOptions,
} from "../../types/migration.types.js";
import { BatchMigrator } from "./batch-migrator.js";
function makeSuccessRunner(id: string): MigrationRunnerLike {
return {
async up(_opts?: RunOptions): Promise<MigrationResult> {
return {
applied: [id],
skipped: [],
failed: [],
direction: "up",
dryRun: false,
};
},
};
}
function makeFailRunner(): MigrationRunnerLike {
return {
async up(): Promise<MigrationResult> {
throw new Error("runner failed");
},
};
}
describe("BatchMigrator", () => {
it("runs all targets successfully", { timeout: 5000 }, async () => {
const migrator = new BatchMigrator({
targets: [
{ name: "a", runner: makeSuccessRunner("001") },
{ name: "b", runner: makeSuccessRunner("001") },
],
});
const result = await migrator.run();
expect(result.succeeded).toEqual(["a", "b"]);
expect(result.failed).toHaveLength(0);
expect(result.results.size).toBe(2);
});
it("collects failed targets without aborting", {
timeout: 5000,
}, async () => {
const migrator = new BatchMigrator({
targets: [
{ name: "ok", runner: makeSuccessRunner("001") },
{ name: "fail", runner: makeFailRunner() },
],
});
const result = await migrator.run();
expect(result.succeeded).toContain("ok");
expect(result.failed).toContain("fail");
});
it("respects concurrency setting", { timeout: 10000 }, async () => {
const order: string[] = [];
const targets = ["a", "b", "c", "d", "e"].map((name) => ({
name,
runner: {
async up(): Promise<MigrationResult> {
order.push(name);
return {
applied: [name],
skipped: [],
failed: [],
direction: "up",
dryRun: false,
};
},
},
}));
const migrator = new BatchMigrator({ targets, concurrency: 2 });
const result = await migrator.run();
expect(result.succeeded).toHaveLength(5);
expect(order).toHaveLength(5);
});
it("returns result stub for failed targets", {
timeout: 5000,
}, async () => {
const migrator = new BatchMigrator({
targets: [{ name: "fail", runner: makeFailRunner() }],
});
const result = await migrator.run();
const stub = result.results.get("fail");
expect(stub?.failed).toContain("fail");
});
it("handles empty targets", { timeout: 5000 }, async () => {
const migrator = new BatchMigrator({ targets: [] });
const result = await migrator.run();
expect(result.succeeded).toHaveLength(0);
expect(result.failed).toHaveLength(0);
});
});

View File

@@ -0,0 +1,56 @@
import type {
BatchMigrationResult,
BatchMigratorConfig,
MigrationResult,
RunOptions,
} from "../../types/migration.types.js";
/**
* Runs migrations across multiple targets with configurable concurrency.
* Each target is an object with a `name` and a `MigrationRunnerLike` instance.
*/
export class BatchMigrator {
protected readonly config: BatchMigratorConfig;
constructor(config: BatchMigratorConfig) {
this.config = config;
}
async run(options?: RunOptions): Promise<BatchMigrationResult> {
const { targets, concurrency = 5 } = this.config;
const results = new Map<string, MigrationResult>();
const failed: string[] = [];
const succeeded: string[] = [];
// Process targets in batches of `concurrency`
for (let i = 0; i < targets.length; i += concurrency) {
const batch = targets.slice(i, i + concurrency);
const settled = await Promise.allSettled(
batch.map(async ({ name, runner }) => {
const result = await runner.up(options);
return { name, result };
})
);
for (const [j, outcome] of settled.entries()) {
const name = batch[j]!.name;
if (outcome.status === "fulfilled") {
results.set(name, outcome.value.result);
succeeded.push(name);
} else {
failed.push(name);
// Store a failure result stub
results.set(name, {
applied: [],
skipped: [],
failed: [name],
direction: "up",
dryRun: false,
});
}
}
}
return { results, failed, succeeded };
}
}

View File

@@ -0,0 +1 @@
export { BatchMigrator } from "./batch-migrator.js";

View File

@@ -0,0 +1,11 @@
/** Default ledger table name used to track applied migrations. */
export const DEFAULT_LEDGER_TABLE = "_migrations";
/** Default advisory lock key. */
export const DEFAULT_LOCK_KEY = "migration_lock";
/** Default lock acquisition timeout in milliseconds. */
export const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
/** Default lock TTL in milliseconds (distributed adapters). */
export const DEFAULT_LOCK_TTL_MS = 60_000;

View File

@@ -0,0 +1,5 @@
export {
MIGRATION_CODES,
MigrationError,
type MigrationErrorCode,
} from "./migration-error.js";

View File

@@ -0,0 +1,64 @@
# migration-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error class and stable error-code registry for the `@suaveplan/migration` package. All thrown errors in this package extend `MigrationError`, which itself extends `SuaveplanError` from `@suaveplan/error`, ensuring that every error carries a machine-readable code, an optional cause chain, and OTel-compatible span events.
Using a dedicated error hierarchy makes it straightforward for callers to distinguish migration failures from other application errors, and to implement targeted recovery logic (e.g. retry on lock timeouts, alert on checksum mismatches).
## Features
- `MIGRATION_CODES` — frozen record mapping reason strings to `MIGRATION.<REASON>` codes, built with `defineCodes`.
- `MigrationError` — concrete error class that accepts one of the defined codes and a human-readable message, forwarding optional `cause` through the SuaveplanError chain.
- Full TypeScript literal typing: `MigrationErrorCode` is inferred from the keys of `MIGRATION_CODES`.
## Usage: Basic Example
```ts
import { MigrationError, MIGRATION_CODES } from "@suaveplan/migration/errors/migration-error";
throw new MigrationError(
MIGRATION_CODES.FAILED,
"Migration 001_create_users failed during execution",
{ cause: originalError },
);
```
## Usage: Advanced Example
```ts
import { MigrationError, MIGRATION_CODES } from "@suaveplan/migration/errors/migration-error";
function assertChecksum(stored: string, computed: string, id: string): void {
if (stored !== computed) {
throw new MigrationError(
MIGRATION_CODES.CHECKSUM_MISMATCH,
`Checksum mismatch for migration ${id}: stored=${stored} computed=${computed}`,
);
}
}
```
## API Reference
### `MIGRATION_CODES`
Frozen record: `{ FAILED, LOCK_TIMEOUT, CHECKSUM_MISMATCH, NOT_FOUND, STORE_ERROR, PLAN_INVALID, ABORTED, DRY_RUN }`.
Each value is the string `"MIGRATION.<REASON>"`.
### `MigrationErrorCode`
Union type of all values in `MIGRATION_CODES`.
### `class MigrationError`
Extends `SuaveplanError<MigrationErrorCode>`.
**Constructor**: `(code: MigrationErrorCode, message: string, options?: { cause?: unknown })`
## Implementation Notes
`defineCodes` from `@suaveplan/error` builds the code map at module load time and freezes it, preventing accidental mutation. The `cause` option is only forwarded when it is an `Error` instance, matching `SuaveplanError`'s convention.

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MIGRATION_CODES, MigrationError } from "./migration-error.js";
describe("MIGRATION_CODES", () => {
it("has all expected codes", { timeout: 5000 }, () => {
expect(MIGRATION_CODES.FAILED).toBe("MIGRATION.FAILED");
expect(MIGRATION_CODES.LOCK_TIMEOUT).toBe("MIGRATION.LOCK_TIMEOUT");
expect(MIGRATION_CODES.CHECKSUM_MISMATCH).toBe(
"MIGRATION.CHECKSUM_MISMATCH"
);
expect(MIGRATION_CODES.NOT_FOUND).toBe("MIGRATION.NOT_FOUND");
expect(MIGRATION_CODES.STORE_ERROR).toBe("MIGRATION.STORE_ERROR");
expect(MIGRATION_CODES.PLAN_INVALID).toBe("MIGRATION.PLAN_INVALID");
expect(MIGRATION_CODES.ABORTED).toBe("MIGRATION.ABORTED");
expect(MIGRATION_CODES.DRY_RUN).toBe("MIGRATION.DRY_RUN");
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(MIGRATION_CODES)).toBe(true);
});
});
describe("MigrationError", () => {
it("creates error with correct code and message", { timeout: 5000 }, () => {
const err = new MigrationError(MIGRATION_CODES.FAILED, "test failed");
expect(err.code).toBe("MIGRATION.FAILED");
expect(err.message).toBe("test failed");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(MigrationError);
});
it("carries cause when provided as Error", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new MigrationError(
MIGRATION_CODES.STORE_ERROR,
"store failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new MigrationError(MIGRATION_CODES.ABORTED, "aborted", {
cause: "string-cause",
});
expect(err.cause).toBeUndefined();
});
it("has correct name", { timeout: 5000 }, () => {
const err = new MigrationError(MIGRATION_CODES.FAILED, "msg");
expect(err.name).toBe("MigrationError");
});
it("works for each code variant", { timeout: 5000 }, () => {
const codes = Object.values(MIGRATION_CODES);
for (const code of codes) {
const err = new MigrationError(code, "msg");
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,29 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the migration package. */
export const MIGRATION_CODES = defineCodes("MIGRATION", [
"FAILED",
"LOCK_TIMEOUT",
"CHECKSUM_MISMATCH",
"NOT_FOUND",
"STORE_ERROR",
"PLAN_INVALID",
"ABORTED",
"DRY_RUN",
] as const);
export type MigrationErrorCode =
(typeof MIGRATION_CODES)[keyof typeof MIGRATION_CODES];
/** Base error class for all migration-package errors. */
export class MigrationError extends SuaveplanError<MigrationErrorCode> {
constructor(
code: MigrationErrorCode,
message: string,
options: { cause?: unknown } = {}
) {
super(code, message, {
cause: options.cause instanceof Error ? options.cause : undefined,
});
}
}

View File

@@ -0,0 +1,98 @@
// Errors
// Adapters
export {
SurrealDbAdapter,
type SurrealDbAdapterConfig,
type SurrealInstance,
} from "./adapters/surrealdb/index.js";
// Batch
export { BatchMigrator } from "./batch/batch-migrator/index.js";
// Constants
export {
DEFAULT_LEDGER_TABLE,
DEFAULT_LOCK_KEY,
DEFAULT_LOCK_TIMEOUT_MS,
DEFAULT_LOCK_TTL_MS,
} from "./constants/index.js";
export {
MIGRATION_CODES,
MigrationError,
type MigrationErrorCode,
} from "./errors/index.js";
export {
DistributedMigrationLock,
InMemoryDistributedLockAdapter,
} from "./lock/distributed-lock/index.js";
// Lock
export { MemoryMigrationLock } from "./lock/memory-lock/index.js";
export { RedisDistributedLock } from "./lock/redis-lock/index.js";
// Planner
export {
checksumForMigration,
planDown,
planUp,
sortMigrations,
} from "./planner/migration-planner/index.js";
export { DryRunReporter } from "./runner/dry-run-reporter/index.js";
export { MigrationExecutor } from "./runner/migration-executor/index.js";
// Runner
export {
createMigrationRunner,
MigrationRunner,
} from "./runner/migration-runner/index.js";
// Schemas
export {
BatchMigratorConfigSchema,
DistributedLockConfigSchema,
MemoryStorageOptionsSchema,
MigrationRunnerOptionsSchema,
RedisLockConfigSchema,
RunOptionsSchema,
} from "./schemas/index.js";
// Status
export { MigrationStatusDashboard } from "./status/migration-status/index.js";
export {
DatabaseMigrationStore,
type DatabaseStoreConfig,
type DatabaseStoreDb,
} from "./store/database-store/index.js";
// Store
export { MemoryMigrationStore } from "./store/memory-store/index.js";
// Types
export type {
BatchMigrationResult,
BatchMigratorConfig,
DistributedLockAdapter,
DistributedLockConfig,
DryRunEntry,
DryRunReport,
ExecutorOptions,
ExtendedMigrationStatus,
Migration,
MigrationContext,
MigrationDirection,
MigrationEventMap,
MigrationHealthCheck,
MigrationHistoryEntry,
MigrationLockAdapter,
MigrationPlan,
MigrationRecord,
MigrationResult,
MigrationRunnerConfig,
MigrationRunnerLike,
MigrationStatusEntry,
MigrationStatusValue,
MigrationStoreAdapter,
MigrationSummary,
MigrationValidationError,
RedisLockClient,
RedisLockConfig,
RiskLevel,
RunOptions,
StatusConfig,
ValidationResult,
} from "./types/index.js";
// Utils
export { checksumMigration, computeChecksum } from "./utils/checksum.js";

View File

@@ -0,0 +1,61 @@
# distributed-lock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a `DistributedMigrationLock` that coordinates migration locking across multiple application instances using a pluggable `DistributedLockAdapter`. Also exports `InMemoryDistributedLockAdapter` for testing and single-node use. The distributed lock uses the same exponential back-off polling strategy as the memory lock, delegating actual lock storage to the adapter.
## Features
- `DistributedMigrationLock` — delegates to any `DistributedLockAdapter` with configurable `key` and `ttl`.
- `InMemoryDistributedLockAdapter` — TTL-based in-memory adapter for testing without external infrastructure.
- Exponential back-off polling from 50 ms up to 1 s ceiling.
## Usage: Basic Example
```ts
import { DistributedMigrationLock, InMemoryDistributedLockAdapter } from "@suaveplan/migration";
const lock = new DistributedMigrationLock({
adapter: new InMemoryDistributedLockAdapter(),
key: "app_migration_lock",
ttl: 60_000,
});
const acquired = await lock.acquire(30_000);
```
## Usage: Advanced Example
```ts
import { DistributedMigrationLock } from "@suaveplan/migration";
import type { DistributedLockAdapter } from "@suaveplan/migration";
class PgAdvisoryLockAdapter implements DistributedLockAdapter {
async acquire(key: string, ttl: number) { /* pg_try_advisory_lock */ return true; }
async release(key: string) { /* pg_advisory_unlock */ }
}
const lock = new DistributedMigrationLock({ adapter: new PgAdvisoryLockAdapter() });
```
## API Reference
### `class DistributedMigrationLock implements MigrationLockAdapter`
**Constructor**: `(config: DistributedLockConfig)`
- `config.adapter` — `DistributedLockAdapter` implementation.
- `config.key?` — lock key (default `"migration_lock"`).
- `config.ttl?` — lock TTL in ms (default `60_000`).
### `class InMemoryDistributedLockAdapter implements DistributedLockAdapter`
- `acquire(key, ttl): Promise<boolean>` — acquires if no unexpired entry for `key` exists.
- `release(key): Promise<void>` — removes the entry.
## Implementation Notes
`isLocked` uses a probe key (`<key>__probe`) with zero TTL to test whether anything is competing for the lock — this avoids reading lock state directly, which the interface does not expose.

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
DistributedMigrationLock,
InMemoryDistributedLockAdapter,
} from "./distributed-lock.js";
describe("InMemoryDistributedLockAdapter", () => {
let adapter: InMemoryDistributedLockAdapter;
beforeEach(() => {
adapter = new InMemoryDistributedLockAdapter();
});
it("acquires a free lock", { timeout: 5000 }, async () => {
const result = await adapter.acquire("key1", 5000);
expect(result).toBe(true);
});
it("rejects a held lock", { timeout: 5000 }, async () => {
await adapter.acquire("key1", 5000);
const result = await adapter.acquire("key1", 5000);
expect(result).toBe(false);
});
it("acquires after release", { timeout: 5000 }, async () => {
await adapter.acquire("key1", 5000);
await adapter.release("key1");
const result = await adapter.acquire("key1", 5000);
expect(result).toBe(true);
});
it("different keys are independent", { timeout: 5000 }, async () => {
await adapter.acquire("key1", 5000);
const result = await adapter.acquire("key2", 5000);
expect(result).toBe(true);
});
it("expired lock can be re-acquired", { timeout: 5000 }, async () => {
await adapter.acquire("key1", 1); // 1ms TTL
await new Promise((r) => setTimeout(r, 10));
const result = await adapter.acquire("key1", 5000);
expect(result).toBe(true);
});
});
describe("DistributedMigrationLock", () => {
let adapter: InMemoryDistributedLockAdapter;
let lock: DistributedMigrationLock;
beforeEach(() => {
adapter = new InMemoryDistributedLockAdapter();
lock = new DistributedMigrationLock({
adapter,
key: "test_lock",
ttl: 5000,
});
});
it("acquires when free", { timeout: 5000 }, async () => {
const result = await lock.acquire(100);
expect(result).toBe(true);
});
it("releases lock", { timeout: 5000 }, async () => {
await lock.acquire(100);
await lock.release();
// Can re-acquire
const result = await lock.acquire(100);
expect(result).toBe(true);
});
it("times out when locked", { timeout: 5000 }, async () => {
await adapter.acquire("test_lock", 60_000);
const result = await lock.acquire(100);
expect(result).toBe(false);
});
it("isLocked returns false when free", { timeout: 5000 }, async () => {
const locked = await lock.isLocked();
expect(locked).toBe(false);
});
it("uses default key and ttl", { timeout: 5000 }, async () => {
const defaultLock = new DistributedMigrationLock({ adapter });
const result = await defaultLock.acquire(100);
expect(result).toBe(true);
});
it("isLocked returns true when lock is held", {
timeout: 5000,
}, async () => {
// Hold both the main key and probe key to force isLocked true
await adapter.acquire("test_lock", 5000);
await adapter.acquire("test_lock__probe", 5000);
const locked = await lock.isLocked();
expect(locked).toBe(true);
});
it("sleep uses remaining time when delay exceeds deadline", {
timeout: 5000,
}, async () => {
// Lock held forever, very short timeout so delay (50ms) > remaining time
await adapter.acquire("test_lock", 60_000);
const result = await lock.acquire(30);
expect(result).toBe(false);
});
it("acquire uses default timeout when called without argument", {
timeout: 5000,
}, async () => {
// Free lock — should acquire using the default timeout (30s) without waiting
const acquired = await lock.acquire();
expect(acquired).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import {
DEFAULT_LOCK_KEY,
DEFAULT_LOCK_TIMEOUT_MS,
DEFAULT_LOCK_TTL_MS,
} from "../../constants/index.js";
import type {
DistributedLockAdapter,
DistributedLockConfig,
MigrationLockAdapter,
} from "../../types/migration.types.js";
/**
* In-memory implementation of the distributed lock adapter interface.
* Suitable for testing without a real Redis or database backend.
*/
export class InMemoryDistributedLockAdapter implements DistributedLockAdapter {
protected readonly locks: Map<string, number> = new Map();
async acquire(key: string, ttl: number): Promise<boolean> {
const now = Date.now();
const existing = this.locks.get(key);
if (existing !== undefined && existing > now) {
return false;
}
this.locks.set(key, now + ttl);
return true;
}
async release(key: string): Promise<void> {
this.locks.delete(key);
}
}
/**
* Distributed advisory lock that delegates acquisition and release to a
* `DistributedLockAdapter`. Supports timeout-based retry.
*/
export class DistributedMigrationLock implements MigrationLockAdapter {
protected readonly adapter: DistributedLockAdapter;
protected readonly key: string;
protected readonly ttl: number;
constructor(config: DistributedLockConfig) {
this.adapter = config.adapter;
this.key = config.key ?? DEFAULT_LOCK_KEY;
this.ttl = config.ttl ?? DEFAULT_LOCK_TTL_MS;
}
async acquire(timeoutMs = DEFAULT_LOCK_TIMEOUT_MS): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
let delay = 50;
while (Date.now() < deadline) {
const acquired = await this.adapter.acquire(this.key, this.ttl);
if (acquired) return true;
await sleep(Math.min(delay, deadline - Date.now()));
delay = Math.min(delay * 2, 1000);
}
return false;
}
async release(): Promise<void> {
await this.adapter.release(this.key);
}
async isLocked(): Promise<boolean> {
// Try to acquire with zero ttl — if it fails, something holds the lock
const canAcquire = await this.adapter.acquire(`${this.key}__probe`, 0);
if (canAcquire) {
await this.adapter.release(`${this.key}__probe`);
return false;
}
return true;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
}

View File

@@ -0,0 +1,4 @@
export {
DistributedMigrationLock,
InMemoryDistributedLockAdapter,
} from "./distributed-lock.js";

View File

@@ -0,0 +1 @@
export { MemoryMigrationLock } from "./memory-lock.js";

View File

@@ -0,0 +1,57 @@
# memory-lock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryMigrationLock` implements `MigrationLockAdapter` using an in-process boolean flag. It prevents concurrent migration runs within a single Node.js process — useful during development and in single-instance deployments. Multi-instance deployments should use `DistributedMigrationLock` or `RedisDistributedLock` instead.
## Features
- `acquire(timeoutMs?)` — polls with exponential back-off (10 ms → 500 ms ceiling) until the lock is available or the timeout expires.
- `release()` — unsets the lock flag immediately.
- `isLocked()` — synchronous check returning the current lock state.
## Usage: Basic Example
```ts
import { MemoryMigrationLock } from "@suaveplan/migration";
const lock = new MemoryMigrationLock();
const acquired = await lock.acquire(5000);
if (acquired) {
// run migrations
await lock.release();
}
```
## Usage: Advanced Example
```ts
import { MemoryMigrationLock } from "@suaveplan/migration";
const lock = new MemoryMigrationLock();
// Attempt to acquire with a short timeout
const acquired = await lock.acquire(100);
if (!acquired) {
throw new Error("Could not acquire migration lock within 100 ms");
}
try {
// critical section
} finally {
await lock.release();
}
```
## API Reference
### `class MemoryMigrationLock implements MigrationLockAdapter`
- `acquire(timeoutMs?: number): Promise<boolean>` — returns `true` if acquired within the timeout (default 30 000 ms), `false` otherwise.
- `release(): Promise<void>` — always resolves, sets locked flag to `false`.
- `isLocked(): Promise<boolean>` — returns current locked state.
## Implementation Notes
The polling loop uses exponential back-off starting at 10 ms and capped at 500 ms to balance responsiveness with CPU efficiency. `Math.max(0, ms)` guards against negative delays at the deadline boundary.

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryMigrationLock } from "./memory-lock.js";
describe("MemoryMigrationLock", () => {
let lock: MemoryMigrationLock;
beforeEach(() => {
lock = new MemoryMigrationLock();
});
it("acquires lock when free", { timeout: 5000 }, async () => {
const acquired = await lock.acquire(100);
expect(acquired).toBe(true);
expect(await lock.isLocked()).toBe(true);
});
it("reports not locked initially", { timeout: 5000 }, async () => {
expect(await lock.isLocked()).toBe(false);
});
it("releases lock", { timeout: 5000 }, async () => {
await lock.acquire(100);
await lock.release();
expect(await lock.isLocked()).toBe(false);
});
it("returns false when timeout exceeded", { timeout: 10000 }, async () => {
await lock.acquire(10000);
// Second acquire should fail quickly
const acquired = await lock.acquire(50);
expect(acquired).toBe(false);
});
it("re-acquires after release", { timeout: 5000 }, async () => {
await lock.acquire(100);
await lock.release();
const acquired = await lock.acquire(100);
expect(acquired).toBe(true);
});
it("release on unlocked lock is no-op", { timeout: 5000 }, async () => {
await lock.release();
expect(await lock.isLocked()).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import type { MigrationLockAdapter } from "../../types/migration.types.js";
/**
* In-process advisory lock using a simple boolean flag. Safe for single-node
* environments. Resolves `acquire` using a polling loop with exponential
* back-off when the lock is held.
*/
export class MemoryMigrationLock implements MigrationLockAdapter {
protected locked = false;
async acquire(timeoutMs = 30_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
let delay = 10;
while (Date.now() < deadline) {
if (!this.locked) {
this.locked = true;
return true;
}
await sleep(Math.min(delay, deadline - Date.now()));
delay = Math.min(delay * 2, 500);
}
return false;
}
async release(): Promise<void> {
this.locked = false;
}
async isLocked(): Promise<boolean> {
return this.locked;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
}

View File

@@ -0,0 +1 @@
export { RedisDistributedLock } from "./redis-lock.js";

View File

@@ -0,0 +1,68 @@
# redis-lock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RedisDistributedLock` implements `MigrationLockAdapter` using Redis `SET NX PX` semantics. This provides a distributed advisory lock suitable for multi-instance deployments where a shared Redis server is available. The implementation requires only a minimal Redis client interface (`RedisLockClient`) with `set` and `del` methods, avoiding any hard dependency on a specific Redis client library.
## Features
- `SET NX PX` lock acquisition — atomic test-and-set with TTL expiry.
- Exponential back-off polling from 50 ms up to 1 s ceiling.
- Configurable `key`, `ttl`, and acquisition `timeoutMs`.
- `isLocked()` probe using a disposable key to avoid reading lock state directly.
## Usage: Basic Example
```ts
import { RedisDistributedLock } from "@suaveplan/migration";
import { createClient } from "redis";
const redisClient = createClient();
await redisClient.connect();
const lock = new RedisDistributedLock({
client: redisClient,
key: "app_migration_lock",
ttl: 30_000,
});
const acquired = await lock.acquire(10_000);
if (acquired) {
try { /* run migrations */ } finally { await lock.release(); }
}
```
## Usage: Advanced Example
```ts
import { RedisDistributedLock } from "@suaveplan/migration";
// Inject a stub Redis client in tests
const stubClient = {
async set() { return "OK"; },
async del() { return 1; },
};
const lock = new RedisDistributedLock({ client: stubClient });
```
## API Reference
### `class RedisDistributedLock implements MigrationLockAdapter`
**Constructor**: `(config: RedisLockConfig)`
- `config.client` — Redis-compatible client with `set(key, value, opts)` and `del(key)` methods.
- `config.key?` — lock key (default `"migration_lock"`).
- `config.ttl?` — lock TTL in ms (default `60_000`).
- `acquire(timeoutMs?): Promise<boolean>` — polls until acquired or timeout.
- `release(): Promise<void>` — deletes the lock key.
- `isLocked(): Promise<boolean>` — uses a probe key to check lock state.
## Implementation Notes
The `isLocked` probe key (`<key>__probe`) uses a 1 ms TTL and is immediately deleted if acquired, minimising leakage. This approach avoids a Redis `GET` which the minimal `RedisLockClient` interface does not expose.

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { RedisLockClient } from "../../types/migration.types.js";
import { RedisDistributedLock } from "./redis-lock.js";
/** Fake Redis client that simulates SET NX PX behavior. */
class FakeRedisClient implements RedisLockClient {
protected readonly store: Map<
string,
{ value: string; expiresAt: number }
> = new Map();
async set(
key: string,
value: string,
options: { NX?: boolean; PX?: number }
): Promise<string | null> {
const now = Date.now();
const existing = this.store.get(key);
if (options.NX && existing && existing.expiresAt > now) {
return null;
}
const expiresAt = now + (options.PX ?? 60_000);
this.store.set(key, { value, expiresAt });
return "OK";
}
async del(key: string): Promise<number> {
const existed = this.store.has(key);
this.store.delete(key);
return existed ? 1 : 0;
}
}
describe("RedisDistributedLock", () => {
let client: FakeRedisClient;
let lock: RedisDistributedLock;
beforeEach(() => {
client = new FakeRedisClient();
lock = new RedisDistributedLock({
client,
key: "test_lock",
ttl: 5000,
});
});
it("acquires a free lock", { timeout: 5000 }, async () => {
const result = await lock.acquire(100);
expect(result).toBe(true);
});
it("times out when lock is held", { timeout: 5000 }, async () => {
await client.set("test_lock", "1", { NX: true, PX: 60_000 });
const result = await lock.acquire(100);
expect(result).toBe(false);
});
it("releases the lock", { timeout: 5000 }, async () => {
await lock.acquire(100);
await lock.release();
const result = await lock.acquire(100);
expect(result).toBe(true);
});
it("isLocked returns false when free", { timeout: 5000 }, async () => {
const locked = await lock.isLocked();
expect(locked).toBe(false);
});
it("isLocked returns true when locked", { timeout: 5000 }, async () => {
// Hold the probe key to simulate a locked state
await client.set("test_lock__probe", "1", { NX: true, PX: 60_000 });
const locked = await lock.isLocked();
expect(locked).toBe(true);
});
it("uses default key and ttl", { timeout: 5000 }, async () => {
const defaultLock = new RedisDistributedLock({ client });
const result = await defaultLock.acquire(100);
expect(result).toBe(true);
});
it("retries after initial null and eventually acquires", {
timeout: 5000,
}, async () => {
let callCount = 0;
const retryClient: RedisLockClient = {
async set(_key, _value, _opts): Promise<string | null> {
callCount++;
if (callCount === 1) return null; // First try fails
return "OK"; // Second try succeeds
},
async del(): Promise<number> {
return 1;
},
};
const retryLock = new RedisDistributedLock({
client: retryClient,
key: "retry_key",
ttl: 1000,
});
const result = await retryLock.acquire(2000);
expect(result).toBe(true);
expect(callCount).toBeGreaterThan(1);
});
it("sleep uses remaining time when delay exceeds deadline", {
timeout: 5000,
}, async () => {
// Use a very short timeout (30ms) so delay (50ms) > remaining time → Math.min uses remaining
const neverAcquires: RedisLockClient = {
async set(): Promise<string | null> {
return null;
},
async del(): Promise<number> {
return 1;
},
};
const shortLock = new RedisDistributedLock({
client: neverAcquires,
key: "short",
ttl: 1000,
});
const result = await shortLock.acquire(30);
expect(result).toBe(false);
});
it("acquire uses default timeout when called without argument", {
timeout: 5000,
}, async () => {
// Free lock — acquires immediately using the default timeout (30s)
const result = await lock.acquire();
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import {
DEFAULT_LOCK_KEY,
DEFAULT_LOCK_TIMEOUT_MS,
DEFAULT_LOCK_TTL_MS,
} from "../../constants/index.js";
import type {
MigrationLockAdapter,
RedisLockClient,
RedisLockConfig,
} from "../../types/migration.types.js";
const LOCK_VALUE = "1";
/**
* Redis-backed advisory lock using `SET NX PX` semantics. Suitable for
* multi-instance deployments where a shared Redis instance is available.
*/
export class RedisDistributedLock implements MigrationLockAdapter {
protected readonly client: RedisLockClient;
protected readonly key: string;
protected readonly ttl: number;
constructor(config: RedisLockConfig) {
this.client = config.client;
this.key = config.key ?? DEFAULT_LOCK_KEY;
this.ttl = config.ttl ?? DEFAULT_LOCK_TTL_MS;
}
async acquire(timeoutMs = DEFAULT_LOCK_TIMEOUT_MS): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
let delay = 50;
while (Date.now() < deadline) {
const result = await this.client.set(this.key, LOCK_VALUE, {
NX: true,
PX: this.ttl,
});
if (result !== null) return true;
await sleep(Math.min(delay, deadline - Date.now()));
delay = Math.min(delay * 2, 1000);
}
return false;
}
async release(): Promise<void> {
await this.client.del(this.key);
}
async isLocked(): Promise<boolean> {
// Attempt a probe set with minimal TTL — if it fails, lock is held
const result = await this.client.set(`${this.key}__probe`, LOCK_VALUE, {
NX: true,
PX: 1,
});
if (result !== null) {
await this.client.del(`${this.key}__probe`);
return false;
}
return true;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
}

View File

@@ -0,0 +1,6 @@
export {
checksumForMigration,
planDown,
planUp,
sortMigrations,
} from "./migration-planner.js";

View File

@@ -0,0 +1,56 @@
# migration-planner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The migration planner produces `MigrationPlan` objects that describe which migrations should be applied or rolled back, without executing them. Separating planning from execution makes dry-run reporting straightforward, enables validation before any database writes, and simplifies unit testing by removing I/O dependencies from the planning step.
## Features
- `sortMigrations(migrations)` — lexicographic sort by `id`, putting numeric prefixes in ascending order.
- `planUp(all, applied)` — returns all pending (not-yet-applied) migrations sorted ascending.
- `planDown(all, applied, steps?)` — returns the last N applied migrations in reverse (rollback) order.
- `checksumForMigration(migration)` — returns the stored checksum or computes one from the up function body.
## Usage: Basic Example
```ts
import { planUp, sortMigrations } from "@suaveplan/migration";
const plan = planUp(allMigrations, []);
// plan.toApply contains all migrations in ascending id order
```
## Usage: Advanced Example
```ts
import { planDown } from "@suaveplan/migration";
// Rollback only the last 2 applied migrations
const plan = planDown(allMigrations, appliedRecords, 2);
// plan.toRollback[0] is the most recently applied migration
```
## API Reference
### `sortMigrations(migrations: Migration[]): Migration[]`
Returns a new array sorted by `id` ascending. Does not mutate the input.
### `planUp(all: Migration[], applied: MigrationRecord[]): MigrationPlan`
Returns `{ toApply: Migration[], toRollback: [] }`. `toApply` contains migrations whose `id` is not in `applied`.
### `planDown(all: Migration[], applied: MigrationRecord[], steps?: number): MigrationPlan`
Returns `{ toApply: [], toRollback: Migration[] }`. `toRollback` is at most `steps` migrations (all if omitted), most-recent first.
### `checksumForMigration(migration: Migration): string`
Returns `migration.checksum` if set, otherwise computes SHA-256 of `migration.up.toString()`.
## Implementation Notes
`planDown` reverses the sorted applied list before slicing, ensuring rollbacks proceed in most-recent-first order (LIFO). `sortMigrations` uses `localeCompare` for reliable multi-character prefix handling.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
Migration,
MigrationRecord,
} from "../../types/migration.types.js";
import {
checksumForMigration,
planDown,
planUp,
sortMigrations,
} from "./migration-planner.js";
function makeMigration(id: string): Migration {
return {
id,
name: `Migration ${id}`,
async up(_ctx) {},
async down(_ctx) {},
};
}
function makeRecord(id: string): MigrationRecord {
return {
id,
name: `Migration ${id}`,
appliedAt: Date.now(),
direction: "up",
};
}
describe("sortMigrations", () => {
it("sorts by id lexicographically", { timeout: 5000 }, () => {
const m1 = makeMigration("003_c");
const m2 = makeMigration("001_a");
const m3 = makeMigration("002_b");
const sorted = sortMigrations([m1, m2, m3]);
expect(sorted.map((m) => m.id)).toEqual(["001_a", "002_b", "003_c"]);
});
it("does not mutate input array", { timeout: 5000 }, () => {
const input = [makeMigration("002"), makeMigration("001")];
const sorted = sortMigrations(input);
expect(input[0]!.id).toBe("002");
expect(sorted[0]!.id).toBe("001");
});
it("handles single migration", { timeout: 5000 }, () => {
const sorted = sortMigrations([makeMigration("001")]);
expect(sorted).toHaveLength(1);
});
it("handles empty array", { timeout: 5000 }, () => {
expect(sortMigrations([])).toEqual([]);
});
});
describe("planUp", () => {
it("returns all migrations when none applied", { timeout: 5000 }, () => {
const all = [makeMigration("001"), makeMigration("002")];
const plan = planUp(all, []);
expect(plan.toApply).toHaveLength(2);
expect(plan.toRollback).toHaveLength(0);
});
it("skips already applied migrations", { timeout: 5000 }, () => {
const all = [makeMigration("001"), makeMigration("002")];
const plan = planUp(all, [makeRecord("001")]);
expect(plan.toApply.map((m) => m.id)).toEqual(["002"]);
});
it("returns empty plan when all applied", { timeout: 5000 }, () => {
const all = [makeMigration("001")];
const plan = planUp(all, [makeRecord("001")]);
expect(plan.toApply).toHaveLength(0);
});
it("is idempotent for fully applied set", { timeout: 5000 }, () => {
const all = [makeMigration("001"), makeMigration("002")];
const applied = [makeRecord("001"), makeRecord("002")];
const plan = planUp(all, applied);
expect(plan.toApply).toHaveLength(0);
});
});
describe("planDown", () => {
it("returns all applied in reverse when no steps specified", {
timeout: 5000,
}, () => {
const all = [makeMigration("001"), makeMigration("002")];
const applied = [makeRecord("001"), makeRecord("002")];
const plan = planDown(all, applied);
expect(plan.toRollback.map((m) => m.id)).toEqual(["002", "001"]);
});
it("limits to specified steps", { timeout: 5000 }, () => {
const all = [
makeMigration("001"),
makeMigration("002"),
makeMigration("003"),
];
const applied = [
makeRecord("001"),
makeRecord("002"),
makeRecord("003"),
];
const plan = planDown(all, applied, 1);
expect(plan.toRollback).toHaveLength(1);
expect(plan.toRollback[0]!.id).toBe("003");
});
it("returns empty when nothing applied", { timeout: 5000 }, () => {
const all = [makeMigration("001")];
const plan = planDown(all, []);
expect(plan.toRollback).toHaveLength(0);
});
it("toApply is always empty", { timeout: 5000 }, () => {
const all = [makeMigration("001")];
const plan = planDown(all, [makeRecord("001")]);
expect(plan.toApply).toHaveLength(0);
});
});
describe("checksumForMigration", () => {
it("returns stored checksum when provided", { timeout: 5000 }, () => {
const m = { ...makeMigration("001"), checksum: "abc123" };
expect(checksumForMigration(m)).toBe("abc123");
});
it("computes checksum when not provided", { timeout: 5000 }, () => {
const m = makeMigration("001");
const result = checksumForMigration(m);
expect(result).toHaveLength(64);
});
it("is deterministic", { timeout: 5000 }, () => {
const m = makeMigration("001");
expect(checksumForMigration(m)).toBe(checksumForMigration(m));
});
});

View File

@@ -0,0 +1,56 @@
import type {
Migration,
MigrationPlan,
MigrationRecord,
} from "../../types/migration.types.js";
import { checksumMigration } from "../../utils/checksum.js";
/**
* Sort migrations by their `id` lexicographically, which puts numeric prefixes
* in ascending order (e.g. "001_", "002_", …).
*/
export function sortMigrations(migrations: Migration[]): Migration[] {
return [...migrations].sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Produce a plan for running `up`: returns all migrations not yet in
* `appliedRecords` with direction "up", sorted in ascending id order.
*/
export function planUp(
all: Migration[],
applied: MigrationRecord[]
): MigrationPlan {
const appliedIds = new Set(
applied.filter((r) => r.direction === "up").map((r) => r.id)
);
const sorted = sortMigrations(all);
const toApply = sorted.filter((m) => !appliedIds.has(m.id));
return { toApply, toRollback: [] };
}
/**
* Produce a plan for running `down`: returns the last `steps` applied
* migrations (with direction "up") in reverse order (most recent first).
* When `steps` is undefined, rolls back all applied migrations.
*/
export function planDown(
all: Migration[],
applied: MigrationRecord[],
steps?: number
): MigrationPlan {
const appliedIds = new Set(
applied.filter((r) => r.direction === "up").map((r) => r.id)
);
const sorted = sortMigrations(all).filter((m) => appliedIds.has(m.id));
const toRollback = sorted.reverse().slice(0, steps ?? sorted.length);
return { toApply: [], toRollback };
}
/**
* Compute the checksum for a migration's `up` function. Uses `checksumMigration`
* from the utils module.
*/
export function checksumForMigration(migration: Migration): string {
return migration.checksum ?? checksumMigration(migration.up);
}

View File

@@ -0,0 +1,56 @@
# dry-run-reporter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DryRunReporter` collects per-migration entries during a dry-run pass and produces a structured `DryRunReport` with an aggregate risk level. It allows callers to understand which migrations would be applied, their individual risk levels, and the worst-case overall risk without executing any SQL. This is particularly valuable in CI pipelines and change-review workflows.
## Features
- `record(entry)` — accumulates a `DryRunEntry` (migrationId, direction, riskLevel, optional reason).
- `report()` — returns a snapshot of all entries, the computed total risk, and a human-readable summary.
- Risk level aggregation: `totalRisk` is the maximum across all entries (`low` < `medium` < `high`).
- Summary string differentiates between `up` and `down` counts.
## Usage: Basic Example
```ts
import { DryRunReporter } from "@suaveplan/migration";
const reporter = new DryRunReporter();
reporter.record({ migrationId: "001", direction: "up", riskLevel: "low" });
const report = reporter.report();
// report.totalRisk === "low"
// report.summary === "Dry run: 1 up migration(s) would be applied. Overall risk: low."
```
## Usage: Advanced Example
```ts
import { DryRunReporter } from "@suaveplan/migration";
const reporter = new DryRunReporter();
reporter.record({ migrationId: "001", direction: "up", riskLevel: "low", reason: "add table" });
reporter.record({ migrationId: "002", direction: "up", riskLevel: "high", reason: "drop column" });
const report = reporter.report();
// report.totalRisk === "high"
```
## API Reference
### `class DryRunReporter`
- `record(entry: DryRunEntry): void` — appends an entry. `entry` fields: `migrationId`, `direction`, `riskLevel` (`"low"|"medium"|"high"`), optional `reason`.
- `report(): DryRunReport` — returns `{ entries, totalRisk, summary }`.
### `DryRunReport`
- `entries: DryRunEntry[]` — snapshot of all recorded entries.
- `totalRisk: RiskLevel` — maximum risk level across all entries.
- `summary: string` — human-readable description.
## Implementation Notes
`RISK_ORDER` maps risk levels to integers for comparison. `report()` returns a spread copy of `entries` so callers cannot mutate the internal list. When no entries are recorded, `totalRisk` is `"low"` and the summary says "No migrations to apply."

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DryRunReporter } from "./dry-run-reporter.js";
describe("DryRunReporter", () => {
it("returns low risk and no-migrations summary when empty", {
timeout: 5000,
}, () => {
const reporter = new DryRunReporter();
const report = reporter.report();
expect(report.entries).toHaveLength(0);
expect(report.totalRisk).toBe("low");
expect(report.summary).toBe("No migrations to apply.");
});
it("records entries", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "up",
riskLevel: "low",
});
const report = reporter.report();
expect(report.entries).toHaveLength(1);
expect(report.entries[0]!.migrationId).toBe("001");
});
it("computes total risk as max", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "up",
riskLevel: "low",
});
reporter.record({
migrationId: "002",
direction: "up",
riskLevel: "high",
});
reporter.record({
migrationId: "003",
direction: "up",
riskLevel: "medium",
});
expect(reporter.report().totalRisk).toBe("high");
});
it("summary includes up count", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "up",
riskLevel: "medium",
});
const report = reporter.report();
expect(report.summary).toContain("1 up");
expect(report.summary).toContain("medium");
});
it("summary includes down count", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "down",
riskLevel: "low",
});
const report = reporter.report();
expect(report.summary).toContain("1 down");
});
it("summary includes both up and down", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "up",
riskLevel: "low",
});
reporter.record({
migrationId: "002",
direction: "down",
riskLevel: "low",
});
const report = reporter.report();
expect(report.summary).toContain("1 up");
expect(report.summary).toContain("1 down");
});
it("report entries are a snapshot", { timeout: 5000 }, () => {
const reporter = new DryRunReporter();
reporter.record({
migrationId: "001",
direction: "up",
riskLevel: "low",
});
const report = reporter.report();
reporter.record({
migrationId: "002",
direction: "up",
riskLevel: "low",
});
expect(report.entries).toHaveLength(1);
});
});

View File

@@ -0,0 +1,46 @@
import type {
DryRunEntry,
DryRunReport,
RiskLevel,
} from "../../types/migration.types.js";
const RISK_ORDER: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
/**
* Collects dry-run entries and produces a summary report with an aggregate
* risk level. Used by the executor when `dryRun: true` is set.
*/
export class DryRunReporter {
protected readonly entries: DryRunEntry[] = [];
record(entry: DryRunEntry): void {
this.entries.push(entry);
}
report(): DryRunReport {
const totalRisk = this.computeTotalRisk();
const summary = this.buildSummary(totalRisk);
return { entries: [...this.entries], totalRisk, summary };
}
protected computeTotalRisk(): RiskLevel {
if (this.entries.length === 0) return "low";
return this.entries.reduce<RiskLevel>((max, e) => {
return RISK_ORDER[e.riskLevel] > RISK_ORDER[max]
? e.riskLevel
: max;
}, "low");
}
protected buildSummary(totalRisk: RiskLevel): string {
const count = this.entries.length;
if (count === 0) return "No migrations to apply.";
const directions = this.entries.map((e) => e.direction);
const upCount = directions.filter((d) => d === "up").length;
const downCount = directions.filter((d) => d === "down").length;
const parts: string[] = [];
if (upCount > 0) parts.push(`${upCount} up`);
if (downCount > 0) parts.push(`${downCount} down`);
return `Dry run: ${parts.join(", ")} migration(s) would be applied. Overall risk: ${totalRisk}.`;
}
}

View File

@@ -0,0 +1 @@
export { DryRunReporter } from "./dry-run-reporter.js";

View File

@@ -0,0 +1 @@
export { MigrationExecutor } from "./migration-executor.js";

View File

@@ -0,0 +1,61 @@
# migration-executor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MigrationExecutor` takes a `MigrationPlan` and actually runs each migration in the specified direction, recording results in the store, emitting lifecycle events, and surfacing structured errors. It is the only component in the migration package that performs database writes. Separating execution from planning makes it easy to test each in isolation and to slot in different execution strategies (e.g. transactional, concurrent) without changing the planner logic.
## Features
- Runs `toApply` (direction `up`) or `toRollback` (direction `down`) from the plan.
- AbortSignal cancellation between each migration step.
- Checksum validation before applying already-recorded migrations.
- Dry-run mode: records which migrations would run without executing SQL.
- Event emission: `migration:plan`, `migration:before`, `migration:after`, `migration:failed`.
- Wraps all thrown errors in `MigrationError(MIGRATION.FAILED)` unless already a `MigrationError`.
## Usage: Basic Example
```ts
import { MigrationExecutor } from "@suaveplan/migration";
const executor = new MigrationExecutor({ store, context, dryRun: false });
const result = await executor.execute(plan, "up");
```
## Usage: Advanced Example
```ts
import { MigrationExecutor } from "@suaveplan/migration";
import { EventBus } from "@suaveplan/events";
const events = new EventBus<MigrationEventMap>();
events.on("migration:after", ({ migration, durationMs }) => {
console.log(`${migration.id} applied in ${durationMs}ms`);
});
const executor = new MigrationExecutor({ store, context, events });
const result = await executor.execute(plan, "up", { signal });
```
## API Reference
### `class MigrationExecutor`
**Constructor**: `(options: ExecutorOptions, clock?: Clock)`
- `options.store` — migration store adapter for recording results.
- `options.context` — migration context injected into each migration function.
- `options.events?` — optional event bus for lifecycle events.
- `options.dryRun?` — if `true`, collects applied IDs without running SQL (default `false`).
- `options.checksumValidation?` — if `true`, validates checksums on up-direction runs (default `true`).
### `execute(plan, direction, runOptions?): Promise<MigrationResult>`
Returns `{ applied, skipped, failed, direction, dryRun }`. Throws `MigrationError` on abort, checksum mismatch, or execution failure.
## Implementation Notes
Checksum validation runs only for `up` migrations that already have a ledger record with a checksum — it does not block the first application. The `down` direction requires `migration.down` to be defined; if absent, a `MIGRATION.NOT_FOUND` error is thrown.

View File

@@ -0,0 +1,244 @@
import { EventBus } from "@suaveplan/events";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MigrationError } from "../../errors/migration-error.js";
import { MemoryMigrationStore } from "../../store/memory-store/memory-store.js";
import type {
Migration,
MigrationContext,
MigrationEventMap,
MigrationPlan,
} from "../../types/migration.types.js";
import { MigrationExecutor } from "./migration-executor.js";
function makeCtx(): MigrationContext {
const executed: string[] = [];
return {
async execute(sql) {
executed.push(sql);
},
async query<T>() {
return [] as T[];
},
logger: {
debug() {},
info() {},
warn() {},
error() {},
} as MigrationContext["logger"],
};
}
function makeMigration(id: string, failUp = false, hasDown = true): Migration {
return {
id,
name: `Migration ${id}`,
async up(_ctx) {
if (failUp) throw new Error(`${id} up failed`);
},
...(hasDown
? {
async down(_ctx) {},
}
: {}),
};
}
describe("MigrationExecutor", () => {
let store: MemoryMigrationStore;
let context: MigrationContext;
beforeEach(() => {
store = new MemoryMigrationStore();
context = makeCtx();
});
it("applies migrations in up direction", { timeout: 5000 }, async () => {
const m1 = makeMigration("001");
const m2 = makeMigration("002");
const plan: MigrationPlan = { toApply: [m1, m2], toRollback: [] };
const executor = new MigrationExecutor({ store, context });
const result = await executor.execute(plan, "up");
expect(result.applied).toEqual(["001", "002"]);
expect(result.failed).toHaveLength(0);
expect(result.direction).toBe("up");
expect(result.dryRun).toBe(false);
});
it("records applied migrations in store", { timeout: 5000 }, async () => {
const m1 = makeMigration("001");
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context });
await executor.execute(plan, "up");
const applied = await store.getApplied();
expect(applied).toHaveLength(1);
expect(applied[0]!.id).toBe("001");
});
it("rolls back migrations in down direction", {
timeout: 5000,
}, async () => {
const m1 = makeMigration("001");
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const plan: MigrationPlan = { toApply: [], toRollback: [m1] };
const executor = new MigrationExecutor({ store, context });
const result = await executor.execute(plan, "down");
expect(result.applied).toEqual(["001"]);
expect(result.direction).toBe("down");
});
it("throws MigrationError when down not defined", {
timeout: 5000,
}, async () => {
const m1 = makeMigration("001", false, false);
const plan: MigrationPlan = { toApply: [], toRollback: [m1] };
const executor = new MigrationExecutor({ store, context });
await expect(executor.execute(plan, "down")).rejects.toBeInstanceOf(
MigrationError
);
});
it("dry-run mode does not execute SQL", { timeout: 5000 }, async () => {
let executed = false;
const m1: Migration = {
id: "001",
name: "m1",
async up() {
executed = true;
},
};
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({
store,
context,
dryRun: true,
});
const result = await executor.execute(plan, "up");
expect(executed).toBe(false);
expect(result.dryRun).toBe(true);
expect(result.applied).toEqual(["001"]);
});
it("throws MigrationError on aborted signal", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
const m1 = makeMigration("001");
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context });
await expect(
executor.execute(plan, "up", { signal: ctrl.signal })
).rejects.toBeInstanceOf(MigrationError);
});
it("wraps execution error in MigrationError", {
timeout: 5000,
}, async () => {
const m1 = makeMigration("001", true);
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context });
await expect(executor.execute(plan, "up")).rejects.toBeInstanceOf(
MigrationError
);
});
it("emits lifecycle events", { timeout: 5000 }, async () => {
const events = new EventBus<MigrationEventMap>();
const emitted: string[] = [];
events.on("migration:plan", () => {
emitted.push("plan");
});
events.on("migration:before", () => {
emitted.push("before");
});
events.on("migration:after", () => {
emitted.push("after");
});
const m1 = makeMigration("001");
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context, events });
await executor.execute(plan, "up");
expect(emitted).toContain("plan");
expect(emitted).toContain("before");
expect(emitted).toContain("after");
});
it("emits failed event on error", { timeout: 5000 }, async () => {
const events = new EventBus<MigrationEventMap>();
let failedEmitted = false;
events.on("migration:failed", () => {
failedEmitted = true;
});
const m1 = makeMigration("001", true);
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context, events });
await expect(executor.execute(plan, "up")).rejects.toBeInstanceOf(
MigrationError
);
expect(failedEmitted).toBe(true);
});
it("skips migrations beyond target", { timeout: 5000 }, async () => {
const m1 = makeMigration("001");
const m2 = makeMigration("003");
const plan: MigrationPlan = { toApply: [m1, m2], toRollback: [] };
const executor = new MigrationExecutor({ store, context });
const result = await executor.execute(plan, "up", { target: "002" });
expect(result.applied).toEqual(["001"]);
expect(result.skipped).toEqual(["003"]);
});
it("throws checksum mismatch when recorded checksum differs", {
timeout: 5000,
}, async () => {
const m1 = makeMigration("001");
// Record with wrong checksum
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
checksum: "wrong",
});
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({
store,
context,
checksumValidation: true,
});
await expect(executor.execute(plan, "up")).rejects.toBeInstanceOf(
MigrationError
);
});
it("emits failed event for non-Error throw", {
timeout: 5000,
}, async () => {
const events = new EventBus<MigrationEventMap>();
let failedPayload: unknown;
events.on("migration:failed", (p) => {
failedPayload = p;
});
const m1: Migration = {
id: "001",
name: "m1",
up() {
return Promise.reject("string-error");
},
};
const plan: MigrationPlan = { toApply: [m1], toRollback: [] };
const executor = new MigrationExecutor({ store, context, events });
await expect(executor.execute(plan, "up")).rejects.toBeInstanceOf(
MigrationError
);
expect(failedPayload).toBeDefined();
});
});

View File

@@ -0,0 +1,158 @@
import type { Clock } from "@suaveplan/types";
import {
MIGRATION_CODES,
MigrationError,
} from "../../errors/migration-error.js";
import { checksumForMigration } from "../../planner/migration-planner/migration-planner.js";
import type {
ExecutorOptions,
Migration,
MigrationDirection,
MigrationPlan,
MigrationResult,
RunOptions,
} from "../../types/migration.types.js";
const defaultClock: Clock = () => Date.now();
/**
* Executes a `MigrationPlan` in the specified direction, recording each
* applied migration in the store and emitting lifecycle events when an event
* bus is configured.
*/
export class MigrationExecutor {
protected readonly options: ExecutorOptions;
protected readonly clock: Clock;
constructor(options: ExecutorOptions, clock?: Clock) {
this.options = options;
this.clock = clock ?? defaultClock;
}
async execute(
plan: MigrationPlan,
direction: MigrationDirection,
runOptions?: RunOptions
): Promise<MigrationResult> {
const migrations = direction === "up" ? plan.toApply : plan.toRollback;
const applied: string[] = [];
const skipped: string[] = [];
const failed: string[] = [];
const {
store,
context,
events,
dryRun = false,
checksumValidation = true,
} = this.options;
if (events) {
await events.emit("migration:plan", { plan, direction });
}
for (const migration of migrations) {
if (runOptions?.signal?.aborted) {
throw new MigrationError(
MIGRATION_CODES.ABORTED,
"Migration run was aborted"
);
}
if (
runOptions?.target &&
migration.id > runOptions.target &&
direction === "up"
) {
skipped.push(migration.id);
continue;
}
if (checksumValidation && direction === "up") {
const applied_records = await store.getApplied();
const record = applied_records.find(
(r) => r.id === migration.id
);
if (record?.checksum) {
const computed = checksumForMigration(migration);
if (computed !== record.checksum) {
failed.push(migration.id);
throw new MigrationError(
MIGRATION_CODES.CHECKSUM_MISMATCH,
`Checksum mismatch for migration ${migration.id}`
);
}
}
}
if (dryRun) {
applied.push(migration.id);
continue;
}
const start = this.clock();
if (events) {
await events.emit("migration:before", { migration, direction });
}
try {
await this.runMigration(migration, direction, context);
const durationMs = this.clock() - start;
const checksum = checksumForMigration(migration);
await store.record({
id: migration.id,
name: migration.name,
appliedAt: this.clock(),
checksum,
direction,
});
applied.push(migration.id);
if (events) {
await events.emit("migration:after", {
migration,
direction,
durationMs,
});
}
} catch (err) {
failed.push(migration.id);
if (events) {
await events.emit("migration:failed", {
migration,
direction,
error:
err instanceof Error ? err : new Error(String(err)),
});
}
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.FAILED,
`Migration ${migration.id} failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
return { applied, skipped, failed, direction, dryRun };
}
protected async runMigration(
migration: Migration,
direction: MigrationDirection,
context: ExecutorOptions["context"]
): Promise<void> {
if (direction === "up") {
await migration.up(context);
} else {
if (!migration.down) {
throw new MigrationError(
MIGRATION_CODES.NOT_FOUND,
`Migration ${migration.id} has no down() function`
);
}
await migration.down(context);
}
}
}

View File

@@ -0,0 +1,4 @@
export {
createMigrationRunner,
MigrationRunner,
} from "./migration-runner.js";

View File

@@ -0,0 +1,84 @@
# migration-runner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MigrationRunner` is the top-level orchestrator for the migration package. It wires together the planner, executor, store, lock, and event bus into a single high-level API. Consumers create a runner with their migration list and store adapter, then call `up()` to apply pending migrations, `down()` to roll back, `status()` to inspect the current state, and `validate()` to verify migration metadata consistency before running.
## Features
- `up(options?)` — plans and applies all pending migrations; respects advisory locks, AbortSignal, and dry-run mode.
- `down(options?)` — plans and rolls back the last N applied migrations.
- `status()` — returns combined status of all registered migrations.
- `validate()` — checks for duplicate IDs and returns structured validation errors.
- Injectable `Clock`, `LoggerLike`, `EventBus`, and `MigrationLockAdapter`.
- `createMigrationRunner` factory function for ergonomic construction.
## Usage: Basic Example
```ts
import { MigrationRunner, MemoryMigrationStore } from "@suaveplan/migration";
const store = new MemoryMigrationStore();
const runner = new MigrationRunner({ migrations, store });
const result = await runner.up();
// result.applied lists migration IDs that were applied
```
## Usage: Advanced Example
```ts
import { MigrationRunner, MemoryMigrationStore, MemoryMigrationLock } from "@suaveplan/migration";
import { EventBus } from "@suaveplan/events";
const store = new MemoryMigrationStore();
const lock = new MemoryMigrationLock();
const events = new EventBus<MigrationEventMap>();
events.on("migration:after", ({ migration, durationMs }) => {
console.log(`Applied ${migration.id} in ${durationMs}ms`);
});
const runner = new MigrationRunner({ migrations, store, lock, events, checksumValidation: true });
await runner.up({ signal: abortController.signal });
```
## API Reference
### `class MigrationRunner`
**Constructor**: `(config: MigrationRunnerConfig, clock?: Clock)`
- `config.migrations` — array of `Migration` objects.
- `config.store` — `MigrationStoreAdapter` for ledger persistence.
- `config.lock?` — optional `MigrationLockAdapter`.
- `config.logger?` — optional `LoggerLike` for structured log output.
- `config.events?` — optional `EventBus<MigrationEventMap>`.
- `config.dryRun?` — default `false`.
- `config.checksumValidation?` — default `true`.
### `up(options?: RunOptions): Promise<MigrationResult>`
Returns `{ applied, skipped, failed, direction: "up", dryRun }`.
### `down(options?: RunOptions & { steps?: number }): Promise<MigrationResult>`
Returns `{ applied, skipped, failed, direction: "down", dryRun }`.
### `status(): Promise<MigrationStatusEntry[]>`
Returns status for every registered migration sorted by id.
### `validate(): Promise<ValidationResult>`
Returns `{ valid, errors }`.
### `createMigrationRunner(config, clock?): MigrationRunner`
Factory function — equivalent to `new MigrationRunner(config, clock)`.
## Implementation Notes
The `buildContext()` method provides a no-op `execute`/`query` implementation suitable for runners that do not need direct database access in migration functions (e.g. when the migration functions close over an external adapter). Consumers requiring SQL execution in migration functions should provide a custom context via a subclass.

View File

@@ -0,0 +1,322 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MigrationError } from "../../errors/migration-error.js";
import { MemoryMigrationLock } from "../../lock/memory-lock/memory-lock.js";
import { MemoryMigrationStore } from "../../store/memory-store/memory-store.js";
import type { Migration } from "../../types/migration.types.js";
import { createMigrationRunner, MigrationRunner } from "./migration-runner.js";
function makeMigration(id: string, failUp = false): Migration {
return {
id,
name: `Migration ${id}`,
async up(_ctx) {
if (failUp) throw new Error(`${id} up failed`);
},
async down(_ctx) {},
};
}
describe("MigrationRunner", () => {
let store: MemoryMigrationStore;
beforeEach(() => {
store = new MemoryMigrationStore();
});
it("applies pending migrations", { timeout: 5000 }, async () => {
const runner = new MigrationRunner({
migrations: [makeMigration("001"), makeMigration("002")],
store,
});
const result = await runner.up();
expect(result.applied).toEqual(["001", "002"]);
expect(result.direction).toBe("up");
});
it("returns empty result when nothing to apply", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
});
const result = await runner.up();
expect(result.applied).toHaveLength(0);
});
it("rolls back with down()", { timeout: 5000 }, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
});
const result = await runner.down();
expect(result.applied).toEqual(["001"]);
expect(result.direction).toBe("down");
});
it("returns empty result when nothing to rollback", {
timeout: 5000,
}, async () => {
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
});
const result = await runner.down();
expect(result.applied).toHaveLength(0);
});
it("limits down rollback by steps", { timeout: 5000 }, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
await store.record({
id: "002",
name: "m2",
appliedAt: 2000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001"), makeMigration("002")],
store,
});
const result = await runner.down({ steps: 1 });
expect(result.applied).toHaveLength(1);
expect(result.applied[0]).toBe("002");
});
it("status returns all migrations with correct status", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001"), makeMigration("002")],
store,
});
const status = await runner.status();
expect(status).toHaveLength(2);
expect(status.find((s) => s.id === "001")?.status).toBe("applied");
expect(status.find((s) => s.id === "002")?.status).toBe("pending");
});
it("status shows rolled-back for down direction", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "down",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
});
const status = await runner.status();
expect(status[0]!.status).toBe("rolled-back");
});
it("validate returns valid for unique ids", { timeout: 5000 }, async () => {
const runner = new MigrationRunner({
migrations: [makeMigration("001"), makeMigration("002")],
store,
});
const result = await runner.validate();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("validate detects duplicate ids", { timeout: 5000 }, async () => {
const runner = new MigrationRunner({
migrations: [makeMigration("001"), makeMigration("001")],
store,
});
const result = await runner.validate();
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
});
it("throws MigrationError when lock cannot be acquired for up", {
timeout: 5000,
}, async () => {
const lock = new MemoryMigrationLock();
await lock.acquire(10000); // Hold the lock
const _runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
lock,
});
// Use a real lock with very short timeout by overriding acquire
const fakeLock = {
async acquire() {
return false;
},
async release() {},
async isLocked() {
return true;
},
};
const runner2 = new MigrationRunner({
migrations: [makeMigration("001")],
store,
lock: fakeLock,
});
await expect(runner2.up()).rejects.toBeInstanceOf(MigrationError);
});
it("throws MigrationError when lock cannot be acquired for down", {
timeout: 5000,
}, async () => {
const fakeLock = {
async acquire() {
return false;
},
async release() {},
async isLocked() {
return true;
},
};
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
lock: fakeLock,
});
await expect(runner.down()).rejects.toBeInstanceOf(MigrationError);
});
it("createMigrationRunner factory works", { timeout: 5000 }, async () => {
const runner = createMigrationRunner({
migrations: [makeMigration("001")],
store,
});
expect(runner).toBeInstanceOf(MigrationRunner);
});
it("up dry-run does not write to store", { timeout: 5000 }, async () => {
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
dryRun: true,
});
await runner.up();
expect(await store.getApplied()).toHaveLength(0);
});
it("up emits migration:plan event when events provided", {
timeout: 30000,
}, async () => {
const { EventBus } = await import("@suaveplan/events");
const events = new EventBus<
import("../../types/migration.types.js").MigrationEventMap
>();
let planEmitted = false;
events.on("migration:plan", () => {
planEmitted = true;
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
events,
});
await runner.up();
expect(planEmitted).toBe(true);
});
it("down emits migration:plan event when events provided", {
timeout: 30000,
}, async () => {
const { EventBus } = await import("@suaveplan/events");
const events = new EventBus<
import("../../types/migration.types.js").MigrationEventMap
>();
let planEmitted = false;
events.on("migration:plan", () => {
planEmitted = true;
});
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
events,
});
await runner.down();
expect(planEmitted).toBe(true);
});
it("down with lock releases lock after running", {
timeout: 5000,
}, async () => {
const fakeLock = {
acquired: false,
released: false,
async acquire() {
this.acquired = true;
return true;
},
async release() {
this.released = true;
},
async isLocked() {
return false;
},
};
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const runner = new MigrationRunner({
migrations: [makeMigration("001")],
store,
lock: fakeLock,
});
await runner.down();
expect(fakeLock.released).toBe(true);
});
it("buildContext execute and query are callable", {
timeout: 5000,
}, async () => {
let executeWasCalled = false;
let queryWasCalled = false;
const m: Migration = {
id: "001",
name: "test ctx",
async up(ctx) {
await ctx.execute("SELECT 1");
executeWasCalled = true;
await ctx.query("SELECT 1");
queryWasCalled = true;
},
};
const runner = new MigrationRunner({ migrations: [m], store });
await runner.up();
expect(executeWasCalled).toBe(true);
expect(queryWasCalled).toBe(true);
});
});

View File

@@ -0,0 +1,254 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import {
MIGRATION_CODES,
MigrationError,
} from "../../errors/migration-error.js";
import {
planDown,
planUp,
sortMigrations,
} from "../../planner/migration-planner/migration-planner.js";
import type {
Migration,
MigrationContext,
MigrationRecord,
MigrationResult,
MigrationRunnerConfig,
MigrationStatusEntry,
RunOptions,
ValidationResult,
} from "../../types/migration.types.js";
import { MigrationExecutor } from "../migration-executor/migration-executor.js";
const defaultClock: Clock = () => Date.now();
function noop() {}
const noopLogger: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
/**
* Orchestrates the full migration lifecycle: planning, locking, executing,
* and reporting. Consumers construct the runner with a list of migrations, a
* store adapter, and optional lock/events/logger.
*/
export class MigrationRunner {
protected readonly config: MigrationRunnerConfig;
protected readonly logger: LoggerLike;
protected readonly clock: Clock;
constructor(config: MigrationRunnerConfig, clock?: Clock) {
this.config = config;
this.logger = config.logger ?? noopLogger;
this.clock = clock ?? defaultClock;
}
/** Apply all pending migrations. */
async up(options: RunOptions = {}): Promise<MigrationResult> {
const {
store,
lock,
migrations,
events,
dryRun = false,
checksumValidation = true,
} = this.config;
const acquired = lock ? await lock.acquire() : true;
if (!acquired) {
throw new MigrationError(
MIGRATION_CODES.LOCK_TIMEOUT,
"Could not acquire migration lock"
);
}
try {
const applied = await store.getApplied();
const plan = planUp(migrations, applied);
if (events) {
await events.emit("migration:plan", { plan, direction: "up" });
}
this.logger.info("migration.up.start", {
pending: plan.toApply.length,
});
if (plan.toApply.length === 0) {
this.logger.info("migration.up.nothing_to_apply");
return {
applied: [],
skipped: [],
failed: [],
direction: "up",
dryRun,
};
}
const context = this.buildContext();
const executor = new MigrationExecutor(
{
store,
context,
...(events !== undefined && { events }),
dryRun,
checksumValidation,
},
this.clock
);
const result = await executor.execute(plan, "up", options);
this.logger.info("migration.up.complete", {
applied: result.applied.length,
});
return result;
} finally {
if (lock) await lock.release();
}
}
/** Roll back applied migrations. */
async down(
options: RunOptions & { steps?: number } = {}
): Promise<MigrationResult> {
const {
store,
lock,
migrations,
events,
dryRun = false,
checksumValidation = true,
} = this.config;
const acquired = lock ? await lock.acquire() : true;
if (!acquired) {
throw new MigrationError(
MIGRATION_CODES.LOCK_TIMEOUT,
"Could not acquire migration lock"
);
}
try {
const applied = await store.getApplied();
const plan = planDown(migrations, applied, options.steps);
if (events) {
await events.emit("migration:plan", {
plan,
direction: "down",
});
}
this.logger.info("migration.down.start", {
toRollback: plan.toRollback.length,
});
if (plan.toRollback.length === 0) {
this.logger.info("migration.down.nothing_to_rollback");
return {
applied: [],
skipped: [],
failed: [],
direction: "down",
dryRun,
};
}
const context = this.buildContext();
const executor = new MigrationExecutor(
{
store,
context,
...(events !== undefined && { events }),
dryRun,
checksumValidation,
},
this.clock
);
const result = await executor.execute(plan, "down", options);
this.logger.info("migration.down.complete", {
applied: result.applied.length,
});
return result;
} finally {
if (lock) await lock.release();
}
}
/** Return combined status of all migrations. */
async status(): Promise<MigrationStatusEntry[]> {
const { store, migrations } = this.config;
const applied = await store.getApplied();
const appliedMap = new Map<string, MigrationRecord>(
applied.map((r) => [r.id, r])
);
const sorted = sortMigrations(migrations);
return sorted.map((m): MigrationStatusEntry => {
const record = appliedMap.get(m.id);
const status = record
? record.direction === "down"
? ("rolled-back" as const)
: ("applied" as const)
: ("pending" as const);
return {
id: m.id,
name: m.name,
status,
...(record !== undefined && { appliedAt: record.appliedAt }),
...(record?.checksum !== undefined && {
checksum: record.checksum,
}),
};
});
}
/** Validate migrations for id uniqueness and order consistency. */
async validate(): Promise<ValidationResult> {
const { migrations } = this.config;
const errors: ValidationResult["errors"] = [];
const seen = new Set<string>();
for (const m of migrations) {
if (seen.has(m.id)) {
errors.push({
migrationId: m.id,
message: `Duplicate migration id: ${m.id}`,
});
}
seen.add(m.id);
}
return { valid: errors.length === 0, errors };
}
protected buildContext(): MigrationContext {
const logger = this.logger;
// Context wraps the store for SQL execution via a noop adapter for non-db runners
return {
async execute(
_sql: string,
_params?: readonly unknown[]
): Promise<void> {},
async query<T>(
_sql: string,
_params?: readonly unknown[]
): Promise<T[]> {
return [];
},
logger,
};
}
}
/** Convenience factory function. */
export function createMigrationRunner(
config: MigrationRunnerConfig,
clock?: Clock
): MigrationRunner {
return new MigrationRunner(config, clock);
}
/** Export Migration type for consumers implementing MigrationContext. */
export type { Migration, MigrationContext };

View File

@@ -0,0 +1,8 @@
export {
BatchMigratorConfigSchema,
DistributedLockConfigSchema,
MemoryStorageOptionsSchema,
MigrationRunnerOptionsSchema,
RedisLockConfigSchema,
RunOptionsSchema,
} from "./migration.schemas.js";

View File

@@ -0,0 +1,46 @@
# migration.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for all public option types in `@suaveplan/migration`. Providing schemas alongside the TypeScript types enables runtime validation of configuration objects, safe deserialization of stored migration state, and structured error messages when callers pass invalid options. Every schema is strict (no unrecognised keys allowed at runtime), ensuring forward compatibility does not accidentally silently accept typo'd option names.
## Features
- `RunOptionsSchema` — validates `signal` (AbortSignal) and `target` (string) run options.
- `MigrationRunnerOptionsSchema` — validates `dryRun` and `checksumValidation` booleans with defaults.
- `DistributedLockConfigSchema` — validates distributed lock `key` and `ttl` options.
- `RedisLockConfigSchema` — validates Redis lock `key` and `ttl` options.
- `BatchMigratorConfigSchema` — validates `concurrency` with a positive-integer constraint and default 5.
- `MemoryStorageOptionsSchema` — validates optional `maxSize` for the in-memory store LRU limit.
## Usage: Basic Example
```ts
import { MigrationRunnerOptionsSchema } from "@suaveplan/migration";
const opts = MigrationRunnerOptionsSchema.parse({ dryRun: true });
// opts.dryRun === true, opts.checksumValidation === true (default)
```
## Usage: Advanced Example
```ts
import { BatchMigratorConfigSchema } from "@suaveplan/migration";
const result = BatchMigratorConfigSchema.safeParse({ concurrency: 0 });
if (!result.success) {
console.error(result.error.format());
// concurrency must be a positive integer
}
```
## API Reference
All exports are `z.ZodObject` instances. Use `.parse()` for throwing validation, `.safeParse()` for non-throwing. All schemas are strict — extra keys cause validation failures.
## Implementation Notes
Schemas use `AbortSignal` via `z.instanceof(AbortSignal)` which requires the schema to be evaluated in an environment where `AbortSignal` is a global. All modern JS runtimes and Node.js ≥ 15 provide this. Numeric constraints use `.positive()` which rejects zero and negatives.

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BatchMigratorConfigSchema,
DistributedLockConfigSchema,
MemoryStorageOptionsSchema,
MigrationRunnerOptionsSchema,
RedisLockConfigSchema,
RunOptionsSchema,
} from "./migration.schemas.js";
describe("RunOptionsSchema", () => {
it("parses empty object", { timeout: 5000 }, () => {
const r = RunOptionsSchema.parse({});
expect(r.signal).toBeUndefined();
expect(r.target).toBeUndefined();
});
it("parses with target", { timeout: 5000 }, () => {
const r = RunOptionsSchema.parse({ target: "001_init" });
expect(r.target).toBe("001_init");
});
it("parses with signal", { timeout: 5000 }, () => {
const ctrl = new AbortController();
const r = RunOptionsSchema.parse({ signal: ctrl.signal });
expect(r.signal).toBe(ctrl.signal);
});
it("rejects extra keys", { timeout: 5000 }, () => {
expect(() => RunOptionsSchema.parse({ unknown: true })).toThrow();
});
});
describe("MigrationRunnerOptionsSchema", () => {
it("applies defaults", { timeout: 5000 }, () => {
const r = MigrationRunnerOptionsSchema.parse({});
expect(r.dryRun).toBe(false);
expect(r.checksumValidation).toBe(true);
});
it("accepts overrides", { timeout: 5000 }, () => {
const r = MigrationRunnerOptionsSchema.parse({
dryRun: true,
checksumValidation: false,
});
expect(r.dryRun).toBe(true);
expect(r.checksumValidation).toBe(false);
});
});
describe("DistributedLockConfigSchema", () => {
it("parses empty object", { timeout: 5000 }, () => {
const r = DistributedLockConfigSchema.parse({});
expect(r.key).toBeUndefined();
});
it("rejects negative ttl", { timeout: 5000 }, () => {
expect(() => DistributedLockConfigSchema.parse({ ttl: -1 })).toThrow();
});
it("accepts positive ttl", { timeout: 5000 }, () => {
const r = DistributedLockConfigSchema.parse({ ttl: 5000 });
expect(r.ttl).toBe(5000);
});
});
describe("RedisLockConfigSchema", () => {
it("parses with key", { timeout: 5000 }, () => {
const r = RedisLockConfigSchema.parse({ key: "my-lock" });
expect(r.key).toBe("my-lock");
});
it("rejects zero ttl", { timeout: 5000 }, () => {
expect(() => RedisLockConfigSchema.parse({ ttl: 0 })).toThrow();
});
});
describe("BatchMigratorConfigSchema", () => {
it("defaults concurrency to 5", { timeout: 5000 }, () => {
const r = BatchMigratorConfigSchema.parse({});
expect(r.concurrency).toBe(5);
});
it("rejects concurrency of 0", { timeout: 5000 }, () => {
expect(() =>
BatchMigratorConfigSchema.parse({ concurrency: 0 })
).toThrow();
});
it("accepts positive concurrency", { timeout: 5000 }, () => {
const r = BatchMigratorConfigSchema.parse({ concurrency: 3 });
expect(r.concurrency).toBe(3);
});
});
describe("MemoryStorageOptionsSchema", () => {
it("parses empty", { timeout: 5000 }, () => {
const r = MemoryStorageOptionsSchema.parse({});
expect(r.maxSize).toBeUndefined();
});
it("accepts maxSize", { timeout: 5000 }, () => {
const r = MemoryStorageOptionsSchema.parse({ maxSize: 100 });
expect(r.maxSize).toBe(100);
});
it("rejects negative maxSize", { timeout: 5000 }, () => {
expect(() =>
MemoryStorageOptionsSchema.parse({ maxSize: -1 })
).toThrow();
});
});

View File

@@ -0,0 +1,41 @@
import z from "zod";
export const RunOptionsSchema = z
.object({
signal: z.instanceof(AbortSignal).optional(),
target: z.string().optional(),
})
.strict();
export const MigrationRunnerOptionsSchema = z
.object({
dryRun: z.boolean().optional().default(false),
checksumValidation: z.boolean().optional().default(true),
})
.strict();
export const DistributedLockConfigSchema = z
.object({
key: z.string().optional(),
ttl: z.number().positive().optional(),
})
.strict();
export const RedisLockConfigSchema = z
.object({
key: z.string().optional(),
ttl: z.number().positive().optional(),
})
.strict();
export const BatchMigratorConfigSchema = z
.object({
concurrency: z.number().int().positive().optional().default(5),
})
.strict();
export const MemoryStorageOptionsSchema = z
.object({
maxSize: z.number().int().positive().optional(),
})
.strict();

View File

@@ -0,0 +1 @@
export { MigrationStatusDashboard } from "./migration-status.js";

View File

@@ -0,0 +1,56 @@
# migration-status
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MigrationStatusDashboard` provides a read-only view over the migration store. It computes per-migration status, aggregate counts, and a health check that can be wired into application readiness probes. Separating status reporting from the runner ensures that status queries never trigger migrations and can be called at any time without locking concerns.
## Features
- `getAll()` — returns every registered migration with its current status (`pending`, `applied`, `rolled-back`).
- `getSummary()` — returns aggregate counts for total, applied, pending, and failed migrations.
- `healthCheck()` — returns `{ healthy, pendingCount, failedCount }` suitable for a `/healthz` endpoint.
- `getHistory(id)` — returns all ledger entries for a specific migration id (supports audit queries).
## Usage: Basic Example
```ts
import { MigrationStatusDashboard, MemoryMigrationStore } from "@suaveplan/migration";
const dashboard = new MigrationStatusDashboard({ store, migrations });
const summary = await dashboard.getSummary();
// { total: 5, applied: 4, pending: 1, failed: 0 }
```
## Usage: Advanced Example
```ts
import { MigrationStatusDashboard } from "@suaveplan/migration";
const dashboard = new MigrationStatusDashboard({ store, migrations });
const health = await dashboard.healthCheck();
if (!health.healthy) {
console.warn(`${health.pendingCount} pending migrations, ${health.failedCount} failed`);
}
```
## API Reference
### `class MigrationStatusDashboard`
**Constructor**: `(config: StatusConfig)`
- `config.store` — `MigrationStoreAdapter`.
- `config.migrations` — registered `Migration[]`.
- `config.clock?` — injectable clock.
- `getAll(): Promise<ExtendedMigrationStatus[]>` — sorted by id.
- `getSummary(): Promise<MigrationSummary>` — `{ total, applied, pending, failed }`.
- `healthCheck(): Promise<MigrationHealthCheck>` — `{ healthy, pendingCount, failedCount }`.
- `getHistory(id: string): Promise<MigrationHistoryEntry[]>` — ledger entries for one migration.
## Implementation Notes
`healthy` is `true` only when both `pending === 0` and `failed === 0`. This is intentionally strict: unnapplied migrations in production are a deployment concern that should always be visible. The `getHistory` method supports audit use cases where the same migration may have been applied and rolled back multiple times.

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryMigrationStore } from "../../store/memory-store/memory-store.js";
import type { Migration } from "../../types/migration.types.js";
import { MigrationStatusDashboard } from "./migration-status.js";
function makeMigration(id: string): Migration {
return { id, name: `Migration ${id}`, async up(_ctx) {} };
}
describe("MigrationStatusDashboard", () => {
let store: MemoryMigrationStore;
const migrations = [
makeMigration("001"),
makeMigration("002"),
makeMigration("003"),
];
beforeEach(() => {
store = new MemoryMigrationStore();
});
it("getAll returns pending for all when nothing applied", {
timeout: 5000,
}, async () => {
const dashboard = new MigrationStatusDashboard({ store, migrations });
const all = await dashboard.getAll();
expect(all).toHaveLength(3);
expect(all.every((s) => s.status === "pending")).toBe(true);
});
it("getAll shows applied status", { timeout: 5000 }, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const all = await dashboard.getAll();
expect(all.find((s) => s.id === "001")?.status).toBe("applied");
expect(all.find((s) => s.id === "002")?.status).toBe("pending");
});
it("getAll shows rolled-back status", { timeout: 5000 }, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "down",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const all = await dashboard.getAll();
expect(all.find((s) => s.id === "001")?.status).toBe("rolled-back");
});
it("getSummary returns correct counts", { timeout: 5000 }, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const summary = await dashboard.getSummary();
expect(summary.total).toBe(3);
expect(summary.applied).toBe(1);
expect(summary.pending).toBe(2);
expect(summary.failed).toBe(0);
});
it("healthCheck is healthy when all applied", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
await store.record({
id: "002",
name: "m2",
appliedAt: 2000,
direction: "up",
});
await store.record({
id: "003",
name: "m3",
appliedAt: 3000,
direction: "up",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const health = await dashboard.healthCheck();
expect(health.healthy).toBe(true);
expect(health.pendingCount).toBe(0);
});
it("healthCheck is unhealthy when pending", { timeout: 5000 }, async () => {
const dashboard = new MigrationStatusDashboard({ store, migrations });
const health = await dashboard.healthCheck();
expect(health.healthy).toBe(false);
expect(health.pendingCount).toBe(3);
});
it("getHistory returns entries for migration id", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const history = await dashboard.getHistory("001");
expect(history).toHaveLength(1);
expect(history[0]!.id).toBe("001");
expect(history[0]!.direction).toBe("up");
});
it("getHistory returns empty for unknown id", {
timeout: 5000,
}, async () => {
const dashboard = new MigrationStatusDashboard({ store, migrations });
const history = await dashboard.getHistory("999");
expect(history).toHaveLength(0);
});
it("works with no clock provided (uses defaultClock)", {
timeout: 5000,
}, async () => {
// Construct without clock to exercise the defaultClock branch
const dashboard = new MigrationStatusDashboard({ store, migrations });
const summary = await dashboard.getSummary();
expect(summary.total).toBe(3);
});
it("getHistory returns checksum when stored", {
timeout: 5000,
}, async () => {
await store.record({
id: "001",
name: "m1",
appliedAt: 1000,
direction: "up",
checksum: "abc123",
});
const dashboard = new MigrationStatusDashboard({ store, migrations });
const history = await dashboard.getHistory("001");
expect(history[0]!.checksum).toBe("abc123");
});
});

View File

@@ -0,0 +1,77 @@
import { sortMigrations } from "../../planner/migration-planner/migration-planner.js";
import type {
ExtendedMigrationStatus,
MigrationHealthCheck,
MigrationHistoryEntry,
MigrationSummary,
StatusConfig,
} from "../../types/migration.types.js";
/**
* Provides a read-only dashboard over the migration store: per-migration
* status, aggregate summaries, health checks, and history per migration id.
*/
export class MigrationStatusDashboard {
protected readonly config: StatusConfig;
constructor(config: StatusConfig) {
this.config = config;
}
async getAll(): Promise<ExtendedMigrationStatus[]> {
const { store, migrations } = this.config;
const applied = await store.getApplied();
const appliedMap = new Map(applied.map((r) => [r.id, r]));
return sortMigrations(migrations).map((m): ExtendedMigrationStatus => {
const record = appliedMap.get(m.id);
const status = record
? record.direction === "down"
? ("rolled-back" as const)
: ("applied" as const)
: ("pending" as const);
return {
id: m.id,
name: m.name,
status,
...(record !== undefined && { appliedAt: record.appliedAt }),
...(record?.checksum !== undefined && {
checksum: record.checksum,
}),
};
});
}
async getSummary(): Promise<MigrationSummary> {
const all = await this.getAll();
return {
total: all.length,
applied: all.filter((s) => s.status === "applied").length,
pending: all.filter((s) => s.status === "pending").length,
failed: all.filter((s) => s.status === "failed").length,
};
}
async healthCheck(): Promise<MigrationHealthCheck> {
const summary = await this.getSummary();
return {
healthy: summary.pending === 0 && summary.failed === 0,
pendingCount: summary.pending,
failedCount: summary.failed,
};
}
async getHistory(id: string): Promise<MigrationHistoryEntry[]> {
const { store } = this.config;
const applied = await store.getApplied();
return applied
.filter((r) => r.id === id)
.map(
(r): MigrationHistoryEntry => ({
id: r.id,
direction: r.direction,
appliedAt: r.appliedAt,
...(r.checksum !== undefined && { checksum: r.checksum }),
})
);
}
}

View File

@@ -0,0 +1,76 @@
# database-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DatabaseMigrationStore` implements `MigrationStoreAdapter` against any SQL database that provides a minimal `query`/`execute` interface. It creates a ledger table (default name `_migrations`) using `CREATE TABLE IF NOT EXISTS`, making it idempotent on every call. Records are upserted with `INSERT OR REPLACE` for SQLite compatibility; Postgres adapters should use `ON CONFLICT DO UPDATE` via a custom tableName or subclass.
## Features
- `ensureTable()` — creates the ledger table if it does not exist; called automatically before every operation.
- Full `MigrationStoreAdapter` implementation: `getApplied`, `record`, `remove`, `clear`.
- Wraps all database errors in `MigrationError(MIGRATION_CODES.STORE_ERROR)` with the original as `cause`.
- Configurable `tableName` (default `_migrations`).
## Usage: Basic Example
```ts
import { DatabaseMigrationStore } from "@suaveplan/migration";
const store = new DatabaseMigrationStore({ adapter: myDb });
const applied = await store.getApplied();
```
## Usage: Advanced Example
```ts
import { DatabaseMigrationStore } from "@suaveplan/migration";
const store = new DatabaseMigrationStore({
adapter: sqliteAdapter,
tableName: "_schema_versions",
});
await store.record({
id: "001_init",
name: "Initial schema",
appliedAt: Date.now(),
checksum: "abc123",
direction: "up",
});
```
## API Reference
### `class DatabaseMigrationStore implements MigrationStoreAdapter`
**Constructor**: `(config: DatabaseStoreConfig)`
- `config.adapter` — object with `query<T>(sql, params?): Promise<T[]>` and `execute(sql, params?): Promise<{ rowsAffected: number }>`.
- `config.tableName?` — override the default `_migrations` ledger table name.
### `ensureTable(): Promise<void>`
Creates the ledger table if absent. Idempotent.
### `getApplied(): Promise<MigrationRecord[]>`
Returns all ledger rows ordered by `applied_at` ascending.
### `record(record: MigrationRecord): Promise<void>`
Upserts a ledger row (`INSERT OR REPLACE`).
### `remove(id: string): Promise<void>`
Deletes the ledger row for `id`.
### `clear(): Promise<void>`
Deletes all ledger rows.
## Implementation Notes
The `DatabaseStoreDb` interface is intentionally minimal so any adapter — SQLite, Postgres, MySQL — can implement it without pulling in the full `DatabaseAdapter` type from `@suaveplan/db`. The `ensureTable` call on every operation ensures the table exists even if the store is constructed before the database is fully initialised. This adds a tiny overhead per operation, but the cost is negligible compared to actual SQL execution.

View File

@@ -0,0 +1,328 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MigrationError } from "../../errors/migration-error.js";
import type { MigrationRecord } from "../../types/migration.types.js";
import {
DatabaseMigrationStore,
type DatabaseStoreDb,
} from "./database-store.js";
function makeRecord(id: string): MigrationRecord {
return { id, name: `Migration ${id}`, appliedAt: 1000, direction: "up" };
}
/** In-memory SQL-like adapter for testing DatabaseMigrationStore. */
class FakeDb implements DatabaseStoreDb {
protected readonly tables: Map<
string,
Map<string, Record<string, unknown>>
> = new Map();
protected shouldFailOn: string | null = null;
setFailOn(op: string | null) {
this.shouldFailOn = op;
}
async query<T>(sql: string, _params?: readonly unknown[]): Promise<T[]> {
if (this.shouldFailOn === "query") throw new Error("query error");
const match = sql.match(/FROM "([^"]+)"/);
if (!match) return [];
const table = this.tables.get(match[1]!);
if (!table) return [];
const rows = [...table.values()];
return rows.sort(
(a, b) => (a["applied_at"] as number) - (b["applied_at"] as number)
) as unknown as T[];
}
async execute(
sql: string,
params?: readonly unknown[]
): Promise<{ rowsAffected: number }> {
if (this.shouldFailOn === "execute") throw new Error("execute error");
if (sql.includes("CREATE TABLE")) {
const match = sql.match(/TABLE IF NOT EXISTS "([^"]+)"/);
if (match) {
const tname = match[1]!;
if (!this.tables.has(tname)) {
this.tables.set(tname, new Map());
}
}
return { rowsAffected: 0 };
}
if (sql.includes("INSERT OR REPLACE")) {
const match = sql.match(/INTO "([^"]+)"/);
if (match) {
const table = this.tables.get(match[1]!);
if (table && params) {
const [id, name, applied_at, checksum, direction] =
params as [
string,
string,
number,
string | null,
string,
];
table.set(id, {
id,
name,
applied_at,
checksum,
direction,
});
}
}
return { rowsAffected: 1 };
}
if (sql.includes("DELETE FROM") && sql.includes("WHERE id")) {
const match = sql.match(/FROM "([^"]+)"/);
if (match && params) {
const table = this.tables.get(match[1]!);
table?.delete(params[0] as string);
}
return { rowsAffected: 1 };
}
if (sql.includes("DELETE FROM")) {
const match = sql.match(/FROM "([^"]+)"/);
if (match) {
this.tables.get(match[1]!)?.clear();
}
return { rowsAffected: 0 };
}
return { rowsAffected: 0 };
}
}
describe("DatabaseMigrationStore", () => {
let db: FakeDb;
let store: DatabaseMigrationStore;
beforeEach(() => {
db = new FakeDb();
store = new DatabaseMigrationStore({ adapter: db });
});
it("returns empty applied list initially", { timeout: 5000 }, async () => {
const applied = await store.getApplied();
expect(applied).toHaveLength(0);
});
it("records and retrieves a migration", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
const applied = await store.getApplied();
expect(applied).toHaveLength(1);
expect(applied[0]!.id).toBe("001");
});
it("upserts on same id", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.record({ ...makeRecord("001"), appliedAt: 9999 });
const applied = await store.getApplied();
expect(applied).toHaveLength(1);
expect(applied[0]!.appliedAt).toBe(9999);
});
it("removes a migration", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.remove("001");
expect(await store.getApplied()).toHaveLength(0);
});
it("clears all migrations", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.record(makeRecord("002"));
await store.clear();
expect(await store.getApplied()).toHaveLength(0);
});
it("uses custom table name", { timeout: 5000 }, async () => {
const custom = new DatabaseMigrationStore({
adapter: db,
tableName: "custom_ledger",
});
await custom.record(makeRecord("001"));
const applied = await custom.getApplied();
expect(applied).toHaveLength(1);
});
it("stores checksum when provided", { timeout: 5000 }, async () => {
await store.record({ ...makeRecord("001"), checksum: "abc123" });
const applied = await store.getApplied();
expect(applied[0]!.checksum).toBe("abc123");
});
it("wraps execute error in MigrationError on record", {
timeout: 5000,
}, async () => {
// First call succeeds (ensureTable), second fails
let callCount = 0;
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
callCount++;
if (callCount > 1) throw new Error("db error");
return { rowsAffected: 0 };
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(
failStore.record(makeRecord("001"))
).rejects.toBeInstanceOf(MigrationError);
});
it("wraps execute error in MigrationError on remove", {
timeout: 5000,
}, async () => {
let callCount = 0;
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
callCount++;
if (callCount > 1) throw new Error("db error");
return { rowsAffected: 0 };
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.remove("001")).rejects.toBeInstanceOf(
MigrationError
);
});
it("wraps execute error in MigrationError on clear", {
timeout: 5000,
}, async () => {
let callCount = 0;
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
callCount++;
if (callCount > 1) throw new Error("db error");
return { rowsAffected: 0 };
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.clear()).rejects.toBeInstanceOf(MigrationError);
});
it("wraps ensureTable error in MigrationError", {
timeout: 5000,
}, async () => {
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
throw new Error("table error");
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.getApplied()).rejects.toBeInstanceOf(
MigrationError
);
});
it("rethrows MigrationError from ensureTable in getApplied", {
timeout: 5000,
}, async () => {
const err = new MigrationError(
"MIGRATION.STORE_ERROR",
"already migration error"
);
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
throw err;
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.getApplied()).rejects.toBe(err);
});
it("wraps query error in MigrationError on getApplied", {
timeout: 5000,
}, async () => {
let _execCount = 0;
const failingDb: DatabaseStoreDb = {
async query() {
throw new Error("query fail");
},
async execute() {
_execCount++;
return { rowsAffected: 0 };
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.getApplied()).rejects.toBeInstanceOf(
MigrationError
);
});
it("rethrows MigrationError from ensureTable in record", {
timeout: 5000,
}, async () => {
const err = new MigrationError(
"MIGRATION.STORE_ERROR",
"already migration error"
);
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
throw err;
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.record(makeRecord("001"))).rejects.toBe(err);
});
it("rethrows MigrationError from ensureTable in remove", {
timeout: 5000,
}, async () => {
const err = new MigrationError(
"MIGRATION.STORE_ERROR",
"already migration error"
);
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
throw err;
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.remove("001")).rejects.toBe(err);
});
it("rethrows MigrationError from ensureTable in clear", {
timeout: 5000,
}, async () => {
const err = new MigrationError(
"MIGRATION.STORE_ERROR",
"already migration error"
);
const failingDb: DatabaseStoreDb = {
async query() {
return [];
},
async execute() {
throw err;
},
};
const failStore = new DatabaseMigrationStore({ adapter: failingDb });
await expect(failStore.clear()).rejects.toBe(err);
});
});

View File

@@ -0,0 +1,146 @@
import { DEFAULT_LEDGER_TABLE } from "../../constants/index.js";
import {
MIGRATION_CODES,
MigrationError,
} from "../../errors/migration-error.js";
import type {
MigrationRecord,
MigrationStoreAdapter,
} from "../../types/migration.types.js";
/** Minimal database interface required by DatabaseMigrationStore. */
export interface DatabaseStoreDb {
query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>;
execute(
sql: string,
params?: readonly unknown[]
): Promise<{ rowsAffected: number }>;
}
export interface DatabaseStoreConfig {
adapter: DatabaseStoreDb;
tableName?: string;
}
interface LedgerRow {
id: string;
name: string;
applied_at: number;
checksum: string | null;
direction: string;
}
/**
* Database-backed migration store. Creates and queries a ledger table to
* persist migration records across process restarts.
*/
export class DatabaseMigrationStore implements MigrationStoreAdapter {
protected readonly adapter: DatabaseStoreDb;
protected readonly tableName: string;
constructor(config: DatabaseStoreConfig) {
this.adapter = config.adapter;
this.tableName = config.tableName ?? DEFAULT_LEDGER_TABLE;
}
async ensureTable(): Promise<void> {
try {
await this.adapter.execute(
`CREATE TABLE IF NOT EXISTS "${this.tableName}" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at INTEGER NOT NULL,
checksum TEXT,
direction TEXT NOT NULL DEFAULT 'up'
)`
);
} catch (err) {
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
`Failed to create ledger table "${this.tableName}"`,
{ cause: err }
);
}
}
async getApplied(): Promise<MigrationRecord[]> {
try {
await this.ensureTable();
const rows = await this.adapter.query<LedgerRow>(
`SELECT id, name, applied_at, checksum, direction FROM "${this.tableName}" ORDER BY applied_at ASC`
);
return rows.map(
(row): MigrationRecord => ({
id: row.id,
name: row.name,
appliedAt: row.applied_at,
direction: row.direction as MigrationRecord["direction"],
...(row.checksum !== null && { checksum: row.checksum }),
})
);
} catch (err) {
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
"Failed to query migration ledger",
{ cause: err }
);
}
}
async record(record: MigrationRecord): Promise<void> {
try {
await this.ensureTable();
await this.adapter.execute(
`INSERT OR REPLACE INTO "${this.tableName}" (id, name, applied_at, checksum, direction)
VALUES (?, ?, ?, ?, ?)`,
[
record.id,
record.name,
record.appliedAt,
record.checksum ?? null,
record.direction,
]
);
} catch (err) {
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
`Failed to record migration ${record.id}`,
{ cause: err }
);
}
}
async remove(id: string): Promise<void> {
try {
await this.ensureTable();
await this.adapter.execute(
`DELETE FROM "${this.tableName}" WHERE id = ?`,
[id]
);
} catch (err) {
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
`Failed to remove migration ${id}`,
{ cause: err }
);
}
}
async clear(): Promise<void> {
try {
await this.ensureTable();
await this.adapter.execute(`DELETE FROM "${this.tableName}"`);
} catch (err) {
if (err instanceof MigrationError) throw err;
throw new MigrationError(
MIGRATION_CODES.STORE_ERROR,
"Failed to clear migration ledger",
{ cause: err }
);
}
}
}

View File

@@ -0,0 +1,5 @@
export {
DatabaseMigrationStore,
type DatabaseStoreConfig,
type DatabaseStoreDb,
} from "./database-store.js";

View File

@@ -0,0 +1 @@
export { MemoryMigrationStore } from "./memory-store.js";

View File

@@ -0,0 +1,54 @@
# memory-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryMigrationStore` implements `MigrationStoreAdapter` using an in-process `Map<string, MigrationRecord>`. It is the zero-dependency store used in unit tests, integration tests, CLI tools, and any scenario where persistence across process restarts is not required. Because it holds no external connections, it is always available and never fails with connectivity errors.
## Features
- Full `MigrationStoreAdapter` implementation: `getApplied`, `record`, `remove`, `clear`.
- Thread-safe within a single event-loop tick (no race conditions in async contexts without concurrent modification).
- Zero-allocation `getApplied()` returns a snapshot array, not the live map.
## Usage: Basic Example
```ts
import { MemoryMigrationStore } from "@suaveplan/migration";
const store = new MemoryMigrationStore();
await store.record({ id: "001", name: "init", appliedAt: Date.now(), direction: "up" });
const applied = await store.getApplied();
// applied has one entry
```
## Usage: Advanced Example
```ts
import { MemoryMigrationStore } from "@suaveplan/migration";
import { MigrationRunner } from "@suaveplan/migration";
const store = new MemoryMigrationStore();
const runner = new MigrationRunner({ migrations, store });
await runner.up();
// After: store holds all applied records
await store.clear();
// Store is now empty — clean state for next test
```
## API Reference
### `class MemoryMigrationStore implements MigrationStoreAdapter`
All methods are async to match the adapter interface, though none perform I/O.
- `getApplied(): Promise<MigrationRecord[]>` — returns a snapshot of all recorded migrations.
- `record(record: MigrationRecord): Promise<void>` — upserts a record by `id`.
- `remove(id: string): Promise<void>` — deletes a record by `id`; no-op if not present.
- `clear(): Promise<void>` — removes all records.
## Implementation Notes
Records are stored with `id` as the map key, making upsert semantics natural. `getApplied` spreads the map values into a new array so callers cannot mutate the internal store by modifying the returned array.

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { MigrationRecord } from "../../types/migration.types.js";
import { MemoryMigrationStore } from "./memory-store.js";
function makeRecord(id: string): MigrationRecord {
return { id, name: `Migration ${id}`, appliedAt: 1000, direction: "up" };
}
describe("MemoryMigrationStore", () => {
let store: MemoryMigrationStore;
beforeEach(() => {
store = new MemoryMigrationStore();
});
it("starts empty", { timeout: 5000 }, async () => {
const applied = await store.getApplied();
expect(applied).toHaveLength(0);
});
it("records a migration", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
const applied = await store.getApplied();
expect(applied).toHaveLength(1);
expect(applied[0]!.id).toBe("001");
});
it("records multiple migrations", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.record(makeRecord("002"));
const applied = await store.getApplied();
expect(applied).toHaveLength(2);
});
it("upserts on same id", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
const updated = { ...makeRecord("001"), appliedAt: 9999 };
await store.record(updated);
const applied = await store.getApplied();
expect(applied).toHaveLength(1);
expect(applied[0]!.appliedAt).toBe(9999);
});
it("removes a migration", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.remove("001");
const applied = await store.getApplied();
expect(applied).toHaveLength(0);
});
it("remove is no-op for missing id", { timeout: 5000 }, async () => {
await store.remove("nonexistent");
expect(await store.getApplied()).toHaveLength(0);
});
it("clears all records", { timeout: 5000 }, async () => {
await store.record(makeRecord("001"));
await store.record(makeRecord("002"));
await store.clear();
expect(await store.getApplied()).toHaveLength(0);
});
it("getApplied returns snapshot not live reference", {
timeout: 5000,
}, async () => {
await store.record(makeRecord("001"));
const first = await store.getApplied();
await store.record(makeRecord("002"));
expect(first).toHaveLength(1);
});
});

View File

@@ -0,0 +1,28 @@
import type {
MigrationRecord,
MigrationStoreAdapter,
} from "../../types/migration.types.js";
/**
* In-memory migration store. Persists migration records in a `Map` for the
* lifetime of the process. Suitable for tests and single-run scripts.
*/
export class MemoryMigrationStore implements MigrationStoreAdapter {
protected readonly records: Map<string, MigrationRecord> = new Map();
async getApplied(): Promise<MigrationRecord[]> {
return [...this.records.values()];
}
async record(record: MigrationRecord): Promise<void> {
this.records.set(record.id, record);
}
async remove(id: string): Promise<void> {
this.records.delete(id);
}
async clear(): Promise<void> {
this.records.clear();
}
}

View File

@@ -0,0 +1,33 @@
export type {
BatchMigrationResult,
BatchMigratorConfig,
DistributedLockAdapter,
DistributedLockConfig,
DryRunEntry,
DryRunReport,
ExecutorOptions,
ExtendedMigrationStatus,
Migration,
MigrationContext,
MigrationDirection,
MigrationEventMap,
MigrationHealthCheck,
MigrationHistoryEntry,
MigrationLockAdapter,
MigrationPlan,
MigrationRecord,
MigrationResult,
MigrationRunnerConfig,
MigrationRunnerLike,
MigrationStatusEntry,
MigrationStatusValue,
MigrationStoreAdapter,
MigrationSummary,
MigrationValidationError,
RedisLockClient,
RedisLockConfig,
RiskLevel,
RunOptions,
StatusConfig,
ValidationResult,
} from "./migration.types.js";

View File

@@ -0,0 +1,64 @@
# migration.types
## Purpose
Central type definitions for the `@suaveplan/migration` package. All interfaces, unions, and type aliases consumed by the runner, planner, executor, store adapters, lock adapters, dry-run reporter, batch migrator, and status dashboard originate here. Keeping types in a single file enables tree-shaking (zero runtime bytes) and prevents circular-dependency risks across the feature modules.
## Features
- `Migration` — the core unit: an `id`, `name`, `up` function (async, receives `MigrationContext`), optional `down`, and optional pre-computed `checksum`.
- `MigrationContext` — injected into each migration function: `execute`, `query`, and a scoped `logger`.
- `MigrationRecord` — the ledger row type persisted by `MigrationStoreAdapter`.
- `MigrationPlan` — produced by the planner: `toApply` and `toRollback` arrays.
- `MigrationResult` — returned by `MigrationRunner.up` / `.down`: applied/skipped/failed id lists.
- Adapter interfaces: `MigrationStoreAdapter`, `MigrationLockAdapter`, `DistributedLockAdapter`.
- `MigrationEventMap` — typed event names for the event bus integration.
- `BatchMigratorConfig` / `BatchMigrationResult` — multi-target orchestration types.
- `StatusConfig` / `ExtendedMigrationStatus` / `MigrationSummary` / `MigrationHealthCheck` — dashboard types.
## Usage: Basic Example
```ts
import type { Migration, MigrationContext } from "@suaveplan/migration";
const createUsers: Migration = {
id: "001_create_users",
name: "Create users table",
async up(ctx: MigrationContext): Promise<void> {
await ctx.execute("CREATE TABLE users (id TEXT PRIMARY KEY)");
},
};
```
## Usage: Advanced Example
```ts
import type {
Migration,
MigrationStoreAdapter,
MigrationRecord,
MigrationRunnerConfig,
} from "@suaveplan/migration";
class MyStore implements MigrationStoreAdapter {
private records: MigrationRecord[] = [];
async getApplied() { return [...this.records]; }
async record(r: MigrationRecord) { this.records.push(r); }
async remove(id: string) { this.records = this.records.filter(r => r.id !== id); }
async clear() { this.records = []; }
}
const config: MigrationRunnerConfig = {
migrations: [createUsers],
store: new MyStore(),
checksumValidation: true,
};
```
## API Reference
See individual type definitions in the source. All are plain TypeScript interfaces with no runtime code — zero bytes in the compiled bundle.
## Implementation Notes
Types are placed in a `.types.ts` file and excluded from coverage thresholds, following the project convention. The `MigrationEventMap` uses string-literal keys matching the pattern `"migration:<event>"` for consistency with other event maps in the project.

View File

@@ -0,0 +1,238 @@
import type { EventBus } from "@suaveplan/events";
import type { Clock, LoggerLike } from "@suaveplan/types";
/** A single migration unit containing an id, SQL, and optional checksum. */
export interface Migration {
/** Unique identifier, typically prefixed with a sequence number e.g. "001_create_users". */
id: string;
/** Human-readable migration name. */
name: string;
/** Forward migration SQL statement(s). */
up: (ctx: MigrationContext) => Promise<void>;
/** Optional rollback SQL. */
down?: (ctx: MigrationContext) => Promise<void>;
/** SHA-256 hex checksum of the up function body. Auto-computed when absent. */
checksum?: string;
}
/** Execution context passed to each migration function. */
export interface MigrationContext {
/** Execute a SQL string with optional params. */
execute(sql: string, params?: readonly unknown[]): Promise<void>;
/** Query and return typed rows. */
query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>;
/** Structured logger scoped to this migration run. */
logger: LoggerLike;
}
/** Direction of migration execution. */
export type MigrationDirection = "up" | "down";
/** Status of a migration as tracked in the ledger. */
export type MigrationStatusValue =
| "pending"
| "applied"
| "failed"
| "rolled-back";
/** Ledger record for an applied (or rolled-back) migration. */
export interface MigrationRecord {
id: string;
name: string;
appliedAt: number;
checksum?: string;
direction: MigrationDirection;
}
/** Combined status of a migration including ledger information. */
export interface MigrationStatusEntry {
id: string;
name: string;
status: MigrationStatusValue;
appliedAt?: number;
checksum?: string;
}
/** Plan produced by the planner, describing which migrations to apply or rollback. */
export interface MigrationPlan {
toApply: Migration[];
toRollback: Migration[];
}
/** Result returned from MigrationRunner.up / .down. */
export interface MigrationResult {
applied: string[];
skipped: string[];
failed: string[];
direction: MigrationDirection;
dryRun: boolean;
}
/** Validation error for a specific migration. */
export interface MigrationValidationError {
migrationId: string;
message: string;
}
/** Aggregate result of validation. */
export interface ValidationResult {
valid: boolean;
errors: MigrationValidationError[];
}
/** Options for run operations. */
export interface RunOptions {
signal?: AbortSignal;
target?: string;
}
/** Configuration for the MigrationRunner. */
export interface MigrationRunnerConfig {
migrations: Migration[];
store: MigrationStoreAdapter;
lock?: MigrationLockAdapter;
logger?: LoggerLike;
events?: EventBus<MigrationEventMap>;
dryRun?: boolean;
checksumValidation?: boolean;
clock?: Clock;
}
/** Adapter interface for persisting migration ledger records. */
export interface MigrationStoreAdapter {
getApplied(): Promise<MigrationRecord[]>;
record(record: MigrationRecord): Promise<void>;
remove(id: string): Promise<void>;
clear(): Promise<void>;
}
/** Adapter interface for distributed advisory locks. */
export interface MigrationLockAdapter {
acquire(timeout?: number): Promise<boolean>;
release(): Promise<void>;
isLocked(): Promise<boolean>;
}
/** Distributed lock backend interface. */
export interface DistributedLockAdapter {
acquire(key: string, ttl: number): Promise<boolean>;
release(key: string): Promise<void>;
}
/** Config for DistributedMigrationLock. */
export interface DistributedLockConfig {
adapter: DistributedLockAdapter;
key?: string;
ttl?: number;
}
/** Redis-compatible client interface for RedisDistributedLock. */
export interface RedisLockClient {
set(key: string, value: string, options: object): Promise<string | null>;
del(key: string): Promise<number>;
}
/** Config for RedisDistributedLock. */
export interface RedisLockConfig {
client: RedisLockClient;
key?: string;
ttl?: number;
}
/** Risk level for dry-run reporting. */
export type RiskLevel = "low" | "medium" | "high";
/** A single entry in the dry-run report. */
export interface DryRunEntry {
migrationId: string;
direction: MigrationDirection;
riskLevel: RiskLevel;
reason?: string;
}
/** Aggregate dry-run report. */
export interface DryRunReport {
entries: DryRunEntry[];
totalRisk: RiskLevel;
summary: string;
}
/** Options for batch migrator configuration. */
export interface BatchMigratorConfig {
targets: Array<{ name: string; runner: MigrationRunnerLike }>;
concurrency?: number;
}
/** Minimal runner interface used by BatchMigrator. */
export interface MigrationRunnerLike {
up(options?: RunOptions): Promise<MigrationResult>;
}
/** Result of a batch migration run. */
export interface BatchMigrationResult {
results: Map<string, MigrationResult>;
failed: string[];
succeeded: string[];
}
/** Status dashboard configuration. */
export interface StatusConfig {
store: MigrationStoreAdapter;
migrations: Migration[];
clock?: Clock;
}
/** Extended status entry for the dashboard. */
export interface ExtendedMigrationStatus extends MigrationStatusEntry {
durationMs?: number;
}
/** Summary from the status dashboard. */
export interface MigrationSummary {
total: number;
applied: number;
pending: number;
failed: number;
}
/** Health check result from the status dashboard. */
export interface MigrationHealthCheck {
healthy: boolean;
pendingCount: number;
failedCount: number;
}
/** History entry for a specific migration. */
export interface MigrationHistoryEntry {
id: string;
direction: MigrationDirection;
appliedAt: number;
checksum?: string;
}
/** Event map for migration lifecycle events. */
export interface MigrationEventMap {
[key: string]: unknown;
"migration:before": { migration: Migration; direction: MigrationDirection };
"migration:after": {
migration: Migration;
direction: MigrationDirection;
durationMs: number;
};
"migration:failed": {
migration: Migration;
direction: MigrationDirection;
error: Error;
};
"migration:plan": { plan: MigrationPlan; direction: MigrationDirection };
}
/** Options for the executor. */
export interface ExecutorOptions {
store: MigrationStoreAdapter;
context: MigrationContext;
events?: EventBus<MigrationEventMap>;
dryRun?: boolean;
checksumValidation?: boolean;
clock?: Clock;
}

View File

@@ -0,0 +1,53 @@
# checksum
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides SHA-256 checksum utilities for the migration package. The checksum system detects when a migration's source has been modified after it was applied to a database, preventing accidental schema drift from silent SQL edits. Using `node:crypto` directly avoids introducing a dependency on `@suaveplan/crypto-hash`, keeping the migration package's dependency graph minimal.
## Features
- `computeChecksum(content)` — returns the SHA-256 hex digest of any string.
- `checksumMigration(up)` — serialises a migration's `up` function to its source string and hashes it.
## Usage: Basic Example
```ts
import { computeChecksum } from "@suaveplan/migration";
const hash = computeChecksum("CREATE TABLE users (id TEXT PRIMARY KEY)");
// "a4c2f..." (64-char hex string)
```
## Usage: Advanced Example
```ts
import { checksumMigration } from "@suaveplan/migration";
async function validateChecksums(migrations, appliedRecords) {
for (const migration of migrations) {
const record = appliedRecords.find(r => r.id === migration.id);
if (!record?.checksum) continue;
const computed = checksumMigration(migration.up);
if (computed !== record.checksum) {
throw new Error(`Checksum mismatch for ${migration.id}`);
}
}
}
```
## API Reference
### `computeChecksum(content: string): string`
Returns the lowercase hex SHA-256 digest of `content`. Uses `node:crypto` synchronously.
### `checksumMigration(up: (ctx: unknown) => Promise<void>): string`
Calls `up.toString()` to obtain the function source and passes it to `computeChecksum`. The result is deterministic for a given function body.
## Implementation Notes
`Function.prototype.toString()` is mandated by the ECMAScript specification to return the original source text (or a representation of it). In practice, transpilers and minifiers may change function bodies; for best reliability, checksums should be computed from source-file content in a pre-commit hook rather than at runtime in production builds.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { checksumMigration, computeChecksum } from "./checksum.js";
describe("computeChecksum", () => {
it("returns 64-char hex string", { timeout: 5000 }, () => {
const result = computeChecksum("hello");
expect(result).toHaveLength(64);
expect(result).toMatch(/^[0-9a-f]+$/);
});
it("is deterministic", { timeout: 5000 }, () => {
expect(computeChecksum("test")).toBe(computeChecksum("test"));
});
it("produces different hashes for different inputs", {
timeout: 5000,
}, () => {
expect(computeChecksum("a")).not.toBe(computeChecksum("b"));
});
it("matches known SHA-256 of empty string", { timeout: 5000 }, () => {
expect(computeChecksum("")).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
});
});
describe("checksumMigration", () => {
it("returns non-empty hex string", { timeout: 5000 }, () => {
const fn = async (_ctx: unknown) => {};
const result = checksumMigration(fn);
expect(result).toHaveLength(64);
expect(result).toMatch(/^[0-9a-f]+$/);
});
it("is deterministic for same function", { timeout: 5000 }, () => {
const fn = async (_ctx: unknown) => {
return;
};
expect(checksumMigration(fn)).toBe(checksumMigration(fn));
});
it("differs for different functions", { timeout: 5000 }, () => {
const fn1 = async (_ctx: unknown) => {
return 1;
};
const fn2 = async (_ctx: unknown) => {
return 2;
};
expect(checksumMigration(fn1)).not.toBe(checksumMigration(fn2));
});
});

View File

@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
/**
* Compute the SHA-256 hex digest of a given string.
*
* Uses `node:crypto` directly — no dependency on `@suaveplan/crypto-hash` —
* to keep the migration package's dependency footprint minimal.
*/
export function computeChecksum(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
/**
* Compute the checksum for a migration's up function body.
*
* Serialises the function to a string and hashes it. This is deterministic
* for a given source file but will change if the migration source is edited.
*/
export function checksumMigration(up: { toString(): string }): string {
return computeChecksum(up.toString());
}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("@suaveplan/migration dep-graph", () => {
it("can import the main entry point", { timeout: 30000 }, async () => {
const mod = await import("../src/index.js");
expect(mod.MigrationRunner).toBeDefined();
expect(mod.MemoryMigrationStore).toBeDefined();
expect(mod.MIGRATION_CODES).toBeDefined();
});
});

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { BatchMigrator } from "../src/batch/batch-migrator/batch-migrator.js";
import { MemoryMigrationLock } from "../src/lock/memory-lock/memory-lock.js";
import { MigrationRunner } from "../src/runner/migration-runner/migration-runner.js";
import { MigrationStatusDashboard } from "../src/status/migration-status/migration-status.js";
import { MemoryMigrationStore } from "../src/store/memory-store/memory-store.js";
import type { Migration } from "../src/types/migration.types.js";
const migrations: Migration[] = [
{
id: "001_create_users",
name: "Create users table",
async up(_ctx) {},
async down(_ctx) {},
},
{
id: "002_add_email",
name: "Add email column",
async up(_ctx) {},
async down(_ctx) {},
},
{
id: "003_create_posts",
name: "Create posts table",
async up(_ctx) {},
async down(_ctx) {},
},
];
describe("MigrationRunner integration", () => {
let store: MemoryMigrationStore;
beforeEach(() => {
store = new MemoryMigrationStore();
});
it("full up/down cycle", { timeout: 30000 }, async () => {
const runner = new MigrationRunner({ migrations, store });
const upResult = await runner.up();
expect(upResult.applied).toHaveLength(3);
expect(upResult.direction).toBe("up");
const statusAfterUp = await runner.status();
expect(statusAfterUp.every((s) => s.status === "applied")).toBe(true);
const downResult = await runner.down({ steps: 1 });
expect(downResult.applied).toHaveLength(1);
expect(downResult.applied[0]).toBe("003_create_posts");
// Up again applies only the rolled-back one
const upResult2 = await runner.up();
expect(upResult2.applied).toHaveLength(1);
});
it("idempotent up", { timeout: 30000 }, async () => {
const runner = new MigrationRunner({ migrations, store });
await runner.up();
const second = await runner.up();
expect(second.applied).toHaveLength(0);
});
it("with lock — runner acquires and releases", {
timeout: 30000,
}, async () => {
const lock = new MemoryMigrationLock();
const runner = new MigrationRunner({ migrations, store, lock });
const result = await runner.up();
expect(result.applied).toHaveLength(3);
expect(await lock.isLocked()).toBe(false);
});
it("dry-run does not write records", { timeout: 30000 }, async () => {
const runner = new MigrationRunner({ migrations, store, dryRun: true });
const result = await runner.up();
expect(result.dryRun).toBe(true);
expect(await store.getApplied()).toHaveLength(0);
});
it("status dashboard reflects runner results", {
timeout: 30000,
}, async () => {
const runner = new MigrationRunner({ migrations, store });
await runner.up();
const dashboard = new MigrationStatusDashboard({ store, migrations });
const health = await dashboard.healthCheck();
expect(health.healthy).toBe(true);
});
it("batch migrator runs multiple targets", { timeout: 30000 }, async () => {
const store2 = new MemoryMigrationStore();
const runner1 = new MigrationRunner({
migrations: migrations.slice(0, 2),
store,
});
const runner2 = new MigrationRunner({
migrations: migrations.slice(0, 2),
store: store2,
});
const batch = new BatchMigrator({
targets: [
{ name: "db1", runner: runner1 },
{ name: "db2", runner: runner2 },
],
});
const result = await batch.run();
expect(result.succeeded).toEqual(["db1", "db2"]);
expect(result.failed).toHaveLength(0);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/migration",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/db",
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/logger",
"@suaveplan/events",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});