Initial Commit
This commit is contained in:
126
packages/api-stack/api/api-migrations/README.md
Normal file
126
packages/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.
|
||||
|
||||
117
packages/api-stack/api/api-migrations/package.json
Normal file
117
packages/api-stack/api/api-migrations/package.json
Normal 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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createPostgresAdapter,
|
||||
type PostgresClient,
|
||||
} from "./postgres.js";
|
||||
@@ -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.
|
||||
@@ -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);");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createSqliteAdapter, type SqliteClient } from "./sqlite.js";
|
||||
@@ -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,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);");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createSurrealqlAdapter,
|
||||
type SurrealqlAdapterOptions,
|
||||
type SurrealqlClient,
|
||||
} from "./surrealql.js";
|
||||
@@ -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,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;");
|
||||
});
|
||||
});
|
||||
@@ -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><table>:`<id>`</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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { MigrationErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
MIGRATION_CODES,
|
||||
MigrationError,
|
||||
} from "./error-codes.js";
|
||||
37
packages/api-stack/api/api-migrations/src/index.ts
Normal file
37
packages/api-stack/api/api-migrations/src/index.ts
Normal 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";
|
||||
5
packages/api-stack/api/api-migrations/src/otel/index.ts
Normal file
5
packages/api-stack/api/api-migrations/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getMigrationInstruments,
|
||||
type MigrationInstruments,
|
||||
resetMigrationInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
computeChecksum,
|
||||
createMigrationRunner,
|
||||
MigrationRunner,
|
||||
} from "./runner.js";
|
||||
119
packages/api-stack/api/api-migrations/src/runner/runner.md
Normal file
119
packages/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.
|
||||
495
packages/api-stack/api/api-migrations/src/runner/runner.test.ts
Normal file
495
packages/api-stack/api/api-migrations/src/runner/runner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
346
packages/api-stack/api/api-migrations/src/runner/runner.ts
Normal file
346
packages/api-stack/api/api-migrations/src/runner/runner.ts
Normal 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);
|
||||
}
|
||||
9
packages/api-stack/api/api-migrations/src/types/index.ts
Normal file
9
packages/api-stack/api/api-migrations/src/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
MigrationAdapter,
|
||||
MigrationDialect,
|
||||
MigrationFile,
|
||||
MigrationRecord,
|
||||
MigrationResult,
|
||||
MigrationRunnerOptions,
|
||||
MigrationRunOptions,
|
||||
} from "./migration.js";
|
||||
113
packages/api-stack/api/api-migrations/src/types/migration.md
Normal file
113
packages/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`.
|
||||
90
packages/api-stack/api/api-migrations/src/types/migration.ts
Normal file
90
packages/api-stack/api/api-migrations/src/types/migration.ts
Normal 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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
32
packages/api-stack/api/api-migrations/tests/property.test.ts
Normal file
32
packages/api-stack/api/api-migrations/tests/property.test.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-migrations/tsconfig.json
Normal file
12
packages/api-stack/api/api-migrations/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-migrations/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-migrations/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/api-stack/api/api-migrations/vite.config.ts
Normal file
38
packages/api-stack/api/api-migrations/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
36
packages/api-stack/api/api-migrations/vitest.config.ts
Normal file
36
packages/api-stack/api/api-migrations/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user