Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# @suaveplan/api-migrations - Database-agnostic migration runner for the Suaveplan API — narrow MigrationAdapter surface with per-dialect DDL bootstrap, real SHA-256 checksum validation against on-disk files, AbortSignal cancellation between migrations, OTel counter + histogram per apply, MigrationError with six MIGRATION.* codes. Ships three adapters (surrealql, postgres, sqlite); MySQL slot reserved in the dialect union. Server-tier — Bun + Node.
Database-agnostic migration runner for the Suaveplan API. Ships a narrow
`MigrationAdapter` surface (six methods), three shipped adapters (`surrealql`,
`postgres`, `sqlite`), a `MigrationRunner` class that ACTUALLY validates
SHA-256 checksums against on-disk migration files before each apply phase,
`AbortSignal` propagation between migrations, OTel counter + histogram per
apply attempt, and `MigrationError` with six namespaced `MIGRATION.*` codes.
Splits and rewrites the upstream `@suaveplan/api`'s `database/migration-runner.ts`
(331 LOC). fixes baked in:
1. Narrow `MigrationAdapter` surface — the runner depends on six methods, not
the whole `DatabaseLike`. Each adapter ships its own `ensureTable` DDL.
2. Checksums are ACTUALLY validated — drift halts apply with
`MigrationError(MIGRATION.CHECKSUM_DRIFT)` and `{name, expected, actual}`
context. Upstream's `validateChecksums: true` was a no-op; this one isn't.
3. `AbortSignal` propagation — `runFromDirectory(dir, { signal })` honors
cancellation between migrations.
4. OTel counter `migrations.applied_total{dialect, outcome}` and histogram
`migrations.apply_duration_ms{name}` emit on every attempt regardless of
success.
5. `MigrationError extends SuaveplanError<MigrationErrorCode>` via
`defineCodes("MIGRATION", [...])` — six codes, every one raised from a
production call site.
<!-- begin:auto-docs -->
## Documentation tree
- [postgres](./src/adapters/postgres/postgres.md) — Implements the `MigrationAdapter` contract against any Postgres-wire-protocol server (Postgres ≥ 12, CockroachDB, Yugab…
- [postgres](./src/adapters/postgres/postgres.md) — Implements the `MigrationAdapter` contract against any Postgres-wire-protocol server (Postgres ≥ 12, CockroachDB, Yugab…
- [sqlite](./src/adapters/sqlite/sqlite.md) — Implements the `MigrationAdapter` contract for SQLite-family engines. The factory `createSqliteAdapter(client)` accepts…
- [sqlite](./src/adapters/sqlite/sqlite.md) — Implements the `MigrationAdapter` contract for SQLite-family engines. The factory `createSqliteAdapter(client)` accepts…
- [surrealql](./src/adapters/surrealql/surrealql.md) — Implements the `MigrationAdapter` contract for SurrealDB. The factory `createSurrealqlAdapter(client)` wraps any Surrea…
- [surrealql](./src/adapters/surrealql/surrealql.md) — Implements the `MigrationAdapter` contract for SurrealDB. The factory `createSurrealqlAdapter(client)` wraps any Surrea…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error registry for `@suaveplan/api-migrations`. Every public failure path in the package…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error registry for `@suaveplan/api-migrations`. Every public failure path in the package…
- [otel](./src/otel/instruments.md) — Lazily builds and caches the two OpenTelemetry instruments that `@suaveplan/api-migrations` emits on every migration ap…
- [instruments](./src/otel/instruments.md) — Lazily builds and caches the two OpenTelemetry instruments that `@suaveplan/api-migrations` emits on every migration ap…
- [runner](./src/runner/runner.md) — The `MigrationRunner` is the dialect-agnostic core of the package. It reads migration files from a directory, sorts the…
- [runner](./src/runner/runner.md) — The `MigrationRunner` is the dialect-agnostic core of the package. It reads migration files from a directory, sorts the…
- [types](./src/types/migration.md) — Declares every shared type that the runner, adapters, and consumers exchange across the `@suaveplan/api-migrations` pac…
- [migration](./src/types/migration.md) — Declares every shared type that the runner, adapters, and consumers exchange across the `@suaveplan/api-migrations` pac…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-migrations
```
## Quick start
```ts
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
import { createSqliteAdapter } from "@suaveplan/api-migrations/adapters/sqlite/sqlite";
const runner = new MigrationRunner(createSqliteAdapter(client), {
fileExtension: ".sql",
});
const controller = new AbortController();
const results = await runner.runFromDirectory("./db/migrations", {
signal: controller.signal,
});
for (const r of results) {
if (!r.success) throw new Error(`Apply halted at ${r.name}: ${r.error}`);
}
```
## Checksum-drift recovery
When `validateChecksums: true` (default) and a previously applied
migration's on-disk content has changed, the runner halts before applying
any pending migration:
```ts
try {
await runner.runFromDirectory("./db/migrations");
} catch (err) {
if (
err instanceof MigrationError &&
err.code === MIGRATION_CODES.CHECKSUM_DRIFT
) {
// err.context = { name, expected, actual }
// Resolution:
// 1. Restore the migration file to its applied form (`git checkout`
// the file at the commit it was applied from).
// 2. If the change is intentional, ship a NEW migration file with
// the additive change. Never rewrite history.
}
throw err;
}
```
## Modules
- [`runner/runner`](./src/runner/runner.md) — `MigrationRunner` class +
`createMigrationRunner` factory + `computeChecksum`.
- [`adapters/surrealql/surrealql`](./src/adapters/surrealql/surrealql.md) —
SurrealDB adapter with SurrealQL DDL.
- [`adapters/postgres/postgres`](./src/adapters/postgres/postgres.md) —
PostgreSQL adapter with standard SQL DDL.
- [`adapters/sqlite/sqlite`](./src/adapters/sqlite/sqlite.md) — SQLite
adapter compatible with `sqlite-wasm` / `bun:sqlite` / `better-sqlite3`.
- [`otel/instruments`](./src/otel/instruments.md) — counter + histogram.
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — six
`MIGRATION.*` codes + `MigrationError`.
- [`types/migration`](./src/types/migration.md) — public type surface.
## Subpath exports
Every feature module is independently importable for maximum
tree-shakeability:
```ts
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
import { createPostgresAdapter } from "@suaveplan/api-migrations/adapters/postgres/postgres";
import { MIGRATION_CODES } from "@suaveplan/api-migrations/error-codes/error-codes";
```
## Tier
Server (Bun + Node). Uses `node:crypto` for SHA-256 and `node:fs/promises`
for directory scanning and file reads.

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/api-migrations",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Database-agnostic migration runner for the Suaveplan API — narrow MigrationAdapter surface with per-dialect DDL bootstrap, real SHA-256 checksum validation against on-disk files, AbortSignal cancellation between migrations, OTel counter + histogram per apply, MigrationError with six MIGRATION.* codes. Ships three adapters (surrealql, postgres, sqlite); MySQL slot reserved in the dialect union. Server-tier — Bun + Node.",
"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"
}
},
"./runner/runner": {
"import": {
"types": "./dist/runner/runner.d.ts",
"default": "./dist/runner/runner.js"
}
},
"./adapters/surrealql/surrealql": {
"import": {
"types": "./dist/adapters/surrealql/surrealql.d.ts",
"default": "./dist/adapters/surrealql/surrealql.js"
}
},
"./adapters/postgres/postgres": {
"import": {
"types": "./dist/adapters/postgres/postgres.d.ts",
"default": "./dist/adapters/postgres/postgres.js"
}
},
"./adapters/sqlite/sqlite": {
"import": {
"types": "./dist/adapters/sqlite/sqlite.d.ts",
"default": "./dist/adapters/sqlite/sqlite.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./types/migration": {
"import": {
"types": "./dist/types/migration.d.ts",
"default": "./dist/types/migration.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts --passWithNoTests",
"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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"surrealdb": "catalog:database",
"postgres": "catalog:database",
"@sqlite.org/sqlite-wasm": "catalog:database"
},
"peerDependenciesMeta": {
"surrealdb": { "optional": true },
"postgres": { "optional": true },
"@sqlite.org/sqlite-wasm": { "optional": true }
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@sqlite.org/sqlite-wasm": "catalog:database",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"surrealdb": "catalog:database",
"testcontainers": ">=11.0.0",
"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,4 @@
export {
createPostgresAdapter,
type PostgresClient,
} from "./postgres.js";

View File

@@ -0,0 +1,88 @@
# `adapters/postgres` — PostgreSQL migration adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `MigrationAdapter` contract against any Postgres-wire-protocol
server (Postgres ≥ 12, CockroachDB, Yugabyte, etc.). The factory
`createPostgresAdapter(client)` accepts any client exposing a single
`unsafe(text, params?)` method that returns rows — the official `postgres`
(porsager/postgres) driver's `Sql` instance and a thin wrapper around
`pg.Client.query(text, values)` both satisfy this surface structurally.
The DDL is plain ANSI SQL (`CREATE TABLE IF NOT EXISTS ... text primary key
... timestamptz`), so the same adapter ports unchanged to Postgres-compatible
engines and is the basis for the spec scenario "postgres adapter emits
standard SQL DDL on ensureTable."
## Features
- Idempotent `CREATE TABLE IF NOT EXISTS` for the tracking table — concurrent
boots converge.
- Parameterised inserts and deletes — no string interpolation of caller data.
- Stores `applied_at` as `timestamptz`, normalises to a JavaScript `Date` on
read.
- Stores `duration` as `duration_ms int` and rehydrates as a plain number.
- Wraps `ensureTable` failures as
`MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED)` so consumers can branch
on the namespaced code rather than the underlying driver error.
## Usage: Basic Example
```ts
import postgres from "postgres";
import { createPostgresAdapter } from "@suaveplan/api-migrations/adapters/postgres/postgres";
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
const sql = postgres(process.env.DATABASE_URL!);
const runner = new MigrationRunner(createPostgresAdapter(sql), {
fileExtension: ".sql",
});
await runner.runFromDirectory("./db/migrations");
```
## Usage: Advanced Example
```ts
import { createPostgresAdapter, type PostgresClient } from "@suaveplan/api-migrations/adapters/postgres/postgres";
// Adapting node-postgres' `pg.Client`:
import pg from "pg";
const pgClient = new pg.Client();
await pgClient.connect();
const adapted: PostgresClient = {
async unsafe<T>(text: string, params?: unknown[]): Promise<T[]> {
const r = await pgClient.query<T>({ text, values: params });
return r.rows;
},
};
const adapter = createPostgresAdapter(adapted);
```
## API Reference
- `interface PostgresClient { unsafe<T>(text: string, params?: unknown[]):
Promise<T[]> }` — structural client surface.
- `createPostgresAdapter(client): MigrationAdapter` — returns an adapter
with `dialect: "postgres"`.
## Implementation Notes
- The choice of a single `unsafe(text, params?)` surface is deliberate: it is
the smallest common denominator between `porsager/postgres` (tagged
templates) and `node-postgres` (positional placeholders), so callers can
bring whichever driver fits their stack.
- The runner generates the `id` field; the adapter does not auto-assign one.
- The DDL avoids Postgres-specific extensions (no `JSONB`, no `GENERATED`,
no `SERIAL`), so it is compatible with the broader Postgres ecosystem.
- `selectApplied` returns rows in unspecified order. The runner sorts by
`name` before comparing against pending files, so order is a runner
concern — not a SQL concern.
- The adapter does not currently forward `AbortSignal` into `client.unsafe`
because neither porsager/postgres nor node-postgres support it uniformly;
the per-migration cancellation gate in the runner still applies between
apply attempts.

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import { createPostgresAdapter, type PostgresClient } from "./postgres.js";
interface Call {
text: string;
params: unknown[] | undefined;
}
class StubClient implements PostgresClient {
public calls: Call[] = [];
public throwOn: string | undefined;
public rows: unknown[] = [];
async unsafe<T>(text: string, params?: unknown[]): Promise<T[]> {
this.calls.push({ text, params });
if (this.throwOn && text.includes(this.throwOn)) {
throw new Error(`stub: ${this.throwOn}`);
}
return this.rows as T[];
}
}
describe("postgres adapter", () => {
it("ensureTable emits standard SQL DDL", { timeout: 5000 }, async () => {
const client = new StubClient();
const adapter = createPostgresAdapter(client);
await adapter.ensureTable("_migrations");
const sql = client.calls[0]!.text;
expect(sql).toContain("CREATE TABLE IF NOT EXISTS _migrations");
expect(sql).toContain("id text primary key");
expect(sql).toContain("applied_at timestamptz");
expect(sql).toContain("duration_ms int not null");
});
it("ensureTable wraps errors as TABLE_BOOTSTRAP_FAILED", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.throwOn = "CREATE TABLE";
const adapter = createPostgresAdapter(client);
await expect(adapter.ensureTable("_migrations")).rejects.toMatchObject({
code: MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
});
});
it("ensureTable preserves non-Error throwables as cause", {
timeout: 5000,
}, async () => {
const failing: PostgresClient = {
async unsafe() {
const reason: unknown = { weird: true };
throw reason;
},
};
const adapter = createPostgresAdapter(failing);
await adapter
.ensureTable("_migrations")
.catch((err: MigrationError) => {
expect(err).toBeInstanceOf(MigrationError);
expect(err.cause).toEqual({ weird: true });
});
});
it("selectApplied normalises rows", { timeout: 5000 }, async () => {
const client = new StubClient();
client.rows = [
{
id: "001",
name: "001",
applied_at: "2026-05-18T00:00:00.000Z",
checksum: "x",
duration_ms: 10,
},
];
const adapter = createPostgresAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]).toMatchObject({ id: "001", duration: 10 });
expect(rows[0]!.appliedAt).toBeInstanceOf(Date);
});
it("selectApplied preserves Date instances", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const when = new Date("2026-05-18T00:00:00Z");
client.rows = [
{
id: "001",
name: "001",
applied_at: when,
checksum: "x",
duration_ms: 10,
},
];
const adapter = createPostgresAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.appliedAt).toBe(when);
});
it("insertApplied issues parameterised INSERT", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createPostgresAdapter(client);
await adapter.insertApplied("_migrations", {
id: "001",
name: "001",
appliedAt: new Date("2026-05-18T00:00:00Z"),
checksum: "h",
duration: 5,
});
expect(client.calls[0]!.text).toContain("INSERT INTO _migrations");
expect(client.calls[0]!.params).toEqual([
"001",
"001",
"2026-05-18T00:00:00.000Z",
"h",
5,
]);
});
it("deleteApplied issues parameterised DELETE", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createPostgresAdapter(client);
await adapter.deleteApplied("_migrations", "001");
expect(client.calls[0]!.text).toBe(
"DELETE FROM _migrations WHERE id = $1"
);
expect(client.calls[0]!.params).toEqual(["001"]);
});
it("execute forwards content unchanged", { timeout: 5000 }, async () => {
const client = new StubClient();
const adapter = createPostgresAdapter(client);
await adapter.execute("CREATE TABLE users (id text);");
expect(client.calls[0]!.text).toBe("CREATE TABLE users (id text);");
});
});

View File

@@ -0,0 +1,108 @@
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import type {
MigrationAdapter,
MigrationRecord,
} from "../../types/migration.js";
/**
* Minimal surface the postgres adapter consumes from a Postgres client. The
* official `postgres` (porsager/postgres) package's `Sql` instance is callable
* as a tagged template AND as `sql.unsafe(text, params)`. To stay compatible
* with both the tagged-template flavour and a plain `pg.Client` (whose
* `query(text, values)` API is also widely used), we depend only on a single
* `unsafe(text, params?)` method that returns rows.
*/
export interface PostgresClient {
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<T[]>;
}
function ddl(table: string): string {
return (
`CREATE TABLE IF NOT EXISTS ${table} (` +
"id text primary key, " +
"name text unique not null, " +
"applied_at timestamptz not null default now(), " +
"checksum text not null, " +
"duration_ms int not null" +
")"
);
}
interface PgRow {
id: string;
name: string;
applied_at: Date | string;
checksum: string;
duration_ms: number;
}
function rowToRecord(row: PgRow): MigrationRecord {
const appliedAt =
row.applied_at instanceof Date
? row.applied_at
: new Date(row.applied_at);
return {
id: row.id,
name: row.name,
appliedAt,
checksum: row.checksum,
duration: row.duration_ms,
};
}
/**
* Build a {@link MigrationAdapter} for PostgreSQL. Uses standard SQL DDL —
* no Postgres-specific extensions — so it works against any PG-wire-protocol
* server (Postgres ≥ 12, CockroachDB, Yugabyte, etc.).
*
* @throws MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED) on `ensureTable`
* failure, wrapping the underlying client error.
*/
export function createPostgresAdapter(
client: PostgresClient
): MigrationAdapter {
return {
dialect: "postgres",
async ensureTable(table) {
try {
await client.unsafe(ddl(table));
} catch (cause) {
throw new MigrationError(
MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
`Failed to bootstrap Postgres tracking table "${table}"`,
{
cause,
context: { dialect: "postgres", table },
}
);
}
},
async selectApplied(table) {
const rows = await client.unsafe<PgRow>(
`SELECT id, name, applied_at, checksum, duration_ms FROM ${table}`
);
return rows.map(rowToRecord);
},
async insertApplied(table, record) {
await client.unsafe(
`INSERT INTO ${table} (id, name, applied_at, checksum, duration_ms) VALUES ($1, $2, $3, $4, $5)`,
[
record.id,
record.name,
record.appliedAt.toISOString(),
record.checksum,
record.duration,
]
);
},
async deleteApplied(table, id) {
await client.unsafe(`DELETE FROM ${table} WHERE id = $1`, [id]);
},
async execute(content) {
await client.unsafe(content);
},
};
}

View File

@@ -0,0 +1 @@
export { createSqliteAdapter, type SqliteClient } from "./sqlite.js";

View File

@@ -0,0 +1,108 @@
# `adapters/sqlite` — SQLite migration adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `MigrationAdapter` contract for SQLite-family engines. The
factory `createSqliteAdapter(client)` accepts any object exposing three
methods (`runScript`, `selectAll`, `runStatement`); a small structural
wrapper makes `@sqlite.org/sqlite-wasm`, `bun:sqlite`, and `better-sqlite3`
all conform. This decouples the adapter from the WASM build cost when a
consumer wants to use a native driver, while still letting universal
callers bring `sqlite-wasm` if they need browser-tier portability.
The DDL is plain ANSI SQLite (`TEXT PRIMARY KEY`, `INTEGER`, `TEXT NOT NULL`),
so the same adapter works against the upstream `sqlite3` CLI and against
any wrapper that supports parameterised statements.
## Features
- Idempotent `CREATE TABLE IF NOT EXISTS` for the tracking table.
- Parameterised inserts and deletes use `?` placeholders — no caller data is
interpolated into SQL.
- Stores `applied_at` as ISO 8601 text (SQLite has no native datetime); the
adapter rehydrates to a JavaScript `Date` on read.
- Stores `duration_ms` as `INTEGER`.
- Wraps `ensureTable` failures as `MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED)`.
- Sync clients (`better-sqlite3`, `bun:sqlite`) work without modification —
the adapter awaits methods that may return `void` synchronously.
## Usage: Basic Example
```ts
import { Database } from "bun:sqlite";
import { createSqliteAdapter } from "@suaveplan/api-migrations/adapters/sqlite/sqlite";
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
const db = new Database(":memory:");
const client = {
runScript(sql: string) { db.run(sql); },
selectAll<T>(sql: string, params: unknown[] = []) {
return db.query<T, unknown[]>(sql).all(...params) as T[];
},
runStatement(sql: string, params: unknown[] = []) {
db.query(sql).run(...params);
},
};
const runner = new MigrationRunner(createSqliteAdapter(client));
await runner.runFromDirectory("./db/migrations");
```
## Usage: Advanced Example
```ts
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
import { createSqliteAdapter } from "@suaveplan/api-migrations/adapters/sqlite/sqlite";
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.DB(":memory:");
const client = {
runScript(sql: string) {
db.executeBatch(sql);
},
selectAll<T>(sql: string, params: unknown[] = []) {
const out: T[] = [];
db.executeBatch({
sql,
bind: params,
rowMode: "object",
callback(row: T) { out.push(row); return undefined; },
});
return out;
},
runStatement(sql: string, params: unknown[] = []) {
db.executeBatch({ sql, bind: params });
},
};
const adapter = createSqliteAdapter(client);
```
## API Reference
- `interface SqliteClient { runScript(sql); selectAll<T>(sql, params?);
runStatement(sql, params?) }` — structural client surface.
- `createSqliteAdapter(client): MigrationAdapter` — returns an adapter with
`dialect: "sqlite"`.
## Implementation Notes
- The three-method surface is the smallest common denominator across the
three SQLite drivers the suaveplan tree is likely to encounter. Every
shipped adapter normalises to async by awaiting the result, so sync
drivers work without modification.
- `applied_at` is stored as ISO 8601 text because SQLite has no native
datetime type. Round-trip preserves wall-clock ordering — sorting on the
raw `applied_at` string yields the same order as sorting on parsed
`Date`.
- The adapter does not currently forward `AbortSignal` into `runScript`
because none of the supported SQLite drivers honor mid-query
cancellation. The runner's per-migration abort gate still applies between
apply attempts.
- The DDL is intentionally minimal — no indices beyond the `name`
uniqueness implicit in `UNIQUE NOT NULL`, no foreign keys. The runner
reads applied rows in unspecified order and sorts by `name` itself.

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import { createSqliteAdapter, type SqliteClient } from "./sqlite.js";
class MemoryDb {
private table: Record<string, unknown>[] = [];
public scripts: string[] = [];
public throwOnScript: string | undefined;
private toClient(): SqliteClient {
return {
runScript: (sql) => {
this.scripts.push(sql);
if (this.throwOnScript && sql.includes(this.throwOnScript)) {
throw new Error("boom");
}
},
selectAll: <T>() => [...this.table] as T[],
runStatement: (sql, params) => {
if (sql.startsWith("INSERT")) {
const [id, name, appliedAt, checksum, durationMs] =
params as [string, string, string, string, number];
this.table.push({
id,
name,
applied_at: appliedAt,
checksum,
duration_ms: durationMs,
});
} else if (sql.startsWith("DELETE")) {
const [id] = params as [string];
this.table = this.table.filter((r) => r.id !== id);
}
},
};
}
public client(): SqliteClient {
return this.toClient();
}
}
describe("sqlite adapter", () => {
it("ensureTable issues CREATE TABLE IF NOT EXISTS", {
timeout: 5000,
}, async () => {
const db = new MemoryDb();
const adapter = createSqliteAdapter(db.client());
await adapter.ensureTable("_migrations");
expect(db.scripts[0]).toContain(
"CREATE TABLE IF NOT EXISTS _migrations"
);
expect(db.scripts[0]).toContain("applied_at TEXT NOT NULL");
});
it("ensureTable wraps failure as TABLE_BOOTSTRAP_FAILED", {
timeout: 5000,
}, async () => {
const db = new MemoryDb();
db.throwOnScript = "CREATE TABLE";
const adapter = createSqliteAdapter(db.client());
await expect(adapter.ensureTable("_migrations")).rejects.toMatchObject({
code: MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
});
});
it("ensureTable preserves non-Error throwables as cause", {
timeout: 5000,
}, async () => {
const failing: SqliteClient = {
runScript() {
const reason: unknown = "stringy";
throw reason;
},
selectAll: () => [],
runStatement: () => {},
};
const adapter = createSqliteAdapter(failing);
await adapter
.ensureTable("_migrations")
.catch((err: MigrationError) => {
expect(err).toBeInstanceOf(MigrationError);
expect(err.cause).toBe("stringy");
});
});
it("insertApplied then selectApplied roundtrip", {
timeout: 5000,
}, async () => {
const db = new MemoryDb();
const adapter = createSqliteAdapter(db.client());
await adapter.ensureTable("_migrations");
const when = new Date("2026-05-18T00:00:00.000Z");
await adapter.insertApplied("_migrations", {
id: "001",
name: "001",
appliedAt: when,
checksum: "h",
duration: 5,
});
const rows = await adapter.selectApplied("_migrations");
expect(rows).toHaveLength(1);
expect(rows[0]!.appliedAt.toISOString()).toBe(when.toISOString());
expect(rows[0]!.duration).toBe(5);
});
it("deleteApplied removes a row", { timeout: 5000 }, async () => {
const db = new MemoryDb();
const adapter = createSqliteAdapter(db.client());
await adapter.ensureTable("_migrations");
await adapter.insertApplied("_migrations", {
id: "001",
name: "001",
appliedAt: new Date("2026-05-18T00:00:00Z"),
checksum: "h",
duration: 5,
});
await adapter.deleteApplied("_migrations", "001");
const rows = await adapter.selectApplied("_migrations");
expect(rows).toHaveLength(0);
});
it("execute runs the migration body through runScript", {
timeout: 5000,
}, async () => {
const db = new MemoryDb();
const adapter = createSqliteAdapter(db.client());
await adapter.execute("CREATE TABLE users (id TEXT);");
expect(db.scripts).toContain("CREATE TABLE users (id TEXT);");
});
});

View File

@@ -0,0 +1,107 @@
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import type {
MigrationAdapter,
MigrationRecord,
} from "../../types/migration.js";
/**
* Minimal surface the sqlite adapter consumes. Compatible structurally with
* `@sqlite.org/sqlite-wasm`, `bun:sqlite`, and `better-sqlite3` after a thin
* wrapper.
*
* - `runScript(sql)` runs zero-result DDL / DML.
* - `selectAll(sql, params?)` returns rows for SELECT.
* - `runStatement(sql, params?)` runs zero-result parameterised statements.
*/
export interface SqliteClient {
runScript(sql: string): void | Promise<void>;
selectAll<T>(sql: string, params?: unknown[]): T[] | Promise<T[]>;
runStatement(sql: string, params?: unknown[]): void | Promise<void>;
}
function ddl(table: string): string {
return (
`CREATE TABLE IF NOT EXISTS ${table} (` +
"id TEXT PRIMARY KEY, " +
"name TEXT UNIQUE NOT NULL, " +
"applied_at TEXT NOT NULL, " +
"checksum TEXT NOT NULL, " +
"duration_ms INTEGER NOT NULL" +
")"
);
}
interface SqliteRow {
id: string;
name: string;
applied_at: string;
checksum: string;
duration_ms: number;
}
function rowToRecord(row: SqliteRow): MigrationRecord {
return {
id: row.id,
name: row.name,
appliedAt: new Date(row.applied_at),
checksum: row.checksum,
duration: row.duration_ms,
};
}
/**
* Build a {@link MigrationAdapter} for SQLite. The adapter accepts any client
* exposing `runScript` / `selectAll` / `runStatement`; a thin wrapper makes
* `@sqlite.org/sqlite-wasm`, `bun:sqlite`, and `better-sqlite3` all conform.
*
* @throws MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED) when `ensureTable`
* fails, wrapping the underlying client error as `cause`.
*/
export function createSqliteAdapter(client: SqliteClient): MigrationAdapter {
return {
dialect: "sqlite",
async ensureTable(table) {
try {
await client.runScript(ddl(table));
} catch (cause) {
throw new MigrationError(
MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
`Failed to bootstrap SQLite tracking table "${table}"`,
{
cause,
context: { dialect: "sqlite", table },
}
);
}
},
async selectApplied(table) {
const rows = await client.selectAll<SqliteRow>(
`SELECT id, name, applied_at, checksum, duration_ms FROM ${table}`
);
return rows.map(rowToRecord);
},
async insertApplied(table, record) {
await client.runStatement(
`INSERT INTO ${table} (id, name, applied_at, checksum, duration_ms) VALUES (?, ?, ?, ?, ?)`,
[
record.id,
record.name,
record.appliedAt.toISOString(),
record.checksum,
record.duration,
]
);
},
async deleteApplied(table, id) {
await client.runStatement(`DELETE FROM ${table} WHERE id = ?`, [
id,
]);
},
async execute(content) {
await client.runScript(content);
},
};
}

View File

@@ -0,0 +1,5 @@
export {
createSurrealqlAdapter,
type SurrealqlAdapterOptions,
type SurrealqlClient,
} from "./surrealql.js";

View File

@@ -0,0 +1,101 @@
# `adapters/surrealql` — SurrealDB migration adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `MigrationAdapter` contract for SurrealDB. The factory
`createSurrealqlAdapter(client)` wraps any SurrealDB client that exposes a
`query(sql, vars?)` method (the official `surrealdb` JavaScript driver and
the in-process `SurrealAdapter` from `@suaveplan/surrealdb` both satisfy this
structurally) and returns the six-method surface the runner consumes.
The SurrealQL DDL for the tracking table lives in this adapter — not in the
runner — so the runner stays dialect-agnostic and a sibling adapter package
may ship a different DDL for the same logical schema.
## Features
- `ensureTable(table)` issues idempotent `DEFINE TABLE / DEFINE FIELD / DEFINE
INDEX` statements gated on `IF NOT EXISTS`, so concurrent boots converge
rather than racing.
- `selectApplied(table)` returns canonical `MigrationRecord` shapes — the
adapter normalises the Surreal `id` (Record ID object or string) to a plain
string and `appliedAt` (ISO string) to a `Date`.
- `insertApplied(table, record)` writes the ledger row using a
backtick-quoted record-id literal (`` CREATE table:`id` SET ... ``) so the
CREATE is parsed as a record-id reference under SurrealDB v3. Field values
use named bindings, never string interpolation. The pre-fix form
`CREATE table:$name SET ...` was silently dropped by SurrealDB v3's
parser (the `$param` was consumed as a literal ident in the record-id
position, not substituted, so the statement returned no row).
- `deleteApplied(table, id)` removes the tracking row via the matching
`` table:`id` `` form so the `selectApplied` → `deleteApplied` round-trip
targets the same row regardless of whether the migration name is a bare
SurrealQL ident. Optionally locked behind `{ rollback: false }` to enforce
forward-only deploys.
- `execute(content)` runs the migration body as-is.
- Bootstrap failures raise `MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED)`
with the underlying client error as `cause`.
- Disabled rollback raises `MigrationError(MIGRATION.ROLLBACK_NOT_SUPPORTED)`
from `deleteApplied`.
## Usage: Basic Example
```ts
import { Surreal } from "surrealdb";
import { createSurrealqlAdapter } from "@suaveplan/api-migrations/adapters/surrealql/surrealql";
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
const client = new Surreal();
await client.connect("ws://localhost:8000/rpc");
await client.use({ namespace: "test", database: "test" });
await client.signin({ username: "root", password: "root" });
const runner = new MigrationRunner(createSurrealqlAdapter(client));
const results = await runner.runFromDirectory("./db/migrations");
```
## Usage: Advanced Example
```ts
import { createSurrealqlAdapter } from "@suaveplan/api-migrations/adapters/surrealql/surrealql";
const forwardOnly = createSurrealqlAdapter(client, { rollback: false });
try {
await forwardOnly.deleteApplied("_migrations", "001_initial");
} catch (err) {
// err.code === "MIGRATION.ROLLBACK_NOT_SUPPORTED"
}
```
## API Reference
- `interface SurrealqlClient { query<T>(sql, vars?): Promise<T[]> }` —
structural client surface; satisfied by the official `surrealdb` driver.
- `interface SurrealqlAdapterOptions { rollback?: boolean }` — defaults to
`true`. When `false`, `deleteApplied` rejects with
`MIGRATION.ROLLBACK_NOT_SUPPORTED`.
- `createSurrealqlAdapter(client, options?): MigrationAdapter` — returns an
adapter with `dialect: "surrealql"`.
## Implementation Notes
- The DDL is emitted as a single multi-statement string so a single round
trip bootstraps the tracking table on first run.
- `appliedAt` is sent as an ISO string in `insertApplied`; SurrealQL accepts
ISO 8601 for `datetime` fields and the round-trip preserves wall-clock
ordering even when the client is in a different time zone.
- `selectApplied` does not enforce ordering — the runner sorts by `name`
before comparing against pending files, so order is a runner concern.
- `selectApplied` normalises the returned `id` to the bare migration name:
strips the `${table}:` prefix and any wrapping backticks, and for object-
form `RecordId { tb, id }` returns the inner string id when present. This
guarantees the value that round-trips into `deleteApplied(table, id)`
produces the same `` table:`id` `` reference that `insertApplied` wrote.
- The adapter never injects an `AbortSignal` into `client.query` because the
upstream `surrealdb` driver does not yet honor one; if a future driver
version gains signal support, the adapter will pick it up by forwarding
the optional `signal` argument through.

View File

@@ -0,0 +1,294 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import { createSurrealqlAdapter, type SurrealqlClient } from "./surrealql.js";
interface Call {
sql: string;
vars?: Record<string, unknown>;
}
class StubClient implements SurrealqlClient {
public calls: Call[] = [];
public throwOn: string | undefined;
public rows: unknown[] = [];
async query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]> {
this.calls.push({ sql, vars });
if (this.throwOn && sql.includes(this.throwOn)) {
throw new Error(`stub: ${this.throwOn}`);
}
return this.rows as T[];
}
}
describe("surrealql adapter", () => {
it("ensureTable emits SurrealQL DDL", { timeout: 5000 }, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
await adapter.ensureTable("_migrations");
expect(client.calls).toHaveLength(1);
expect(client.calls[0]!.sql).toContain(
"DEFINE TABLE IF NOT EXISTS _migrations SCHEMAFULL"
);
expect(client.calls[0]!.sql).toContain(
"DEFINE INDEX IF NOT EXISTS idx_name"
);
});
it("ensureTable wraps client failures as TABLE_BOOTSTRAP_FAILED", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.throwOn = "DEFINE TABLE";
const adapter = createSurrealqlAdapter(client);
await expect(adapter.ensureTable("_migrations")).rejects.toMatchObject({
code: MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
});
});
it("ensureTable preserves non-Error rejections as cause", {
timeout: 5000,
}, async () => {
const failing: SurrealqlClient = {
async query() {
const reason: unknown = "stringy";
throw reason;
},
};
const adapter = createSurrealqlAdapter(failing);
const promise = adapter.ensureTable("_migrations");
await expect(promise).rejects.toBeInstanceOf(MigrationError);
await promise.catch((err: MigrationError) => {
expect(err.cause).toBe("stringy");
});
});
it("selectApplied strips table prefix from string ids", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.rows = [
{
id: "_migrations:001",
name: "001",
appliedAt: "2026-05-18T00:00:00.000Z",
checksum: "x",
duration: 1,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.appliedAt).toBeInstanceOf(Date);
// After v3 fix: bare id, ready to round-trip into `deleteApplied`.
expect(rows[0]!.id).toBe("001");
});
it("selectApplied strips backtick-quoted id form", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.rows = [
{
id: "_migrations:`001_initial`",
name: "001_initial",
appliedAt: "2026-05-18T00:00:00.000Z",
checksum: "x",
duration: 1,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe("001_initial");
});
it("selectApplied handles RecordId object form", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const objId = { tb: "_migrations", id: "002" };
const when = new Date("2026-05-18T00:00:00Z");
client.rows = [
{
id: objId,
name: "002",
appliedAt: when,
checksum: "y",
duration: 2,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe("002");
expect(rows[0]!.appliedAt).toBe(when);
});
it("selectApplied stringifies object id without bare id", {
timeout: 5000,
}, async () => {
const client = new StubClient();
// RecordId-like object whose nested id is not a string (e.g. a
// numeric or RecordIdValue subclass). Fallback path stringifies.
const objId = { tb: "_migrations", id: 42 };
client.rows = [
{
id: objId,
name: "002b",
appliedAt: "2026-05-18T00:00:00Z",
checksum: "y",
duration: 2,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe(String(objId));
});
it("selectApplied falls back to name when id is null", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.rows = [
{
id: null,
name: "003",
appliedAt: "2026-05-18T00:00:00Z",
checksum: "z",
duration: 3,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe("003");
});
it("selectApplied handles non-string non-object id", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.rows = [
{
id: 7,
name: "004",
appliedAt: "2026-05-18T00:00:00Z",
checksum: "z",
duration: 4,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe("7");
});
it("selectApplied returns un-prefixed string id unchanged", {
timeout: 5000,
}, async () => {
const client = new StubClient();
client.rows = [
{
id: "raw-id",
name: "raw-id",
appliedAt: "2026-05-18T00:00:00Z",
checksum: "z",
duration: 5,
},
];
const adapter = createSurrealqlAdapter(client);
const rows = await adapter.selectApplied("_migrations");
expect(rows[0]!.id).toBe("raw-id");
});
it("insertApplied uses backticked record-id literal (v3-safe)", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
await adapter.insertApplied("_migrations", {
id: "001",
name: "001",
appliedAt: new Date("2026-05-18T00:00:00Z"),
checksum: "h",
duration: 5,
});
// Regression: must NOT use `${table}:$name` form — v3 parses
// `$name` in the record-id position as a literal ident, not as
// a parameter, so the CREATE silently records nothing.
expect(client.calls[0]!.sql).not.toContain("$name SET");
expect(client.calls[0]!.sql).toContain("CREATE _migrations:`001` SET");
expect(client.calls[0]!.vars).toMatchObject({
name: "001",
checksum: "h",
duration: 5,
});
});
it("insertApplied binds appliedAt as a Date (not a string)", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
const when = new Date("2026-05-18T00:00:00Z");
await adapter.insertApplied("_migrations", {
id: "001",
name: "001",
appliedAt: when,
checksum: "h",
duration: 5,
});
// Regression: SurrealDB v3's `TYPE datetime` column refuses an ISO
// string ("2026-…Z") and rejects the CREATE with
// `InternalError: Couldn't coerce value for field 'appliedAt'`. The
// adapter must pass the raw Date so the SDK CBOR-encodes it as a
// SurrealDB `datetime`.
expect(client.calls[0]!.vars?.appliedAt).toBe(when);
expect(client.calls[0]!.vars?.appliedAt).toBeInstanceOf(Date);
});
it("insertApplied escapes backticks inside the migration id", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
await adapter.insertApplied("_migrations", {
id: "weird`id",
name: "weird`id",
appliedAt: new Date("2026-05-18T00:00:00Z"),
checksum: "h",
duration: 5,
});
expect(client.calls[0]!.sql).toContain(
"CREATE _migrations:`weird``id` SET"
);
});
it("deleteApplied uses backticked record-id literal (v3-safe)", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
await adapter.deleteApplied("_migrations", "001");
expect(client.calls[0]!.sql).toBe("DELETE _migrations:`001`");
});
it("deleteApplied refuses when rollback disabled", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client, { rollback: false });
await expect(
adapter.deleteApplied("_migrations", "001")
).rejects.toMatchObject({
code: MIGRATION_CODES.ROLLBACK_NOT_SUPPORTED,
});
});
it("execute forwards content to client.query", {
timeout: 5000,
}, async () => {
const client = new StubClient();
const adapter = createSurrealqlAdapter(client);
await adapter.execute("DEFINE TABLE users SCHEMAFULL;");
expect(client.calls[0]!.sql).toBe("DEFINE TABLE users SCHEMAFULL;");
});
});

View File

@@ -0,0 +1,185 @@
import {
MIGRATION_CODES,
MigrationError,
} from "../../error-codes/error-codes.js";
import type {
MigrationAdapter,
MigrationRecord,
} from "../../types/migration.js";
/**
* Minimal surface the surrealql adapter consumes from a SurrealDB client.
* Compatible with the official `surrealdb` package and any structural
* equivalent used in tests.
*/
export interface SurrealqlClient {
query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]>;
}
/**
* Construction options for `createSurrealqlAdapter`.
*
* - `rollback`: when `false`, `deleteApplied` rejects with
* `MigrationError(MIGRATION.ROLLBACK_NOT_SUPPORTED)`. Defaults to `true`.
*/
export interface SurrealqlAdapterOptions {
readonly rollback?: boolean;
}
/**
* Escape a migration id for use inside a SurrealQL backticked record-id
* literal. SurrealDB v3 record ids may be arbitrary strings when quoted
* with backticks; the only character that needs escaping inside a
* backtick-quoted ident is the backtick itself (doubled).
*/
function escapeBacktickedId(id: string): string {
return id.replace(/`/g, "``");
}
function ddl(table: string): string {
return `
DEFINE TABLE IF NOT EXISTS ${table} SCHEMAFULL;
DEFINE FIELD IF NOT EXISTS name ON ${table} TYPE string;
DEFINE FIELD IF NOT EXISTS appliedAt ON ${table} TYPE datetime;
DEFINE FIELD IF NOT EXISTS checksum ON ${table} TYPE string;
DEFINE FIELD IF NOT EXISTS duration ON ${table} TYPE int;
DEFINE INDEX IF NOT EXISTS idx_name ON ${table} FIELDS name UNIQUE;
`;
}
interface SurrealRow {
id: unknown;
name: string;
appliedAt: string | Date;
checksum: string;
duration: number;
}
/**
* Strip the `<table>:` prefix and any wrapping backticks from a SurrealDB-
* returned id string. SurrealDB v3 round-trips record ids as either a
* `RecordId` object (`{ tb, id }`) or the literal `<table>:<id>` /
* <code>&lt;table&gt;:`&lt;id&gt;`</code> form depending on whether the id is a bare
* ident. Callers need the bare id so `deleteApplied(table, id)` produces
* the same `<table>:\`<id>\`` form `insertApplied` writes.
*/
function bareId(rawId: unknown, table: string, fallback: string): string {
if (rawId === null || rawId === undefined) return fallback;
if (typeof rawId === "object") {
const rec = rawId as { id?: unknown };
if (typeof rec.id === "string") return rec.id;
return String(rawId);
}
if (typeof rawId !== "string") return String(rawId);
const prefix = `${table}:`;
const stripped = rawId.startsWith(prefix)
? rawId.slice(prefix.length)
: rawId;
return stripped.startsWith("`") && stripped.endsWith("`")
? stripped.slice(1, -1).replace(/``/g, "`")
: stripped;
}
function rowToRecord(row: SurrealRow, table: string): MigrationRecord {
const id = bareId(row.id, table, row.name);
const appliedAt =
row.appliedAt instanceof Date ? row.appliedAt : new Date(row.appliedAt);
return {
id,
name: row.name,
appliedAt,
checksum: row.checksum,
duration: row.duration,
};
}
/**
* Build a {@link MigrationAdapter} for SurrealDB. The adapter speaks SurrealQL
* for both the tracking-table DDL and the migration body.
*
* @throws MigrationError(MIGRATION.TABLE_BOOTSTRAP_FAILED) on `ensureTable`
* failure, wrapping the underlying client error as `cause`.
* @throws MigrationError(MIGRATION.ROLLBACK_NOT_SUPPORTED) on `deleteApplied`
* when the adapter is constructed with `{ rollback: false }`.
*/
export function createSurrealqlAdapter(
client: SurrealqlClient,
options: SurrealqlAdapterOptions = {}
): MigrationAdapter {
const rollbackEnabled = options.rollback ?? true;
return {
dialect: "surrealql",
async ensureTable(table) {
try {
await client.query(ddl(table));
} catch (cause) {
throw new MigrationError(
MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
`Failed to bootstrap SurrealQL tracking table "${table}"`,
{
cause,
context: { dialect: "surrealql", table },
}
);
}
},
async selectApplied(table) {
const rows = await client.query<SurrealRow>(
`SELECT * FROM ${table}`
);
return rows.map((row) => rowToRecord(row, table));
},
async insertApplied(table, record) {
// SurrealDB v3 regression fix:
//
// Previous form: `CREATE ${table}:$name SET ...` — SurrealDB v3
// parses `$name` in the record-id position as an unresolved ident
// literal (NOT as a parameter substitution), so the statement is
// parsed and acknowledged but no row is created. The ledger
// therefore silently records nothing, causing the runner to
// re-execute completed migrations on the next boot.
//
// v3-safe form: splice a backtick-quoted record-id literal
// (`<table>:\`<id>\``) directly into the SQL. The id is
// backtick-escaped (any embedded backtick doubled). This is the
// same shape that `@suaveplan/better-auth-surrealdb` proved
// against the v3 testcontainer in `prepareRecordId`. Field
// values remain parameterised via named bindings so user content
// never reaches the parser.
const recordRef = `${table}:\`${escapeBacktickedId(record.id)}\``;
// `appliedAt` is bound as the raw `Date` object so the SDK
// CBOR-encodes it as a SurrealDB `datetime` value. SurrealDB v3
// refuses a coercion from `string` ('2026-…Z') to `datetime`
// when the field is declared `TYPE datetime` (the DDL above)
// and rejects the CREATE with `InternalError: Couldn't coerce
// value for field 'appliedAt'`. Sending the JS Date keeps the
// boundary parameterised AND v3-compatible.
await client.query(
`CREATE ${recordRef} SET name = $name, appliedAt = $appliedAt, checksum = $checksum, duration = $duration`,
{
name: record.name,
appliedAt: record.appliedAt,
checksum: record.checksum,
duration: record.duration,
}
);
},
async deleteApplied(table, id) {
if (!rollbackEnabled) {
throw new MigrationError(
MIGRATION_CODES.ROLLBACK_NOT_SUPPORTED,
`SurrealQL adapter for "${table}" was constructed with rollback disabled`,
{ context: { dialect: "surrealql", table, id } }
);
}
// Match the same v3-safe record-ref form used by `insertApplied`.
// The previous unquoted form (`DELETE ${table}:${id}`) silently
// dropped on any id that wasn't a bare SurrealQL ident.
const recordRef = `${table}:\`${escapeBacktickedId(id)}\``;
await client.query(`DELETE ${recordRef}`);
},
async execute(content) {
await client.query(content);
},
};
}

View File

@@ -0,0 +1,130 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createSurrealqlAdapter } from "./adapters/surrealql/surrealql.js";
import { MIGRATION_CODES, MigrationError } from "./error-codes/error-codes.js";
import { computeChecksum, MigrationRunner } from "./runner/runner.js";
import type { MigrationAdapter } from "./types/migration.js";
describe("spec-conformance — every MIGRATION_* code is thrown from a production path", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "api-migrations-conformance-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("MIGRATION.TABLE_BOOTSTRAP_FAILED", { timeout: 5000 }, async () => {
const failing: MigrationAdapter = createSurrealqlAdapter({
query: async () => {
throw new Error("nope");
},
});
await expect(failing.ensureTable("_migrations")).rejects.toMatchObject({
code: MIGRATION_CODES.TABLE_BOOTSTRAP_FAILED,
});
});
it("MIGRATION.APPLY_FAILED", { timeout: 5000 }, async () => {
await writeFile(join(dir, "001.sql"), "BOOM", "utf8");
const adapter: MigrationAdapter = {
dialect: "sqlite",
ensureTable: async () => {},
selectApplied: async () => [],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {
throw new Error("apply blew up");
},
};
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results[0]!.success).toBe(false);
expect(results[0]!.error).toContain("001");
});
it("MIGRATION.CHECKSUM_DRIFT", { timeout: 5000 }, async () => {
await writeFile(join(dir, "001.sql"), "ORIGINAL", "utf8");
const adapter: MigrationAdapter = {
dialect: "sqlite",
ensureTable: async () => {},
selectApplied: async () => [
{
id: "001",
name: "001",
appliedAt: new Date(),
checksum: computeChecksum("DIFFERENT"),
duration: 1,
},
],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {},
};
const runner = new MigrationRunner(adapter);
await expect(runner.runFromDirectory(dir)).rejects.toMatchObject({
code: MIGRATION_CODES.CHECKSUM_DRIFT,
});
});
it("MIGRATION.DIRECTORY_NOT_FOUND", { timeout: 5000 }, async () => {
const adapter: MigrationAdapter = {
dialect: "sqlite",
ensureTable: async () => {},
selectApplied: async () => [],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {},
};
const runner = new MigrationRunner(adapter);
await expect(
runner.runFromDirectory(join(dir, "missing"))
).rejects.toMatchObject({
code: MIGRATION_CODES.DIRECTORY_NOT_FOUND,
});
});
it("MIGRATION.INVALID_FILE_NAME", { timeout: 5000 }, async () => {
await writeFile(join(dir, "no_digits.sql"), "X", "utf8");
const adapter: MigrationAdapter = {
dialect: "sqlite",
ensureTable: async () => {},
selectApplied: async () => [],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {},
};
const runner = new MigrationRunner(adapter);
await expect(runner.runFromDirectory(dir)).rejects.toMatchObject({
code: MIGRATION_CODES.INVALID_FILE_NAME,
});
});
it("MIGRATION.ROLLBACK_NOT_SUPPORTED", { timeout: 5000 }, async () => {
const adapter = createSurrealqlAdapter(
{ query: async () => [] },
{ rollback: false }
);
await expect(
adapter.deleteApplied("_migrations", "001")
).rejects.toMatchObject({
code: MIGRATION_CODES.ROLLBACK_NOT_SUPPORTED,
});
});
it("MigrationError instance carries the typed code", {
timeout: 5000,
}, () => {
const err = new MigrationError(MIGRATION_CODES.CHECKSUM_DRIFT, "drift");
expect(err).toBeInstanceOf(MigrationError);
expect(err.migrationCode).toBe(MIGRATION_CODES.CHECKSUM_DRIFT);
});
});

View File

@@ -0,0 +1,98 @@
# `error-codes` — `MIGRATION_*` registry and `MigrationError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, namespaced error registry for `@suaveplan/api-migrations`.
Every public failure path in the package raises a `MigrationError` carrying
exactly one of the six `MIGRATION.*` codes defined here. The codes are
authored once at draft time and may not be renumbered or renamed without an
OpenSpec change, so consumers may branch on the literal string values across
versions.
The pairing of `defineCodes("MIGRATION", [...])` (from `@suaveplan/error`)
with a typed `SuaveplanError` subclass keeps the surface narrow: the runtime
sees a frozen registry object, TypeScript sees a literal union, and consumers
get an `instanceof MigrationError` narrowing the `code` field to
`MigrationErrorCode`.
## Features
- Frozen registry of exactly six codes.
- `MigrationError` is a public-constructor subclass of `SuaveplanError`.
- `instanceof MigrationError` narrows `error.code` to `MigrationErrorCode`.
- The `migrationCode` field is a synonym for `code` typed as the literal
union, useful inside generic helpers that have already widened `code`.
- Every code is exercised by the package's spec-conformance test.
## Usage: Basic Example
```ts
import {
MIGRATION_CODES,
MigrationError,
} from "@suaveplan/api-migrations/error-codes/error-codes";
throw new MigrationError(
MIGRATION_CODES.DIRECTORY_NOT_FOUND,
"Migration directory does not exist: ./db/migrations",
{ context: { dir: "./db/migrations" } }
);
```
## Usage: Advanced Example
```ts
import {
MIGRATION_CODES,
MigrationError,
type MigrationErrorCode,
} from "@suaveplan/api-migrations/error-codes/error-codes";
function isRecoverable(code: MigrationErrorCode): boolean {
return (
code === MIGRATION_CODES.CHECKSUM_DRIFT ||
code === MIGRATION_CODES.INVALID_FILE_NAME
);
}
try {
await runner.runFromDirectory("./db/migrations");
} catch (err) {
if (err instanceof MigrationError && isRecoverable(err.code)) {
// Operator-fixable conditions: stale file or rename.
console.error("Recoverable migration error:", err.code, err.context);
} else {
throw err;
}
}
```
## API Reference
- `MIGRATION_CODES` — frozen registry `{ TABLE_BOOTSTRAP_FAILED, APPLY_FAILED,
CHECKSUM_DRIFT, DIRECTORY_NOT_FOUND, INVALID_FILE_NAME, ROLLBACK_NOT_SUPPORTED }`,
each value the literal string of the form `"MIGRATION.<NAME>"`.
- `type MigrationErrorCode` — union of the six literal strings.
- `class MigrationError extends SuaveplanError<MigrationErrorCode>` —
constructor `(code, message, options?)`. `options.context` and
`options.cause` are forwarded to the base class. `migrationCode` is a
typed alias of `code`.
## Implementation Notes
- The registry is authored as `as const` and passed to `defineCodes`, which
freezes the resulting object and returns a strongly typed map. Renaming or
renumbering codes is a breaking change that must be made through OpenSpec.
- `MigrationError`'s constructor is `public` so call sites may instantiate it
directly; `SuaveplanError`'s constructor is `protected` and would otherwise
refuse construction outside of subclasses.
- `migrationCode` exists in addition to `code` because some generic helpers
(logging, OTel attribute attachment) widen the type to `string` for
ergonomics; tests and downstream consumers that want the narrow union read
`migrationCode` instead.
- Every code is thrown from at least one production call site — see
`src/api-migrations.spec-conformance.test.ts` for the inventory.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MIGRATION_CODES,
MigrationError,
type MigrationErrorCode,
} from "./error-codes.js";
describe("error-codes", () => {
it("exposes exactly the six expected codes", { timeout: 5000 }, () => {
expect(Object.keys(MIGRATION_CODES).sort()).toEqual([
"APPLY_FAILED",
"CHECKSUM_DRIFT",
"DIRECTORY_NOT_FOUND",
"INVALID_FILE_NAME",
"ROLLBACK_NOT_SUPPORTED",
"TABLE_BOOTSTRAP_FAILED",
]);
});
it("every code is namespaced 'MIGRATION.*'", { timeout: 5000 }, () => {
for (const value of Object.values(MIGRATION_CODES)) {
expect(value.startsWith("MIGRATION.")).toBe(true);
}
});
it("MigrationError narrows code to MigrationErrorCode", {
timeout: 5000,
}, () => {
const err = new MigrationError(
MIGRATION_CODES.CHECKSUM_DRIFT,
"drift",
{ context: { name: "001_initial" } }
);
expect(err).toBeInstanceOf(MigrationError);
expect(err.code).toBe(MIGRATION_CODES.CHECKSUM_DRIFT);
expect(err.migrationCode).toBe(MIGRATION_CODES.CHECKSUM_DRIFT);
expect(err.context).toEqual({ name: "001_initial" });
const narrowed: MigrationErrorCode = err.migrationCode;
expect(narrowed).toBe("MIGRATION.CHECKSUM_DRIFT");
});
it("forwards cause to SuaveplanError base", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new MigrationError(MIGRATION_CODES.APPLY_FAILED, "apply", {
cause,
});
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,57 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-migrations`.
*
* Every public failure path raises a {@link MigrationError} carrying one of
* these namespaced `MIGRATION.*` codes. Consumers branch on the string value
* (e.g. `error.code === MIGRATION_CODES.CHECKSUM_DRIFT`).
*
* Six codes cover:
* - `TABLE_BOOTSTRAP_FAILED` — `adapter.ensureTable(...)` threw while creating
* the tracking table on first run.
* - `APPLY_FAILED` — `adapter.execute(content)` threw or `adapter.insertApplied`
* rejected while recording a migration. Wraps the underlying cause.
* - `CHECKSUM_DRIFT` — an already-applied migration's on-disk SHA-256 no longer
* matches the stored checksum. `context.{name, expected, actual}` populated.
* - `DIRECTORY_NOT_FOUND` — `runFromDirectory(dir)` was passed a path that does
* not exist on disk.
* - `INVALID_FILE_NAME` — a migration file's basename does not start with one
* or more digits, so no deterministic order can be derived.
* - `ROLLBACK_NOT_SUPPORTED` — reserved for adapters that explicitly opt out of
* `deleteApplied` (the surrealql adapter raises this when configured with
* `{ rollback: false }`).
*/
export const MIGRATION_CODES = defineCodes("MIGRATION", [
"TABLE_BOOTSTRAP_FAILED",
"APPLY_FAILED",
"CHECKSUM_DRIFT",
"DIRECTORY_NOT_FOUND",
"INVALID_FILE_NAME",
"ROLLBACK_NOT_SUPPORTED",
] as const);
export type MigrationErrorCode =
(typeof MIGRATION_CODES)[keyof typeof MIGRATION_CODES];
/**
* Concrete error class for the api-migrations package. Extends
* {@link SuaveplanError} so `instanceof MigrationError` narrows `code` to
* {@link MigrationErrorCode}.
*
* The constructor is widened to `public` so callers may construct errors
* directly (the base class is `protected`).
*/
export class MigrationError extends SuaveplanError<MigrationErrorCode> {
public readonly migrationCode: MigrationErrorCode;
public constructor(
code: MigrationErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.migrationCode = code;
}
}

View File

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

View File

@@ -0,0 +1,37 @@
export {
createPostgresAdapter,
type PostgresClient,
} from "./adapters/postgres/postgres.js";
export {
createSqliteAdapter,
type SqliteClient,
} from "./adapters/sqlite/sqlite.js";
export {
createSurrealqlAdapter,
type SurrealqlAdapterOptions,
type SurrealqlClient,
} from "./adapters/surrealql/surrealql.js";
export {
MIGRATION_CODES,
MigrationError,
type MigrationErrorCode,
} from "./error-codes/error-codes.js";
export {
getMigrationInstruments,
type MigrationInstruments,
resetMigrationInstruments,
} from "./otel/instruments.js";
export {
computeChecksum,
createMigrationRunner,
MigrationRunner,
} from "./runner/runner.js";
export type {
MigrationAdapter,
MigrationDialect,
MigrationFile,
MigrationRecord,
MigrationResult,
MigrationRunnerOptions,
MigrationRunOptions,
} from "./types/migration.js";

View File

@@ -0,0 +1,5 @@
export {
getMigrationInstruments,
type MigrationInstruments,
resetMigrationInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,78 @@
# `otel/instruments` — counter + histogram per migration apply
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazily builds and caches the two OpenTelemetry instruments that
`@suaveplan/api-migrations` emits on every migration apply attempt:
- counter `migrations.applied_total{dialect, outcome}`
- histogram `migrations.apply_duration_ms{name}`
Lazy construction defers `metrics.getMeter(...)` until the first apply, so
the meter resolves against whatever `MeterProvider` is globally installed at
that moment — including the test in-memory provider that
`tests/setup/polyfills.ts` registers before any source module loads.
## Features
- Single `recordApply(dialect, name, durationMs, outcome)` entry point — keeps
the call-site code in `runner.ts` short and uniform regardless of success
or failure path.
- Emits both instruments on every call, so observability does not depend on
whether a migration succeeded.
- `resetMigrationInstruments()` lets the test suite discard the cached
instance between `MeterProvider` swaps.
- Counter attributes are deliberately low-cardinality (`dialect` × two
outcomes) so the time series is stable across releases.
## Usage: Basic Example
```ts
import { getMigrationInstruments } from "@suaveplan/api-migrations/otel/instruments";
const instruments = getMigrationInstruments();
instruments.recordApply("sqlite", "001_initial", 17, "success");
```
## Usage: Advanced Example
```ts
import {
getMigrationInstruments,
resetMigrationInstruments,
} from "@suaveplan/api-migrations/otel/instruments";
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
// Swap providers between test phases — call reset to rebuild the cached
// instruments against the new meter.
metrics.setGlobalMeterProvider(new MeterProvider());
resetMigrationInstruments();
getMigrationInstruments().recordApply("postgres", "002_users", 42, "error");
```
## API Reference
- `interface MigrationInstruments { recordApply(dialect, name, durationMs,
outcome): void }`.
- `getMigrationInstruments(): MigrationInstruments` — returns the cached
instance, building it on first call against the currently registered
`MeterProvider`.
- `resetMigrationInstruments(): void` — clears the cache (test-only).
## Implementation Notes
- The module-level `instruments` cache is the only mutable state and is
scoped to the module — re-importing the module under a fresh ESM realm
yields a fresh cache, which is the desired behaviour for isolated tests.
- The counter is namespaced `migrations.applied_total`; the histogram is
namespaced `migrations.apply_duration_ms` — both names match the spec
delta literally so downstream dashboards do not need translation tables.
- Attribute cardinality: `dialect` is bounded by `MigrationDialect`
(currently four members), `outcome` is exactly two, and `name` is the
user-supplied migration basename. The histogram attribute is intentionally
`name`-only — adding `outcome` here would explode the bucket count.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getMigrationInstruments,
resetMigrationInstruments,
} from "./instruments.js";
describe("otel/instruments", () => {
it("counter increments on success and emits histogram", {
timeout: 5000,
}, async () => {
resetMigrationInstruments();
const instruments = getMigrationInstruments();
instruments.recordApply("sqlite", "001_initial", 17, "success");
const collected = await flushTestMetrics();
const metricsByName = new Map<string, unknown>();
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
metricsByName.set(m.descriptor.name, m);
}
}
}
expect(metricsByName.has("migrations.applied_total")).toBe(true);
expect(metricsByName.has("migrations.apply_duration_ms")).toBe(true);
});
it("counter increments on error", { timeout: 5000 }, async () => {
resetMigrationInstruments();
const instruments = getMigrationInstruments();
instruments.recordApply("postgres", "002_users", 42, "error");
const collected = await flushTestMetrics();
let sawErrorOutcome = false;
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name !== "migrations.applied_total") {
continue;
}
for (const dp of m.dataPoints) {
if (
(dp.attributes as Record<string, unknown>)
.outcome === "error"
) {
sawErrorOutcome = true;
}
}
}
}
}
expect(sawErrorOutcome).toBe(true);
});
it("caches the instruments between calls", { timeout: 5000 }, () => {
resetMigrationInstruments();
const a = getMigrationInstruments();
const b = getMigrationInstruments();
expect(a).toBe(b);
});
it("resetMigrationInstruments rebuilds on next call", {
timeout: 5000,
}, () => {
resetMigrationInstruments();
const a = getMigrationInstruments();
resetMigrationInstruments();
const b = getMigrationInstruments();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,56 @@
import { metrics } from "@opentelemetry/api";
import type { MigrationDialect } from "../types/migration.js";
const METER_NAME = "@suaveplan/api-migrations";
export interface MigrationInstruments {
readonly recordApply: (
dialect: MigrationDialect,
name: string,
durationMs: number,
outcome: "success" | "error"
) => void;
}
let instruments: MigrationInstruments | undefined;
function buildInstruments(): MigrationInstruments {
const meter = metrics.getMeter(METER_NAME);
const counter = meter.createCounter("migrations.applied_total", {
description:
"Total number of migration apply attempts, partitioned by dialect and outcome.",
});
const histogram = meter.createHistogram("migrations.apply_duration_ms", {
description:
"Wall-clock duration of a single migration apply in milliseconds.",
unit: "ms",
});
return {
recordApply(dialect, name, durationMs, outcome) {
counter.add(1, { dialect, outcome });
histogram.record(durationMs, { name });
},
};
}
/**
* Resolve the lazily-built {@link MigrationInstruments}. The first call
* registers the meter against whatever `MeterProvider` is globally
* installed. Subsequent calls return the cached instance.
*/
export function getMigrationInstruments(): MigrationInstruments {
if (instruments === undefined) {
instruments = buildInstruments();
}
return instruments;
}
/**
* Discard the cached instruments. Test-only — call between provider swaps
* to force the next `getMigrationInstruments()` call to rebuild against the
* fresh provider.
*/
export function resetMigrationInstruments(): void {
instruments = undefined;
}

View File

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

View File

@@ -0,0 +1,119 @@
# `runner` — `MigrationRunner` class + `createMigrationRunner` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `MigrationRunner` is the dialect-agnostic core of the package. It reads
migration files from a directory, sorts them by leading-digit order, applies
any not-yet-applied ones via the injected `MigrationAdapter`, and records
each application with a real SHA-256 checksum. Unlike the upstream runner —
which declared `validateChecksums: true` but never actually compared
checksums — this implementation reads every already-applied migration's file
content from disk, recomputes the hash, and halts with
`MigrationError(MIGRATION.CHECKSUM_DRIFT)` on mismatch BEFORE any pending
migration is touched.
The runner is server-tier: it uses `node:fs/promises` for directory and file
access and `node:crypto` for SHA-256. Both Bun and Node ship these natively.
## Features
- Per-dialect default file extension (`.surql` for `surrealql`, `.sql`
otherwise).
- Numerical ordering by leading-digit prefix (`001_initial.sql` → order 1).
- Real SHA-256 checksum validation against on-disk files — drift detected
pre-apply and surfaced as `MIGRATION.CHECKSUM_DRIFT` with `{name,
expected, actual}` context.
- `AbortSignal` propagation — the runner checks `signal.aborted` between
pending migrations; aborts return the partial result array.
- Dry-run mode (`{ dryRun: true }`) logs but does not invoke
`adapter.execute` / `adapter.insertApplied`.
- Halt on first failure — the runner does NOT continue past a failed apply,
so partial-success deployments stay consistent.
- OTel counter + histogram per apply attempt, regardless of outcome.
- `rollback()` removes the tracking record of the most recent migration
(sorted by `appliedAt` desc); does not revert schema.
- `MIGRATION.DIRECTORY_NOT_FOUND` when `runFromDirectory` is given a missing
path.
- `MIGRATION.INVALID_FILE_NAME` when a `.sql`/`.surql` file does not start
with one or more digits.
## Usage: Basic Example
```ts
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
import { createSqliteAdapter } from "@suaveplan/api-migrations/adapters/sqlite/sqlite";
const runner = new MigrationRunner(createSqliteAdapter(client));
const results = await runner.runFromDirectory("./db/migrations");
for (const r of results) {
console.log(r.success ? `OK ${r.name} (${r.duration}ms)` : `ERR ${r.name}: ${r.error}`);
}
```
## Usage: Advanced Example
```ts
import { MigrationRunner } from "@suaveplan/api-migrations/runner/runner";
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const runner = new MigrationRunner(adapter, {
tableName: "_app_migrations",
fileExtension: ".sql",
validateChecksums: true,
logger: {
debug() {},
info: (m) => console.log(`[mig] ${m}`),
warn: (m) => console.warn(`[mig] ${m}`),
error: (m) => console.error(`[mig] ${m}`),
},
});
const results = await runner.runFromDirectory("./db/migrations", {
signal: controller.signal,
});
const failed = results.find((r) => !r.success);
if (failed) {
throw new Error(`Apply halted at ${failed.name}: ${failed.error}`);
}
```
## API Reference
- `class MigrationRunner` — constructor `(adapter, options?)`.
- `runFromDirectory(dirPath, { signal? }?): Promise<MigrationResult[]>`.
- `run(files, { signal? }?): Promise<MigrationResult[]>`.
- `runMigration(file, signal?): Promise<MigrationResult>`.
- `getAppliedMigrations(): Promise<MigrationRecord[]>`.
- `getPendingMigrations(dir): Promise<MigrationFile[]>`.
- `rollback(): Promise<MigrationResult | null>`.
- `scanDirectory(dir): Promise<MigrationFile[]>`.
- `createMigrationRunner(adapter, options?)` — factory mirroring upstream.
- `computeChecksum(content)` — `node:crypto` SHA-256, lowercase hex.
- Options: `tableName` (default `"_migrations"`), `fileExtension`
(per-dialect default), `validateChecksums` (default `true`), `dryRun`
(default `false`), `logger` (default noop), `clock` (default
`Date.now`).
## Implementation Notes
- Checksum validation happens BEFORE any pending migration is applied. If a
drift is detected the runner halts — the operator must restore the file
or rerun with `validateChecksums: false` (audited and discouraged).
- Pre-aborted signals are honored before the first apply attempt — the
result array is empty in that case.
- Failures inside `adapter.execute` or `adapter.insertApplied` are wrapped
in `MigrationError(MIGRATION.APPLY_FAILED)`, with the underlying client
error as `cause`. The runner returns `{success: false, error}` and breaks
the loop, so subsequent migrations are not attempted.
- Rollback only removes the most recent tracking row — sorting is
performed in memory by `appliedAt` desc so adapters do not need to
guarantee returned-row order.
- `MIGRATION.ROLLBACK_NOT_SUPPORTED` is raised by adapters configured to
refuse `deleteApplied` (e.g. the surrealql adapter with `{rollback:
false}`). The runner surfaces this code untouched.

View File

@@ -0,0 +1,495 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { MIGRATION_CODES, MigrationError } from "../error-codes/error-codes.js";
import type { MigrationAdapter, MigrationRecord } from "../types/migration.js";
import {
computeChecksum,
createMigrationRunner,
MigrationRunner,
} from "./runner.js";
class MemoryAdapter implements MigrationAdapter {
public readonly dialect = "sqlite" as const;
public table: MigrationRecord[] = [];
public ensured = 0;
public executed: string[] = [];
public executeThrowOn: string | undefined;
public executeThrowNonError = false;
public clock = 1_000;
async ensureTable(): Promise<void> {
this.ensured += 1;
}
async selectApplied(): Promise<MigrationRecord[]> {
return this.table.map((r) => ({ ...r }));
}
async insertApplied(
_table: string,
record: MigrationRecord
): Promise<void> {
this.table.push({ ...record });
}
async deleteApplied(_table: string, id: string): Promise<void> {
this.table = this.table.filter((r) => r.id !== id);
}
async execute(content: string): Promise<void> {
this.executed.push(content);
if (this.executeThrowNonError) {
const reason: unknown = "stringy";
throw reason;
}
if (this.executeThrowOn && content.includes(this.executeThrowOn)) {
throw new Error("boom");
}
}
}
async function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "api-migrations-test-"));
}
async function writeMigration(
dir: string,
name: string,
content: string
): Promise<void> {
await writeFile(join(dir, name), content, "utf8");
}
describe("MigrationRunner", () => {
let dir: string;
beforeEach(async () => {
dir = await makeTempDir();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("applies pending in numerical order", { timeout: 5000 }, async () => {
await writeMigration(dir, "002_b.sql", "BBB");
await writeMigration(dir, "003_c.sql", "CCC");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "001_a",
name: "001_a",
appliedAt: new Date(),
checksum: computeChecksum("AAA"),
duration: 1,
});
const runner = new MigrationRunner(adapter, {
validateChecksums: false,
});
const results = await runner.runFromDirectory(dir);
expect(results.map((r) => r.name)).toEqual(["002_b", "003_c"]);
expect(adapter.executed).toEqual(["BBB", "CCC"]);
});
it("halts on first failure", { timeout: 5000 }, async () => {
await writeMigration(dir, "002_b.sql", "BBB");
await writeMigration(dir, "003_c.sql", "CCC");
await writeMigration(dir, "004_d.sql", "DDD");
const adapter = new MemoryAdapter();
adapter.executeThrowOn = "CCC";
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results).toHaveLength(2);
expect(results[0]!.success).toBe(true);
expect(results[1]!.success).toBe(false);
expect(adapter.executed).toEqual(["BBB", "CCC"]);
});
it("checksum drift raises MIGRATION_CHECKSUM_DRIFT", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001_initial.sql", "ORIGINAL");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "001_initial",
name: "001_initial",
appliedAt: new Date(),
checksum: computeChecksum("DIFFERENT"),
duration: 1,
});
const runner = new MigrationRunner(adapter);
await expect(runner.runFromDirectory(dir)).rejects.toMatchObject({
code: MIGRATION_CODES.CHECKSUM_DRIFT,
});
expect(adapter.executed).toEqual([]);
});
it("validateChecksums false skips drift check", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001_initial.sql", "ORIGINAL");
await writeMigration(dir, "002_next.sql", "NEXT");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "001_initial",
name: "001_initial",
appliedAt: new Date(),
checksum: "wrong",
duration: 1,
});
const runner = new MigrationRunner(adapter, {
validateChecksums: false,
});
const results = await runner.runFromDirectory(dir);
expect(results.map((r) => r.name)).toEqual(["002_next"]);
});
it("pre-aborted signal applies nothing", { timeout: 5000 }, async () => {
await writeMigration(dir, "001.sql", "A");
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter);
const controller = new AbortController();
controller.abort();
const results = await runner.runFromDirectory(dir, {
signal: controller.signal,
});
expect(results).toEqual([]);
expect(adapter.executed).toEqual([]);
});
it("abort mid-loop returns partial result", { timeout: 5000 }, async () => {
await writeMigration(dir, "001.sql", "A");
await writeMigration(dir, "002.sql", "B");
await writeMigration(dir, "003.sql", "C");
const controller = new AbortController();
class AbortingAdapter extends MemoryAdapter {
override async execute(content: string): Promise<void> {
await super.execute(content);
if (content === "B") {
controller.abort();
}
}
}
const adapter = new AbortingAdapter();
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir, {
signal: controller.signal,
});
expect(results.map((r) => r.name)).toEqual(["001", "002"]);
});
it("dryRun does not call adapter.execute or insertApplied", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
await writeMigration(dir, "002.sql", "B");
await writeMigration(dir, "003.sql", "C");
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter, { dryRun: true });
const results = await runner.runFromDirectory(dir);
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.success).toBe(true);
}
expect(adapter.executed).toEqual([]);
expect(adapter.table).toEqual([]);
});
it("rollback removes last record", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const now = Date.now();
for (let i = 1; i <= 3; i++) {
adapter.table.push({
id: `00${i}`,
name: `00${i}`,
appliedAt: new Date(now + i),
checksum: "x",
duration: 1,
});
}
const runner = new MigrationRunner(adapter);
const result = await runner.rollback();
expect(result).not.toBeNull();
expect(result!.name).toBe("003");
expect(adapter.table.find((r) => r.id === "003")).toBeUndefined();
});
it("rollback empty returns null", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter);
const result = await runner.rollback();
expect(result).toBeNull();
});
it("scanDirectory throws DIRECTORY_NOT_FOUND for missing path", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter);
await expect(
runner.runFromDirectory(join(dir, "does-not-exist"))
).rejects.toMatchObject({
code: MIGRATION_CODES.DIRECTORY_NOT_FOUND,
});
});
it("scanDirectory throws INVALID_FILE_NAME for non-numeric prefix", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "bad_name.sql", "X");
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter);
await expect(runner.runFromDirectory(dir)).rejects.toMatchObject({
code: MIGRATION_CODES.INVALID_FILE_NAME,
});
});
it("scanDirectory ignores non-matching extensions and subdirectories", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001_initial.sql", "A");
await writeMigration(dir, "README.md", "ignore me");
await mkdir(join(dir, "001_subdir.sql"), { recursive: true });
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter, {
validateChecksums: false,
});
const files = await runner.scanDirectory(dir);
expect(files.map((f) => f.name)).toEqual(["001_initial"]);
});
it("applies wraps non-Error throwables as APPLY_FAILED", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
const adapter = new MemoryAdapter();
adapter.executeThrowNonError = true;
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results).toHaveLength(1);
expect(results[0]!.success).toBe(false);
expect(results[0]!.error).toContain("001");
});
it("returns empty array when no pending migrations", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "001",
name: "001",
appliedAt: new Date(),
checksum: computeChecksum("A"),
duration: 1,
});
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results).toEqual([]);
});
it("getPendingMigrations returns unapplied files in numerical order", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
await writeMigration(dir, "002.sql", "B");
await writeMigration(dir, "003.sql", "C");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "002",
name: "002",
appliedAt: new Date(),
checksum: computeChecksum("B"),
duration: 1,
});
const runner = new MigrationRunner(adapter);
const pending = await runner.getPendingMigrations(dir);
expect(pending.map((f) => f.name)).toEqual(["001", "003"]);
expect(pending.map((f) => f.order)).toEqual([1, 3]);
});
it("uses .surql extension by default for surrealql dialect", {
timeout: 5000,
}, async () => {
const surrealAdapter: MigrationAdapter = {
dialect: "surrealql",
ensureTable: async () => {},
selectApplied: async () => [],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {},
};
await writeMigration(dir, "001.surql", "DEFINE TABLE users;");
const runner = new MigrationRunner(surrealAdapter);
const files = await runner.scanDirectory(dir);
expect(files.map((f) => f.name)).toEqual(["001"]);
});
it("respects custom tableName", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const calls: string[] = [];
const wrapped: MigrationAdapter = {
...adapter,
ensureTable: async (t) => {
calls.push(`ensure:${t}`);
return adapter.ensureTable(t);
},
selectApplied: (t) => adapter.selectApplied(t),
insertApplied: (t, r) => adapter.insertApplied(t, r),
deleteApplied: (t, id) => adapter.deleteApplied(t, id),
execute: (c) => adapter.execute(c),
};
await writeMigration(dir, "001.sql", "A");
const runner = new MigrationRunner(wrapped, { tableName: "_custom" });
await runner.runFromDirectory(dir);
expect(calls).toEqual(["ensure:_custom"]);
});
it("createMigrationRunner factory returns a configured runner", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await writeMigration(dir, "001.sql", "A");
const runner = createMigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results).toHaveLength(1);
expect(results[0]!.success).toBe(true);
});
it("computeChecksum is stable for identical content", {
timeout: 5000,
}, () => {
const a = computeChecksum("hello world");
const b = computeChecksum("hello world");
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it("logger receives debug per migration and error on failure", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
await writeMigration(dir, "002.sql", "B");
const messages: string[] = [];
const adapter = new MemoryAdapter();
adapter.executeThrowOn = "B";
const runner = new MigrationRunner(adapter, {
logger: {
debug: (m) => messages.push(`debug:${m}`),
info: (m) => messages.push(`info:${m}`),
warn: (m) => messages.push(`warn:${m}`),
error: (m) => messages.push(`error:${m}`),
},
});
await runner.runFromDirectory(dir);
expect(messages.some((m) => m.startsWith("debug:Path:"))).toBe(true);
expect(messages.some((m) => m.startsWith("error:Apply failed"))).toBe(
true
);
});
it("run accepts no options argument and uses NOOP logger", {
timeout: 5000,
}, async () => {
// Exercises the `runOptions: MigrationRunOptions = {}` default
// branch and the NOOP_LOGGER default by calling `run(files)`
// without a second argument.
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter);
const results = await runner.run([
{ name: "001", path: "/dev/null", order: 1, content: "A" },
]);
expect(results).toHaveLength(1);
expect(results[0]!.success).toBe(true);
});
it("logger receives info when no pending migrations", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
const messages: string[] = [];
const adapter = new MemoryAdapter();
adapter.table.push({
id: "001",
name: "001",
appliedAt: new Date(),
checksum: computeChecksum("A"),
duration: 1,
});
const runner = new MigrationRunner(adapter, {
logger: {
debug() {},
info: (m) => messages.push(`info:${m}`),
warn: (m) => messages.push(`warn:${m}`),
error() {},
},
});
await runner.runFromDirectory(dir);
expect(messages).toContain("info:No pending migrations");
});
it("custom clock controls duration values", { timeout: 5000 }, async () => {
await writeMigration(dir, "001.sql", "A");
let t = 100;
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter, {
clock: () => (t += 10),
});
const results = await runner.runFromDirectory(dir);
expect(results[0]!.success).toBe(true);
expect(results[0]!.duration).toBeGreaterThan(0);
});
it("MigrationError thrown by adapter is preserved as APPLY_FAILED message", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
const adapter: MigrationAdapter = {
dialect: "sqlite",
ensureTable: async () => {},
selectApplied: async () => [],
insertApplied: async () => {},
deleteApplied: async () => {},
execute: async () => {
throw new MigrationError(
MIGRATION_CODES.APPLY_FAILED,
"adapter-raised"
);
},
};
const runner = new MigrationRunner(adapter);
const results = await runner.runFromDirectory(dir);
expect(results[0]!.success).toBe(false);
expect(results[0]!.error).toBe("adapter-raised");
});
it("checksum drift skips files not present in applied set", {
timeout: 5000,
}, async () => {
await writeMigration(dir, "001.sql", "A");
const adapter = new MemoryAdapter();
adapter.table.push({
id: "orphan",
name: "orphan",
appliedAt: new Date(),
checksum: "zzz",
duration: 1,
});
const runner = new MigrationRunner(adapter);
// 001 is unapplied, orphan is applied with no file on disk → no drift check.
const results = await runner.runFromDirectory(dir);
expect(results.map((r) => r.name)).toEqual(["001"]);
});
it("dryRun preserves duration from clock", { timeout: 5000 }, async () => {
await writeMigration(dir, "001.sql", "A");
let t = 0;
const adapter = new MemoryAdapter();
const runner = new MigrationRunner(adapter, {
dryRun: true,
clock: () => (t += 5),
});
const results = await runner.runFromDirectory(dir);
expect(results[0]!.duration).toBe(5);
});
});

View File

@@ -0,0 +1,346 @@
import { createHash } from "node:crypto";
import { readdir, readFile, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import type { Clock, LoggerLike } from "@suaveplan/types";
import { MIGRATION_CODES, MigrationError } from "../error-codes/error-codes.js";
import { getMigrationInstruments } from "../otel/instruments.js";
import type {
MigrationAdapter,
MigrationFile,
MigrationRecord,
MigrationResult,
MigrationRunnerOptions,
MigrationRunOptions,
} from "../types/migration.js";
const DEFAULT_TABLE = "_migrations";
const DEFAULT_EXTENSION_BY_DIALECT: Record<string, string> = {
surrealql: ".surql",
};
const NOOP_LOGGER: LoggerLike = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
const DEFAULT_CLOCK: Clock = () => Date.now();
interface ResolvedOptions {
readonly tableName: string;
readonly fileExtension: string;
readonly validateChecksums: boolean;
readonly dryRun: boolean;
readonly logger: LoggerLike;
readonly clock: Clock;
}
function resolveOptions(
dialect: string,
options: MigrationRunnerOptions
): ResolvedOptions {
return {
tableName: options.tableName ?? DEFAULT_TABLE,
fileExtension:
options.fileExtension ??
DEFAULT_EXTENSION_BY_DIALECT[dialect] ??
".sql",
validateChecksums: options.validateChecksums ?? true,
dryRun: options.dryRun ?? false,
logger: options.logger ?? NOOP_LOGGER,
clock: options.clock ?? DEFAULT_CLOCK,
};
}
/**
* Compute the lowercase hex SHA-256 of `content`.
*
* Uses Node's `crypto.createHash` — the same digest function the upstream
* runner uses via `crypto.subtle`. Identical byte input ⇒ identical digest.
*/
export function computeChecksum(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
/**
* Database-agnostic migration runner.
*
* Reads migration files from a directory, applies pending ones in numerical
* order, records each application via the injected {@link MigrationAdapter},
* and ACTUALLY validates SHA-256 checksums against on-disk file content
* before every apply phase. Honors an `AbortSignal` between migrations.
*/
export class MigrationRunner {
private readonly adapter: MigrationAdapter;
private readonly options: ResolvedOptions;
public constructor(
adapter: MigrationAdapter,
options: MigrationRunnerOptions = {}
) {
this.adapter = adapter;
this.options = resolveOptions(adapter.dialect, options);
}
/**
* Read migration files from `dirPath` and apply any pending ones.
*
* @throws MigrationError(MIGRATION.DIRECTORY_NOT_FOUND) when `dirPath`
* does not exist.
* @throws MigrationError(MIGRATION.CHECKSUM_DRIFT) when an already-
* applied migration's on-disk content no longer matches the stored
* checksum and `validateChecksums` is `true` (default).
*/
public async runFromDirectory(
dirPath: string,
runOptions: MigrationRunOptions = {}
): Promise<MigrationResult[]> {
const files = await this.scanDirectory(dirPath);
return this.run(files, runOptions);
}
/**
* Apply pending migrations from `files`. Files whose `name` already
* appears in the tracking table are skipped.
*/
public async run(
files: MigrationFile[],
runOptions: MigrationRunOptions = {}
): Promise<MigrationResult[]> {
await this.adapter.ensureTable(this.options.tableName);
const applied = await this.getAppliedMigrations();
if (this.options.validateChecksums) {
await this.assertNoChecksumDrift(files, applied);
}
const appliedNames = new Set(applied.map((m) => m.name));
const pending = files
.filter((f) => !appliedNames.has(f.name))
.sort((a, b) => a.order - b.order);
if (pending.length === 0) {
this.options.logger.info("No pending migrations");
return [];
}
const results: MigrationResult[] = [];
for (const file of pending) {
if (runOptions.signal?.aborted) {
break;
}
const result = await this.runMigration(file, runOptions.signal);
results.push(result);
if (!result.success) {
this.options.logger.warn(`Migration failed: ${file.name}`);
break;
}
}
return results;
}
/**
* Apply a single migration file and record it in the tracking table.
*/
public async runMigration(
file: MigrationFile,
signal?: AbortSignal
): Promise<MigrationResult> {
const startTime = this.options.clock();
const instruments = getMigrationInstruments();
this.options.logger.info(`Running migration: ${file.name}`);
this.options.logger.debug(`Path: ${file.path} (order ${file.order})`);
const content = file.content ?? (await readFile(file.path, "utf-8"));
if (this.options.dryRun) {
this.options.logger.info(`[DRY RUN] Would apply: ${file.name}`);
const duration = this.options.clock() - startTime;
instruments.recordApply(
this.adapter.dialect,
file.name,
duration,
"success"
);
return { name: file.name, success: true, duration };
}
try {
await this.adapter.execute(content, signal);
const checksum = computeChecksum(content);
const duration = this.options.clock() - startTime;
const record: MigrationRecord = {
id: file.name,
name: file.name,
appliedAt: new Date(this.options.clock()),
checksum,
duration,
};
await this.adapter.insertApplied(this.options.tableName, record);
instruments.recordApply(
this.adapter.dialect,
file.name,
duration,
"success"
);
return { name: file.name, success: true, duration };
} catch (cause) {
const duration = this.options.clock() - startTime;
instruments.recordApply(
this.adapter.dialect,
file.name,
duration,
"error"
);
const wrapped =
cause instanceof MigrationError
? cause
: new MigrationError(
MIGRATION_CODES.APPLY_FAILED,
`Migration "${file.name}" failed to apply`,
{
cause,
context: {
dialect: this.adapter.dialect,
name: file.name,
},
}
);
this.options.logger.error(
`Apply failed for ${file.name}: ${wrapped.message}`
);
return {
name: file.name,
success: false,
duration,
error: wrapped.message,
};
}
}
/** Fetch the full set of applied migration records. */
public async getAppliedMigrations(): Promise<MigrationRecord[]> {
return this.adapter.selectApplied(this.options.tableName);
}
/** Return the list of migration files in `dirPath` not yet applied. */
public async getPendingMigrations(
dirPath: string
): Promise<MigrationFile[]> {
const files = await this.scanDirectory(dirPath);
const applied = await this.getAppliedMigrations();
const appliedNames = new Set(applied.map((m) => m.name));
return files
.filter((f) => !appliedNames.has(f.name))
.sort((a, b) => a.order - b.order);
}
/**
* Remove the tracking record of the most recently applied migration.
* Does NOT revert the migration's schema effects — that is application
* policy. Returns `null` when the tracking table is empty.
*/
public async rollback(): Promise<MigrationResult | null> {
const applied = await this.getAppliedMigrations();
if (applied.length === 0) {
this.options.logger.info("No migrations to rollback");
return null;
}
const sorted = [...applied].sort(
(a, b) => b.appliedAt.getTime() - a.appliedAt.getTime()
);
const last = sorted[0]!;
this.options.logger.info(`Rolling back: ${last.name}`);
await this.adapter.deleteApplied(this.options.tableName, last.id);
return { name: last.name, success: true, duration: 0 };
}
/**
* Enumerate migration files in `dirPath`, parse leading-digit order, and
* sort ascending. Files whose basenames do not start with one or more
* digits raise `MigrationError(MIGRATION.INVALID_FILE_NAME)`.
*/
public async scanDirectory(dirPath: string): Promise<MigrationFile[]> {
let entries: string[];
try {
entries = await readdir(dirPath);
} catch (cause) {
throw new MigrationError(
MIGRATION_CODES.DIRECTORY_NOT_FOUND,
`Migration directory not found: ${dirPath}`,
{ cause, context: { dirPath } }
);
}
const ext = this.options.fileExtension;
const files: MigrationFile[] = [];
for (const entry of entries) {
if (!entry.endsWith(ext)) {
continue;
}
const filePath = join(dirPath, entry);
const stats = await stat(filePath);
if (!stats.isFile()) {
continue;
}
const orderMatch = entry.match(/^(\d+)/);
if (!orderMatch) {
throw new MigrationError(
MIGRATION_CODES.INVALID_FILE_NAME,
`Migration file "${entry}" does not start with a numeric order`,
{ context: { entry, dirPath } }
);
}
const order = Number.parseInt(orderMatch[1]!, 10);
files.push({
name: basename(entry, ext),
path: filePath,
order,
});
}
return files.sort((a, b) => a.order - b.order);
}
private async assertNoChecksumDrift(
files: MigrationFile[],
applied: MigrationRecord[]
): Promise<void> {
if (applied.length === 0) {
return;
}
const fileByName = new Map(files.map((f) => [f.name, f]));
for (const record of applied) {
const file = fileByName.get(record.name);
if (!file) {
continue;
}
const content =
file.content ?? (await readFile(file.path, "utf-8"));
file.content = content;
const actual = computeChecksum(content);
if (actual !== record.checksum) {
throw new MigrationError(
MIGRATION_CODES.CHECKSUM_DRIFT,
`Checksum drift detected for migration "${record.name}"`,
{
context: {
name: record.name,
expected: record.checksum,
actual,
},
}
);
}
}
}
}
/** Convenience factory mirroring upstream's `createMigrationRunner`. */
export function createMigrationRunner(
adapter: MigrationAdapter,
options?: MigrationRunnerOptions
): MigrationRunner {
return new MigrationRunner(adapter, options);
}

View File

@@ -0,0 +1,9 @@
export type {
MigrationAdapter,
MigrationDialect,
MigrationFile,
MigrationRecord,
MigrationResult,
MigrationRunnerOptions,
MigrationRunOptions,
} from "./migration.js";

View File

@@ -0,0 +1,113 @@
# `types/migration` — public type surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares every shared type that the runner, adapters, and consumers exchange
across the `@suaveplan/api-migrations` package: the dialect literal, the
on-disk `MigrationFile` shape, the persisted `MigrationRecord` row, the
per-call `MigrationResult` outcome, the narrow `MigrationAdapter` contract,
and the option bundles for construction and per-call invocation. Centralising
the contract here keeps the runner's import graph short and lets adapters be
authored in isolation against a single canonical interface.
The module is types-only at runtime — every export is an `interface` or
`type`, so the emitted JavaScript file is empty under `verbatimModuleSyntax`.
## Features
- `MigrationDialect` — open literal union including `mysql` for the future
out-of-tree adapter package.
- `MigrationFile`, `MigrationRecord`, `MigrationResult` — the three on-disk /
in-database / in-memory shapes.
- `MigrationAdapter` — the per-dialect surface the runner depends on.
- `MigrationRunnerOptions` — options bag at construction time.
- `MigrationRunOptions` — options bag per `runFromDirectory` / `run` call
(currently `signal` only — splits cancellation out from the construction
options so a single runner instance can be re-used across cancellable
apply phases).
## Usage: Basic Example
```ts
import type {
MigrationAdapter,
MigrationDialect,
MigrationRecord,
} from "@suaveplan/api-migrations/types/migration";
const dialect: MigrationDialect = "sqlite";
const records: MigrationRecord[] = [
{
id: "001_initial",
name: "001_initial",
appliedAt: new Date(),
checksum: "deadbeef".repeat(8),
duration: 17,
},
];
```
## Usage: Advanced Example
```ts
import type {
MigrationAdapter,
MigrationRecord,
} from "@suaveplan/api-migrations/types/migration";
class CountingAdapter implements MigrationAdapter {
readonly dialect = "sqlite" as const;
public applies = 0;
constructor(private readonly underlying: MigrationAdapter) {}
ensureTable(t: string) { return this.underlying.ensureTable(t); }
selectApplied(t: string) { return this.underlying.selectApplied(t); }
insertApplied(t: string, r: MigrationRecord) {
return this.underlying.insertApplied(t, r);
}
deleteApplied(t: string, id: string) {
return this.underlying.deleteApplied(t, id);
}
async execute(content: string, signal?: AbortSignal) {
this.applies += 1;
return this.underlying.execute(content, signal);
}
}
```
## API Reference
- `type MigrationDialect = "surrealql" | "postgres" | "sqlite" | "mysql"`.
- `interface MigrationFile { name; path; order; content? }` — fields are
read-only; `content` is mutable so the runner can cache the buffer after
the first read.
- `interface MigrationRecord extends Record<string, unknown> { id; name;
appliedAt; checksum; duration }` — extends the index signature so adapter
implementations may attach dialect-specific extras (Postgres `oid`, etc.).
- `interface MigrationResult { name; success; duration; error? }` — the
per-migration outcome aggregated by `run`.
- `interface MigrationAdapter { dialect; ensureTable; selectApplied;
insertApplied; deleteApplied; execute }` — the runner depends on exactly
these six methods.
- `interface MigrationRunnerOptions { tableName?; fileExtension?;
validateChecksums?; dryRun?; logger?; clock? }`.
- `interface MigrationRunOptions { signal? }`.
## Implementation Notes
- `MigrationDialect` is intentionally open (closed unions are banned per
§0a). Adding a new dialect is a non-breaking literal-union extension that
every adapter package can perform via module augmentation.
- `MigrationRecord` extends `Record<string, unknown>` so the surrealql
adapter can decorate rows with the `_migrations:001_initial` Surreal Record
ID without needing a second type.
- The runner consumes `MigrationAdapter` structurally — adapters do not need
to `implements MigrationAdapter` if they return a literal of the right
shape from their factory.
- `MigrationRunOptions` is split from `MigrationRunnerOptions` so a single
runner instance can be reused across many cancellable apply phases without
the construction options needing to carry an `AbortSignal`.

View File

@@ -0,0 +1,90 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
/**
* Literal union of every dialect for which a `MigrationAdapter` is currently
* defined in the wider Suaveplan ecosystem. The genesis tree ships adapters
* for `surrealql`, `postgres`, and `sqlite`. `mysql` is reserved here so the
* union stays open for the future `@suaveplan/api-migrations-mysql` package
* without forcing a breaking change.
*/
export type MigrationDialect = "surrealql" | "postgres" | "sqlite" | "mysql";
/**
* One migration file on disk, paired with its parsed `order` (leading digits
* of the basename) and the canonical `name` (basename with extension stripped).
* `content` is populated lazily by the runner the first time the file is
* applied or its checksum re-computed.
*/
export interface MigrationFile {
readonly name: string;
readonly path: string;
readonly order: number;
content?: string;
}
/**
* Persistent record stored in the tracking table by `adapter.insertApplied`.
* `appliedAt` is wall-clock at apply time; `duration` is milliseconds spent
* inside `adapter.execute`.
*/
export interface MigrationRecord extends Record<string, unknown> {
readonly id: string;
readonly name: string;
readonly appliedAt: Date;
readonly checksum: string;
readonly duration: number;
}
/**
* Per-migration result returned by `runMigration` and aggregated by `run` /
* `runFromDirectory`. `success: false` carries the `error` message; the
* underlying cause is wrapped in the thrown `MigrationError`.
*/
export interface MigrationResult {
readonly name: string;
readonly success: boolean;
readonly duration: number;
readonly error?: string;
}
/**
* Narrow per-dialect adapter surface. Each shipped adapter
* (`createSurrealqlAdapter`, `createPostgresAdapter`, `createSqliteAdapter`)
* returns this shape. The runner depends only on this contract — no
* `DatabaseLike` coupling.
*
* `execute` MAY accept an `AbortSignal`; adapters that cannot honor mid-query
* cancellation may simply ignore it.
*/
export interface MigrationAdapter {
readonly dialect: MigrationDialect;
ensureTable(table: string): Promise<void>;
selectApplied(table: string): Promise<MigrationRecord[]>;
insertApplied(table: string, record: MigrationRecord): Promise<void>;
deleteApplied(table: string, id: string): Promise<void>;
execute(content: string, signal?: AbortSignal): Promise<void>;
}
/**
* Construction options for `MigrationRunner`. All fields are optional; the
* runner picks per-dialect defaults for `fileExtension` (`.surql` for
* `surrealql`, `.sql` for everything else) and ships sensible logger / clock
* defaults.
*/
export interface MigrationRunnerOptions {
readonly tableName?: string;
readonly fileExtension?: string;
readonly validateChecksums?: boolean;
readonly dryRun?: boolean;
readonly logger?: LoggerLike;
readonly clock?: Clock;
}
/**
* Per-call options for `runFromDirectory` / `run`. Splits the `AbortSignal`
* out of the construction options so consumers can re-use the same runner
* across multiple cancellable apply phases.
*/
export interface MigrationRunOptions {
readonly signal?: AbortSignal;
}

View File

@@ -0,0 +1,74 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const SRC = join(PKG_ROOT, "src");
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
for (const entry of await readdir(dir)) {
const full = join(dir, entry);
const s = await stat(full);
if (s.isDirectory()) {
out.push(...(await walk(full)));
} else {
out.push(full);
}
}
return out;
}
describe("dep-graph", () => {
it("each adapter has its own subpath", { timeout: 5000 }, async () => {
const pkg = JSON.parse(
await readFile(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const exportKeys = Object.keys(pkg.exports);
expect(exportKeys).toContain("./adapters/surrealql/surrealql");
expect(exportKeys).toContain("./adapters/postgres/postgres");
expect(exportKeys).toContain("./adapters/sqlite/sqlite");
});
it("subpath exports cover every feature module", {
timeout: 5000,
}, async () => {
const pkg = JSON.parse(
await readFile(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const expected = [
".",
"./runner/runner",
"./adapters/surrealql/surrealql",
"./adapters/postgres/postgres",
"./adapters/sqlite/sqlite",
"./otel/instruments",
"./error-codes/error-codes",
"./types/migration",
];
for (const key of expected) {
expect(Object.keys(pkg.exports)).toContain(key);
}
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, async () => {
const files = await walk(SRC);
const missing: string[] = [];
for (const file of files) {
if (!file.endsWith(".ts")) continue;
if (file.endsWith(".test.ts")) continue;
if (file.endsWith(".types.ts")) continue;
if (file.endsWith("/index.ts")) continue;
if (file.endsWith("/api-migrations.spec-conformance.test.ts")) {
continue;
}
const md = file.replace(/\.ts$/, ".md");
try {
await stat(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,29 @@
# Integration tests — deferred
The three dialect roundtrip tests (`sqlite`, `postgres`, `surrealql`) are
deferred to a follow-up change. Justification:
- **sqlite-wasm** requires a Web Worker and the WASM binary; running it under
vitest+Node in `forks` pool needs a non-trivial polyfill layer that does
not yet ship in `@suaveplan/testing` and would inflate this change beyond
scope. The `MemoryAdapter` in `src/runner/runner.test.ts` exercises the
full apply/select/insert/delete roundtrip against a real in-memory store
satisfying the same `MigrationAdapter` contract — every spec scenario is
covered.
- **postgres** and **surrealql** roundtrips require Docker testcontainers
(`@suaveplan/testing-integration-postgres`, `-surrealdb`) which are
available but pull large images on every CI run. They will land in
`add-api-migrations-integration-tests` together with the sqlite-wasm worker
setup so the env requirements stay coherent.
The shipped unit tests cover:
- DDL emission per dialect (`src/adapters/<dialect>/<dialect>.test.ts`).
- Row normalisation roundtrip per dialect.
- Parameterised insert/delete per dialect.
- Full apply / drift-detect / rollback flow via the in-memory `MemoryAdapter`
in `src/runner/runner.test.ts`.
These do not stub crypto, fs, or the adapter contract — every test
exercises the real `node:crypto` `createHash` and `node:fs/promises`
`readdir`/`readFile`/`stat` calls.

View File

@@ -0,0 +1,24 @@
/**
* Vitest globalSetup — registers the SurrealDB fixture in the globalSetup
* process's module scope, then re-exports the testing-integration
* `setup`/`teardown` contract.
*
* The same module pattern used by `@suaveplan/surrealdb` integration tests:
* vitest's `globalSetup` runs in a separate process from the worker pool, so
* the fixture must be registered in THIS file (which the globalSetup process
* loads) for `createIntegrationSetup`'s registry to be populated when
* `setup`/`teardown` run.
*
* @module
*/
import { createIntegrationSetup } from "@suaveplan/testing-integration";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
createIntegrationSetup({
surrealdb: new SurrealDBFixture(),
});
export {
setup,
teardown,
} from "@suaveplan/testing-integration/setup/global-setup";

View File

@@ -0,0 +1,48 @@
/**
* Shared integration-setup module — exposes the typed `inject` helper and a
* `surrealUnavailable` boolean for `describe.skipIf(...)` skip-when-down
* behaviour. Fixture registration happens in the sibling `global-setup.ts`
* file because vitest runs `globalSetup` in a separate process from the
* worker pool.
*
* @module
*/
import { inject as vitestInject } from "@suaveplan/testing/runner";
import type { SurrealDBConfig } from "@suaveplan/testing-integration-surrealdb";
interface IntegrationConfigs {
surrealdb: SurrealDBConfig;
}
export function inject<K extends keyof IntegrationConfigs>(
key: K
): IntegrationConfigs[K] {
const all = (vitestInject as (k: string) => IntegrationConfigs)(
"__integration_configs__"
);
return all[key];
}
/**
* `true` when the SurrealDB fixture failed to start (no Docker, env-bridge
* incomplete) or when the test runs outside the integration config (no
* globalSetup at all). Test files use this with `describe.skipIf(...)` so
* the suite SKIPS rather than FAILS.
*/
export const surrealUnavailable: boolean = (() => {
try {
const failed = (vitestInject as (k: string) => string[] | undefined)(
"__integration_failed__"
);
if (failed === undefined) return true;
if (failed.includes("surrealdb")) return true;
const configs = (
vitestInject as (
k: string
) => Record<string, Record<string, unknown>> | undefined
)("__integration_configs__");
return configs === undefined || configs.surrealdb === undefined;
} catch {
return true;
}
})();

View File

@@ -0,0 +1,188 @@
/**
* SurrealQL adapter — live SurrealDB v3 ledger regression suite.
*
* Reproduces the silent-ledger-drop discovered while shipping
* `@suaveplan/api-plugin-database`: with the pre-fix code, the runner's
* `CREATE ${table}:$name SET ...` form parses cleanly under SurrealDB v3
* but records nothing, so the ledger reports zero applied migrations and
* every boot re-runs the same migration.
*
* These tests run against a real `surrealdb:v3.x` testcontainer started by
* `tests/integration/global-setup.ts`. No mocks.
*
* @module
*/
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { Surreal } from "surrealdb";
import {
createSurrealqlAdapter,
type SurrealqlClient,
} from "../../src/adapters/surrealql/surrealql.js";
import { createMigrationRunner } from "../../src/runner/runner.js";
import { inject, surrealUnavailable } from "./integration-setup.js";
const TABLE = "_migrations";
/**
* Thin wrapper that adapts the official `surrealdb` JS SDK to the structural
* `SurrealqlClient` shape the migration adapter expects. The SDK's
* `query<R extends unknown[]>(sql, bindings)` returns the raw statement-result
* array; we flatten the single statement to the rows shape `selectApplied`
* consumes.
*/
function wrapSurreal(client: Surreal): SurrealqlClient {
return {
async query<T>(
sql: string,
vars?: Record<string, unknown>
): Promise<T[]> {
const result = (await client.query(sql, vars)) as unknown[];
// `query` returns an array of statement results. For multi-
// statement SQL we flatten everything that's an array of rows;
// single-row results are tolerated.
const flat: T[] = [];
for (const r of result) {
if (Array.isArray(r)) flat.push(...(r as T[]));
else if (r !== null && r !== undefined) flat.push(r as T);
}
return flat;
},
};
}
async function connect(): Promise<Surreal> {
const cfg = inject("surrealdb");
const db = new Surreal();
await db.connect(cfg.url);
await db.signin({ username: cfg.username, password: cfg.password });
await db.use({ namespace: cfg.namespace, database: cfg.database });
return db;
}
describe.skipIf(surrealUnavailable)(
"createSurrealqlAdapter — live SurrealDB v3 ledger",
() => {
let dir: string;
let db: Surreal;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "api-migrations-surreal-"));
db = await connect();
// Each test gets its own namespace/database for isolation. The
// fixture's default ns/db are sufficient because we DROP the
// ledger table in afterEach.
}, 60000);
afterEach(async () => {
try {
await db.query(`REMOVE TABLE ${TABLE};`);
} catch {
// best-effort cleanup
}
await db.close();
await rm(dir, { recursive: true, force: true });
}, 60000);
it("ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)", async () => {
// Arrange: write a single migration body. The body creates a
// table whose presence we can independently observe.
const fileName = "001_initial.surql";
const body = "DEFINE TABLE post_init SCHEMAFULL;";
await writeFile(join(dir, fileName), body, "utf-8");
const client = wrapSurreal(db);
const adapter = createSurrealqlAdapter(client);
const runner = createMigrationRunner(adapter, {
tableName: TABLE,
fileExtension: ".surql",
});
// Act
const results = await runner.runFromDirectory(dir);
// Assert 1: runner reports success.
expect(results).toHaveLength(1);
expect(results[0]!.success).toBe(true);
expect(results[0]!.name).toBe("001_initial");
// Assert 2: migration body actually applied.
const bodyApplied = (await db.query("INFO FOR DB;")) as unknown[];
expect(JSON.stringify(bodyApplied)).toContain("post_init");
// Assert 3 (the regression): the ledger row exists in the
// SAME live connection. With the pre-fix `${table}:$name`
// form this query returns []; the runner re-applies the
// migration on every boot.
const ledgerRows = (await db.query(
`SELECT name, checksum, duration FROM ${TABLE};`
)) as Array<
Array<{ name: string; checksum: string; duration: number }>
>;
const rows = ledgerRows[0] ?? [];
expect(rows).toHaveLength(1);
expect(rows[0]!.name).toBe("001_initial");
expect(typeof rows[0]!.checksum).toBe("string");
expect(rows[0]!.checksum).toMatch(/^[0-9a-f]{64}$/);
}, 60000);
it("second run sees the ledger and skips already-applied migrations", async () => {
const fileName = "001_idempotent.surql";
const body = "DEFINE TABLE idempotent_t SCHEMAFULL;";
await writeFile(join(dir, fileName), body, "utf-8");
const adapter = createSurrealqlAdapter(wrapSurreal(db));
const runner = createMigrationRunner(adapter, {
tableName: TABLE,
fileExtension: ".surql",
});
const first = await runner.runFromDirectory(dir);
expect(first).toHaveLength(1);
// Second invocation MUST be a no-op. With the pre-fix bug
// the ledger was empty so the runner would re-apply the
// migration — which then fails because `DEFINE TABLE` is
// idempotent here but in real schemas would crash on
// duplicate constraints.
const second = await runner.runFromDirectory(dir);
expect(second).toEqual([]);
// Ledger still has exactly one row.
const ledgerRows = (await db.query(
`SELECT name FROM ${TABLE};`
)) as Array<Array<{ name: string }>>;
expect(ledgerRows[0] ?? []).toHaveLength(1);
}, 60000);
it("rollback() removes the ledger row written by insertApplied (round-trip)", async () => {
const fileName = "001_rollback.surql";
const body = "DEFINE TABLE rollback_t SCHEMAFULL;";
await writeFile(join(dir, fileName), body, "utf-8");
const adapter = createSurrealqlAdapter(wrapSurreal(db));
const runner = createMigrationRunner(adapter, {
tableName: TABLE,
fileExtension: ".surql",
});
await runner.runFromDirectory(dir);
const result = await runner.rollback();
expect(result?.success).toBe(true);
expect(result?.name).toBe("001_rollback");
const ledgerRows = (await db.query(
`SELECT name FROM ${TABLE};`
)) as Array<Array<{ name: string }>>;
expect(ledgerRows[0] ?? []).toHaveLength(0);
}, 60000);
}
);

View File

@@ -0,0 +1,32 @@
import { describe, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { computeChecksum } from "../src/runner/runner.js";
describe("property — SHA-256 checksum stability", () => {
it("identical content produces identical SHA-256", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string(), (content) => {
const a = computeChecksum(content);
const b = computeChecksum(content);
return a === b && /^[0-9a-f]{64}$/.test(a);
})
);
});
it("different content produces different SHA-256", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1 }),
fc.string({ minLength: 1 }),
(a, b) => {
if (a === b) return true;
return computeChecksum(a) !== computeChecksum(b);
}
)
);
});
});

View File

@@ -0,0 +1,39 @@
/**
* Test-environment setup for @suaveplan/api-migrations.
*
* Wires a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module is imported, so the meter resolved at module load is the real
* provider's meter — not the noop fallback. Tests that inspect emitted
* metrics (counter `migrations.applied_total`, histogram
* `migrations.apply_duration_ms`) read them out of
* `testInMemoryMetricExporter`.
*
* Node ≥18 exposes `crypto.subtle` globally, so no crypto polyfill is
* required. Server-tier: `node:fs/promises` is available natively.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"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,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-migrations",
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\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"surrealdb",
"postgres",
"@sqlite.org/sqlite-wasm",
],
},
},
});

View File

@@ -0,0 +1,36 @@
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/**",
"tests/integration/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
},
});

View File

@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const globalSetup = fileURLToPath(
new URL("./tests/integration/global-setup.ts", import.meta.url)
);
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
globalSetup: [globalSetup],
include: ["tests/integration/**/*.integration.test.ts"],
exclude: ["dist/**", "node_modules/**", "src/**"],
testTimeout: 60000,
hookTimeout: 120000,
},
});