3.1 KiB
migrations.ts
Purpose
HostMigrationRunner is the forward-only idempotent migration engine
the API host runs against its plugin ledger DB. It owns the closed set
of host-side schema migrations — every table the host uses to track
plugins, journal rollback entries, and reserve future saga + 2PC
storage. The runner records every applied migration in a sentinel
__host_migrations table so re-instantiating it against an
already-migrated DB is a true no-op.
The runner is "forward only" by design — there is no downgrade path.
Out-of-order or backwards-only declarations raise
LEDGER.MIGRATION_OUT_OF_ORDER so config drift between deployments
fails loudly before any rows are touched. Migration callback failures
raise LEDGER.MIGRATION_FAILED and the runner deliberately does NOT
record the failed id, so the next attempt re-runs the migration.
Features
- Forward-only ledger with a
__host_migrationssentinel table. - Two migrations shipped:
core-tables(the active plugin + journal schema) andsaga-2pc-reserved(Stage-6 reserved schemas). - Idempotent re-apply: the runner only executes a migration whose id
has no row in
__host_migrations. - Out-of-order detection: applied ids that aren't declared by the current runner, or pending ids ≤ the max applied id, are both rejected.
- Injectable clock for deterministic
applied_attimestamps.
Usage: Basic
import { Database } from "bun:sqlite";
import { HostMigrationRunner } from "@suaveplan/api-host/plugin-ledger";
const db = new Database(":memory:");
const runner = new HostMigrationRunner(db);
const applied = runner.migrate(); // [1, 2]
runner.migrate(); // []
Usage: Advanced
// Inject a fake clock + a slimmer migration list for tests.
import { HOST_LEDGER_MIGRATIONS } from "@suaveplan/api-host/plugin-ledger";
const onlyCore = HOST_LEDGER_MIGRATIONS.filter((m) => m.id === 1);
const runner = new HostMigrationRunner(
db,
onlyCore,
() => new Date("2026-05-25T00:00:00Z")
);
runner.migrate();
const applied = runner.listApplied(); // [1]
API Reference
new HostMigrationRunner(db, migrations?, now?)— defaults toHOST_LEDGER_MIGRATIONSand() => new Date().migrate(): readonly number[]— returns the ids run by this call.listApplied(): readonly number[]— every previously-applied id ascending.HOST_LEDGER_MIGRATIONS— closed forward-only migration set.LedgerMigration { id, name, up(db) }— declaration shape.
Implementation Notes
Each migration's up callback runs DDL through db.run(sql) (the
single-statement DDL path on a SQLite-shaped handle). Multi-statement
migrations declare each statement separately rather than relying on
the driver to split — this keeps the runner driver-agnostic.
The sentinel insert (INSERT INTO __host_migrations) runs AFTER the
up callback returns. If the callback throws, the runner does not
record the id, so the next migrate() retries the migration. A
single migration is not transactional with respect to its own DDL —
SQLite's CREATE TABLE IF NOT EXISTS is idempotent within a single
migration, so a partial first-pass apply still succeeds on retry.