first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# `database`
Database adapters, migrations, seeding, and SQLite support.
<!-- begin:auto-docs -->
## Packages in `database`
5 packages.
| Package | Description |
|---|---|
| [`@suaveplan/db`](./db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
| [`@suaveplan/db-sqlite`](./db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
| [`@suaveplan/migration`](./migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
| [`@suaveplan/seed`](./seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempote… |
| [`@suaveplan/surrealdb`](./surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Database adapters, migrations, seeding, and SQLite support.

View File

@@ -0,0 +1,110 @@
# @suaveplan/db-sqlite - Universal SQLite adapter for Suaveplan: bun:sqlite on the Bun server, @sqlite.org/sqlite-wasm in the browser, unified behind a Drizzle-ORM surface with LRU prepared-statement cache, forward-only migration runner, OTel instrumentation, and structured SQLITE.* error codes.
## Summary
Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a single `SqliteAdapter` that implements `DatabaseAdapter` from `@suaveplan/db`. Includes an LRU prepared-statement cache (128 entries by default), a forward-only ledger-tracked `MigrationRunner` with dry-run support, OpenTelemetry span instrumentation, structured `SQLITE_*` error codes, and a `./unsafe` subpath for raw queries.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ - [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ - [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/db-sqlite
```
## Quick start
```ts
import { createSqliteAdapter } from "@suaveplan/db-sqlite/adapter";
const db = await createSqliteAdapter({ url: ":memory:" });
await db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL
);
`);
await db.run("INSERT INTO users (id, email) VALUES (?, ?)", [
"user-123",
"alice@example.com",
]);
const rows = await db.all<{ id: string; email: string }>(
"SELECT id, email FROM users WHERE id = ?",
["user-123"],
);
await db.close();
```
## Modules
- [Adapter (universal)](./src/adapter/adapter.md) — `SqliteAdapter`, `createSqliteAdapter`, runtime selection
- [Adapter (Bun)](./src/adapter/adapter.bun.md) — `bun:sqlite` driver binding
- [Adapter (WASM)](./src/adapter/adapter.wasm.md) — browser/Worker `@sqlite.org/sqlite-wasm` binding
- [Cache](./src/cache/cache.md) — `PreparedStatementCache` (LRU)
- [Connection](./src/connection/connection.md) — connection helpers and singleton management
- [Bun driver](./src/drivers/bun-driver.md) — low-level Bun SQLite driver
- [WASM driver](./src/drivers/wasm-driver.md) — low-level WASM SQLite driver
- [WASM worker](./src/drivers/wasm-worker.md) — Web Worker host for WASM driver
- [Errors](./src/errors/errors.md) — `SqliteError`, `SQLITE_*` codes
- [Migration](./src/migration/migration.md) — `MigrationRunner`, forward-only migration ledger
- [OTel](./src/otel/otel.md) — span and metric instrumentation
- [Schemas](./src/schemas/schemas.md) — Zod-validated config types
- [Conformance](./src/conformance/conformance-suite.md) — shared `DatabaseAdapter` conformance suite
- [Unsafe](./src/unsafe/unsafe.md) — `./unsafe` subpath for raw queries

View File

@@ -0,0 +1,61 @@
# adapter.bun
## Purpose
Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun runtime resolves `import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"bun"` condition in the package's `exports` field. It re-exports `SqliteAdapter` and the `SqliteAdapterOptions` type so consumers see a single, stable surface regardless of which environment condition matched.
The separation between adapter entry points prevents browser bundles from pulling in `bun:sqlite` references, and prevents Bun server bundles from pulling in WASM worker code. Each entry is a thin barrel that resolves at bundle time.
## Features
- Conditional export target for the `"bun"` condition in `package.json`
- Re-exports `SqliteAdapter` from `./adapter.js`
- Re-exports `SqliteAdapterOptions` type from `../schemas/index.js`
- Zero runtime overhead — pure re-export barrel
## Usage: Basic Example
```ts
// Resolved automatically by Bun via conditional exports
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
const db = new SqliteAdapter();
await db.connect();
const rows = await db.query("SELECT 1 AS n");
await db.disconnect();
```
## Usage: Advanced Example
```ts
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
import type { SqliteAdapterOptions } from "@suaveplan/db-sqlite/adapter";
const opts: SqliteAdapterOptions = {
filename: "/var/data/app.db",
readonly: false,
};
const adapter = new SqliteAdapter(opts);
await adapter.connect();
await adapter.migrate({ migrationsFolder: "./migrations" });
await adapter.transaction(async (tx) => {
await tx.execute("INSERT INTO users (name) VALUES (?)", ["Alice"]);
});
await adapter.disconnect();
```
## API Reference
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`adapter.md`](./adapter.md) (`SqliteAdapter`) and [`../schemas/schemas.md`](../schemas/schemas.md) (`SqliteAdapterOptions`).
**Conditional export condition:** `"bun"` — selected when the consumer runs under the Bun runtime or when a bundler sets the `bun` condition (e.g. via `resolve.conditions: ["bun"]` in Vite).
## Implementation Notes
The file intentionally contains no logic. Any change to the Bun-specific adapter behaviour lives in `adapter.ts`, not here. This design keeps the conditional export system orthogonal to implementation: adding a new runtime condition (e.g. `"deno"`) only requires adding a new entry-point barrel and a new `exports` condition, with no changes to the core adapter code.
Vite excludes this file from the default ESM browser bundle because the `"browser"` condition resolves to `adapter.wasm.ts` instead.

View File

@@ -0,0 +1,113 @@
# adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- adapter.wasm — Browser-runtime entry point for the……
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- adapter.wasm — Browser-runtime entry point for the…
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
## Purpose
`SqliteAdapter` is the main `DatabaseAdapter` implementation for `@suaveplan/db-sqlite`. It implements the full `DatabaseAdapter` contract from `@suaveplan/db`, backing it with a `bun:sqlite` database instance, an LRU `PreparedStatementCache`, a `MigrationRunner`, and OTel span instrumentation on every public operation.
The adapter is designed for the Bun server runtime only. Attempting to construct it outside Bun throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` immediately. Browser callers should use `createWasmDriver` directly via the `./adapter` conditional export, which resolves to `adapter.wasm.ts` in browser bundles.
## Features
- Implements `DatabaseAdapter` from `@suaveplan/db` — fully substitutable wherever `DatabaseLike` is expected
- Idempotent `connect()` — concurrent callers share one in-flight connection promise
- `PreparedStatementCache` (LRU, default 128 entries) reused across `query()` calls
- `transaction()` uses `BEGIN IMMEDIATE` to prevent deadlocks on concurrent writes; auto-rollback on failure
- `migrate()` delegates to `MigrationRunner` and invalidates the cache on success
- `AbortSignal` respected on every public method — throws `SqliteError(SQLITE.*)` when aborted
- All operations wrapped in `withQuerySpan` for OTel tracing and latency metrics
- `sideEffects: false` — safe for tree-shaking
## Usage: Basic Example
```ts
import { SqliteAdapter } from "@suaveplan/db-sqlite";
const adapter = new SqliteAdapter({ filename: ":memory:" });
await adapter.connect();
const rows = await adapter.query<{ id: number }>(
"SELECT id FROM users WHERE active = ?",
[1]
);
await adapter.disconnect();
```
## Usage: Advanced Example
```ts
import { SqliteAdapter } from "@suaveplan/db-sqlite";
const adapter = new SqliteAdapter({ filename: "/data/app.db" });
await adapter.connect();
// Transactional insert
await adapter.transaction(async (tx) => {
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [1, "Alice"]);
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [2, "Bob"]);
});
// Run migrations
const result = await adapter.migrate({
migrationsFolder: "./db/migrations",
});
console.log(`Applied ${result.applied} migration(s)`);
await adapter.disconnect();
```
## API Reference
### `new SqliteAdapter(options?, schema?)`
Validates options, throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun.
### `connect(config?, opts?): Promise<void>`
Opens the SQLite file or `:memory:` database. Idempotent under concurrent calls — only one connection is opened.
### `disconnect(opts?): Promise<void>`
Closes the database, invalidates the prepared-statement cache, and resets connection state.
### `query<T>(sql, params?, opts?): Promise<T[]>`
Executes a `SELECT` query. Uses the prepared-statement cache for repeated SQL strings.
### `execute(sql, params?, opts?): Promise<{ rowsAffected: number }>`
Executes a `INSERT`/`UPDATE`/`DELETE` statement. Returns affected row count.
### `transaction<T>(fn, opts?): Promise<T>`
Runs `fn` inside a `BEGIN IMMEDIATE` transaction. Commits on success, rolls back on failure.
### `migrate(config?, opts?): Promise<MigrationResult>`
Delegates to `MigrationRunner` and invalidates the prepared-statement cache on success.
### `isConnected: boolean`
`true` after `connect()` and before `disconnect()`.
## Implementation Notes
The `_connectingPromise` field serialises concurrent `connect()` calls — the second caller awaits the same in-flight promise rather than opening a second connection. This is safe because `bun:sqlite` uses synchronous I/O under the covers and does not support concurrent connections to the same file.
`transaction()` casts `this` to `DatabaseLike` when passing it to `fn`. This is sound because `SqliteAdapter` implements all `DatabaseLike` methods. The cast avoids exposing internal `_sqlite` state to transaction callbacks while keeping the type constraint tight.

View File

@@ -0,0 +1,64 @@
# adapter.wasm
## Purpose
Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler resolves `import { createWasmDriver } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"browser"` condition in the package's `exports` field. It re-exports the WASM-backed driver factory and its related types, giving browser consumers a clean surface that never references `bun:sqlite` or any Node.js-only API.
The separation between Bun and WASM adapter entry points keeps tree-shaking effective: a Bun server bundle never pulls in `@sqlite.org/sqlite-wasm` or the Web Worker, and a browser bundle never pulls in `bun:sqlite`. Each entry point is resolved at build time by the bundler's condition matching.
## Features
- Conditional export target for the `"browser"` condition in `package.json`
- Re-exports `createWasmDriver`, `WasmDriverResult`, and `WasmProxyDb` from the WASM driver
- Re-exports `WasmDriverOptions` type from the shared schemas
- Zero runtime overhead — pure re-export barrel
## Usage: Basic Example
```ts
// Resolved automatically by the browser bundler via conditional exports
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
const { db, close } = await createWasmDriver();
const rows = await db.query("SELECT 1 AS n");
await close();
```
## Usage: Advanced Example
```ts
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
import type { WasmDriverOptions } from "@suaveplan/db-sqlite/adapter";
const opts: WasmDriverOptions = {
filename: "app.db", // OPFS-backed persistent storage
};
const { db, close } = await createWasmDriver(opts);
await db.execute(
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
);
await db.execute("INSERT INTO notes (body) VALUES (?)", ["Hello from the browser"]);
const rows = await db.query<{ id: number; body: string }>(
"SELECT * FROM notes ORDER BY id"
);
console.log(rows);
await close();
```
## API Reference
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`../drivers/wasm-driver.md`](../drivers/wasm-driver.md).
**Conditional export condition:** `"browser"` — selected when a bundler uses browser-targeting conditions (e.g. Vite's default, Webpack's `target: "web"`).
**Default fallback:** When neither `"bun"` nor `"browser"` conditions are active (e.g. vanilla Node.js `require()`), the `exports.default` condition falls back to the Bun adapter entry point. The WASM path is never the fallback to avoid loading 1.5 MB of WASM in server-side contexts.
## Implementation Notes
`WasmDriverOptions` is imported from `../schemas/index.js` rather than from `wasm-driver.js` because the Zod schemas live in the shared schemas module — keeping the driver files free of Zod as a direct import. This pattern is consistent across all driver options types in this package.
The WASM worker bundle (`wasm-worker.js`) is inlined as an asset URL by Vite at build time using `new URL("./wasm-worker.js", import.meta.url)`. The worker receives messages for `init`, `query`, `execute`, and `close`, and responds synchronously from SQLite's in-memory or OPFS-backed database.

View File

@@ -0,0 +1,88 @@
# cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PreparedStatementCache` is an LRU-bounded cache for `bun:sqlite` prepared statement objects, keyed by SQL text. Reusing prepared statements avoids the overhead of repeated SQL parsing and optimisation — a significant win for hot query paths. The cache is bounded to prevent unbounded memory growth in applications that generate many distinct SQL strings.
The cache integrates with OpenTelemetry to surface hit/miss/eviction counters at `sqlite.cache.hits`, `sqlite.cache.misses`, and `sqlite.cache.evictions`. These are no-ops when no OTel provider is registered, keeping the module safe for use in zero-dependency environments.
## Features
- LRU eviction when `size >= maxSize` (default `128`)
- `get(sql)` — returns cached statement or `undefined`; updates LRU order
- `set(sql, stmt)` — inserts or updates; evicts LRU entry when at capacity, calling `stmt.finalize()` on the evicted statement
- `invalidate()` — clears all entries and calls `finalize()` on each held statement; called by `SqliteAdapter.migrate()` to prevent stale plan reuse after schema changes
- `size` / `maxSize` read-only getters
- OTel counters: `sqlite.cache.hits`, `sqlite.cache.misses`, `sqlite.cache.evictions`
- `static resetInstruments()` — clears the OTel lazy singleton for test isolation
## Usage: Basic Example
```ts
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
const cache = new PreparedStatementCache();
const stmt = sqlite.prepare("SELECT * FROM users WHERE id = ?");
cache.set("SELECT * FROM users WHERE id = ?", stmt);
const hit = cache.get("SELECT * FROM users WHERE id = ?");
// hit === stmt
```
## Usage: Advanced Example
```ts
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
// Small cache for testing eviction
const cache = new PreparedStatementCache(3);
for (let i = 0; i < 4; i++) {
const sql = `SELECT ${i} FROM t`;
cache.set(sql, { finalize: () => undefined }); // mock statement
}
console.log(cache.size); // 3 — first entry evicted
cache.invalidate();
console.log(cache.size); // 0 — all entries cleared
```
## API Reference
### `new PreparedStatementCache(maxSize?: number)`
Constructs a new cache. `maxSize` defaults to `128`.
### `get(sql: string): Statement | undefined`
Returns the cached statement for `sql`, or `undefined` on miss. Increments `sqlite.cache.hits` or `sqlite.cache.misses`. Updates LRU order on hit.
### `set(sql: string, stmt: Statement): void`
Inserts or updates the cache entry for `sql`. On capacity overflow, evicts the least-recently-used entry, calling `stmt.finalize()` on it, and increments `sqlite.cache.evictions`.
### `invalidate(): void`
Clears all entries. Calls `stmt.finalize()` on each held statement before clearing. Called automatically by `SqliteAdapter.migrate()`.
### `size: number`
Current number of cached entries.
### `maxSize: number`
Maximum capacity as set at construction.
### `static resetInstruments(): void`
Clears the lazy OTel instruments singleton. Use in tests to ensure each test suite starts with a fresh counter baseline.
## Implementation Notes
LRU ordering is maintained with a parallel `string[]` array (`#lruOrder`) rather than a doubly-linked list. This is simpler and sufficient for cache sizes up to a few hundred entries — `Array.indexOf` and `splice` on an array of at most 128 strings add negligible overhead compared to a SQLite query round-trip. For much larger caches a linked-list implementation would be preferable.
`finalize()` is called defensively with a try/catch because `bun:sqlite` statements may already be finalised if the underlying database was closed before the cache was cleared.

View File

@@ -0,0 +1,64 @@
# conformance-suite
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `runConformanceSuite`, a driver-agnostic test factory that accepts a `DriverFactory` callback and executes a standard suite of CRUD, transaction, and rollback assertions against any SQLite driver. Running the same suite against both the Bun driver and a WASM driver ensures that both runtimes satisfy the same behavioural contract, catching driver-specific regressions before they reach production.
The suite is intentionally minimal — it tests the lowest-level SQL operations that the adapter layer depends on. Higher-level tests (prepared-statement cache behaviour, migration runner idempotency, OTel counter increments) live in their respective unit test files.
## Features
- `runConformanceSuite(name, factory)` — registers a Vitest `describe` block named `${name} conformance`
- Tests: table creation, SELECT, INSERT with parameters, transaction commit, transaction rollback, UPDATE, DELETE
- `DriverFactory` interface — a single `create()` method returning `{ sqlite, close }`
- Runs under the Bun driver via `bun-conformance.test.ts`
## Usage: Basic Example
```ts
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
runConformanceSuite("Bun driver", {
async create() {
return createBunDriver({ filename: ":memory:" });
},
});
```
## Usage: Advanced Example
```ts
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
// Custom driver shim for testing
runConformanceSuite("Custom driver", {
async create() {
const driver = await MyCustomDriver.open(":memory:");
return { sqlite: driver.raw, close: async () => driver.close() };
},
});
```
## API Reference
### `DriverFactory`
```ts
interface DriverFactory {
create(): Promise<{ sqlite: any; close: () => Promise<void> }>;
}
```
### `runConformanceSuite(name: string, factory: DriverFactory): void`
Registers a Vitest `describe` block with 5 test cases covering the core SQLite contract.
## Implementation Notes
The conformance suite uses raw `sqlite.run()` / `sqlite.query().all()` calls rather than going through `SqliteAdapter` or Drizzle. This keeps the suite focused on driver-level semantics rather than adapter-layer concerns, making it useful for validating new driver implementations independently of the adapter code.
Each test creates fresh tables with unique names to avoid interference between tests within the same suite run.

View File

@@ -0,0 +1,83 @@
# connection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides thin singleton-management wrappers for `SqliteAdapter`, built on top of the `@suaveplan/db` singleton helpers. These functions let callers share a single connected `SqliteAdapter` instance across an application without manually tracking the singleton lifecycle or passing the adapter through every layer of the call stack.
The helpers default the singleton name to `"default"` so simple applications need only call `getDefaultSqlite()` with no arguments. More complex applications with multiple databases use explicit names.
## Features
- `getDefaultSqlite(options?, config?, name?)` — creates-or-returns a connected `SqliteAdapter` singleton
- `closeSqlite(name?)` — disconnects and removes the named singleton
- `closeAllSqlite()` — disconnects and removes all `SqliteAdapter` singletons
- `hasSqlite(name?)` — returns `true` if the named singleton exists and is connected
- Default name: `"default"` — single-database apps never need to supply a name
## Usage: Basic Example
```ts
import { getDefaultSqlite } from "@suaveplan/db-sqlite/connection/connection";
const adapter = await getDefaultSqlite();
const rows = await adapter.query("SELECT * FROM users");
```
## Usage: Advanced Example
```ts
import {
closeAllSqlite,
closeSqlite,
getDefaultSqlite,
hasSqlite,
} from "@suaveplan/db-sqlite/connection/connection";
// Primary database
const primary = await getDefaultSqlite(
{ filename: "/data/app.db" },
undefined,
"primary"
);
// Analytics read-only shard
const analytics = await getDefaultSqlite(
{ filename: "/data/analytics.db", readonly: true },
undefined,
"analytics"
);
console.log(hasSqlite("primary")); // true
console.log(hasSqlite("analytics")); // true
await closeSqlite("analytics");
console.log(hasSqlite("analytics")); // false
// Shutdown — closes all remaining singletons
await closeAllSqlite();
```
## API Reference
### `getDefaultSqlite(options?, config?, name?): Promise<SqliteAdapter>`
Creates a new `SqliteAdapter` using the given `options`, calls `connect(config)` on it, caches it under `name`, and returns it. Subsequent calls with the same `name` return the cached instance without reconnecting.
### `closeSqlite(name?): Promise<void>`
Disconnects and removes the singleton named `name`. No-op if no singleton by that name exists.
### `closeAllSqlite(): Promise<void>`
Disconnects and removes all singletons. Throws `AggregateError` if any disconnect fails.
### `hasSqlite(name?): boolean`
Returns `true` if a singleton exists under `name`.
## Implementation Notes
These functions delegate entirely to `@suaveplan/db`'s `getSingleton`, `closeSingleton`, `closeAllSingletons`, and `hasSingleton`. The `SqliteAdapter`-specific names (`getDefaultSqlite`, `closeSqlite`, etc.) exist for discoverability — callers working with SQLite need not know the generic singleton API. The return type is narrowed from `DatabaseAdapter` to `SqliteAdapter` via a cast so callers have access to SQLite-specific properties.

View File

@@ -0,0 +1,81 @@
# bun-driver
## Purpose
Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlite` (a built-in Bun module) and returns a thin wrapper holding the raw `Database` instance plus an idempotent `close()` function. The `isBunEnvironment()` guard ensures the driver is never accidentally called in a browser context — doing so raises `SQLITE.ENVIRONMENT_ERROR` immediately rather than surfacing a confusing module-not-found error at import time.
The dynamic `import("bun:sqlite")` pattern keeps the module evaluatable in non-Bun environments (so bundlers can tree-shake it out and browser builds do not fail), while still providing full type information from `@types/bun`.
## Features
- `createBunDriver(options?)` — opens an in-memory (default) or file-backed SQLite database via `bun:sqlite`
- `isBunEnvironment()` — detects Bun runtime via `process.versions.bun`
- `BunDriverResult` — `{ sqlite, close }` shape; `close()` is idempotent (safe to call multiple times)
- Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` when called outside Bun
- Throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if `Database` constructor throws (e.g. bad path, locked file)
- `readonly` option forwarded to `bun:sqlite` `Database` constructor
## Usage: Basic Example
```ts
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
const { sqlite, close } = await createBunDriver();
// sqlite is a bun:sqlite Database instance — run raw SQL or pass to Drizzle
await close();
```
## Usage: Advanced Example
```ts
import { createBunDriver, isBunEnvironment } from "@suaveplan/db-sqlite/drivers/bun-driver";
if (!isBunEnvironment()) {
throw new Error("Server-side only path");
}
const { sqlite, close } = await createBunDriver({
filename: "/data/app.db",
readonly: false,
});
try {
sqlite.run("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)");
sqlite.run("INSERT OR REPLACE INTO kv VALUES (?, ?)", ["hello", "world"]);
} finally {
await close(); // second call is a no-op
await close(); // safe
}
```
## API Reference
### `isBunEnvironment(): boolean`
Returns `true` when `process.versions.bun` is defined. Safe to call in any environment.
### `createBunDriver(options?: BunDriverOptions): Promise<BunDriverResult>`
Opens a SQLite database. Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun, `SqliteError(SQLITE.DRIVER_INIT_FAILED)` on open failure.
**Parameters**
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `options.filename` | `string` | `":memory:"` | File path or `":memory:"` |
| `options.readonly` | `boolean` | `false` | Open in read-only mode |
### `BunDriverResult`
```ts
interface BunDriverResult {
sqlite: Database; // bun:sqlite Database instance
close: () => Promise<void>; // idempotent — safe to call multiple times
}
```
## Implementation Notes
`close()` sets a `closed` flag so repeated calls are no-ops rather than throwing "database already closed". This is intentional: callers cleaning up in `finally` blocks should not have to track whether they already closed.
The `bun:sqlite` import is deferred via `await import("bun:sqlite")` inside the function body so that bundlers processing non-Bun targets (e.g. a Vite browser build) can externalize or ignore the module without causing a build error at parse time. The `isBunEnvironment()` guard before the import provides an early exit in the rare case this driver is invoked in a test environment that stubs the process object.

View File

@@ -0,0 +1,89 @@
# wasm-driver
## Purpose
Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running `wasm-worker.ts`, which loads `@sqlite.org/sqlite-wasm` and manages a SQLite database instance. Communication between the caller and the worker uses a message-passing protocol with sequential IDs mapped to `Promise` resolve/reject pairs, giving a fully async interface without blocking the main thread.
OPFS (Origin Private File System) persistence is attempted automatically when a non-`:memory:` filename is supplied and the worker context supports `sqlite3.oo1.OpfsDb`. When OPFS headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`) are absent, the worker falls back to in-memory storage with a console warning.
## Features
- `createWasmDriver(options?, workerFactory?)` — spawns worker, awaits ready handshake, returns `{ db, close }`
- Injectable `workerFactory` parameter for test isolation — avoids real Worker spawning in Vitest
- `WasmProxyDb` — `{ query(sql, params?), execute(sql, params?) }` interface returned by the driver
- `close()` is idempotent and drains all pending requests with rejection before terminating the worker
- Worker init failure terminates the worker and throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
- Worker runtime errors reject all pending promises with `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
## Usage: Basic Example
```ts
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
const { db, close } = await createWasmDriver();
await db.execute("CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT)", []);
await db.execute("INSERT INTO kv VALUES (?, ?)", ["hello", "world"]);
const rows = await db.query("SELECT * FROM kv WHERE key = ?", ["hello"]);
await close();
```
## Usage: Advanced Example
```ts
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
// With OPFS persistence (requires COOP/COEP headers on the page)
const { db, close } = await createWasmDriver({ filename: "app.db" });
try {
await db.execute("BEGIN IMMEDIATE", []);
await db.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"]);
await db.execute("COMMIT", []);
} catch (err) {
await db.execute("ROLLBACK", []);
throw err;
} finally {
await close();
}
```
## API Reference
### `createWasmDriver<TSchema>(options?, workerFactory?): Promise<WasmDriverResult<TSchema>>`
Spawns the WASM worker and awaits the `ready` handshake before resolving.
**Parameters**
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `options.filename` | `string` | `undefined` (→ `:memory:`) | OPFS file path or omit for in-memory |
| `workerFactory` | `() => Worker` | `defaultWorkerFactory` | Injectable for testing |
**Throws** `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if the worker fails to initialise.
### `WasmProxyDb<TSchema>`
```ts
interface WasmProxyDb<TSchema> {
query(sql: string, params?: unknown[]): Promise<unknown[][]>;
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
}
```
### `WasmDriverResult<TSchema>`
```ts
interface WasmDriverResult<TSchema> {
db: WasmProxyDb<TSchema>;
close: () => Promise<void>;
}
```
## Implementation Notes
The worker URL is constructed with `new URL("./wasm-worker.js", import.meta.url)` so bundlers (Vite, webpack) can resolve it as a worker entry point and emit the correct asset path. The `.js` extension (not `.ts`) is used because the compiled output is what the browser loads at runtime.
The pending-promise map (`Map<id, {resolve, reject}>`) ensures that each in-flight request receives exactly one response, and that a stale response for a cancelled request is silently ignored. On `close()`, all pending promises are rejected before worker termination to prevent callers from hanging indefinitely.
OPFS support requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` response headers. The worker automatically falls back to in-memory storage without these headers rather than crashing, since detecting header presence from inside a Worker is unreliable.

View File

@@ -0,0 +1,81 @@
# wasm-worker
## Purpose
Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `createWasmDriver` and handles all direct SQLite operations using `@sqlite.org/sqlite-wasm`. It communicates with the main thread via a simple message-passing protocol, keeping the WASM heap and SQLite connection completely isolated from the main thread's event loop.
Running SQLite operations in a worker prevents blocking the UI thread during expensive queries and migrations, and satisfies browser restrictions around synchronous WASM I/O on the main thread (required for OPFS-backed databases via `sqlite3_vfs`).
## Features
- Loads `@sqlite.org/sqlite-wasm` lazily on `init` message
- Supports `init`, `query`, `execute`, `begin_tx`, `commit_tx`, `rollback_tx`, and `close` message types
- Sequential request IDs for reliable request-response correlation
- Returns structured `{ id, ok, result }` or `{ id, ok: false, error }` responses
- Opens either an in-memory database (default) or a named database when `filename` is provided
## Usage: Basic Example
This module is not imported directly. It is instantiated by `createWasmDriver` as a Web Worker:
```ts
// Worker is created internally by the driver
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
const { db, close } = await createWasmDriver();
await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
const rows = await db.query("SELECT * FROM t");
await close();
```
## Usage: Advanced Example
For testing purposes, the worker's message protocol can be exercised directly by creating a mock worker that implements the same interface. The `createWasmDriver` function accepts an injectable `workerFactory` parameter for this:
```ts
import { createWasmDriver } from "@suaveplan/db-sqlite";
function mockWorkerFactory(): Worker {
let onmessage: ((ev: MessageEvent) => void) | null = null;
const post = (data: unknown) => { if (onmessage) onmessage({ data } as MessageEvent); };
return {
set onmessage(fn) { onmessage = fn; },
postMessage(msg: unknown) {
const m = msg as { type: string; id: number };
if (m.type === "init") post({ id: m.id, ok: true, result: { type: "ready" } });
if (m.type === "query") post({ id: m.id, ok: true, result: [] });
},
terminate() {},
} as unknown as Worker;
}
const { db, close } = await createWasmDriver(undefined, mockWorkerFactory);
```
## API Reference
### Message Protocol (inbound → worker)
| `type` | Extra fields | Description |
|---------------|----------------------------|-----------------------------------------|
| `init` | `filename?: string` | Load WASM, open database, reply ready |
| `query` | `sql: string`, `params?` | Run `all()`, reply with rows array |
| `execute` | `sql: string`, `params?` | Run `run()`, reply with `rowsAffected` |
| `begin_tx` | — | `BEGIN IMMEDIATE` |
| `commit_tx` | — | `COMMIT` |
| `rollback_tx` | — | `ROLLBACK` |
| `close` | — | Close database, terminate worker |
### Message Protocol (outbound ← worker)
All responses carry `{ id: number, ok: boolean }`.
- Success: `{ id, ok: true, result: <payload> }`
- Failure: `{ id, ok: false, error: "<message>" }`
## Implementation Notes
The `/// <reference lib="webworker" />` triple-slash directive at the top of the file provides TypeScript access to `self`, `MessageEvent`, `postMessage`, and other Web Worker globals without requiring the file to be compiled with `"lib": ["WebWorker"]` globally — that would pollute the non-worker files.
The module uses the `@sqlite.org/sqlite-wasm` default export which is a zero-argument async factory: `const sqlite3 = await initSqlite()`. Previous versions of this package accepted a configuration argument; the current API does not.

View File

@@ -0,0 +1,93 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error hierarchy for `@suaveplan/db-sqlite`. All runtime failures — connection problems, query errors, migration failures, environment mismatches — are reported as `SqliteError` instances carrying a stable `SQLITE.*` code from the `SQLITE_CODES` registry. This makes error handling programmatic and refactor-safe: callers switch on `err.code` rather than matching message strings.
The companion `mapSqliteCode` helper normalises raw error values thrown by `bun:sqlite` (which surfaces result-code strings like `SQLITE_BUSY` or `SQLITE_CONSTRAINT`) into the same stable code namespace, so adapters never leak driver-specific strings into calling code.
## Features
- `SQLITE_CODES` registry — 10 stable `SQLITE.*` code strings defined via `defineCodes`
- `SqliteError extends SuaveplanError<SqliteCode>` — carries `code`, `message`, `cause`, and full `SuaveplanError` wire-format serialisation
- `mapSqliteCode(err)` — maps raw `bun:sqlite` result codes and message substrings to stable `SqliteCode` values; falls back to `SQLITE.QUERY_FAILED`
- Type alias `SqliteCode` — union of all valid code strings
## Usage: Basic Example
```ts
import { SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
throw new SqliteError(
SQLITE_CODES.NOT_CONNECTED,
"Cannot query: adapter is not connected"
);
```
## Usage: Advanced Example
```ts
import { mapSqliteCode, SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
async function safeQuery(db: unknown, sql: string): Promise<unknown[]> {
try {
// ... execute query
return [];
} catch (raw) {
const code = mapSqliteCode(raw);
if (code === SQLITE_CODES.BUSY) {
// caller can retry on BUSY
throw new SqliteError(code, "Database is busy — retry after a moment", { cause: raw instanceof Error ? raw : undefined });
}
throw new SqliteError(code, `Query failed: ${raw instanceof Error ? raw.message : String(raw)}`, {
cause: raw instanceof Error ? raw : undefined,
});
}
}
```
## API Reference
### `SQLITE_CODES`
Registry object produced by `defineCodes("SQLITE", [...])`. Keys and values:
| Key | Value |
|-----|-------|
| `NOT_CONNECTED` | `"SQLITE.NOT_CONNECTED"` |
| `QUERY_FAILED` | `"SQLITE.QUERY_FAILED"` |
| `EXECUTE_FAILED` | `"SQLITE.EXECUTE_FAILED"` |
| `TRANSACTION_FAILED` | `"SQLITE.TRANSACTION_FAILED"` |
| `MIGRATION_FAILED` | `"SQLITE.MIGRATION_FAILED"` |
| `ENVIRONMENT_ERROR` | `"SQLITE.ENVIRONMENT_ERROR"` |
| `BUSY` | `"SQLITE.BUSY"` |
| `CONSTRAINT` | `"SQLITE.CONSTRAINT"` |
| `CORRUPT` | `"SQLITE.CORRUPT"` |
| `DRIVER_INIT_FAILED` | `"SQLITE.DRIVER_INIT_FAILED"` |
### `SqliteError`
```ts
class SqliteError extends SuaveplanError<SqliteCode> {
constructor(code: SqliteCode, message: string, options?: SuaveplanErrorOptions): SqliteError
}
```
Inherits `.code`, `.message`, `.cause`, `.name` from `SuaveplanError`. Fully serialisable to the `@suaveplan/error` wire format.
### `mapSqliteCode(err: unknown): SqliteCode`
Maps a raw thrown value from `bun:sqlite` to a stable `SqliteCode`. Checks `err.message` for known result-code strings (`SQLITE_BUSY`, `SQLITE_LOCKED`, `SQLITE_CONSTRAINT`, `SQLITE_CORRUPT`, `SQLITE_NOTADB`). Returns `SQLITE.QUERY_FAILED` as the safe default when no pattern matches.
### `SqliteCode`
Type alias: `(typeof SQLITE_CODES)[keyof typeof SQLITE_CODES]` — the union of all ten code strings.
## Implementation Notes
The `noUselessConstructor` biome rule is suppressed on `SqliteError` because `SuaveplanError` uses a `protected` constructor; the subclass constructor exists solely to expose a `public` entry point — without it TypeScript raises TS2674.
`mapSqliteCode` checks `err instanceof SqliteError` first so re-thrown errors are not re-wrapped. The result-code detection loop converts both the error message and the raw value to upper-case before matching, handling both `"SQLITE_BUSY: database is locked"` messages and bare result-code strings.

View File

@@ -0,0 +1,84 @@
# migration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MigrationRunner` applies forward-only SQL migration files to a SQLite database. It maintains a ledger table (`__sqlite_migrations` by default) that records which migration files have been applied, making runs idempotent — re-running the same set of migration files produces the same database state regardless of how many times it is called.
Each migration is executed inside a `BEGIN IMMEDIATE` transaction. A failure mid-migration triggers an automatic `ROLLBACK`, ensuring the database is never left in a partially-applied state. A `dryRun()` mode reads and validates files but makes no database changes, letting callers preview what would be applied.
## Features
- Forward-only, filename-ordered migration application (`.sql` files sorted lexicographically)
- Idempotent: skips already-applied migrations via ledger table lookup
- Transactional: each migration runs in `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK`
- Dry-run mode: previews pending migrations without modifying the database
- Injectable `Clock` for deterministic `applied_at` timestamps in tests
- Configurable `migrationsFolder`, `migrationsTable`, and `dryRun` via `MigrationRunnerOptionsSchema`
- Throws `SqliteError(SQLITE.MIGRATION_FAILED)` with original cause on any failure
## Usage: Basic Example
```ts
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
const { sqlite, close } = await createBunDriver({ filename: "app.db" });
try {
const runner = new MigrationRunner(sqlite, { migrationsFolder: "./db/migrations" });
const result = await runner.run();
console.log(`Applied ${result.applied} migration(s):`, result.migrations);
} finally {
await close();
}
```
## Usage: Advanced Example
```ts
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
// Dry-run to preview without applying
const runner = new MigrationRunner(sqlite, {
migrationsFolder: "./db/migrations",
migrationsTable: "custom_ledger",
});
const preview = await runner.dryRun();
console.log("Would apply:", preview.migrations);
if (preview.applied > 0) {
const result = await runner.run();
console.log("Applied:", result.migrations);
}
```
## API Reference
### `new MigrationRunner(sqlite, options?, clock?)`
Constructs a `MigrationRunner`. Validates `options` via `MigrationRunnerOptionsSchema`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sqlite` | `bun:sqlite Database` | Database instance to migrate |
| `options` | `Partial<MigrationRunnerOptions>` | Folder, table, dry-run settings |
| `clock` | `Clock` | Injectable clock for `applied_at` (default: `Date.now`) |
### `run(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
Reads `.sql` files from `migrationsFolder`, skips already-applied entries, applies each in order, records to ledger. Returns `{ applied: number, migrations: string[] }`.
### `dryRun(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
Same as `run()` but makes no database changes. Useful for change-preview UIs and CI checks.
## Implementation Notes
Migrations are sorted lexicographically by filename. Conventional naming (`001_init.sql`, `002_add_users.sql`) produces chronological order. Non-`.sql` files in the folder are silently ignored.
The `#isMigrationApplied` check uses a raw SQL query rather than a prepared statement to avoid circular dependency with `PreparedStatementCache` — the migration runner runs before the adapter is fully connected and the cache is available.
The `applied_at` column stores a Unix millisecond timestamp as `INTEGER` rather than ISO 8601 text. This keeps sorting and comparison simple and avoids timezone ambiguity. Consumers that need a `Date` object divide by 1000 or use `new Date(applied_at)`.

View File

@@ -0,0 +1,65 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation for `@suaveplan/db-sqlite`. The `withQuerySpan` function wraps any async database operation in a named OTel span, setting `db.system=sqlite` and `db.statement` (sanitised) as span attributes, recording exceptions on failure, and emitting latency and count metrics. It is a no-op when no OTel SDK is registered — the `@opentelemetry/api` no-op provider handles all calls silently.
`sanitizeSql` strips string literals and numeric values from SQL strings before attaching them to span attributes, preventing PII leakage into telemetry backends. Parameterised queries still record their structure (`SELECT * FROM users WHERE id = ?`) without revealing actual parameter values.
## Features
- `withQuerySpan(operationName, rawSql, fn)` — wraps fn in a named span with `db.system=sqlite` and sanitised `db.statement`
- `sanitizeSql(sql)` — replaces string literals, quoted identifiers, and bare numbers with `?` placeholders
- Histograms: `sqlite.query.duration_ms` (labels: `operation`)
- Counters: `sqlite.query.count`, `sqlite.error.count` (labels: `operation`)
- `resetOtelInstruments()` — clears the lazy instruments singleton for test isolation
## Usage: Basic Example
```ts
import { withQuerySpan } from "@suaveplan/db-sqlite/otel";
const rows = await withQuerySpan(
"db.query",
"SELECT * FROM users WHERE id = 42",
async () => sqlite.query("SELECT * FROM users WHERE id = ?").all([42])
);
```
## Usage: Advanced Example
```ts
import { sanitizeSql, withQuerySpan } from "@suaveplan/db-sqlite/otel";
// Preview sanitisation before instrumentation
const safe = sanitizeSql("SELECT * FROM t WHERE name = 'Alice' AND age = 30");
// => "SELECT * FROM t WHERE name = ? AND age = ?"
const result = await withQuerySpan("db.execute", safe, async () => {
sqlite.run("INSERT INTO events VALUES (?, ?)", ["login", 1234567890]);
return { rowsAffected: 1 };
});
```
## API Reference
### `withQuerySpan<T>(operationName, rawSql, fn): Promise<T>`
Starts a span named `operationName`, sets `db.system=sqlite` and `db.statement=sanitizeSql(rawSql)`, then calls `fn()`. On success records latency and increments `sqlite.query.count`. On failure records exception, sets `ERROR` status, and increments `sqlite.error.count`. Always ends the span.
### `sanitizeSql(sql: string): string`
Replaces single-quoted strings, double-quoted strings, and numeric literals (integer and decimal) with `?`. Safe to call on arbitrary SQL strings — does not parse SQL, only applies a regex substitution.
### `resetOtelInstruments(): void`
Clears the lazy OTel instruments singleton. Use in tests so each suite starts with fresh counter baselines. Also called internally by `SqliteAdapter.disconnect()`.
## Implementation Notes
The instruments lazy singleton is initialised on the first `withQuerySpan` call rather than at module load to ensure the OTel provider is registered before the meter is obtained. Tests that call `resetOtelInstruments()` between suites prevent counter state from leaking across test files.
`sanitizeSql` uses a single-pass regex and is intentionally simple — it does not understand SQL syntax, so complex nested strings or edge-case identifiers may not be fully sanitised. This is acceptable for telemetry purposes where approximate sanitisation is sufficient.

View File

@@ -0,0 +1,71 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas and inferred TypeScript types for all `@suaveplan/db-sqlite` configuration objects. Centralising validation in one module ensures that every entry point — `createBunDriver`, `createWasmDriver`, `SqliteAdapter`, `MigrationRunner` — applies the same rules with the same error messages, rather than duplicating ad-hoc checks across modules.
All schemas use `.strict()` where applicable so extra unknown properties are rejected at runtime, preventing silent misconfiguration from typos in option objects.
## Features
- `BunDriverOptionsSchema` — validates `filename` (optional string) and `readonly` (optional boolean, default `false`)
- `WasmDriverOptionsSchema` — validates `filename` (optional string) for OPFS persistence
- `SqliteAdapterOptionsSchema` — alias for `BunDriverOptionsSchema`; adapter and driver share the same shape
- `MigrationRunnerOptionsSchema` — validates `migrationsFolder` (default `"./migrations"`), `migrationsTable` (default `"__sqlite_migrations"`), `dryRun` (default `false`)
- Inferred TypeScript types exported for all four schemas
## Usage: Basic Example
```ts
import { BunDriverOptionsSchema } from "@suaveplan/db-sqlite/schemas";
const opts = BunDriverOptionsSchema.parse({ filename: ":memory:" });
// opts.readonly === false (default applied)
```
## Usage: Advanced Example
```ts
import { MigrationRunnerOptionsSchema } from "@suaveplan/db-sqlite/schemas";
const opts = MigrationRunnerOptionsSchema.parse({
migrationsFolder: "./db/migrations",
dryRun: true,
});
// opts.migrationsTable === "__sqlite_migrations" (default applied)
// opts.dryRun === true
```
## API Reference
### `BunDriverOptionsSchema`
`z.object({ filename?: string, readonly?: boolean }).strict()` — `readonly` defaults to `false`.
### `WasmDriverOptionsSchema`
`z.object({ filename?: string }).strict()` — no defaults; absence means in-memory.
### `SqliteAdapterOptionsSchema`
Alias for `BunDriverOptionsSchema`. Both driver and adapter accept the same option shape.
### `MigrationRunnerOptionsSchema`
`z.object({ migrationsFolder?: string, migrationsTable?: string, dryRun?: boolean }).strict()`.
Defaults: `migrationsFolder = "./migrations"`, `migrationsTable = "__sqlite_migrations"`, `dryRun = false`.
### Type aliases
- `BunDriverOptions = z.infer<typeof BunDriverOptionsSchema>`
- `WasmDriverOptions = z.infer<typeof WasmDriverOptionsSchema>`
- `SqliteAdapterOptions = BunDriverOptions`
- `MigrationRunnerOptions = z.infer<typeof MigrationRunnerOptionsSchema>`
## Implementation Notes
`SqliteAdapterOptions` is deliberately an alias for `BunDriverOptions` rather than a separate schema so that passing an `SqliteAdapterOptions` object to `createBunDriver` is type-safe without casting. If the WASM adapter ever gains `readonly` support the alias can be split without a breaking change to the option names callers already use.

View File

@@ -0,0 +1,68 @@
# unsafe
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `unsafe` subpath exports `rawQuery` — a direct SQL execution function that bypasses the Drizzle ORM layer entirely. It is intentionally segregated behind the `@suaveplan/db-sqlite/unsafe` subpath import to make its use explicit and auditable: any import of this path stands out in code review as a deliberate bypass of the normal query builder.
**Security warning:** `rawQuery` provides no SQL injection protection. Never pass user-supplied or externally-sourced strings as the `sql` argument. Use only for migration scripts, low-level tooling, or read-only administrative queries where the SQL is fully under developer control.
## Features
- `rawQuery<T>(db, sql, params?)` — executes a prepared statement on any `bun:sqlite`-compatible Database instance
- Returns rows typed as `T[]` (caller asserts the row shape)
- Maps execution errors to `SqliteError(SQLITE.QUERY_FAILED)`
- Supports parameterised queries via the `params` array — always use `?` placeholders and pass values in `params` rather than string-interpolating them
## Usage: Basic Example
```ts
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
const rows = await rawQuery<{ count: number }>(
sqlite,
"SELECT count(*) AS count FROM sqlite_master WHERE type = ?",
["table"]
);
console.log(rows[0]?.count);
```
## Usage: Advanced Example
```ts
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
// Used in a migration helper to read schema state before Drizzle is set up
async function tableExists(sqlite: unknown, name: string): Promise<boolean> {
const rows = await rawQuery<{ found: number }>(
sqlite,
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?",
[name]
);
return rows.length > 0;
}
```
## API Reference
### `rawQuery<T>(db, sql, params?): Promise<T[]>`
Executes `sql` as a prepared statement on `db`. Throws `SqliteError(SQLITE.QUERY_FAILED)` on error.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| `db` | `any` | A `bun:sqlite` Database instance |
| `sql` | `string` | The SQL to execute — must not contain user input |
| `params` | `unknown[]` | Bind parameters for `?` placeholders |
**Returns** `Promise<T[]>` — rows shaped as `T`
## Implementation Notes
`rawQuery` uses `db.prepare(sql).all(params)` rather than `db.query(sql).all(params)` to be consistent with the caching strategy used by `SqliteAdapter`. The `params` spread form (`stmt.all(...params)`) passes each parameter as a positional argument, matching `bun:sqlite`'s expected calling convention.
The `db` parameter is typed as `any` to accept both `bun:sqlite Database` instances and test doubles without requiring a concrete import of `bun:sqlite` types from this module.

View File

@@ -0,0 +1,93 @@
# @suaveplan/db - Universal relational database adapter interface: DatabaseAdapter (extends DatabaseLike), structured DB.* error codes, Zod-validated config types, injectable connection singletons, MigrationRunner interface, and full OTel instrumentation.
## Summary
Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), injectable connection singletons, structured `DB.*` error codes, Zod-validated config types, a `MigrationRunner` interface, and full OpenTelemetry instrumentation.
This package is the stable foundation that `@suaveplan/db-sqlite` and future adapters build upon.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ - [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ - [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/db
```
## Quick Start
```ts
import { getSingleton, closeSingleton } from "@suaveplan/db";
import { SqliteAdapter } from "@suaveplan/db-sqlite";
// Get-or-create a named singleton connection
const db = await getSingleton("app", () => new SqliteAdapter({ filename: "./app.db" }));
// Query
const users = await db.query<{ id: number; name: string }>(
"SELECT id, name FROM users WHERE active = ?",
[1]
);
// Transaction
const result = await db.transaction(async (tx) => {
await tx.execute("INSERT INTO audit (msg) VALUES (?)", ["login"]);
return tx.query<{ id: number }>("SELECT last_insert_rowid() AS id");
});
// Cleanup
await closeSingleton("app");
```
## Modules
| Module | Description |
|---|---|
| [`src/adapter/database-adapter.md`](src/adapter/database-adapter.md) | `DatabaseAdapter` interface — full connection contract |
| [`src/connection/singleton.md`](src/connection/singleton.md) | `getSingleton` / `closeSingleton` / `hasSingleton` / `closeAllSingletons` |
| [`src/errors/database-error.md`](src/errors/database-error.md) | `DatabaseError`, `MigrationError`, `DB_CODES` |
| [`src/migration/migration-runner.md`](src/migration/migration-runner.md) | `MigrationRunner` interface |
| [`src/telemetry/instruments.md`](src/telemetry/instruments.md) | OTel metric instruments (histogram, counters) |
| [`src/telemetry/trace.md`](src/telemetry/trace.md) | `withDbSpan` tracing helper |
| [`src/types/database-config.md`](src/types/database-config.md) | `DatabaseConfig`, `MigrationConfig`, Zod schemas |
## Error Codes
| Code | When thrown |
|---|---|
| `DB.CONNECTION_FAILED` | `connect()` fails |
| `DB.NOT_CONNECTED` | Operation attempted before `connect()` |
| `DB.QUERY_FAILED` | `query()` or `execute()` throws |
| `DB.TRANSACTION_FAILED` | `transaction()` throws or rolls back |
| `DB.MIGRATION_FAILED` | `migrate()` fails on a specific migration |
## Benchmarks
| Operation | Median |
|---|---|
| `getSingleton` cache-hit | ~2 µs |
| `hasSingleton` lookup | < 1 µs |
| `DatabaseConfigSchema.parse` | ~5 µs |
| `withDbSpan` overhead (no-op OTel) | ~3 µs |

View File

@@ -0,0 +1,106 @@
# DatabaseAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DatabaseAdapter` is the full database connection contract for `@suaveplan/db`. It extends `DatabaseLike` from `@suaveplan/types` (the minimal DI interface used by packages that inject a database without depending on a concrete driver) and adds lifecycle management, migration support, and `AbortSignal` propagation on every async entrypoint. Concrete adapters — `@suaveplan/db-sqlite`, future Postgres and Turso adapters — implement this interface.
Keeping `DatabaseLike` as the base means any `DatabaseAdapter` can be passed wherever a `DatabaseLike` is expected, enabling clean dependency injection across the project without introducing circular dependencies between domain packages and their database layer.
## Features
- Extends `DatabaseLike` — structurally compatible with the minimal DI interface
- `connect` / `disconnect` lifecycle with optional `DatabaseConfig`
- `query<T>` — typed SELECT, returns `T[]`
- `execute` — INSERT / UPDATE / DELETE / DDL, returns `{ rowsAffected: number }`
- `transaction<T>` — callback receives active `DatabaseLike` tx handle; auto-commit / auto-rollback
- `migrate` — delegates to a `MigrationRunner`; accepts optional `MigrationConfig`
- `isConnected` — boolean state predicate; checked by singleton helpers before reconnecting
- `AbortSignal` on every async method via `DbOperationOptions`
- `close()` alias from `DatabaseLike` — concrete adapters may implement as `this.disconnect()`
## Usage: Basic Example
```ts
import type { DatabaseAdapter } from "@suaveplan/db";
class SqliteAdapter implements DatabaseAdapter {
#connected = false;
get isConnected() { return this.#connected; }
async connect(config?) { /* open bun:sqlite */ this.#connected = true; }
async disconnect() { /* close */ this.#connected = false; }
async close() { await this.disconnect(); }
async query<T>(sql, params?) { /* ... */ return [] as T[]; }
async execute(sql, params?) { return { rowsAffected: 0 }; }
async transaction(fn) { return fn(this); }
async migrate(config?) { return { applied: 0, migrations: [] }; }
}
```
## Usage: Advanced Example
```ts
import { getSingleton, DB_CODES, DatabaseError } from "@suaveplan/db";
import { SqliteAdapter } from "@suaveplan/db-sqlite";
// Register a named singleton with an isolated registry (safe in tests)
const reg = new Map();
const db = await getSingleton(
"app",
() => new SqliteAdapter({ filename: "./app.db" }),
undefined,
undefined,
reg
);
// Abort a long-running query
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
try {
const rows = await db.query<{ id: number }>(
"SELECT id FROM events WHERE ts > ?",
[Date.now() - 86_400_000],
{ signal: ac.signal }
);
} catch (err) {
if (err instanceof DatabaseError && err.code === DB_CODES.QUERY_FAILED) {
console.error("query failed", err.message);
}
}
```
## API Reference
### `DatabaseAdapter`
Extends `DatabaseLike`.
| Method | Signature | Description |
|---|---|---|
| `connect` | `(config?, opts?) => Promise<void>` | Open the connection |
| `disconnect` | `(opts?) => Promise<void>` | Close and release resources |
| `close` | `() => Promise<void>` | `DatabaseLike` alias for `disconnect` |
| `query<T>` | `(sql, params?, opts?) => Promise<T[]>` | Typed SELECT |
| `execute` | `(sql, params?, opts?) => Promise<{ rowsAffected }>` | DML/DDL |
| `transaction<T>` | `(fn, opts?) => Promise<T>` | Auto-commit/rollback |
| `migrate` | `(config?, opts?) => Promise<MigrationResult>` | Apply pending migrations |
| `isConnected` | `boolean` (readonly) | Connection state |
### `DbOperationOptions`
```ts
interface DbOperationOptions {
signal?: AbortSignal;
}
```
## Implementation Notes
The `transaction` callback signature uses `DatabaseLike` (not `DatabaseAdapter`) so implementations can pass a lightweight transaction handle that exposes only `query`/`execute`/`transaction`/`close` without the lifecycle methods. This matches how ORMs typically expose transaction objects and avoids callers calling `connect` or `migrate` on a transaction handle.
`disconnect` and `close` serve the same purpose — `disconnect` is the richer signature (accepts `DbOperationOptions`); `close` is the `DatabaseLike`-compatible alias with no arguments. Concrete adapters typically implement `disconnect` and delegate `close` to it.

View File

@@ -0,0 +1,120 @@
# Connection Singletons
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getSingleton`, `closeSingleton`, `hasSingleton`, and `closeAllSingletons` provide a lightweight singleton registry for `DatabaseAdapter` instances. The primary purpose is to avoid opening multiple connections to the same database within a single process — `getSingleton` is idempotent: the first call creates and connects the adapter, subsequent calls with the same name return the cached reference without reconnecting.
By accepting an optional `registry` parameter (a plain `Map<string, DatabaseAdapter>`), the helpers are safe for test isolation: each test suite can pass its own private map and have zero cross-test bleed. In production, omitting `registry` uses a module-level default map, replicating the familiar global-singleton pattern without requiring any global state management from callers.
## Features
- Idempotent `getSingleton` — second call with same name returns cached adapter
- `DatabaseConfigSchema` validation before `connect` is called
- Wraps `connect` failures as `DatabaseError` with `DB.CONNECTION_FAILED`
- `closeSingleton` — graceful disconnect + registry removal (no-op for unknown names)
- `closeAllSingletons` — drains the entire registry; collects disconnect errors into `AggregateError`
- Injectable `registry` parameter — full test isolation without global state
- `AbortSignal` threading via `DbOperationOptions`
## Usage: Basic Example
```ts
import { getSingleton, closeSingleton } from "@suaveplan/db";
import { SqliteAdapter } from "@suaveplan/db-sqlite";
// Get or create the default "app" connection
const db = await getSingleton("app", () => new SqliteAdapter({ filename: "./app.db" }));
const rows = await db.query<{ id: number }>("SELECT id FROM users");
// Clean up at process exit
await closeSingleton("app");
```
## Usage: Advanced Example
```ts
import {
getSingleton,
closeAllSingletons,
hasSingleton,
} from "@suaveplan/db";
import type { DatabaseAdapter } from "@suaveplan/db";
// Test-isolated registry — no cross-test bleed
const testRegistry = new Map<string, DatabaseAdapter>();
beforeEach(async () => {
await getSingleton(
"test-db",
() => new SqliteAdapter({ filename: ":memory:" }),
undefined,
undefined,
testRegistry
);
});
afterEach(async () => {
await closeAllSingletons(undefined, testRegistry);
});
it("has singleton after setup", () => {
expect(hasSingleton("test-db", testRegistry)).toBe(true);
});
```
## API Reference
### `getSingleton`
```ts
getSingleton(
name: string,
factory: () => DatabaseAdapter,
config?: Partial<DatabaseConfig>,
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<DatabaseAdapter>
```
Returns the cached adapter for `name`, or creates one via `factory`, validates `config`, connects it, and caches it. Throws `DatabaseError(DB.CONNECTION_FAILED)` if `connect` fails.
### `closeSingleton`
```ts
closeSingleton(
name: string,
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<void>
```
Calls `disconnect` on the named adapter and removes it from the registry. No-op if `name` is not registered.
### `hasSingleton`
```ts
hasSingleton(name: string, registry?: Map<string, DatabaseAdapter>): boolean
```
Returns `true` if an adapter is registered under `name`.
### `closeAllSingletons`
```ts
closeAllSingletons(
opts?: DbOperationOptions,
registry?: Map<string, DatabaseAdapter>
): Promise<void>
```
Clears the registry immediately, then disconnects all adapters concurrently. If one or more disconnects fail, an `AggregateError` is thrown after all attempts complete.
## Implementation Notes
The registry is cleared before `disconnect` is called in `closeAllSingletons`. This ensures that even if a `disconnect` rejects, the registry is empty and a subsequent `getSingleton` call will create a fresh adapter rather than returning a half-closed one. The AggregateError is thrown after all disconnect attempts so callers get complete failure information rather than a short-circuit on the first error.
Config validation uses `DatabaseConfigSchema.parse` (not `.safeParse`) so invalid config throws a `ZodError` immediately — fail-fast at the boundary is preferable to a cryptic driver-level error.

View File

@@ -0,0 +1,107 @@
# DatabaseError / MigrationError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DatabaseError` is the structured error class for all database failures in `@suaveplan/db`. It extends `SuaveplanError` from `@suaveplan/error`, ensuring every database error carries a stable, machine-readable `DB.*` code alongside a human-readable message and optional cause chain. `MigrationError` is a specialisation that records which named migration failed.
Using `SuaveplanError` as the base means consumers can perform type-safe `instanceof` checks, extract OTel-compatible codes from `error.code`, and inspect cause chains consistently across the entire Suaveplan project.
## Features
- Extends `SuaveplanError<DbCode>` — participates in the project-wide error hierarchy
- Stable `DB.*` codes registered via `defineCodes` — never change between versions
- `cause` threading — wraps lower-level driver errors without losing the original stack
- `MigrationError.migration` field — names the exact migration file that failed
- `name` property set to the concrete class name for easy identification in logs
## Usage: Basic Example
```ts
import { DatabaseError, MigrationError, DB_CODES } from "@suaveplan/db";
// Throw a connection failure
throw new DatabaseError(
DB_CODES.CONNECTION_FAILED,
"Could not reach postgres://localhost:5432/app"
);
// Throw a migration failure
throw new MigrationError("003_add_index", "duplicate column name: email");
```
## Usage: Advanced Example
```ts
import { DatabaseError, DB_CODES } from "@suaveplan/db";
import { SuaveplanError } from "@suaveplan/error";
async function runQuery(sql: string): Promise<unknown[]> {
try {
return await driver.query(sql);
} catch (cause) {
throw new DatabaseError(
DB_CODES.QUERY_FAILED,
`Query failed: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause: cause instanceof Error ? cause : undefined }
);
}
}
// Narrowing in catch blocks
try {
await runQuery("SELECT * FROM users");
} catch (err) {
if (err instanceof DatabaseError) {
console.error(err.code, err.message); // "DB.QUERY_FAILED Query failed: ..."
}
}
```
## API Reference
### `DatabaseError`
```ts
class DatabaseError extends SuaveplanError<DbCode> {
constructor(code: DbCode, message: string, options?: SuaveplanErrorOptions): void
}
```
- **`code`** (`DbCode`) — one of `DB.CONNECTION_FAILED`, `DB.NOT_CONNECTED`, `DB.QUERY_FAILED`, `DB.TRANSACTION_FAILED`, `DB.MIGRATION_FAILED`
- **`message`** (`string`) — human-readable description
- **`options.cause`** (`Error | undefined`) — lower-level error that triggered this one
### `MigrationError`
```ts
class MigrationError extends DatabaseError {
readonly migration: string;
constructor(migration: string, detail: string, options?: SuaveplanErrorOptions): void
}
```
- **`migration`** (`string`) — the migration name (e.g. `"001_init"`)
- **`detail`** (`string`) — description of what went wrong
- Message format: `"Migration '<migration>' failed: <detail>"`
- Code is always `DB.MIGRATION_FAILED`
### `DB_CODES`
```ts
const DB_CODES: {
readonly CONNECTION_FAILED: "DB.CONNECTION_FAILED";
readonly NOT_CONNECTED: "DB.NOT_CONNECTED";
readonly QUERY_FAILED: "DB.QUERY_FAILED";
readonly TRANSACTION_FAILED:"DB.TRANSACTION_FAILED";
readonly MIGRATION_FAILED: "DB.MIGRATION_FAILED";
}
```
## Implementation Notes
Both classes pass the `message` string directly to `SuaveplanError` as a literal message key rather than an i18n key, so no message-resolver configuration is required. This keeps `@suaveplan/db` dependency-free of any i18n infrastructure. If localisation is needed in a future version, the message key can be replaced with an i18n key and the `context` option used for interpolation.
`MigrationError` explicitly sets `this.name = "MigrationError"` in the constructor because `SuaveplanError` sets `this.name = new.target.name` — the explicit assignment in the subclass constructor ensures the name is accurate when the class is transpiled or minified.

View File

@@ -0,0 +1,101 @@
# MigrationRunner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MigrationRunner` is the interface that `DatabaseAdapter.migrate` delegates to in concrete adapter implementations. Separating the migration contract from the adapter contract lets adapters swap migration strategies without changing their top-level API — for example, a SQLite adapter might use Drizzle's migration runner while a SurrealDB adapter uses a custom `.surql` file runner, yet both expose the same `DatabaseAdapter.migrate` signature to callers.
The three-method design mirrors the principle of least surprise: `pendingMigrations` is a read-only query (safe to call for status checks without making changes), `applyMigration` is the atomic unit of work, and `run` is the high-level orchestrator that wires them together.
## Features
- `pendingMigrations` — read-only: lists unapplied migrations without side effects
- `applyMigration` — atomic unit: applies a single named migration by SQL string
- `run` — orchestrator: discovers, orders, and applies all pending migrations
- `MigrationError` — thrown with the exact migration name on failure
- `AbortSignal` on both `applyMigration` and `run` via `DbOperationOptions`
- Accepts optional `MigrationConfig` to override folder/table defaults
## Usage: Basic Example
```ts
import type { MigrationRunner } from "@suaveplan/db";
import { MigrationError } from "@suaveplan/db";
class FileMigrationRunner implements MigrationRunner {
async pendingMigrations(config?) {
const applied = await this.loadApplied(config);
const all = await this.discoverFiles(config);
return all
.filter(m => !applied.has(m.name))
.map(m => ({ name: m.name, applied: false }));
}
async applyMigration(name, sql, opts?) {
try {
await this.db.execute(sql, [], opts);
await this.recordApplied(name);
} catch (err) {
throw new MigrationError(name, err instanceof Error ? err.message : String(err), { cause: err });
}
}
async run(config?, opts?) {
const pending = await this.pendingMigrations(config);
const names: string[] = [];
for (const { name } of pending) {
const sql = await this.loadSql(name, config);
await this.applyMigration(name, sql, opts);
names.push(name);
}
return { applied: names.length, migrations: names };
}
}
```
## Usage: Advanced Example
```ts
import type { MigrationRunner } from "@suaveplan/db";
import { MigrationError, withDbSpan } from "@suaveplan/db";
// Wrapping run with OTel tracing
async function runMigrations(runner: MigrationRunner): Promise<void> {
const result = await withDbSpan("db.migrate", () =>
runner.run({ migrationsFolder: "./db/migrations" })
);
console.log(`Applied ${result.applied} migration(s):`, result.migrations);
}
// Status check without applying
async function checkMigrationStatus(runner: MigrationRunner): Promise<void> {
const pending = await runner.pendingMigrations();
if (pending.length > 0) {
console.warn(`${pending.length} unapplied migrations:`, pending.map(m => m.name));
}
}
```
## API Reference
### `MigrationRunner`
```ts
interface MigrationRunner {
pendingMigrations(config?: MigrationConfig): Promise<MigrationStatus[]>;
applyMigration(name: string, sql: string, opts?: DbOperationOptions): Promise<void>;
run(config?: MigrationConfig, opts?: DbOperationOptions): Promise<MigrationResult>;
}
```
- **`pendingMigrations`** — returns only unapplied migrations; never mutates state
- **`applyMigration`** — applies one migration; throws `MigrationError` on failure with the migration's name in `error.migration`
- **`run`** — discovers pending migrations via `pendingMigrations` and applies them in order via `applyMigration`; returns aggregate `MigrationResult`
## Implementation Notes
`MigrationRunner` is intentionally an interface rather than an abstract class. This keeps the dependency graph clean — the runner itself has no implementation, so it imposes no runtime cost. Concrete adapters in `@suaveplan/db-sqlite` and future packages supply the implementation.
The `AbortSignal` is only threaded through `applyMigration` and `run` — `pendingMigrations` is a read-only operation and is not expected to take long enough to warrant cancellation in typical use cases. Implementors may add signal support to `pendingMigrations` as a non-breaking extension.

View File

@@ -0,0 +1,97 @@
# OTel Instruments
## Purpose
`instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating meters at module load time (which would execute OTel SDK code before any SDK is registered and could silently fail), instruments are created on first use via a module-level singleton guard. This ensures the package works correctly both when an OTel SDK exporter is configured and when it is not — in the latter case, the OTel no-op provider handles all calls without errors.
Four instruments cover the core observability surface: query/execute latency, connection lifecycle, transaction outcomes, and migration counts.
## Features
- Lazy initialisation — OTel meter created on first call, not at module load
- `resetInstruments()` — clears the singleton for test isolation
- `recordQueryDuration(ms, operation)` — histogram labelled by `db.operation`
- `recordConnectionDelta(±1, dbName)` — UpDownCounter labelled by `db.name`
- `recordTransaction(outcome)` — counter labelled by `db.outcome`
- `recordMigration(name)` — counter labelled by `db.migration.name`
- Works with no-op OTel provider — safe when no SDK is configured
## Usage: Basic Example
```ts
import {
recordQueryDuration,
recordConnectionDelta,
} from "@suaveplan/db/telemetry/instruments";
// In a concrete adapter's connect():
recordConnectionDelta(1, "main");
// In a concrete adapter's query():
const start = performance.now();
const rows = await driver.query(sql);
recordQueryDuration(performance.now() - start, "query");
```
## Usage: Advanced Example
```ts
import {
recordTransaction,
recordMigration,
resetInstruments,
} from "@suaveplan/db/telemetry/instruments";
// In a concrete adapter's transaction():
let outcome: "committed" | "rolled_back" = "rolled_back";
try {
await fn(tx);
outcome = "committed";
} finally {
recordTransaction(outcome);
}
// In a migration runner after each applied migration:
recordMigration("003_add_index");
// In tests — reset between test runs to avoid state leak:
beforeEach(() => resetInstruments());
```
## API Reference
### `recordQueryDuration(durationMs, operation)`
Records a query or execute duration.
- `durationMs` — elapsed time in milliseconds
- `operation` — `"query"` or `"execute"`
### `recordConnectionDelta(delta, dbName)`
Adjusts the active connection count.
- `delta` — `1` on connect, `-1` on disconnect
- `dbName` — logical name of the database (used as the `db.name` attribute)
### `recordTransaction(outcome)`
Increments the transaction counter.
- `outcome` — `"committed"` or `"rolled_back"`
### `recordMigration(migrationName)`
Increments the migration counter.
- `migrationName` — the name of the applied migration file
### `resetInstruments()`
Clears the cached instruments singleton. Useful in unit tests to avoid state from a prior test bleeding into subsequent ones.
## Implementation Notes
The instruments singleton guard uses an `undefined` check rather than a lazy initialiser class so there is no closure overhead — the check is a single `!== undefined` comparison per call. The `metrics.getMeter("@suaveplan/db")` call is safe under the OTel no-op provider because it returns a no-op meter whose `createHistogram`, `createUpDownCounter`, and `createCounter` methods all return no-op instruments that silently discard all data.
`recordQueryDuration` uses a histogram (not a counter) so downstream dashboards can compute percentiles — P50/P95/P99 query latency is far more useful than a total call count for diagnosing slow queries.

View File

@@ -0,0 +1,76 @@
# withDbSpan
## Purpose
`withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boilerplate of starting a span, setting its status on success or error, recording exceptions, and always ending the span — so concrete adapters and migration runners can add tracing with a single wrapper call rather than repeating the span lifecycle pattern in every method.
Like the instruments module, `withDbSpan` relies on the OTel no-op tracer when no SDK is configured, so it is safe to call in any environment including tests with no OTel setup.
## Features
- Creates a named span via `trace.getTracer("@suaveplan/db")`
- Sets `SpanStatusCode.OK` on successful completion
- Sets `SpanStatusCode.ERROR` and records the exception on throw
- Always ends the span (via `finally`) — no span leaks
- Accepts optional `Attributes` for span metadata (e.g. `db.name`, `db.operation`)
- Works with the OTel no-op provider — zero overhead when no exporter is registered
## Usage: Basic Example
```ts
import { withDbSpan } from "@suaveplan/db/telemetry/trace";
const rows = await withDbSpan("db.query", async () => {
return driver.query("SELECT * FROM users");
});
```
## Usage: Advanced Example
```ts
import { withDbSpan } from "@suaveplan/db/telemetry/trace";
// With span attributes for richer trace data
const result = await withDbSpan(
"db.execute",
async () => driver.execute("DELETE FROM sessions WHERE expires_at < ?", [Date.now()]),
{
"db.name": "app",
"db.operation": "execute",
"db.statement": "DELETE FROM sessions",
}
);
// Wrapping a full transaction
const transferResult = await withDbSpan("db.transaction.transfer", async () => {
return adapter.transaction(async (tx) => {
await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, "sender"]);
await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, "receiver"]);
return { ok: true };
});
}, { "transfer.amount": 100 });
```
## API Reference
### `withDbSpan<T>`
```ts
withDbSpan<T>(
name: string,
fn: () => Promise<T>,
attrs?: Attributes
): Promise<T>
```
- **`name`** — OTel span name (e.g. `"db.query"`, `"db.migrate"`)
- **`fn`** — async function to execute inside the span
- **`attrs`** — optional `Attributes` map attached to the span at creation time
- **Returns** — the resolved value of `fn()`
- **Throws** — re-throws whatever `fn()` throws after recording the exception on the span
## Implementation Notes
`withDbSpan` uses the module-level `tracer` constant (created once at import time via `trace.getTracer("@suaveplan/db")`) rather than calling `getTracer` on every invocation. The OTel spec guarantees that `getTracer` with the same name returns the same tracer instance, so this is a safe optimisation.
Non-Error thrown values (e.g. strings, numbers) are wrapped in `new Error(String(err))` before being passed to `span.recordException`. This satisfies the OTel SDK's type requirement for `recordException` while still re-throwing the original value, so callers that catch strings continue to work as before.

View File

@@ -0,0 +1,117 @@
# DatabaseConfig / MigrationConfig
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DatabaseConfig` and `MigrationConfig` are the two primary configuration shapes for `@suaveplan/db`. They define the options accepted by `DatabaseAdapter.connect` and `DatabaseAdapter.migrate` respectively, and are validated at runtime via their companion Zod schemas. Centralising config shapes here ensures every concrete adapter (SQLite, Postgres, SurrealDB) receives the same validated input.
By deriving the TypeScript types from Zod schemas (`z.infer<typeof ...>`), the types and runtime validation are always in sync — adding a new field in one place adds it to both automatically.
## Features
- `DatabaseConfig` — connection settings: optional file path, network URL, and read-only mode flag
- `MigrationConfig` — migration settings: migrations folder path and tracking table name, both with sensible defaults
- `MigrationStatus` — per-migration state returned by `pendingMigrations`
- `MigrationResult` — aggregate result returned by `migrate` / `MigrationRunner.run`
- Strict Zod schemas (`.strict()`) — unknown fields throw validation errors rather than being silently stripped
- Default values baked into the Zod schemas: `readonlyMode` defaults to `false`, `migrationsFolder` to `"./drizzle"`, `migrationsTable` to `"__drizzle_migrations"`
## Usage: Basic Example
```ts
import {
DatabaseConfigSchema,
MigrationConfigSchema,
} from "@suaveplan/db";
// Parse and validate adapter config
const dbConfig = DatabaseConfigSchema.parse({
filename: ":memory:",
readonlyMode: false,
});
// Parse and validate migration config
const migConfig = MigrationConfigSchema.parse({
migrationsFolder: "./db/migrations",
});
// { migrationsFolder: "./db/migrations", migrationsTable: "__drizzle_migrations" }
```
## Usage: Advanced Example
```ts
import type { DatabaseConfig, MigrationConfig } from "@suaveplan/db";
import { DatabaseConfigSchema } from "@suaveplan/db";
function buildAdapter(raw: Partial<DatabaseConfig>): MyAdapter {
// Validate at the boundary — unknown fields throw ZodError
const config = DatabaseConfigSchema.parse(raw);
return new MyAdapter(config);
}
// TypeScript consumers that don't need runtime validation can use the type directly:
function connectPg(config: DatabaseConfig): Promise<void> {
if (!config.url) throw new Error("url required for PostgreSQL");
// ...
}
```
## API Reference
### `DatabaseConfig`
```ts
interface DatabaseConfig {
filename?: string; // File path for embedded databases (e.g. ':memory:')
url?: string; // Connection URL for network databases
readonlyMode?: boolean; // Default: false
}
```
### `DatabaseConfigSchema`
Zod schema matching `DatabaseConfig`. Strict — rejects unknown fields.
### `MigrationConfig`
```ts
interface MigrationConfig {
migrationsFolder?: string; // Default: './drizzle'
migrationsTable?: string; // Default: '__drizzle_migrations'
}
```
### `MigrationConfigSchema`
Zod schema matching `MigrationConfig`. Strict — rejects unknown fields.
### `MigrationStatus`
```ts
interface MigrationStatus {
name: string;
applied: boolean;
appliedAt?: Date;
}
```
Returned by `MigrationRunner.pendingMigrations`.
### `MigrationResult`
```ts
interface MigrationResult {
applied: number; // Count of migrations applied in this run
migrations: string[]; // Names of applied migrations in order
}
```
Returned by `DatabaseAdapter.migrate` and `MigrationRunner.run`.
## Implementation Notes
Both schemas use `.strict()` to catch typos in field names early. If a future version needs to support passthrough (e.g. for driver-specific extensions), that should be handled by extending the base schema rather than loosening it — keeping the core strict preserves the type-safety guarantee at the adapter boundary.
The `MigrationStatus` and `MigrationResult` types are plain interfaces (not Zod-derived) because they are output shapes returned to callers, not input shapes validated at the boundary.

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
# @suaveplan/seed - Universal declarative database seeding and fixture framework: SeedBuilder fluent API, SeedRegistry with environment filters, SeedRunner with topological ordering, upsert strategies, batching, dry-run, AbortSignal, injectable LoggerLike, FixtureFactory, InMemorySeedStore, store-agnostic.
Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempotent upserts, batching, dry-runs, and clean cancellation. The runner is store-agnostic: any object satisfying the `SeedStore` interface plugs in, so the same seed definitions drive in-memory tests, local SurrealDB instances, and production databases without rewriting the data layer.
<!-- begin:auto-docs -->
## Documentation tree
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
- [execute](./src/runner/execute.md) — The `executeSeed` function applies one `SeedDefinition` to one `SeedStore`, returning a single `SeedResult`. It is the…
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
- [types](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
- [common](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
<!-- end:auto-docs -->
## Purpose
Ships a handful of database adapters (`db`, `db-sqlite`, `surrealdb`, `migration`) but no consistent way to populate them. Hand-rolled per-package seeders drift in shape, swallow errors, skip dependency ordering, and re-insert duplicates on every run. `@suaveplan/seed` consolidates the orchestration concerns — registration, topological execution, environment scoping, batched upserts, dry-runs, progress reporting, and cancellation — behind one pluggable contract. The package is intentionally universal: zero `node:`/`bun:` imports, no IO of its own, and a single runtime dependency on `@suaveplan/error`. Adapters that need a production store ship in dedicated `seed-store-*` packages and implement the `SeedStore` interface; the core runs unchanged in tests, server processes, and worker contexts.
## Features
- **Fluent `SeedBuilder` + `defineSeed(name)` convenience** — chainable API for declaring tables, dependencies, environments, upsert strategies, priorities, and per-seed truncation
- **`SeedRegistry`** — duplicate-name detection (typed `SEED.DUPLICATE_NAME`), defensive-copy reads, environment-aware queries
- **`SeedRunner`** — Kahn's-algorithm topological sort with priority tiebreak, `only`/`exclude` filtering, three upsert strategies (`skip`/`update`/`replace`), configurable batching, `dryRun` mode, `continueOnError`, per-seed `onProgress` callback
- **`AbortSignal` plumbing** — abort mid-run; remaining seeds are reported as `skipped` with `error: "Aborted"`
- **Injectable `LoggerLike`** (`@suaveplan/types`) — structured events (`seed.run.start`, `seed.completed`, `seed.skipped`, `seed.failed`, `seed.run.complete`); no-op default keeps the runtime silent when unwired
- **`FixtureFactory<T>`** — deterministic per-record overrides with auto-incrementing index, `createMany`, `reset`
- **`InMemorySeedStore`** — full `SeedStore` shape with `getRecords`/`clear` test affordances
- **`SeedError` + `SEED_CODES` registry** — every public failure carries a stable `SEED.*` code and threads `cause` per the `@suaveplan/error` contract
- **Tree-shakeable subpath exports** — import `@suaveplan/seed/runner/runner` without pulling fixtures
- **100% statement/branch/function/line coverage** via Istanbul
## Usage: Basic
```ts
import {
defineSeed,
SeedRegistry,
SeedRunner,
createInMemoryStore,
} from "@suaveplan/seed";
const registry = new SeedRegistry();
registry.register(
defineSeed("users")
.table("user")
.data([
{ id: 1, name: "alice" },
{ id: 2, name: "bob" },
])
.upsert("skip", ["id"])
.build(),
);
registry.register(
defineSeed("posts")
.dependsOn("users")
.data([
{ id: 1, authorId: 1, title: "Hello" },
{ id: 2, authorId: 2, title: "World" },
])
.upsert("skip", ["id"])
.build(),
);
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
console.log(result.success, result.seedsRun, result.seedsSkipped);
```
## Usage: Advanced
```ts
import {
defineSeed,
SeedRegistry,
SeedRunner,
type LoggerLike,
} from "@suaveplan/seed";
import { mySurrealStore } from "./my-surreal-store";
const registry = new SeedRegistry();
registry.registerMany([
defineSeed("tenants")
.environments("dev", "staging")
.data(async () => fetchTenantsFromCsv())
.upsert("update", ["slug"])
.priority(0)
.build(),
defineSeed("admin-users")
.dependsOn("tenants")
.dataFactory(async () => seedFactoryFor(currentTenant))
.upsert("update", ["tenantId", "email"])
.priority(10)
.build(),
defineSeed("audit-log-buckets")
.dependsOn("tenants")
.truncateFirst()
.data([])
.priority(20)
.build(),
]);
const logger: LoggerLike = createStructuredLogger();
const runner = new SeedRunner(registry, mySurrealStore, logger);
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // hard 30s budget
const result = await runner.run({
environment: "staging",
only: ["tenants", "admin-users"],
batchSize: 500,
continueOnError: false,
signal: controller.signal,
onProgress: (r) =>
logger.info(`seed.progress`, { name: r.name, status: r.status }),
});
if (!result.success) {
throw new Error(
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
);
}
```
## API Reference
### Builder
- `defineSeed(name: string): SeedBuilder` — convenience factory
- `class SeedBuilder` — chainable: `table`, `environments`, `dependsOn`, `data`, `dataFactory`, `upsert`, `description`, `priority`, `truncateFirst`, terminal `build()`
### Registry
- `class SeedRegistry` — `register` / `registerMany` / `get` / `getAll` / `getForEnvironment(env)` / `has` / `remove` / `clear` / `size` / `names`. Raises `SeedError({ code: SEED.DUPLICATE_NAME })` on collision.
### Runner
- `class SeedRunner(registry, store, logger?)` — `async run(config: SeedRunConfig): Promise<SeedRunResult>`
- `SeedRunConfig`: `environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`
- `SeedRunResult`: `environment`, `totalDuration`, `results`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`
- `SeedResult`: `name`, `table`, `status`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration`, `error?`
### Fixtures
- `class FixtureFactory<T extends SeedRecord>` + `createFixtureFactory(defaults)` — `create(overrides?)`, `createMany(count, overrides?)`, `reset()`, `counter`
- `class InMemorySeedStore` + `createInMemoryStore()` — full `SeedStore` shape plus `getRecords(table)` / `clear()`
### Utils
- `topoSort(seeds): string[]` — Kahn's algorithm with priority tiebreak; raises `SEED.UNREGISTERED_DEPENDENCY` / `SEED.CIRCULAR_DEPENDENCY` / `SEED.DEPENDENCY_DEPTH_EXCEEDED`
- `detectCycles(seeds): string[]` — DFS cycle reporter; returns `["a -> b -> a", ...]`; empty array on clean graph
### Errors
- `SeedError extends SuaveplanError<SeedErrorCode>` with optional `seedName` / `dependency` discriminants
- `SEED_CODES`: `DUPLICATE_NAME`, `UNREGISTERED_DEPENDENCY`, `CIRCULAR_DEPENDENCY`, `DEPENDENCY_DEPTH_EXCEEDED`, `INVALID_CONFIG`, `STORE_ERROR`, `ABORTED`
### Constants
- `DEFAULT_ENVIRONMENTS = ["dev", "staging", "test", "production"] as const`
- `MAX_DEPENDENCY_DEPTH = 50`
- `DEFAULT_BATCH_SIZE = 100`
- `SEED_STATUS = { PENDING, RUNNING, COMPLETED, FAILED, SKIPPED }`
## Implementation Notes
- **Universal** — no `node:`/`bun:` imports anywhere in `src/`. The only platform-aware call is `performance.now()`, which every supported runtime provides natively.
- **Store-agnostic by design** — `SeedRunner` receives a `SeedStore` via constructor injection. The core ships only `InMemorySeedStore` (for tests); production stores live in dedicated `seed-store-*` packages.
- **Errors thread `cause`** — store exceptions are wrapped as `SeedError({ code: SEED.STORE_ERROR, cause })` so callers can inspect the underlying error without losing the stable code.
- **Topological sort uses Kahn's algorithm** — one clean pass, priority breaks ties for independent seeds, a defensive iteration guard catches pathological cases before they hang.
- **AbortSignal is checked between seeds** — in-flight `SeedStore` calls receive the signal too (every `SeedStore` method accepts an optional `signal?: AbortSignal`).
- **Logger is opt-in** — `LoggerLike` defaults to a no-op so the runner stays silent when unwired. Wire it to get `seed.run.start` / `seed.completed` / `seed.skipped` / `seed.failed` / `seed.run.complete` events.
- **Enterprise extras are out of scope here** — plugin lifecycle, SurrealDB schema migrations, JWT/RBAC, audit logging, rate limiting, webhooks, OTel spans, and Prometheus metrics all belong to a future `api-plugin-seed` package that wraps this core.

View File

@@ -0,0 +1,92 @@
# SeedBuilder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that callers can declare seeds in a readable, self-documenting form without hand-assembling the underlying object literal and without worrying about empty-collection or unset-field noise leaking into the serialised output.
The builder is the single canonical entry point for authoring seed definitions: `SeedRegistry`, `SeedRunner`, and every downstream tool consume the `SeedDefinition` value it produces. By centralising construction here we keep upstream parity with the `../suaveplan/packages/server/seed` reference implementation while honouring the rule that unset optional fields must be omitted entirely from the produced definition rather than serialised as empty arrays, empty strings, or `false`.
## Features
- Chainable fluent API — every setter returns the same builder instance.
- Default `table` derives from the seed `name` so most call-sites need only one identifier.
- `data(records)` and `dataFactory(fn)` share a single internal slot; the most recent call wins, supporting both static record arrays and lazy async factories.
- `upsert(strategy, keys)` co-sets the upsert strategy and key tuple atomically.
- `truncateFirst()` defaults `enabled` to `true` so the most common opt-in stays terse.
- `build()` produces a deeply-immutable shape: optional fields the caller never touched (`environments`, `dependencies`, `upsertKeys`, `description`, `truncate`) are omitted entirely.
- Implements the `ISeedBuilder` interface so consumers can depend on the contract rather than the concrete class.
## Usage: Basic
```ts
import { defineSeed } from "@suaveplan/seed/builder/builder";
const usersSeed = defineSeed("users")
.data([
{ id: 1, email: "alice@example.com" },
{ id: 2, email: "bob@example.com" },
])
.build();
// usersSeed.name === "users"
// usersSeed.table === "users" (defaults to name)
// usersSeed.upsertStrategy === "skip"
// usersSeed.priority === 0
// usersSeed has no environments / dependencies / upsertKeys / description / truncate fields
```
## Usage: Advanced
```ts
import { defineSeed, SeedBuilder } from "@suaveplan/seed/builder/builder";
import type { ISeedBuilder } from "@suaveplan/seed/builder/builder";
function makeTenantSeed(name: string): ISeedBuilder {
return defineSeed(name)
.table(`tenant_${name}`)
.environments("dev", "staging")
.dependsOn("roles", "permissions")
.dataFactory(async () => {
const rows = await loadFromFixtureService(name);
return rows.map((r, i) => ({ ...r, idx: i }));
})
.upsert("update", ["id"])
.description(`Tenant records for ${name}`)
.priority(10)
.truncateFirst();
}
const usersDef = makeTenantSeed("users").build();
const postsDef = new SeedBuilder("posts")
.dependsOn("users")
.data([{ id: 1, authorId: 1, body: "hello" }])
.upsert("skip", ["id"])
.build();
declare function loadFromFixtureService(name: string): Promise<Array<Record<string, unknown>>>;
```
## API Reference
- `defineSeed(name: string): SeedBuilder` — returns a new builder seeded with `name` (also the default `table`).
- `class SeedBuilder implements ISeedBuilder` — constructor takes the seed `name`.
- `table(name: string): this` — overrides the default `table`.
- `environments(...envs: SeedEnvironment[]): this` — restrict to listed environments; empty rest-args clears the field (omitted from `build()`).
- `dependsOn(...names: string[]): this` — list seeds that must run first; empty rest-args clears the field.
- `data(records: SeedRecord[]): this` — static record array; replaces any factory.
- `dataFactory(fn: SeedDataFactory): this` — sync-or-async factory; replaces any static array.
- `upsert(strategy: UpsertStrategy, keys: string[]): this` — atomic strategy + key-tuple setter.
- `description(text: string): this` — empty string is treated as unset.
- `priority(n: number): this` — lower priorities run earlier when no dependency edge exists.
- `truncateFirst(enabled?: boolean): this` — default `true`; passing `false` clears the field.
- `build(): SeedDefinition` — terminal accessor; safe to call multiple times.
## Implementation Notes
The builder tracks each optional field in its own private slot and only emits it from `build()` when the slot is non-empty (`length > 0` for arrays/strings, truthy for booleans). The mandatory shape — `name`, `table`, `data`, `upsertStrategy`, `priority` — is always present, matching the upstream reference contract. Defaults: `_environments = []`, `_dependencies = []`, `_data = []`, `_upsertStrategy = "skip"`, `_upsertKeys = []`, `_description = ""`, `_priority = 0`, `_truncate = false`.
Because `data(...)` and `dataFactory(...)` write to the same slot, ordering matters: calling `.data([...]).dataFactory(fn)` resolves to the factory; the reverse resolves to the array. Both return `this` so the fluent chain is uninterrupted regardless of which slot wins. `build()` is a pure read; it never mutates internal state, so the same builder may produce multiple equivalent definitions or be further refined and re-built. All chainable methods return the typed `this` so subclassing preserves the fluent return type without explicit casts.

View File

@@ -0,0 +1,128 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `@suaveplan/seed` package centralises every failure mode it can produce
behind a single registry built with `defineCodes("SEED", [...])` from
`@suaveplan/error`. The registry is the stable contract callers depend on:
code strings are documented, frozen at module load, and never renamed
without an explicit migration. This module exports the `SEED_CODES`
registry, the derived `SeedErrorCode` type, and the `SeedError` class that
every public failure path raises.
`SeedError` extends `SuaveplanError<SeedErrorCode>` so `instanceof SeedError`
narrows `code` to the exact union of `SEED.*` strings. It adds optional
`seedName` and `dependency` discriminants that diagnose which seed (or
which missing dependency) triggered the error, while leaving the base
`context` map free for caller-supplied structured data. The class threads
`cause` straight through `SuaveplanError`, so store-layer exceptions
wrapped by the runner remain inspectable through `error.cause`.
## Features
- Frozen `SEED_CODES` registry with seven namespaced codes
- `SeedErrorCode` literal-union type derived from the registry
- `SeedError` class extending `SuaveplanError<SeedErrorCode>`
- Optional `seedName` and `dependency` diagnostic discriminants
- Full `cause`-chain preservation via `SuaveplanError`
- Compatible with the OTel span-event adapter shipped by `@suaveplan/error`
## Usage: Basic
```ts
import { SEED_CODES, SeedError } from "@suaveplan/seed";
if (registry.has(seed.name)) {
throw new SeedError(SEED_CODES.DUPLICATE_NAME, `seed "${seed.name}" already registered`, {
seedName: seed.name,
});
}
```
## Usage: Advanced
```ts
import { SEED_CODES, SeedError } from "@suaveplan/seed";
import { SuaveplanError } from "@suaveplan/error";
try {
await store.insert(seed.table, batch);
} catch (cause) {
throw new SeedError(SEED_CODES.STORE_ERROR, `store rejected batch for ${seed.table}`, {
cause: cause instanceof Error ? cause : undefined,
seedName: seed.name,
context: { table: seed.table, batchSize: batch.length },
});
}
// Downstream:
catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.STORE_ERROR) {
logger.error("seed.store.failure", {
seedName: err.seedName,
rootMessage: err.cause instanceof Error ? err.cause.message : String(err.cause),
});
}
}
```
## API Reference
### `SEED_CODES`
A frozen object literal of seven namespaced codes:
| Property | Value |
| ----------------------------- | ---------------------------------- |
| `DUPLICATE_NAME` | `"SEED.DUPLICATE_NAME"` |
| `UNREGISTERED_DEPENDENCY` | `"SEED.UNREGISTERED_DEPENDENCY"` |
| `CIRCULAR_DEPENDENCY` | `"SEED.CIRCULAR_DEPENDENCY"` |
| `DEPENDENCY_DEPTH_EXCEEDED` | `"SEED.DEPENDENCY_DEPTH_EXCEEDED"` |
| `INVALID_CONFIG` | `"SEED.INVALID_CONFIG"` |
| `STORE_ERROR` | `"SEED.STORE_ERROR"` |
| `ABORTED` | `"SEED.ABORTED"` |
### `type SeedErrorCode`
The literal union of every value in `SEED_CODES`.
### `class SeedError extends SuaveplanError<SeedErrorCode>`
| Member | Type |
| ------------------ | ---------------------------------------------------------------------- |
| `code` | `SeedErrorCode` (narrowed) |
| `seedName?` | `string` |
| `dependency?` | `string` |
| `cause?` | `unknown` (inherited) |
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
| `constructor(...)` | `(code, message, options?: SeedErrorOptions)` |
`SeedErrorOptions` extends `SuaveplanErrorOptions` (`cause`, `context`,
`clock`, `traceId`) with the additional optional `seedName` and
`dependency` fields.
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record with `null` prototype, so
the registry cannot be mutated, reassigned, or extended at runtime — any
attempt fails in strict mode (which TypeScript modules enforce). The
literal type machinery preserves the exact `${Namespace}.${Reason}`
string template across every consumer, so `switch (err.code)` enjoys
exhaustive narrowing without runtime cost.
`SeedError`'s constructor passes `message` straight through to
`SuaveplanError`'s `messageKey` parameter. The base class resolves the
key via the i18n resolver if one has been registered with
`setMessageResolver`, falling back to the raw string otherwise — so this
package works without i18n wiring and lights up automatically when
`@suaveplan/i18n` is present.
Optional `seedName` / `dependency` fields are stored on the error instance
directly (not on `context`) so they survive structured-cloning and
serialisation via the `toJSON` helper from `@suaveplan/error`. Consumers
that programmatically dispatch on a missing dependency can read
`err.dependency` without first parsing `err.context`.

View File

@@ -0,0 +1,142 @@
# fixtures
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `fixtures` module supplies the two universal building blocks used to
exercise seed definitions without a real database: `FixtureFactory` for
generating deterministic, counter-stamped record sets, and `InMemorySeedStore`
for satisfying the `SeedStore` contract entirely in process memory. Together
they let every test in the wider ecosystem author realistic seed data and run
the same `SeedRunner` paths a production adapter would run, without touching
any I/O or external service. The module deliberately has zero dependencies on
the runner, registry, or builder layers — it is a leaf consumed by tests and
by integration harnesses that want a drop-in store.
## Features
- Auto-incrementing fixture indices via a private monotonic counter.
- Per-record overrides for `create` and indexed overrides for `createMany`.
- Counter reset to support deterministic test sequencing.
- Full `SeedStore` implementation: `insert`, `update`, `findExisting`,
`truncate`, `tableExists`.
- Composite-key support: any number of `keyFields` joined with `"::"`.
- Test affordances: `getRecords` returns a defensive copy, `clear` drops
every table.
- Optional `AbortSignal` parameter accepted on every method for contract
compatibility (ignored — operations are synchronous in-memory).
- Factory helpers `createFixtureFactory` and `createInMemoryStore` for code
that prefers function calls over `new`.
## Usage: Basic
```ts
import {
createFixtureFactory,
createInMemoryStore,
} from "@suaveplan/seed/fixtures/fixtures";
const userFactory = createFixtureFactory((index) => ({
id: index,
name: `User ${index}`,
email: `user${index}@example.com`,
}));
const users = userFactory.createMany(3);
const store = createInMemoryStore();
await store.insert("users", users);
```
## Usage: Advanced
```ts
import {
FixtureFactory,
InMemorySeedStore,
} from "@suaveplan/seed/fixtures/fixtures";
interface Membership extends Record<string, unknown> {
orgId: string;
userId: string;
role: "member" | "admin";
}
const memberships = new FixtureFactory<Membership>((i) => ({
orgId: `org-${i % 2}`,
userId: `user-${i}`,
role: "member",
}));
const rows = memberships.createMany(4, [
undefined as never,
{ role: "admin" },
]);
const store = new InMemorySeedStore();
await store.insert("memberships", rows);
const updated = await store.update(
"memberships",
[{ orgId: "org-0", userId: "user-0", role: "admin" }],
["orgId", "userId"]
);
memberships.reset();
```
## API Reference
### `FixtureFactory<T>`
- `new FixtureFactory(defaults: (index: number) => T)` — wrap a defaults
function.
- `create(overrides?: Partial<T>): T` — emit one record, advance the counter.
- `createMany(count: number, overrides?: Partial<T>[]): T[]` — emit `count`
records; `overrides[i]` (if present) merges over `defaults(counter)`.
- `reset(): void` — set the counter back to 0.
- `get counter(): number` — read the current counter.
### `createFixtureFactory<T>(defaults)`
Convenience wrapper for `new FixtureFactory(defaults)`.
### `InMemorySeedStore implements SeedStore`
- `insert(table, records, signal?)` — append; returns `records.length`.
- `update(table, records, keyFields, signal?)` — composite-key upsert into
matching rows; returns the count actually updated.
- `findExisting(table, records, keyFields, signal?)` — return stored rows
whose composite key appears in the input set.
- `truncate(table, signal?)` — empty the table while keeping it present.
- `tableExists(table, signal?)` — `true` iff `insert` or `truncate` has ever
named the table.
- `getRecords(table)` — defensive copy of the table's rows.
- `clear()` — drop every table.
### `createInMemoryStore()`
Returns a new `InMemorySeedStore`.
## Implementation Notes
The composite key is built by mapping each `keyField` to `String(record[k] ??
"")` and joining with `"::"`. Nullish field values collapse to the empty
string, so records with `undefined` and `null` for the same field share a
key; callers that need to distinguish them must use a key field that is
always populated. `update` mutates the stored array in place via index
assignment so subsequent `getRecords` calls observe the merge; the spread
order is `{ ...existing[idx], ...record }`, meaning incoming fields win on
collision.
`AbortSignal` arguments are intentionally ignored: there is no I/O to
abort. The parameters exist only so the in-memory store satisfies the same
shape as a network-bound store and can be swapped in for one in tests.
`getRecords` returns a shallow copy of the underlying array so mutating the
returned list does not corrupt subsequent assertions. `clear` removes the
map entries themselves, after which `tableExists` returns `false` for every
table — distinguishing it from `truncate`, which preserves the entry while
emptying its contents.

View File

@@ -0,0 +1,113 @@
# SeedRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns insertion-order bookkeeping, duplicate-name detection, and environment-scoped query helpers, providing every other layer (builder, runner, fixtures) a single source of truth for "what seeds exist in this process".
The registry is intentionally simple: no I/O, no concurrency primitives, no dependency resolution. Its sole invariants are (1) seed names are unique, (2) every read accessor returns a defensive shallow copy so caller mutations never leak into internal storage, and (3) duplicate registrations raise a structured `SeedError` with code `SEED.DUPLICATE_NAME` rather than a bare `Error`, so downstream catch sites can dispatch programmatically.
## Features
- O(1) name-keyed insertion, lookup, and deletion backed by a `Map`.
- Bulk registration via `registerMany` that fails fast on the first duplicate.
- Environment-scoped query (`getForEnvironment`) where unrestricted seeds (undefined or empty `environments`) match every environment.
- Defensive shallow copies returned from every read accessor (`get`, `getAll`, `getForEnvironment`).
- `SeedError`-typed failure with stable `SEED.DUPLICATE_NAME` code and `seedName` discriminant.
- Insertion-order iteration via `names` getter (Map semantics).
- Implements `ISeedRegistry` so consumers can substitute alternative registry implementations in tests.
## Usage: Basic
```ts
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
const registry = new SeedRegistry();
registry.register({
name: "users",
table: "users",
data: [{ id: 1, email: "a@example.com" }],
});
const seed = registry.get("users");
if (seed) {
// seed.data is a defensive copy; safe to inspect without affecting storage.
}
console.log(registry.size); // 1
console.log(registry.names); // ["users"]
```
## Usage: Advanced
```ts
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
import {
SEED_CODES,
SeedError,
} from "@suaveplan/seed/error-codes/error-codes";
import type { SeedDefinition } from "@suaveplan/seed/types/common";
const registry = new SeedRegistry();
const defs: SeedDefinition[] = [
{ name: "users", table: "users", data: [] },
{
name: "fixtures",
table: "fixtures",
environments: ["test"],
data: [],
},
{
name: "demo-data",
table: "demo",
environments: ["dev"],
data: [],
},
];
try {
registry.registerMany(defs);
} catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.DUPLICATE_NAME) {
// Structured: err.seedName identifies the colliding entry.
}
throw err;
}
// Environment-scoped query: unrestricted + matching seeds only.
const forTest = registry.getForEnvironment("test");
// → users (unrestricted) + fixtures (env=test)
// Remove yields a boolean; missing keys are a no-op.
registry.remove("demo-data");
registry.clear();
```
## API Reference
| Member | Description |
| ----------------------------------- | ----------------------------------------------------------------- |
| `register(seed)` | Insert one seed; throws `SeedError(SEED.DUPLICATE_NAME)` on collision. |
| `registerMany(seeds)` | Iterate and `register` each seed; aborts on first duplicate. |
| `get(name)` | Defensive copy of the named seed, or `undefined`. |
| `getAll()` | Defensive-copy array of every registered seed. |
| `getForEnvironment(env)` | Subset matching `env` or unrestricted (`undefined`/empty). |
| `has(name)` | `true` if the name is present, `false` otherwise. |
| `remove(name)` | Delete; returns `true` if the entry existed. |
| `clear()` | Remove every registered seed. |
| `size` | Current entry count. |
| `names` | Snapshot of registered names in insertion order. |
## Implementation Notes
- Storage uses `Map<string, SeedDefinition>` so insertion order is preserved by `names`, `getAll`, and `getForEnvironment`.
- Defensive copies are *shallow* (`{ ...seed }`). Nested arrays (`environments`, `dependencies`, `upsertKeys`, `data`) are not cloned; they remain `readonly` on the `SeedDefinition` type, so callers cannot mutate them without an explicit cast. This matches the upstream reference behaviour and avoids deep-clone overhead during runner setup.
- `getForEnvironment` filters the already-defensive `getAll()` result. The two-pass approach keeps the filter logic small and reuses the copy contract.
- `register` throws *before* mutating the map, so a duplicate registration leaves the registry intact. `registerMany` therefore preserves a partial-success prefix when an entry mid-stream collides — consumers who need transactional semantics should snapshot `names` and roll back via `remove` after catching.
- Error construction always includes `seedName` so downstream `instanceof SeedError` checks can dispatch without parsing the message string.
- No internal concurrency control: the registry is intended for single-threaded use during process startup and test setup. Consumers requiring concurrent registration must serialise externally.

View File

@@ -0,0 +1,123 @@
# execute
## Purpose
The `executeSeed` function applies one `SeedDefinition` to one
`SeedStore`, returning a single `SeedResult`. It is the per-seed body
that `SeedRunner.run` invokes inside its topo-ordered loop. The
function is split out of `runner.ts` so the orchestration class can
focus on dependency ordering, abort handling, and logging while the
batching, upsert-strategy, and error-wrapping mechanics live here in
their own file under the 250-line soft limit.
The split is purely organisational: callers do not import
`executeSeed` directly — the public entry point is `SeedRunner.run`,
which composes this function with the registry, topological sort,
logger, and abort signal. The function is exported as a named symbol
from the same `runner/` folder so tests can target either layer
independently when that helps with isolation.
## Features
- Resolves `SeedDefinition.data` (array or async factory) into a
concrete record array.
- Honours `dryRun` — returns a synthesised `completed` result with the
resolved record count and zero IO against the store.
- Calls `store.truncate(seed.table, signal)` first when
`seed.truncate` is true.
- Batches the records by `config.batchSize ?? DEFAULT_BATCH_SIZE`.
- Applies the requested upsert strategy per batch:
- `skip` with non-empty `upsertKeys`: filters out records whose key
tuple already exists in the store.
- `update` with non-empty `upsertKeys`: inserts absent records and
updates the rest in two store calls.
- `replace` or empty `upsertKeys`: inserts every record directly.
- Wraps every store exception as
`SeedError({ code: SEED.STORE_ERROR, cause })` so the runner
surfaces a stable code while preserving the original via the
cause-chain.
- Threads `AbortSignal` from the runner into every store call.
## Usage: Basic
```ts
import { executeSeed } from "./execute.js";
import { createInMemoryStore } from "../fixtures/fixtures.js";
const store = createInMemoryStore();
const result = await executeSeed(
store,
{ name: "users", table: "u", data: [{ id: 1 }] },
{ environment: "dev" },
);
console.log(result.status, result.recordsInserted);
```
## Usage: Advanced
```ts
import { executeSeed } from "./execute.js";
const controller = new AbortController();
const result = await executeSeed(
productionStore,
{
name: "users",
table: "user",
data: async () => fetchFromCsv("/users.csv"),
upsertStrategy: "update",
upsertKeys: ["tenantId", "email"],
truncate: false,
},
{
environment: "production",
batchSize: 500,
signal: controller.signal,
},
);
if (result.status === "failed") {
console.error("seed", result.name, "failed:", result.error);
}
```
## API Reference
### `async function executeSeed(store, seed, config): Promise<SeedResult>`
Parameters:
| Name | Type | Description |
| -------- | ---------------- | ------------------------------------------------- |
| `store` | `SeedStore` | Adapter the function calls into. |
| `seed` | `SeedDefinition` | The seed to apply. |
| `config` | `SeedRunConfig` | Run-scoped options (batch size, dry-run, signal). |
Returns `Promise<SeedResult>` — always resolves; failures are captured
on the result rather than thrown. The result's `status` is
`"completed"` on success and `"failed"` when the store rejects;
`recordsInserted` / `recordsUpdated` / `recordsSkipped` reflect the
partial progress at the point of failure.
## Implementation Notes
A single private `processBatch` helper handles the three upsert
strategies so the main `try/catch` block stays short. Empty batches
take the explicit `: 0` ternary branch on both the `insert` and
`update` calls — that branch fires when every record in the batch
already exists (skip strategy) or when there are no inserts or updates
to perform (update strategy with all-existing or all-fresh inputs).
The `buildKey` helper collapses missing or `null` key-field values via
`record[k] ?? ""`. Records sharing the same key tuple — including
those whose key fields are all absent — are deduplicated by the
strategy, which matches the upstream `@suaveplan/server/seed`
behaviour exactly. Adapters that need strict null handling should
provide their own `findExisting` semantics rather than rely on the
runner's key collapse.
Errors that are already `instanceof SeedError` pass through
untouched — useful when downstream code already wraps its failures
into the `SEED.*` code space. Anything else is wrapped with
`SEED.STORE_ERROR` and the original captured on `cause`; the
human-readable message is stored on `SeedResult.error`.

View File

@@ -0,0 +1,215 @@
# runner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `SeedRunner` orchestrates a single seeding run against an injected
`SeedStore`. It is the public entry point for actually applying a set of
declarative `SeedDefinition` objects: it picks the seeds that apply to
the requested environment, filters them by the caller's `only` /
`exclude` lists, topologically sorts the survivors, and executes each in
turn with the configured upsert strategy, batching, dry-run, and
cancellation behaviour.
The runner is intentionally store-agnostic. It does not know about
SurrealDB, Postgres, or any particular driver — it just calls the
`SeedStore` interface declared in `types/common.ts`. That keeps the core
universal (no `node:` / `bun:` imports) and lets the same runner
drive in-memory tests, local containers, and production databases
without rewriting the orchestration layer.
## Features
- **Dependency-ordered execution** via Kahn's-algorithm topological sort
(uses the sibling `utils/topo-sort` module).
- **Filtering**: `only` includes a whitelist; `exclude` removes from the
result; the two compose (`only` first, then `exclude`).
- **Three upsert strategies** (`skip`, `update`, `replace`) honoured at
the batch level — the runner delegates to
`SeedStore.findExisting`, `SeedStore.insert`, and `SeedStore.update`
according to the strategy and the presence of `upsertKeys`.
- **Batching** by `SeedRunConfig.batchSize`, defaulting to
`DEFAULT_BATCH_SIZE` (100). Records are passed to the store in
`Array.slice`-derived chunks; the store reports the actual insert /
update counts.
- **Dry-run mode** synthesises `completed` results without invoking the
store — useful for CI smoke tests and for validating environment
filters.
- **Dependency-aware skipping**: when a seed fails, every dependent in
the topo-order is recorded with `status: "skipped"` and
`error: "Dependency failed"` rather than attempted.
- **`AbortSignal` plumbing**: the signal is checked between seeds and
forwarded into every `SeedStore` call. Aborted runs return
`success: false` and mark every remaining seed `skipped` with
`error: "Aborted"`.
- **Injectable `LoggerLike`** (`@suaveplan/types`) with a no-op default.
Emits structured records at `debug` / `info` / `warn` / `error`
levels for every observable transition.
- **Store-error wrapping**: exceptions thrown by `SeedStore` methods are
wrapped as `SeedError` with code `SEED.STORE_ERROR` and the original
exception attached on `error.cause`. `SeedResult.error` carries the
human-readable message.
## Usage: Basic
```ts
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
const registry = new SeedRegistry();
registry.register(
defineSeed("users")
.data([{ id: 1, name: "alice" }])
.upsert("skip", ["id"])
.build()
);
const store = createInMemoryStore();
const runner = new SeedRunner(registry, store);
const result = await runner.run({ environment: "dev" });
console.log(result.success, result.seedsRun);
```
## Usage: Advanced
```ts
import {
createInMemoryStore,
defineSeed,
SeedRegistry,
SeedRunner,
} from "@suaveplan/seed";
import type { LoggerLike } from "@suaveplan/types";
const registry = new SeedRegistry();
registry.registerMany([
defineSeed("tenants")
.environments("staging", "production")
.data(async () => fetchTenantsFromCsv())
.upsert("update", ["slug"])
.priority(0)
.build(),
defineSeed("admin-users")
.dependsOn("tenants")
.data(adminUsers)
.upsert("update", ["tenantId", "email"])
.priority(10)
.build(),
defineSeed("audit-buckets")
.dependsOn("tenants")
.truncateFirst()
.data([])
.priority(20)
.build(),
]);
const logger: LoggerLike = createStructuredLogger();
const runner = new SeedRunner(registry, productionStore, logger);
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const result = await runner.run({
environment: "staging",
only: ["tenants", "admin-users"],
batchSize: 500,
continueOnError: false,
signal: controller.signal,
onProgress: (r) => logger.info("seed.progress", {
name: r.name,
status: r.status,
duration: r.duration,
}),
});
if (!result.success) {
throw new Error(
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
);
}
```
## API Reference
### `class SeedRunner implements ISeedRunner`
```ts
constructor(
registry: SeedRegistry,
store: SeedStore,
logger?: LoggerLike,
)
run(config: SeedRunConfig): Promise<SeedRunResult>
```
### `SeedRunConfig`
| Field | Type | Default |
| ----------------- | ----------------------------------- | ---------------------- |
| `environment` | `SeedEnvironment` | (required) |
| `only?` | `readonly string[]` | undefined (all seeds) |
| `exclude?` | `readonly string[]` | undefined |
| `dryRun?` | `boolean` | `false` |
| `batchSize?` | `number` | `DEFAULT_BATCH_SIZE` |
| `continueOnError?`| `boolean` | `false` |
| `onProgress?` | `(r: SeedResult) => void` | undefined |
| `signal?` | `AbortSignal` | undefined |
### `SeedRunResult`
| Field | Type | Meaning |
| ---------------- | ------------------- | -------------------------------------------------- |
| `environment` | `SeedEnvironment` | The environment the run was scoped to |
| `totalDuration` | `number` | Milliseconds via `performance.now()` |
| `results` | `readonly SeedResult[]` | One entry per attempted (or skipped) seed |
| `success` | `boolean` | True iff zero failures AND not aborted |
| `seedsRun` | `number` | Results with status other than `"skipped"` |
| `seedsSkipped` | `number` | Results with status `"skipped"` |
| `seedsFailed` | `number` | Results with status `"failed"` |
### Logger events
| Event | Level | Fields |
| ------------------ | ------- | --------------------------------------------------------------------------------------- |
| `seed.run.preparing` | debug | `environment`, `filteredCount`, `orderedCount` |
| `seed.run.start` | info | `environment`, `totalSeeds` |
| `seed.completed` | info | `name`, `table`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration` |
| `seed.skipped` | warn | `name`, `table`, `reason` (`"Dependency failed"` \| `"Aborted"`) |
| `seed.failed` | error | `name`, `table`, `error`, `duration` |
| `seed.run.complete` | info | `environment`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`, `totalDuration` |
## Implementation Notes
The actual batched insert/update/findExisting work lives in a sibling
`execute.ts` module so this file stays under the 250-line soft limit.
The split is purely organisational: `executeSeed(store, seed, config)`
is invoked from `SeedRunner.run` and returns a single `SeedResult` per
seed. Errors thrown by the store inside `executeSeed` are wrapped as
`SeedError({ code: SEED.STORE_ERROR, cause })`; the runner records the
message string on `SeedResult.error` while the structured fields
(`code`, `seedName`, `cause`) remain reachable to consumers that catch
the wrapped error from higher up.
`SeedRunner` keeps the abort-on-signal check at the **top** of the
execution loop so a signal raised between seeds short-circuits before
the next seed's data factory is even invoked. `AbortSignal` is also
forwarded into every `SeedStore` method via the optional `signal?`
parameter on the interface, letting network-bound adapters honour
cancellation mid-batch. The `InMemorySeedStore` shipped in
`fixtures/fixtures.ts` accepts but ignores the signal — it has nothing
to cancel.
Defensive `if (!seed) continue;` checks against `seedMap.get(name)`
were removed: `order` and `seedMap` are both built from the same
`filtered` array, so every name `topoSort` returns is guaranteed to
resolve. The TypeScript assertion (`as SeedDefinition`) records that
invariant without the false-positive coverage gap a runtime check would
introduce.

View File

@@ -0,0 +1,151 @@
# types/common
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared type module for `@suaveplan/seed`. Every public class and function
in the package — `SeedBuilder`, `SeedRegistry`, `SeedRunner`,
`FixtureFactory`, `InMemorySeedStore`, `topoSort`, `detectCycles` —
references the type contracts declared here. Defining them in one
location keeps the public surface coherent, lets consumers pull a single
import path when they want to type their own `SeedStore` adapters, and
ensures cross-module type-narrowing works without circular imports.
The types are all data-shapes (`SeedDefinition`, `SeedRecord`,
`SeedResult`, `SeedRunResult`, `SeedRunConfig`, `SeedStore`) plus their
supporting unions (`SeedEnvironment`, `SeedStatus`, `UpsertStrategy`,
`SeedDataFactory`). They are intentionally framework-free — no Zod
schemas, no runtime validators — because the package's developer-facing
API is constructed in-process, not received over a wire.
## Features
- `SeedDefinition` and `SeedRecord` shapes
- `SeedResult` / `SeedRunResult` shapes returned by the runner
- `SeedRunConfig` shape consumed by `SeedRunner.run`
- `SeedStore` adapter contract (`insert` / `update` / `findExisting` / `truncate` / `tableExists`) with optional `AbortSignal` plumbing
- `SeedEnvironment` open string-literal union accepting custom project environments
- `SeedStatus` and `UpsertStrategy` enums-as-unions
- `SeedDataFactory` async-or-sync data producer signature
- `readonly` on every collection so consumers cannot mutate definitions in-flight
## Usage: Basic
```ts
import type { SeedDefinition, SeedStore } from "@suaveplan/seed";
const usersSeed: SeedDefinition = {
name: "users",
table: "user",
data: [{ id: 1, name: "alice" }],
upsertStrategy: "skip",
upsertKeys: ["id"],
};
class MyStore implements SeedStore {
async insert(table, records) {
// ...
return records.length;
}
async update() { return 0; }
async findExisting() { return []; }
async truncate() {}
async tableExists() { return true; }
}
```
## Usage: Advanced
```ts
import type {
SeedDataFactory,
SeedDefinition,
SeedEnvironment,
SeedRunConfig,
SeedRunResult,
SeedStore,
} from "@suaveplan/seed";
const tenantsFactory: SeedDataFactory = async () => {
const csv = await fetch("/tenants.csv").then((r) => r.text());
return parseTenants(csv);
};
const def: SeedDefinition = {
name: "tenants",
table: "tenant",
environments: ["staging", "production"],
dependencies: [],
data: tenantsFactory,
upsertStrategy: "update",
upsertKeys: ["slug"],
priority: 0,
};
async function runFor(
env: SeedEnvironment,
store: SeedStore,
signal: AbortSignal,
): Promise<SeedRunResult> {
const config: SeedRunConfig = {
environment: env,
batchSize: 500,
continueOnError: false,
signal,
onProgress: (r) => console.log(r.name, r.status, r.duration),
};
return runner.run(config);
}
```
## API Reference
### Types
- `SeedEnvironment` — string-literal union with `"dev" | "staging" | "test" | "production"` plus any custom string
- `SeedStatus` — `"pending" | "running" | "completed" | "failed" | "skipped"`
- `SeedRecord` — `Record<string, unknown>`
- `SeedDataFactory` — `() => SeedRecord[] | Promise<SeedRecord[]>`
- `UpsertStrategy` — `"skip" | "update" | "replace"`
### Interfaces
- `SeedDefinition` — every field declarative on a seed (`name`, `table`, `environments?`, `dependencies?`, `data`, `upsertStrategy?`, `upsertKeys?`, `description?`, `priority?`, `truncate?`)
- `SeedResult` — single-seed outcome (`name`, `table`, `status`, counts, `duration`, `error?`)
- `SeedRunResult` — aggregate outcome of one `run()` call
- `SeedStore` — adapter contract (`insert`, `update`, `findExisting`, `truncate`, `tableExists`) with optional `signal` parameter on every method
- `SeedRunConfig` — input to `SeedRunner.run` (`environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`)
## Implementation Notes
`SeedEnvironment` uses the `(string & {})` trick — a no-op intersection
that preserves IDE autocomplete for the well-known values while still
accepting arbitrary strings. This is the canonical TypeScript idiom for
"open enum" string unions and matches the pattern used elsewhere in the
project (e.g. `LocaleCode`, `MimeType`).
Every array field on `SeedDefinition` and `SeedRunConfig` is `readonly`,
so callers (or the registry's defensive copies) cannot mutate the
definitions mid-run. `SeedStore` methods accept `readonly SeedRecord[]`
inputs, freeing implementations from copying defensively while still
allowing them to enumerate.
`SeedDataFactory` returns either a sync `SeedRecord[]` or a
`Promise<SeedRecord[]>`. The runner always `await`s the result so callers
can treat both forms identically; in practice the sync form covers
inline test fixtures and the async form covers loading CSVs, fetching
from remote APIs, or generating records from `FixtureFactory.createMany`.
`SeedStore.insert` returns the number of records actually inserted, not
the count the caller passed in — adapters MAY filter (e.g. via
`ON CONFLICT DO NOTHING`) and the runner trusts the returned count when
populating `SeedResult.recordsInserted`.
The optional `signal?: AbortSignal` parameter on every `SeedStore` method
lets network-bound stores honour cancellation; the `InMemorySeedStore`
ignores it. The runner propagates `SeedRunConfig.signal` to every store
call but ALSO performs its own check between seeds so a cancelled run
shuts down even if the store ignores the signal.

View File

@@ -0,0 +1,109 @@
# topo-sort
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where every dependency precedes its dependents. Without it, the runner could not enforce the contract that, for example, `categories` is populated before `products` references it. The module exposes two functions: `topoSort`, which produces a single linear execution order (or throws when the graph is broken), and `detectCycles`, a non-throwing diagnostic counterpart that returns explicit cycle traces for tooling and error reporting. Both functions are pure and operate over structurally-typed inputs, so they work equally well on `SeedDefinition` arrays and on lightweight test fixtures.
## Features
- Single-pass [Kahn's algorithm](https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm) topological sort.
- Stable priority tiebreak: among independent seeds, lower `priority` runs first (default `0`).
- Stable, `SeedError`-typed failure modes:
- `SEED.UNREGISTERED_DEPENDENCY` when a `dependencies[]` entry names a seed that is not present in the input.
- `SEED.CIRCULAR_DEPENDENCY` when the graph contains a cycle (detected via residual unprocessed nodes after the queue drains).
- `SEED.DEPENDENCY_DEPTH_EXCEEDED` defensive guard against pathological iteration counts.
- DFS-based `detectCycles` that returns an array of cycle traces and never throws — ideal for surfacing diagnostics in test setup or CI pre-flight checks.
- Universal: zero `node:` / `bun:` imports, runs in browsers, Bun, Node, workers, and Deno.
- Accepts `readonly` arrays and `readonly string[]` dependency lists for cheap interop with frozen domain types.
## Usage: Basic
```ts
import { topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
const order = topoSort([
{ name: "products", dependencies: ["categories"] },
{ name: "categories" },
{ name: "reviews", dependencies: ["products", "users"] },
{ name: "users" },
]);
// → ["categories", "users", "products", "reviews"] (priority ties broken stably)
```
## Usage: Advanced
```ts
import { SeedError, SEED_CODES } from "@suaveplan/seed";
import { detectCycles, topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
const seeds = [
{ name: "a", dependencies: ["b"], priority: 0 },
{ name: "b", dependencies: ["a"], priority: 0 },
];
// Pre-flight: surface every cycle as an explicit trace.
const cycles = detectCycles(seeds);
if (cycles.length > 0) {
for (const trace of cycles) {
console.error(`cycle: ${trace}`);
}
}
// Execution path throws a typed error.
try {
topoSort(seeds);
} catch (err) {
if (err instanceof SeedError && err.code === SEED_CODES.CIRCULAR_DEPENDENCY) {
// remediate
}
}
```
## API Reference
### `topoSort(seeds): string[]`
```ts
function topoSort(
seeds: readonly {
readonly name: string;
readonly dependencies?: readonly string[];
readonly priority?: number;
}[]
): string[];
```
Returns a fresh array of seed names in execution order. Throws `SeedError` (with one of `SEED.UNREGISTERED_DEPENDENCY`, `SEED.CIRCULAR_DEPENDENCY`, `SEED.DEPENDENCY_DEPTH_EXCEEDED`) on any structural problem.
### `detectCycles(seeds): string[]`
```ts
function detectCycles(
seeds: readonly {
readonly name: string;
readonly dependencies?: readonly string[];
}[]
): string[];
```
Returns an array of cycle-trace strings shaped like `"a -> b -> a"`. Returns `[]` for an empty input or any DAG. Never throws. Dependencies whose names are not present in the graph are silently skipped — they are reported by `topoSort` as `UNREGISTERED_DEPENDENCY` instead.
## Implementation Notes
`topoSort` performs exactly one Kahn's-algorithm pass:
1. Build three maps in a single loop: `graph` (name → deps), `inDegree` (name → 0), and `priority` (name → priority).
2. Validate every dependency edge: when a name in `dependencies` is absent from `graph`, throw `UNREGISTERED_DEPENDENCY`; otherwise increment the dependent's in-degree.
3. Seed the queue with every name whose `inDegree === 0`, sorted by ascending priority.
4. Drain the queue: pop, append to `result`, decrement the in-degree of every node that depends on the popped name. When a dependent's in-degree falls to zero, enqueue it and re-sort by priority. Stable priority ordering is preserved by re-sorting on insertion rather than maintaining a binary heap — for the small graphs typical of seed sets, the simpler approach is faster and more legible.
5. After the loop, if `result.length !== graph.size`, identify the unprocessed names and throw `CIRCULAR_DEPENDENCY` with the offending names embedded in the message.
The `DEPENDENCY_DEPTH_EXCEEDED` guard is structurally unreachable from valid input because Kahn's algorithm enqueues each node at most once. It remains in place as belt-and-braces against future refactors that might violate that invariant — the corresponding line is excluded from coverage with `/* c8 ignore */` plus a justifying comment.
`detectCycles` uses iterative DFS with two sets: `visited` (fully explored) and `stack` (current recursion path). When DFS encounters a node already on `stack`, it slices the path from that node forward, appends the cycle-closing edge, and joins with `" -> "`. Dependencies that name non-existent nodes are skipped so external references (e.g., seeds defined outside the current registry slice) do not surface as false-positive cycles.
Both functions are pure: they allocate no shared state, do not mutate their inputs, and produce deterministic output for any given input. Priority is the only tiebreak — insertion order is not preserved beyond what priority encodes, which is intentional so callers cannot accidentally depend on registration order.

View File

@@ -0,0 +1,105 @@
# @suaveplan/surrealdb - Universal SurrealDB client wrapper implementing DatabaseAdapter: connection lifecycle, singleton registry, typed CRUD, schema discovery, initialisation, version detection, OTel instrumentation, retry integration, and structured SURREAL.* error codes.
## Summary
Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, schema discovery, database initialisation, version detection, structured `SURREAL.*` error codes, and OTel instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ - [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — `crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `Su…
- [crud](./src/adapter/crud/crud.md) — `crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `Su…
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — `query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its…
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — `query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its…
- [transaction](./src/adapter/transaction/transaction.md) — `transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TR…
- [transaction](./src/adapter/transaction/transaction.md) — `transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TR…
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_## Packages
| Module | Description |
|---|---|
| [errors/error](src/errors/error.md) | `SurrealError` class and `SURREAL_CODES` registry |
| [types/types](src/types/types.md) | Shared TypeScript types and the `SurrealClientLike` injection interface |
| [schemas/schemas](src/schemas/schemas.md) | Zod schemas for all public option types |
| [connection/connection](src/connection/connection.md) | Connection lifecycle and singleton registry |
| [adapter/adapter](src/adapter/adapter/adapter.md) | `SurrealAdapter` implementing `DatabaseAdapter` |
| [adapter/crud](src/adapter/crud/crud.md) | Typed record-level CRUD primitives |
| [adapter/transaction](src/adapter/transaction/transaction.md) | `BEGIN`/`COMMIT`/`CANCEL TRANSACTION` orchestration |
| [adapter/query-helpers](src/adapter/query-helpers/query-helpers.md) | Positional-param conversion, error wrapping, result flattening |
| [schema/discovery](src/schema/discovery.md) | Schema file discovery, sorting, and reading |
| [init/init](src/init/init.md) | Database initialisation from schema files |
| [utils/version-detector](src/utils/version-detector.md) | SurrealDB version parsing and detection |
| [singleton/singleton](src/singleton/singleton.md) | Adapter-level singleton registry |
## Quick Start
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
const adapter = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "prod",
database: "app",
username: "root",
password: "root",
});
await adapter.connect();
const user = await adapter.create("user", { name: "Alice", email: "alice@example.com" });
console.log(user.id); // "user:alice..."
await adapter.disconnect();
```
## Integration tests
The package ships a parallel test suite under `tests/integration/` that exercises the adapter against a real SurrealDB v3.0.4 container via `@suaveplan/testing-integration-surrealdb`. The unit suite (`src/**/*.test.ts`) runs against a dependency-injected fake; the integration suite proves the same code paths work against the live engine.
Run the integration suite:
```bash
bunx turbo test:integration --filter=@suaveplan/surrealdb
# or, from the package directory:
bun run test:integration
```
Requirements:
- Docker available locally; the fixture pulls `surrealdb/surrealdb:v3.0.4` on first run.
- `@suaveplan/testing-integration` and `@suaveplan/testing-integration-surrealdb` resolve as devDependencies (already wired).
- The integration config points at `vitest.integration.config.ts` (separate from the unit `vitest.config.ts`) and only matches `tests/integration/**/*.integration.test.ts`.
What the suite covers:
- `connect → use → signin → query → close` round-trip and parameter passthrough.
- DDL via `execute("DEFINE TABLE …")` followed by `INFO FOR DB` to confirm the schema took.
- `transaction()` commit-on-resolve / rollback-on-throw semantics.
- Lifecycle: `getSingleton` deduplication and `closeAllSingletons` ordering.
- Connection failure surfaces `DB.CONNECTION_FAILED` with the documented `ConnectionRefused` / `Unable to connect` patterns.
- Version detection against the pinned `v3.0.4` image.
- The `INSERT INTO` workaround for the `db.create()` content-drop bug.
- Schema discovery returns the expected table/field shapes.
If Docker is unavailable, every integration test is skipped via `skipIfUnavailable("surrealdb", inject)` so CI does not falsely fail when the runner has no Docker daemon.

View File

@@ -0,0 +1,108 @@
# adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SurrealAdapter` is the central class of `@suaveplan/surrealdb`. It implements `@suaveplan/db`'s `DatabaseAdapter` interface, providing a stable, typed surface over the raw `surrealdb.js` driver. The adapter handles connection lifecycle, typed CRUD operations, transactions, and query execution — all with structured `SURREAL.*` error codes and optional OTel instrumentation.
A critical design decision is that all record creation uses `INSERT INTO <table> CONTENT $data` rather than the driver's `db.create()` method, which has a known bug in surrealdb.js v2+ where the content field is silently dropped.
## Features
- Implements the full `DatabaseAdapter` interface from `@suaveplan/db`
- Injectable `SurrealClientFactory` for dependency-injection-based testing without a live server
- Typed CRUD: `create`, `select`, `update`, `delete`, `selectAll`
- `transaction()` wrapping `BEGIN`/`COMMIT`/`CANCEL TRANSACTION` blocks
- `execute()` for non-query DDL statements
- `connect()` / `disconnect()` lifecycle with idempotent reconnect guard
- AbortSignal propagation on all async operations
- Optional `LoggerLike` for structured logging
- `migrate()` stub delegating to `@suaveplan/migration`
## Usage: Basic Example
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb/adapter/adapter";
const adapter = new SurrealAdapter({ url: "ws://localhost:8000", namespace: "app", database: "main", username: "root", password: "root" });
await adapter.connect();
const users = await adapter.selectAll("user");
await adapter.disconnect();
```
## Usage: Advanced Example
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb/adapter/adapter";
import type { SurrealClientLike } from "@suaveplan/surrealdb/types/types";
// Inject a fake for unit testing
class FakeClient implements SurrealClientLike { /* ... */ }
const adapter = new SurrealAdapter({}, undefined, () => new FakeClient());
await adapter.connect();
const user = await adapter.create("user", { name: "Alice", email: "alice@example.com" });
const found = await adapter.select<typeof user>("user", "alice");
await adapter.update("user", "alice", { name: "Alice Smith" });
await adapter.delete("user", "alice");
```
## API Reference
### `constructor(options?, logger?, clientFactory?)`
- `options.url` — defaults to `ws://localhost:8000`
- `options.namespace`, `options.database` — defaults to `"test"`
- `options.username`, `options.password` — defaults to `"root"`
- `clientFactory` — optional `() => SurrealClientLike`; defaults to `new Surreal()`
### `connect(config?, opts?): Promise<void>`
Idempotent. Throws `SurrealError(CONNECTION_FAILED)` or `SurrealError(AUTH_FAILED)`.
### `disconnect(opts?): Promise<void>` / `close(): Promise<void>`
Both names are supported. Sets state to `disconnected`.
### `query<T>(sql, params?, opts?): Promise<T[]>`
Executes a SurrealQL statement. Params are positional (`p0`, `p1`, ...).
### `execute(sql, params?, opts?): Promise<{ rowsAffected: number }>`
Executes a non-query statement. Returns `{ rowsAffected: 0 }` (SurrealDB does not report affected rows).
### `transaction<T>(fn, opts?): Promise<T>`
Runs `fn(tx)` inside `BEGIN`/`COMMIT`; cancels on throw.
### `create<T>(table, data, opts?): Promise<T & { id: string }>`
Uses `INSERT INTO <table> CONTENT $data` to avoid the surrealdb.js v2 content-drop bug.
### `select<T>(table, id, opts?): Promise<T | null>`
Fetches by `type::thing($table, $id)`. Returns `null` if not found.
### `update<T>(table, id, data, opts?): Promise<T | null>`
Merges `data` into the existing record.
### `delete(table, id, opts?): Promise<void>`
Deletes by `type::thing($table, $id)`.
### `selectAll<T>(table, opts?): Promise<T[]>`
Returns all records in `table`.
## Implementation Notes
The `require("surrealdb")` call in the default factory is wrapped in a closure to delay the import until `connect()` is called, keeping the module tree-shakeable in environments that inject a client factory.
Record-level CRUD (`create`/`select`/`update`/`delete`/`selectAll`) is implemented in `../crud/crud.js`; transaction orchestration in `../transaction/transaction.js`; positional-param conversion, error wrapping, and result-shape flattening in `../query-helpers/query-helpers.js`. This class is the lifecycle owner — it tracks the client + state and delegates execution to those modules so each can be tested in isolation. The `_requireClient()` helper centralises the `NOT_CONNECTED` guard and returns the non-null client so call sites get type narrowing for free.
The `paramsToRecord` helper from `../query-helpers/query-helpers.js` converts positional params to named `$p0`, `$p1`, ... bindings as SurrealQL uses named parameters.

View File

@@ -0,0 +1,76 @@
# crud
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `SurrealAdapter`. Splitting them into a standalone module keeps `adapter.ts` under the 300-line hard limit and lets advanced consumers reach for the helpers directly when they already hold a `SurrealClientLike` reference.
Each helper is a plain async function that takes the client as its first positional argument — it never reads adapter state — which makes the CRUD layer trivially testable with a real fake client and free of hidden lifecycle coupling.
## Features
- `surrealCreate` — `INSERT INTO <table> CONTENT $data` (works around the surrealdb.js v2+ create bug)
- `surrealSelect` / `surrealUpdate` / `surrealDelete` — record-id-scoped operations using `type::thing($table, $id)`
- `surrealSelectAll` — full-table read
- Driver errors are normalised through `wrapQueryError` so every failure surfaces as a `SurrealError` with a stable code
- `AbortSignal` is honoured at the start of every call via `signal.throwIfAborted()`
## Usage: Basic Example
```ts
import { surrealCreate, surrealSelect } from "@suaveplan/surrealdb/adapter/crud";
const created = await surrealCreate(client, "user", { name: "Alice" });
const fetched = await surrealSelect<{ name: string }>(client, "user", "alice");
```
## Usage: Advanced Example
```ts
import {
surrealSelectAll,
surrealUpdate,
surrealDelete,
} from "@suaveplan/surrealdb/adapter/crud";
const all = await surrealSelectAll<{ id: string; archived: boolean }>(client, "doc");
for (const doc of all) {
if (!doc.archived) continue;
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await surrealUpdate(client, "doc", doc.id, { archived: false }, { signal: ac.signal });
await surrealDelete(client, "tombstone", doc.id, { signal: ac.signal });
}
```
## API Reference
### `surrealCreate<T>(client, table, data, opts?): Promise<T & { id: string }>`
Throws `SurrealError(QUERY_FAILED)` when the driver returns zero rows or any other error.
### `surrealSelect<T>(client, table, id, opts?): Promise<T | null>`
Returns `null` when no row matches. Throws `SurrealError(QUERY_FAILED)` on driver errors.
### `surrealUpdate<T>(client, table, id, data, opts?): Promise<T | null>`
Returns `null` when no row matched. Throws `SurrealError(QUERY_FAILED)` on driver errors.
### `surrealDelete(client, table, id, opts?): Promise<void>`
Throws `SurrealError(QUERY_FAILED)` on driver errors.
### `surrealSelectAll<T>(client, table, opts?): Promise<T[]>`
Returns the row array (possibly empty). Throws `SurrealError(QUERY_FAILED)` on driver errors.
## Implementation Notes
- Each helper accepts a `SurrealClientLike` rather than the full adapter so consumers can reuse them with custom client wrappers, mocks, or in transactional bodies that already hold the client.
- `AbortSignal` is consulted only at the entry point; the underlying driver does not currently accept a per-call signal, so once the `await client.query(...)` is in flight cancellation cannot interrupt it. Callers needing harder cancellation should race the call against a timeout external to the helper.
- All error paths unify on `wrapQueryError`, except `surrealCreate` which can throw a code-specific `SurrealError(QUERY_FAILED)` when the row array is empty — that case is distinguishable from a thrown driver error because it carries the exact `INSERT INTO <table> returned no rows` message.
- The `flattenRows` helper from `query-helpers` shields callers from the surrealdb.js v2 `[[...]]` result-wrapping detail.

View File

@@ -0,0 +1,79 @@
# query-helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its sibling CRUD/transaction modules: positional-parameter conversion, error normalisation, and result-shape flattening.
Centralising these helpers keeps `adapter.ts` focused on the `DatabaseAdapter` lifecycle (connect/disconnect/state) and lets the CRUD and transaction modules each consume the same battle-tested implementations without duplication.
## Features
- `paramsToRecord(params?)` — turn `readonly unknown[]` into `{ p0, p1, ... }` (or `undefined` for empty input)
- `wrapQueryError(err, sql)` — coerce arbitrary thrown values into structured `SurrealError(QUERY_FAILED)` with cause preservation
- `flattenRows<T>(results)` — flatten the surrealdb.js `[[row, ...]]` nested shape into `T[]`
- All helpers are pure, allocation-free on the empty-input fast paths, and free of any client/IO coupling
## Usage: Basic Example
```ts
import {
paramsToRecord,
wrapQueryError,
flattenRows,
} from "@suaveplan/surrealdb/adapter/query-helpers";
const vars = paramsToRecord(["alice", 42]);
// vars === { p0: "alice", p1: 42 }
try {
// ...driver call...
} catch (err) {
throw wrapQueryError(err, "SELECT * FROM user");
}
```
## Usage: Advanced Example
```ts
import {
flattenRows,
wrapQueryError,
} from "@suaveplan/surrealdb/adapter/query-helpers";
async function safeQuery<T>(
client: { query<U>(sql: string): Promise<U[]> },
sql: string
): Promise<T[]> {
try {
const results = await client.query<T>(sql);
return flattenRows<T>(results);
} catch (err) {
throw wrapQueryError(err, sql);
}
}
```
## API Reference
### `paramsToRecord(params?: readonly unknown[]): Record<string, unknown> | undefined`
Returns `undefined` when `params` is missing or empty. Otherwise returns a fresh record keyed `p0`, `p1`, ... in order.
### `wrapQueryError(err: unknown, sql: string): SurrealError`
Returns `err` unchanged when it is already a `SurrealError`. Otherwise wraps it as `SurrealError(SURREAL.QUERY_FAILED, "Query failed [<sql>]: <message>", { cause })` where `cause` is preserved only for `Error` instances.
### `flattenRows<T>(results: unknown): T[]`
If `results` is `[[...]]` returns the inner array. If `results` is `[T, ...]` returns it as-is. For empty or non-array inputs returns `[]`.
## Implementation Notes
- `paramsToRecord` allocates a fresh object on every call to keep callers safe from accidental mutation; the empty-input branch avoids the allocation entirely.
- `wrapQueryError` mirrors the original behaviour from `SurrealAdapter._wrapQueryError` — it must NOT swallow `SurrealError` subclasses, since callers rely on the code discriminator surviving the `await` boundary.
- `flattenRows` accepts `unknown` so call-sites can pass the raw driver return value without an upfront cast; the structural test exhausts both observed shapes (single-statement and multi-statement query responses).
- These functions are deliberately leaf utilities: they import only `../../errors/index.js`, never the driver or adapter. That keeps the dependency graph acyclic when the CRUD and transaction modules depend on them.

View File

@@ -0,0 +1,76 @@
# transaction
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TRANSACTION` / `COMMIT TRANSACTION` envelope, cancelling the transaction on failure. It is extracted from `adapter.ts` so the adapter stays under the 300-line hard limit while the transaction machinery remains independently testable.
The function exposed here, `runSurrealTransaction`, never reads `SurrealAdapter` state directly — it takes the `SurrealClientLike` as a positional argument — which makes it reusable in any other surrealdb.js-backed code that already holds a client.
## Features
- `BEGIN TRANSACTION` / `COMMIT TRANSACTION` happy path
- `CANCEL TRANSACTION` rollback on caller-thrown errors with the rollback errors deliberately swallowed
- Surfaces a `DatabaseLike` to the caller callback so it can call `query`, `execute`, and even nested `transaction` (which flattens onto the same handle)
- `SurrealError` instances thrown inside `fn` pass through verbatim
- Other thrown values are normalised as `SurrealError(SURREAL.TRANSACTION_FAILED)` with the original preserved as `cause`
- Honours `AbortSignal` at entry
## Usage: Basic Example
```ts
import { runSurrealTransaction } from "@suaveplan/surrealdb/adapter/transaction";
await runSurrealTransaction(client, async (tx) => {
await tx.query("CREATE user CONTENT { name: 'Alice' };");
await tx.execute("UPDATE counter SET value += 1;");
});
```
## Usage: Advanced Example
```ts
import { runSurrealTransaction } from "@suaveplan/surrealdb/adapter/transaction";
import { SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb";
try {
const result = await runSurrealTransaction(
client,
async (tx) => {
const rows = await tx.query<{ id: string; ok: boolean }>(
"SELECT * FROM staging;"
);
if (rows.some((r) => !r.ok)) {
throw new Error("rollback");
}
return rows.length;
},
{ signal: AbortSignal.timeout(5000) }
);
console.log("committed", result);
} catch (err) {
if (err instanceof SurrealError && err.code === SURREAL_CODES.TRANSACTION_FAILED) {
// ...
}
}
```
## API Reference
### `runSurrealTransaction<T>(client, fn, opts?): Promise<T>`
- `client` — a `SurrealClientLike`
- `fn` — receives a `DatabaseLike` and returns `Promise<T>`
- `opts?.signal` — optional `AbortSignal` checked once before `BEGIN TRANSACTION`
Throws `SurrealError(TRANSACTION_FAILED)` for any non-`SurrealError` thrown by the body or the surrounding driver calls; passes existing `SurrealError`s through unchanged.
## Implementation Notes
- The `CANCEL TRANSACTION` call is wrapped in `.catch(() => undefined)`. The original caller-thrown error must propagate even if the rollback itself fails — masking it would hide the root cause that triggered the rollback.
- Nested `transaction()` calls inside `fn` deliberately reuse the same `DatabaseLike` rather than nesting a real savepoint. SurrealDB does not support nested transactions; the contract on `@suaveplan/db.DatabaseLike` still requires the method to exist, so we make it a flat pass-through and the outer transaction guarantees atomicity for everything inside.
- `SurrealError` instances raised by `fn` bypass the `TRANSACTION_FAILED` wrapping so callers can switch on their own pre-existing error codes (`NOT_CONNECTED`, `RECORD_NOT_FOUND`, etc.).
- The function is intentionally free of OTel instrumentation here — `SurrealAdapter.transaction` is the caller and the spans that exist higher up cover the call.

View File

@@ -0,0 +1,84 @@
# connection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Manages the lifecycle of SurrealDB driver connections: creating new connections via a `SurrealClientFactory`, signing in, selecting namespace and database, and maintaining a module-level singleton registry. The singleton pattern is essential for long-lived server processes that should reuse a single authenticated connection across requests rather than reconnecting on every operation.
The factory injection point (`SurrealClientFactory`) decouples this module from the `surrealdb` npm package, allowing the connection logic to be tested end-to-end without a running SurrealDB instance.
## Features
- `getDefaultConfig()` — returns development-friendly defaults (localhost, root/root)
- `createConnection()` — connects, signs in, and selects namespace/database in one call
- `connectWith()` — returns a cached singleton or creates one
- `hasSurreal()` — inspects the singleton registry
- `closeSurreal()` — disconnects and removes a named singleton
- `closeAllSurreal()` — disconnects all registered singletons
- AbortSignal support for early cancellation
- Optional `LoggerLike` for structured connection lifecycle logs
## Usage: Basic Example
```ts
import { createConnection, getDefaultConfig } from "@suaveplan/surrealdb/connection/connection";
import { Surreal } from "surrealdb";
const db = await createConnection(getDefaultConfig(), () => new Surreal());
```
## Usage: Advanced Example
```ts
import {
closeSurreal,
connectWith,
hasSurreal,
} from "@suaveplan/surrealdb/connection/connection";
import { Surreal } from "surrealdb";
const KEY = "primary";
async function getDb() {
return connectWith(KEY, { url: "wss://db.prod.example.com", namespace: "app", database: "main", auth: { username: "app", password: process.env.DB_PASS! } }, () => new Surreal());
}
// Later, on graceful shutdown:
if (hasSurreal(KEY)) {
await closeSurreal(KEY);
}
```
## API Reference
### `getDefaultConfig(): SurrealDBConfig`
Returns `{ url: "ws://localhost:8000", namespace: "test", database: "test", auth: { username: "root", password: "root" } }`.
### `createConnection(config, factory, opts?): Promise<SurrealClientLike>`
Merges config with defaults, validates via Zod, connects, signs in, and selects namespace/database.
**Throws:** `SurrealError(CONNECTION_FAILED)` | `SurrealError(AUTH_FAILED)`.
### `connectWith(key, config, factory, opts?): Promise<SurrealClientLike>`
Returns cached singleton or creates via `createConnection`.
### `hasSurreal(key: string): boolean`
Returns `true` if a singleton is registered under `key`.
### `closeSurreal(key: string): Promise<void>`
Disconnects and removes the named singleton.
### `closeAllSurreal(): Promise<void>`
Disconnects all singletons. Aggregates errors.
## Implementation Notes
The module-level `singletonRegistry` Map is safe in Bun's single-threaded model. The `connectWith` function checks for an existing entry before calling `createConnection`, which prevents duplicate connections when called concurrently from different async call stacks in the same tick.

View File

@@ -0,0 +1,73 @@
# error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `SurrealError` class and the `SURREAL_CODES` registry for all structured errors emitted by `@suaveplan/surrealdb`. Every throw site in the package uses one of these stable codes so callers can distinguish failure modes without fragile string matching against error messages. Extending `SuaveplanError` ensures compatibility with cause-chain traversal, wire serialisation, and OTel span-event adapters provided by `@suaveplan/error`.
The `mapSurrealCode` helper normalises raw driver exceptions into the stable code set, enabling a consistent error surface even when the underlying `surrealdb` driver changes its error messages between releases.
## Features
- Ten stable `SURREAL.*` codes covering connection, authentication, query, transaction, timeout, version, schema, init, and record-not-found failure modes
- `SurrealError` extends `SuaveplanError<SurrealCode>` with a public constructor
- `mapSurrealCode(err)` heuristically maps raw errors by message substring to the closest stable code
## Usage: Basic Example
```ts
import { SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb/errors/error";
throw new SurrealError(SURREAL_CODES.NOT_CONNECTED, "adapter is not connected");
```
## Usage: Advanced Example
```ts
import { mapSurrealCode, SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb/errors/error";
try {
await db.query("SELECT * FROM missing");
} catch (raw) {
const code = mapSurrealCode(raw);
throw new SurrealError(code, `query failed: ${raw instanceof Error ? raw.message : String(raw)}`, {
cause: raw instanceof Error ? raw : undefined,
});
}
```
## API Reference
### `SURREAL_CODES`
`Record<string, SurrealCode>` — stable code dictionary produced by `defineCodes("SURREAL", [...])`.
| Key | Value |
|---|---|
| `CONNECTION_FAILED` | `"SURREAL.CONNECTION_FAILED"` |
| `NOT_CONNECTED` | `"SURREAL.NOT_CONNECTED"` |
| `QUERY_FAILED` | `"SURREAL.QUERY_FAILED"` |
| `AUTH_FAILED` | `"SURREAL.AUTH_FAILED"` |
| `TIMEOUT` | `"SURREAL.TIMEOUT"` |
| `VERSION_UNSUPPORTED` | `"SURREAL.VERSION_UNSUPPORTED"` |
| `SCHEMA_LOAD_FAILED` | `"SURREAL.SCHEMA_LOAD_FAILED"` |
| `INIT_FAILED` | `"SURREAL.INIT_FAILED"` |
| `TRANSACTION_FAILED` | `"SURREAL.TRANSACTION_FAILED"` |
| `RECORD_NOT_FOUND` | `"SURREAL.RECORD_NOT_FOUND"` |
### `SurrealError`
```ts
class SurrealError extends SuaveplanError<SurrealCode>
constructor(code: SurrealCode, message: string, options?: SuaveplanErrorOptions)
```
### `mapSurrealCode(err: unknown): SurrealCode`
Maps an unknown thrown value to the closest `SurrealCode` by heuristic substring match. Falls back to `SURREAL.QUERY_FAILED`.
## Implementation Notes
Codes are registered at import time via `defineCodes`, which stamps them into the global `@suaveplan/error` code registry. The `SurrealError` public constructor widens `SuaveplanError`'s protected constructor and normalises empty/whitespace messages to `SurrealError(<code>)` so log surfaces never render an empty `Error:` line — the body does real work, so no static-analysis suppression is needed.

View File

@@ -0,0 +1,70 @@
# init
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `initDatabase` and `createInitializer` for applying SurrealQL schema files to a connected database. The init subsystem bridges schema discovery (`@suaveplan/surrealdb/schema/discovery`) with an active `SurrealClientLike` instance, executing schema files sequentially with optional before/after hooks and dry-run support.
This is the primary entry point for bootstrapping a SurrealDB database from a set of `.surql` or `.sql` files. It handles partial failure gracefully: failed files are collected in the `errors` array rather than aborting the entire run, unless all files fail.
## Features
- Accepts explicit `schemas` array or auto-discovers from `schemaDir`
- Applies `filter` predicate if provided
- Runs `SchemaHook.before` and `after` callbacks around each file
- Dry-run mode: discovers and reads files but skips execution
- Returns `InitResult` with `applied`, `skipped`, and `errors` arrays
- `createInitializer` binds options into a reusable function
## Usage: Basic Example
```ts
import { initDatabase } from "@suaveplan/surrealdb/init/init";
const result = await initDatabase(db, {
schemaDir: "./db/schema",
});
console.log(`Applied ${result.applied.length} schemas`);
```
## Usage: Advanced Example
```ts
import { createInitializer } from "@suaveplan/surrealdb/init/init";
const init = createInitializer({
schemaDir: "./db/schema",
filter: (f) => !f.name.includes("seed"),
hooks: [{ before: async (f) => console.log("Applying", f.name) }],
dryRun: process.env.DRY_RUN === "1",
});
await init(db);
```
## API Reference
### `initDatabase(db, options?): Promise<InitResult>`
Applies schema files to `db`. Returns `{ applied, skipped, errors }`.
**Parameters:**
- `db` — connected `SurrealClientLike` instance
- `options.schemas` — explicit file list (takes priority over `schemaDir`)
- `options.schemaDir` — directory to discover schemas from
- `options.filter` — predicate to exclude files
- `options.hooks` — `SchemaHook[]` with optional `before`/`after` callbacks
- `options.dryRun` — if `true`, skips execution and adds files to `skipped`
**Throws:** `SurrealError(SURREAL.INIT_FAILED)` if all files fail and none were applied.
### `createInitializer(options): (db) => Promise<InitResult>`
Returns a bound function that calls `initDatabase(db, options)`.
## Implementation Notes
Files are applied sequentially, not in parallel, because schema definitions typically have ordering dependencies (tables before indexes, tables before events). The `dryRun` flag still runs hooks, which allows pre-flight validation hooks to run without mutating the database state.

View File

@@ -0,0 +1,75 @@
# discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides file-system-based schema discovery for SurrealDB schema files (`.surql`, `.sql`). Schema discovery is the first step of the database initialisation workflow: given a directory path, `discoverSchemas` enumerates matching files, orders them according to `DEFAULT_SCHEMA_ORDER`, and returns typed `SchemaFile` descriptors. The `filterSchemas` and `getSchemasToLoad` helpers compose discovery with predicate filtering for conditional loading (e.g. skipping seed files in production).
## Features
- `getDefaultSchemaDir()` — returns conventional `./db/schema` path
- `discoverSchemas()` — enumerates `.surql`/`.sql` files, optionally recursive
- `sortSchemaFiles()` — orders files by `DEFAULT_SCHEMA_ORDER` then alphabetically
- `filterSchemas()` — applies a `SchemaFilter` predicate to a file list
- `getSchemasToLoad()` — discovery + filtering in one call
- `readSchemaContent()` — reads file content with caching of pre-populated `content` field
## Usage: Basic Example
```ts
import { discoverSchemas, readSchemaContent } from "@suaveplan/surrealdb/schema/discovery";
const files = await discoverSchemas("./db/schema");
for (const file of files) {
const content = await readSchemaContent(file);
console.log(file.name, content.length, "bytes");
}
```
## Usage: Advanced Example
```ts
import { getSchemasToLoad } from "@suaveplan/surrealdb/schema/discovery";
const schemas = await getSchemasToLoad(
"./db/schema",
(f) => !f.name.includes("seed"), // skip seeds in production
{ extensions: [".surql"], recursive: true }
);
```
## API Reference
### `getDefaultSchemaDir(): string`
Returns `path.join(process.cwd(), "db", "schema")`.
### `discoverSchemas(dir, options?): Promise<SchemaFile[]>`
Reads `dir` for files with matching extensions. Returns sorted `SchemaFile[]`.
**Throws:** `SurrealError(SURREAL.SCHEMA_LOAD_FAILED)` if directory read fails.
### `sortSchemaFiles(files): SchemaFile[]`
Pure. Orders by `DEFAULT_SCHEMA_ORDER` membership then lexicographically.
### `filterSchemas(files, filter): SchemaFile[]`
Pure. Returns `files.filter(filter)`.
### `getSchemasToLoad(dir, filter?, options?): Promise<SchemaFile[]>`
Combines `discoverSchemas` and `filterSchemas`.
### `readSchemaContent(file): Promise<string>`
Returns `file.content` if set, otherwise reads from `file.path`.
**Throws:** `SurrealError(SURREAL.SCHEMA_LOAD_FAILED)` on read failure.
## Implementation Notes
Schema ordering uses `DEFAULT_SCHEMA_ORDER` from the constants module. Files matching known schema names (`"schema"`, `"tables"`, `"indexes"`, etc.) are placed first in that order; unrecognised names follow alphabetically. This matches the conventional SurrealDB schema application order where table definitions must precede index definitions.

View File

@@ -0,0 +1,78 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod schemas for all public option types in `@suaveplan/surrealdb`. Schema validation runs at the boundaries where user-provided configuration enters the package, catching misconfigured URLs, missing credentials, or malformed init options before they reach the driver. Using Zod allows the same schema to generate TypeScript types via `z.infer`, keeping type definitions and runtime validation in sync.
Schemas are exported independently so consumers can use them in their own validation pipelines without importing full adapter classes.
## Features
- `AuthCredentialsSchema` — validates username and password fields
- `SurrealDBConfigSchema` — validates full connection config including URL format
- `DiscoverOptionsSchema` — validates schema discovery options (extensions, recursive)
- `SchemaFileSchema` — validates individual schema file descriptors
- `InitOptionsSchema` — validates database init options
- `AdvancedInitOptionsSchema` — extends init options with parallel and timeout fields
## Usage: Basic Example
```ts
import { SurrealDBConfigSchema } from "@suaveplan/surrealdb/schemas/schemas";
const config = SurrealDBConfigSchema.parse({
url: "ws://localhost:8000",
namespace: "prod",
database: "app",
auth: { username: "root", password: "secret" },
});
```
## Usage: Advanced Example
```ts
import {
InitOptionsSchema,
SurrealDBConfigSchema,
} from "@suaveplan/surrealdb/schemas/schemas";
// Validate and merge defaults
const opts = InitOptionsSchema.parse({ dryRun: true });
const cfg = SurrealDBConfigSchema.safeParse(rawInput);
if (!cfg.success) {
console.error("Invalid SurrealDB config:", cfg.error.flatten());
}
```
## API Reference
### `AuthCredentialsSchema`
`z.object({ username: z.string().min(1), password: z.string().min(1) })`
### `SurrealDBConfigSchema`
Validates `url` (URL format), `namespace`, `database`, `auth`.
### `DiscoverOptionsSchema`
Optional `extensions: string[]` and `recursive: boolean`.
### `SchemaFileSchema`
Required `name` and `path`, optional `content`.
### `InitOptionsSchema`
Optional `schemaDir`, `schemas`, `dryRun`, `parallel`, `timeout`. Strict — no extra keys.
### `AdvancedInitOptionsSchema`
Extends `InitOptionsSchema` with `parallel` and `timeout` fields.
## Implementation Notes
All schemas use `.strict()` where appropriate to reject extra keys and surface accidental typos in configuration objects early. URL validation in `SurrealDBConfigSchema` uses `z.string().url()`, which accepts both `ws://` and `wss://` URLs as Zod v4 validates them as valid URL schemes.

View File

@@ -0,0 +1,68 @@
# singleton
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Manages adapter-level singletons for `SurrealAdapter` instances. Long-lived server processes typically need a single authenticated, pre-connected `SurrealAdapter` that is reused across all requests. The singleton registry prevents duplicate connections from being opened when multiple modules call `getSurrealSingleton` concurrently during startup.
The registry is module-scoped (not global) to avoid cross-test contamination when the module is imported in test suites.
## Features
- `getSurrealSingleton()` — creates and connects on first call, returns cached thereafter
- `closeSurrealSingleton()` — disconnects and removes a named singleton
- `hasSurrealSingleton()` — inspects the registry without side effects
- `closeAllSurrealSingletons()` — bulk disconnection with error aggregation
- Named registries: multiple adapters can coexist under different string keys
## Usage: Basic Example
```ts
import { getSurrealSingleton, closeSurrealSingleton } from "@suaveplan/surrealdb/singleton/singleton";
const adapter = await getSurrealSingleton({ url: "ws://localhost:8000", namespace: "prod", database: "app", username: "root", password: "root" });
const users = await adapter.selectAll("user");
// On shutdown:
await closeSurrealSingleton();
```
## Usage: Advanced Example
```ts
import {
closeAllSurrealSingletons,
getSurrealSingleton,
} from "@suaveplan/surrealdb/singleton/singleton";
// Multiple named singletons
const read = await getSurrealSingleton({ url: "ws://read.db:8000", ... }, "read-replica");
const write = await getSurrealSingleton({ url: "ws://write.db:8000", ... }, "primary");
// Graceful shutdown
await closeAllSurrealSingletons();
```
## API Reference
### `getSurrealSingleton(options?, name?, clientFactory?): Promise<SurrealAdapter>`
Returns cached or newly-connected `SurrealAdapter`. Default name: `"surreal:default"`.
### `closeSurrealSingleton(name?): Promise<void>`
Disconnects and removes the named singleton. No-op if not registered.
### `hasSurrealSingleton(name?): boolean`
Returns `true` if a singleton is registered under `name`.
### `closeAllSurrealSingletons(): Promise<void>`
Disconnects all singletons. Collects errors and throws `AggregateError` if any disconnection fails.
## Implementation Notes
The `clientFactory` parameter is forwarded to `new SurrealAdapter(options, undefined, clientFactory)` to enable injection in tests. The registry Map is module-scoped, not process-global, which means each test file that imports the module gets an isolated registry when tests run in separate worker forks.

View File

@@ -0,0 +1,80 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises all shared TypeScript types and interfaces used throughout `@suaveplan/surrealdb`. Keeping these in one types file prevents circular imports between implementation modules, enables tree-shaking of runtime code, and gives consumers a single import path for type-level constructs without pulling in any driver runtime.
The file deliberately imports nothing from `surrealdb` to stay free of runtime dependencies and to allow the `SurrealClientLike` injection interface to be implemented in tests without the real driver.
## Features
- `SurrealDBConfig` — primary connection configuration block
- `SurrealClientLike` — driver injection interface enabling DI-based testing
- `SurrealClientFactory` — factory function type for DI
- `SurrealTransaction` — scoped transaction handle interface
- `VersionInfo` / `ConnectionState` — lifecycle and version metadata
- `SchemaFile` / `SchemaFilter` / `SchemaHook` — schema discovery types
- `InitOptions` / `AdvancedInitOptions` / `InitResult` / `DbConfig` — init subsystem types
## Usage: Basic Example
```ts
import type { SurrealDBConfig } from "@suaveplan/surrealdb/types/types";
const config: SurrealDBConfig = {
url: "ws://localhost:8000",
namespace: "prod",
database: "app",
auth: { username: "root", password: "secret" },
};
```
## Usage: Advanced Example
```ts
import type {
SurrealClientFactory,
SurrealClientLike,
} from "@suaveplan/surrealdb/types/types";
// Implement a fake for testing
class FakeSurrealClient implements SurrealClientLike {
async connect(_url: string): Promise<void> {}
async use(_opts: { namespace: string; database: string }): Promise<void> {}
async signin(_creds: { username: string; password: string }): Promise<string> { return "fake-token"; }
async query<T = unknown>(_sql: string, _vars?: Record<string, unknown>): Promise<T[]> { return [] as T[]; }
async close(): Promise<void> {}
async version(): Promise<string> { return "surrealdb-1.0.0"; }
}
const factory: SurrealClientFactory = () => new FakeSurrealClient();
```
## API Reference
### `SurrealDBConfig`
Full connection configuration. All fields required except through partial usage.
### `SurrealClientLike`
Injection interface for the driver. Implement for testing or alternative backends.
### `SurrealTransaction`
Handle passed to `SurrealAdapter.transaction()` callbacks. Exposes `query` and `execute`.
### `VersionInfo`
`{ version, major, minor, patch, prerelease? }` — parsed semver from `db.version()`.
### `ConnectionState`
Union: `"disconnected" | "connecting" | "connected" | "authenticated" | "error"`.
## Implementation Notes
Types are intentionally split from implementation files to allow the tsup/vite build to tree-shake them from runtime bundles where they are only consumed as TypeScript constructs. The `SurrealClientLike` interface mirrors only the subset of the `surrealdb` driver API that `SurrealAdapter` actually calls — this keeps the fake implementation surface small.

View File

@@ -0,0 +1,65 @@
# version-detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `parseVersion` and `detectVersion` for resolving the version of a running SurrealDB instance. Version detection is critical for adapting query patterns and feature availability across the supported major versions (1, 2, 3). By centralising this logic, the adapter and init subsystems can make version-conditional decisions without re-parsing the raw version string in multiple places.
`parseVersion` is a pure function suitable for property-based testing. `detectVersion` wraps the async `db.version()` driver call with structured error handling.
## Features
- Strips the `"surrealdb-"` prefix that the official driver prepends to version strings
- Parses `major.minor.patch[-prerelease]` semver format
- Validates that the major version is within the supported range (1–3)
- Returns a typed `VersionInfo` object
- Propagates cause-chain on query failures
## Usage: Basic Example
```ts
import { parseVersion } from "@suaveplan/surrealdb/utils/version-detector";
const info = parseVersion("surrealdb-2.1.0");
// { version: "2.1.0", major: 2, minor: 1, patch: 0 }
```
## Usage: Advanced Example
```ts
import { detectVersion } from "@suaveplan/surrealdb/utils/version-detector";
import type { SurrealClientLike } from "@suaveplan/surrealdb/types/types";
async function checkCompatibility(db: SurrealClientLike): Promise<void> {
const info = await detectVersion(db);
if (info.major < 2) {
console.warn("SurrealDB v1 detected — some features are unavailable");
}
}
```
## API Reference
### `parseVersion(raw: string): VersionInfo`
Pure function. Parses a raw version string. Throws `SurrealError(VERSION_UNSUPPORTED)` for unparseable strings or unsupported major versions.
**Parameters:**
- `raw` — raw version string from the driver, e.g. `"surrealdb-2.1.0"` or `"1.5.3-beta.1"`
**Returns:** `VersionInfo` with `version`, `major`, `minor`, `patch`, and optional `prerelease`.
**Throws:** `SurrealError(SURREAL.VERSION_UNSUPPORTED)` if the string cannot be parsed or the major version is outside [1, 3].
### `detectVersion(db: SurrealClientLike): Promise<VersionInfo>`
Async. Calls `db.version()` and delegates to `parseVersion`.
**Throws:** `SurrealError(SURREAL.QUERY_FAILED)` if the driver call throws.
## Implementation Notes
The regex anchors to the beginning of the string after prefix stripping to handle prerelease suffixes like `-beta.1` or `-rc.2`. The `MIN_SUPPORTED_MAJOR` and `MAX_SUPPORTED_MAJOR` constants are read from the `constants/index.ts` module rather than hard-coded, making the supported range easy to update when new major versions are released.