first commit
This commit is contained in:
126
docs/api-stack/api/api-migrations/README.md
Normal file
126
docs/api-stack/api/api-migrations/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
108
docs/api-stack/api/api-migrations/src/adapters/sqlite/sqlite.md
Normal file
108
docs/api-stack/api/api-migrations/src/adapters/sqlite/sqlite.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
78
docs/api-stack/api/api-migrations/src/otel/instruments.md
Normal file
78
docs/api-stack/api/api-migrations/src/otel/instruments.md
Normal 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.
|
||||
119
docs/api-stack/api/api-migrations/src/runner/runner.md
Normal file
119
docs/api-stack/api/api-migrations/src/runner/runner.md
Normal 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.
|
||||
113
docs/api-stack/api/api-migrations/src/types/migration.md
Normal file
113
docs/api-stack/api/api-migrations/src/types/migration.md
Normal 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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user