Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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,97 @@
{
"name": "@suaveplan/db",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter/database-adapter": {
"types": "./dist/adapter/database-adapter.d.ts",
"default": "./dist/adapter/database-adapter.js"
},
"./connection/singleton": {
"types": "./dist/connection/singleton.d.ts",
"default": "./dist/connection/singleton.js"
},
"./errors/database-error": {
"types": "./dist/errors/database-error.d.ts",
"default": "./dist/errors/database-error.js"
},
"./migration/migration-runner": {
"types": "./dist/migration/migration-runner.d.ts",
"default": "./dist/migration/migration-runner.js"
},
"./telemetry/instruments": {
"types": "./dist/telemetry/instruments.d.ts",
"default": "./dist/telemetry/instruments.js"
},
"./telemetry/trace": {
"types": "./dist/telemetry/trace.d.ts",
"default": "./dist/telemetry/trace.js"
},
"./types/database-config": {
"types": "./dist/types/database-config.d.ts",
"default": "./dist/types/database-config.js"
},
"./types/migration-config": {
"types": "./dist/types/migration-config.d.ts",
"default": "./dist/types/migration-config.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:bench": "vitest bench",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DatabaseLike } from "@suaveplan/types";
import * as fc from "fast-check";
import type { DatabaseAdapter, DbOperationOptions } from "../adapter/index.js";
import {
closeAllSingletons,
closeSingleton,
getSingleton,
} from "../connection/singleton.js";
import type {
DatabaseConfig,
MigrationConfig,
MigrationResult,
} from "../types/index.js";
import { DatabaseConfigSchema } from "../types/index.js";
// ── Shared stub ───────────────────────────────────────────────────────────────
class PbAdapter implements DatabaseAdapter {
#connected = false;
get isConnected(): boolean {
return this.#connected;
}
async connect(
_c?: Partial<DatabaseConfig>,
opts?: DbOperationOptions
): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
this.#connected = true;
}
async disconnect(opts?: DbOperationOptions): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
this.#connected = false;
}
async close(): Promise<void> {
await this.disconnect();
}
async query<T>(): Promise<T[]> {
return [];
}
async execute(): Promise<{ rowsAffected: number }> {
return { rowsAffected: 0 };
}
async transaction<T>(fn: (tx: DatabaseLike) => Promise<T>): Promise<T> {
return fn(this);
}
async migrate(_c?: MigrationConfig): Promise<MigrationResult> {
return { applied: 0, migrations: [] };
}
}
// ── Property-based tests ──────────────────────────────────────────────────────
describe("Connection-state invariant", () => {
it("connect/disconnect sequences leave isConnected consistent", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.boolean(), { minLength: 1, maxLength: 10 }),
async (ops) => {
const adapter = new PbAdapter();
for (const shouldConnect of ops) {
if (shouldConnect) {
await adapter.connect();
expect(adapter.isConnected).toBe(true);
} else {
await adapter.disconnect();
expect(adapter.isConnected).toBe(false);
}
}
}
),
{ numRuns: 50 }
);
});
});
describe("Registry invariant", () => {
it("any sequence of get/close/closeAll leaves no dangling entries", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.oneof(
fc.record({
op: fc.constant("get"),
name: fc.constantFrom("a", "b", "c"),
}),
fc.record({
op: fc.constant("close"),
name: fc.constantFrom("a", "b", "c"),
}),
fc.record({
op: fc.constant("closeAll"),
name: fc.constant(""),
})
),
{ minLength: 1, maxLength: 10 }
),
async (ops) => {
const reg = new Map<string, DatabaseAdapter>();
for (const { op, name } of ops) {
if (op === "get") {
await getSingleton(
name,
() => new PbAdapter(),
undefined,
undefined,
reg
);
} else if (op === "close") {
await closeSingleton(name, undefined, reg);
} else {
await closeAllSingletons(undefined, reg);
expect(reg.size).toBe(0);
}
}
// After any sequence, every entry in registry must be connected
for (const adapter of reg.values()) {
expect(adapter.isConnected).toBe(true);
}
}
),
{ numRuns: 50 }
);
});
it("getSingleton with same name returns === reference", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (name) => {
const reg = new Map<string, DatabaseAdapter>();
const r1 = await getSingleton(
name,
() => new PbAdapter(),
undefined,
undefined,
reg
);
const r2 = await getSingleton(
name,
() => new PbAdapter(),
undefined,
undefined,
reg
);
expect(r1).toBe(r2);
await closeAllSingletons(undefined, reg);
}
),
{ numRuns: 30 }
);
});
});
describe("DatabaseConfigSchema round-trip", () => {
it("arbitrary partial configs with valid fields parse without throwing", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.record(
{
filename: fc.oneof(fc.string(), fc.constant(undefined)),
url: fc.oneof(fc.string(), fc.constant(undefined)),
readonlyMode: fc.oneof(
fc.boolean(),
fc.constant(undefined)
),
},
{ requiredKeys: [] }
),
(partial) => {
// Remove undefined keys to simulate partial input
const cleaned = Object.fromEntries(
Object.entries(partial).filter(
([, v]) => v !== undefined
)
);
expect(() =>
DatabaseConfigSchema.parse(cleaned)
).not.toThrow();
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake: subpath exports", () => {
it("main barrel exports expected symbols", { timeout: 30000 }, async () => {
const mod = await import("../index.js");
expect(typeof mod.getSingleton).toBe("function");
expect(typeof mod.closeSingleton).toBe("function");
expect(typeof mod.hasSingleton).toBe("function");
expect(typeof mod.closeAllSingletons).toBe("function");
expect(typeof mod.DatabaseError).toBe("function");
expect(typeof mod.MigrationError).toBe("function");
expect(typeof mod.DB_CODES).toBe("object");
expect(typeof mod.DatabaseConfigSchema).toBe("object");
expect(typeof mod.MigrationConfigSchema).toBe("object");
expect(typeof mod.withDbSpan).toBe("function");
expect(typeof mod.recordQueryDuration).toBe("function");
});
it("importing adapter subpath yields no side effects on global state", {
timeout: 30000,
}, async () => {
const before = Object.keys(globalThis).length;
await import("../adapter/database-adapter.js");
const after = Object.keys(globalThis).length;
expect(after).toBe(before);
});
});

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,168 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DatabaseLike } from "@suaveplan/types";
import type {
DatabaseConfig,
MigrationConfig,
MigrationResult,
} from "../types/index.js";
import type {
DatabaseAdapter,
DbOperationOptions,
} from "./database-adapter.js";
// Minimal in-memory stub that satisfies DatabaseAdapter
class StubAdapter implements DatabaseAdapter {
#connected = false;
readonly calls: string[] = [];
get isConnected(): boolean {
return this.#connected;
}
async connect(
_config?: Partial<DatabaseConfig>,
opts?: DbOperationOptions
): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
this.#connected = true;
this.calls.push("connect");
}
async disconnect(opts?: DbOperationOptions): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
this.#connected = false;
this.calls.push("disconnect");
}
async close(): Promise<void> {
await this.disconnect();
}
async query<T = Record<string, unknown>>(
_sql: string,
_params?: readonly unknown[],
opts?: DbOperationOptions
): Promise<T[]> {
if (opts?.signal?.aborted) throw new Error("aborted");
return [] as T[];
}
async execute(
_sql: string,
_params?: readonly unknown[],
opts?: DbOperationOptions
): Promise<{ rowsAffected: number }> {
if (opts?.signal?.aborted) throw new Error("aborted");
return { rowsAffected: 0 };
}
async transaction<T>(
fn: (tx: DatabaseLike) => Promise<T>,
opts?: DbOperationOptions
): Promise<T> {
if (opts?.signal?.aborted) throw new Error("aborted");
return fn(this);
}
async migrate(
_config?: MigrationConfig,
opts?: DbOperationOptions
): Promise<MigrationResult> {
if (opts?.signal?.aborted) throw new Error("aborted");
return { applied: 0, migrations: [] };
}
}
describe("DatabaseAdapter (StubAdapter)", () => {
it("isConnected reflects connect/disconnect lifecycle", {
timeout: 5000,
}, async () => {
const adapter = new StubAdapter();
expect(adapter.isConnected).toBe(false);
await adapter.connect();
expect(adapter.isConnected).toBe(true);
await adapter.disconnect();
expect(adapter.isConnected).toBe(false);
});
it("satisfies DatabaseLike structurally (close alias)", {
timeout: 5000,
}, async () => {
const adapter: DatabaseLike = new StubAdapter();
await expect(adapter.close()).resolves.toBeUndefined();
});
it("query returns typed rows", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const rows = await adapter.query<{ id: number }>("SELECT 1");
expect(Array.isArray(rows)).toBe(true);
});
it("execute returns rowsAffected", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const result = await adapter.execute("DELETE FROM t");
expect(result).toHaveProperty("rowsAffected");
});
it("transaction passes tx handle to fn", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
let txRef: DatabaseLike | undefined;
await adapter.transaction(async (tx) => {
txRef = tx;
});
expect(txRef).toBeDefined();
});
it("migrate returns MigrationResult", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const result = await adapter.migrate();
expect(result).toEqual({ applied: 0, migrations: [] });
});
it("AbortSignal on connect is propagated", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const ac = new AbortController();
ac.abort();
await expect(
adapter.connect(undefined, { signal: ac.signal })
).rejects.toThrow("aborted");
});
it("AbortSignal on query is propagated", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const ac = new AbortController();
ac.abort();
await expect(
adapter.query("SELECT 1", [], { signal: ac.signal })
).rejects.toThrow("aborted");
});
it("AbortSignal on execute is propagated", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const ac = new AbortController();
ac.abort();
await expect(
adapter.execute("DELETE FROM t", [], { signal: ac.signal })
).rejects.toThrow("aborted");
});
it("AbortSignal on transaction is propagated", {
timeout: 5000,
}, async () => {
const adapter = new StubAdapter();
const ac = new AbortController();
ac.abort();
await expect(
adapter.transaction(async () => {}, { signal: ac.signal })
).rejects.toThrow("aborted");
});
it("AbortSignal on migrate is propagated", { timeout: 5000 }, async () => {
const adapter = new StubAdapter();
const ac = new AbortController();
ac.abort();
await expect(
adapter.migrate(undefined, { signal: ac.signal })
).rejects.toThrow("aborted");
});
});

View File

@@ -0,0 +1,63 @@
import type { DatabaseLike } from "@suaveplan/types";
import type {
DatabaseConfig,
MigrationConfig,
MigrationResult,
} from "../types/index.js";
export interface DbOperationOptions {
signal?: AbortSignal;
}
/**
* Full database adapter contract.
*
* Extends `DatabaseLike` (the minimal DI interface from `@suaveplan/types`)
* with lifecycle management, migration support, and AbortSignal propagation.
* Concrete adapters (SQLite, Postgres, SurrealDB) implement this interface.
*/
export interface DatabaseAdapter extends DatabaseLike {
/** Open the connection. Idempotent if already connected. */
connect(
config?: Partial<DatabaseConfig>,
opts?: DbOperationOptions
): Promise<void>;
/**
* Close the connection and release all held resources.
* Alias of `DatabaseLike.close`; both names are honoured.
*/
disconnect(opts?: DbOperationOptions): Promise<void>;
/** Execute a query and return all result rows cast to `T`. */
query<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[],
opts?: DbOperationOptions
): Promise<T[]>;
/** Execute a non-query statement and return affected-row metadata. */
execute(
sql: string,
params?: readonly unknown[],
opts?: DbOperationOptions
): Promise<{ rowsAffected: number }>;
/**
* Run `fn` inside a transaction. Commits on resolve, rolls back on throw.
* The callback receives the active transaction handle as `DatabaseLike`.
*/
transaction<T>(
fn: (tx: DatabaseLike) => Promise<T>,
opts?: DbOperationOptions
): Promise<T>;
/** Apply pending migrations according to `config`. */
migrate(
config?: MigrationConfig,
opts?: DbOperationOptions
): Promise<MigrationResult>;
/** `true` when the adapter has an active connection. */
readonly isConnected: boolean;
}

View File

@@ -0,0 +1,4 @@
export type {
DatabaseAdapter,
DbOperationOptions,
} from "./database-adapter.js";

View File

@@ -0,0 +1,6 @@
export {
closeAllSingletons,
closeSingleton,
getSingleton,
hasSingleton,
} from "./singleton.js";

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,295 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DatabaseLike } from "@suaveplan/types";
import type { DatabaseAdapter, DbOperationOptions } from "../adapter/index.js";
import { DatabaseError } from "../errors/index.js";
import type {
DatabaseConfig,
MigrationConfig,
MigrationResult,
} from "../types/index.js";
import {
closeAllSingletons,
closeSingleton,
getSingleton,
hasSingleton,
} from "./singleton.js";
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeRegistry(): Map<string, DatabaseAdapter> {
return new Map();
}
class MockAdapter implements DatabaseAdapter {
#connected = false;
connectCalls = 0;
disconnectCalls = 0;
shouldFailConnect = false;
get isConnected(): boolean {
return this.#connected;
}
async connect(
_c?: Partial<DatabaseConfig>,
opts?: DbOperationOptions
): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
if (this.shouldFailConnect) throw new Error("connection refused");
this.#connected = true;
this.connectCalls++;
}
async disconnect(opts?: DbOperationOptions): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
this.#connected = false;
this.disconnectCalls++;
}
async close(): Promise<void> {
await this.disconnect();
}
async query<T>(): Promise<T[]> {
return [];
}
async execute(): Promise<{ rowsAffected: number }> {
return { rowsAffected: 0 };
}
async transaction<T>(fn: (tx: DatabaseLike) => Promise<T>): Promise<T> {
return fn(this);
}
async migrate(_c?: MigrationConfig): Promise<MigrationResult> {
return { applied: 0, migrations: [] };
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("getSingleton", () => {
it("creates and caches adapter on first call", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
const result = await getSingleton(
"db",
() => adapter,
undefined,
undefined,
reg
);
expect(result).toBe(adapter);
expect(adapter.connectCalls).toBe(1);
expect(reg.size).toBe(1);
});
it("returns same reference on second call without reconnecting", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
const r1 = await getSingleton(
"db",
() => adapter,
undefined,
undefined,
reg
);
const r2 = await getSingleton(
"db",
() => adapter,
undefined,
undefined,
reg
);
expect(r1).toBe(r2);
expect(adapter.connectCalls).toBe(1);
});
it("validates config via DatabaseConfigSchema", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
const result = await getSingleton(
"db",
() => adapter,
{ filename: ":memory:", readonlyMode: false },
undefined,
reg
);
expect(result).toBe(adapter);
});
it("wraps connect failure as DatabaseError with CONNECTION_FAILED", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
adapter.shouldFailConnect = true;
await expect(
getSingleton("db", () => adapter, undefined, undefined, reg)
).rejects.toThrow(DatabaseError);
await expect(
getSingleton("db", () => adapter, undefined, undefined, reg)
).rejects.toMatchObject({ code: "DB.CONNECTION_FAILED" });
});
it("propagates AbortSignal to connect", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
const ac = new AbortController();
ac.abort();
await expect(
getSingleton(
"db",
() => adapter,
undefined,
{ signal: ac.signal },
reg
)
).rejects.toThrow();
});
});
describe("hasSingleton", () => {
it("returns false for empty registry", { timeout: 5000 }, () => {
expect(hasSingleton("db", makeRegistry())).toBe(false);
});
it("returns true after getSingleton", { timeout: 30000 }, async () => {
const reg = makeRegistry();
await getSingleton(
"db",
() => new MockAdapter(),
undefined,
undefined,
reg
);
expect(hasSingleton("db", reg)).toBe(true);
});
it("returns false after closeSingleton", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
await getSingleton(
"db",
() => new MockAdapter(),
undefined,
undefined,
reg
);
await closeSingleton("db", undefined, reg);
expect(hasSingleton("db", reg)).toBe(false);
});
});
describe("closeSingleton", () => {
it("disconnects and removes from registry", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const adapter = new MockAdapter();
await getSingleton("db", () => adapter, undefined, undefined, reg);
await closeSingleton("db", undefined, reg);
expect(adapter.disconnectCalls).toBe(1);
expect(reg.has("db")).toBe(false);
});
it("is a no-op for unregistered name", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
await expect(
closeSingleton("nope", undefined, reg)
).resolves.toBeUndefined();
});
});
describe("closeAllSingletons", () => {
it("disconnects all and clears registry", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const a = new MockAdapter();
const b = new MockAdapter();
await getSingleton("a", () => a, undefined, undefined, reg);
await getSingleton("b", () => b, undefined, undefined, reg);
await closeAllSingletons(undefined, reg);
expect(a.disconnectCalls).toBe(1);
expect(b.disconnectCalls).toBe(1);
expect(reg.size).toBe(0);
});
it("clears registry even when one disconnect rejects", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const good = new MockAdapter();
const bad = new MockAdapter();
bad.disconnect = async () => {
throw new Error("disconnect failed");
};
await getSingleton("good", () => good, undefined, undefined, reg);
await getSingleton("bad", () => bad, undefined, undefined, reg);
await expect(closeAllSingletons(undefined, reg)).rejects.toThrow(
AggregateError
);
expect(reg.size).toBe(0);
expect(good.disconnectCalls).toBe(1);
});
});
// ── Default-registry coverage ──────────────────────────────────────────────────
// These tests use the module-level default registry (no explicit registry param)
// in order to cover the `= defaultRegistry` default parameter branches.
// Each test cleans up after itself to avoid cross-test bleed.
describe("default registry (module-level)", () => {
const TEST_KEY = "__default_registry_coverage_test__";
it("getSingleton / hasSingleton / closeSingleton use default registry", {
timeout: 30000,
}, async () => {
const adapter = new MockAdapter();
// getSingleton — default registry branch
const result = await getSingleton(TEST_KEY, () => adapter);
expect(result).toBe(adapter);
// hasSingleton — default registry branch
expect(hasSingleton(TEST_KEY)).toBe(true);
// closeSingleton — default registry branch
await closeSingleton(TEST_KEY);
expect(hasSingleton(TEST_KEY)).toBe(false);
});
it("closeAllSingletons uses default registry", {
timeout: 30000,
}, async () => {
const adapter = new MockAdapter();
await getSingleton(TEST_KEY, () => adapter);
// closeAllSingletons — default registry branch
await closeAllSingletons();
expect(hasSingleton(TEST_KEY)).toBe(false);
});
});
describe("getSingleton non-Error connect failure", () => {
it("wraps non-Error thrown value in DatabaseError", {
timeout: 30000,
}, async () => {
const reg = makeRegistry();
const badAdapter = new MockAdapter();
badAdapter.connect = async () => {
throw "string failure";
};
await expect(
getSingleton("db", () => badAdapter, undefined, undefined, reg)
).rejects.toMatchObject({
code: "DB.CONNECTION_FAILED",
message: expect.stringContaining("string failure"),
});
});
});

View File

@@ -0,0 +1,89 @@
import type { DatabaseAdapter, DbOperationOptions } from "../adapter/index.js";
import { DatabaseError, DB_CODES } from "../errors/index.js";
import { type DatabaseConfig, DatabaseConfigSchema } from "../types/index.js";
const defaultRegistry = new Map<string, DatabaseAdapter>();
/**
* Return an existing adapter for `name` from `registry`, or create one via
* `factory`, connect it, and cache it. Subsequent calls with the same name
* return the cached adapter without reconnecting.
*/
export async function getSingleton(
name: string,
factory: () => DatabaseAdapter,
config?: Partial<DatabaseConfig>,
opts?: DbOperationOptions,
registry: Map<string, DatabaseAdapter> = defaultRegistry
): Promise<DatabaseAdapter> {
const existing = registry.get(name);
if (existing !== undefined) return existing;
const validConfig =
config !== undefined ? DatabaseConfigSchema.parse(config) : undefined;
const adapter = factory();
try {
await adapter.connect(validConfig, opts);
} catch (err) {
throw new DatabaseError(
DB_CODES.CONNECTION_FAILED,
`Failed to connect singleton '${name}': ${err instanceof Error ? err.message : String(err)}`,
{ cause: err instanceof Error ? err : undefined }
);
}
registry.set(name, adapter);
return adapter;
}
/**
* Disconnect and remove the named adapter from `registry`.
* No-op if the name is not registered.
*/
export async function closeSingleton(
name: string,
opts?: DbOperationOptions,
registry: Map<string, DatabaseAdapter> = defaultRegistry
): Promise<void> {
const adapter = registry.get(name);
if (adapter === undefined) return;
registry.delete(name);
await adapter.disconnect(opts);
}
/** Return `true` if a live adapter is registered under `name`. */
export function hasSingleton(
name: string,
registry: Map<string, DatabaseAdapter> = defaultRegistry
): boolean {
return registry.has(name);
}
/**
* Disconnect and remove all adapters in `registry`. Disconnection errors are
* collected; after all adapters are attempted the registry is cleared and any
* errors are re-thrown as an `AggregateError`.
*/
export async function closeAllSingletons(
opts?: DbOperationOptions,
registry: Map<string, DatabaseAdapter> = defaultRegistry
): Promise<void> {
const entries = [...registry.entries()];
registry.clear();
const errors: unknown[] = [];
await Promise.all(
entries.map(async ([, adapter]) => {
try {
await adapter.disconnect(opts);
} catch (err) {
errors.push(err);
}
})
);
if (errors.length > 0) {
throw new AggregateError(
errors,
"One or more singletons failed to disconnect"
);
}
}

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,84 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DatabaseError, MigrationError } from "./database-error.js";
import { DB_CODES } from "./db-codes.js";
describe("DatabaseError", () => {
it("is instanceof SuaveplanError", { timeout: 5000 }, () => {
const err = new DatabaseError(DB_CODES.QUERY_FAILED, "query failed");
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(DatabaseError);
});
it("has correct stable code", { timeout: 5000 }, () => {
const err = new DatabaseError(DB_CODES.NOT_CONNECTED, "not connected");
expect(err.code).toBe("DB.NOT_CONNECTED");
});
it("has correct name", { timeout: 5000 }, () => {
const err = new DatabaseError(DB_CODES.CONNECTION_FAILED, "failed");
expect(err.name).toBe("DatabaseError");
});
it("preserves cause in chain", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new DatabaseError(DB_CODES.QUERY_FAILED, "wrapped", {
cause,
});
expect(err.cause).toBe(cause);
});
it("message is passed through directly", { timeout: 5000 }, () => {
const err = new DatabaseError(DB_CODES.TRANSACTION_FAILED, "tx failed");
expect(err.message).toBe("tx failed");
});
it("all DB codes produce valid code strings", { timeout: 5000 }, () => {
expect(DB_CODES.CONNECTION_FAILED).toBe("DB.CONNECTION_FAILED");
expect(DB_CODES.NOT_CONNECTED).toBe("DB.NOT_CONNECTED");
expect(DB_CODES.QUERY_FAILED).toBe("DB.QUERY_FAILED");
expect(DB_CODES.TRANSACTION_FAILED).toBe("DB.TRANSACTION_FAILED");
expect(DB_CODES.MIGRATION_FAILED).toBe("DB.MIGRATION_FAILED");
});
});
describe("MigrationError", () => {
it("is instanceof DatabaseError and SuaveplanError", {
timeout: 5000,
}, () => {
const err = new MigrationError("001_init", "syntax error");
expect(err).toBeInstanceOf(MigrationError);
expect(err).toBeInstanceOf(DatabaseError);
expect(err).toBeInstanceOf(SuaveplanError);
});
it("always uses DB.MIGRATION_FAILED code", { timeout: 5000 }, () => {
const err = new MigrationError("001_init", "bad sql");
expect(err.code).toBe("DB.MIGRATION_FAILED");
});
it("has correct name", { timeout: 5000 }, () => {
const err = new MigrationError("001_init", "detail");
expect(err.name).toBe("MigrationError");
});
it("formats message as Migration '<name>' failed: <detail>", {
timeout: 5000,
}, () => {
const err = new MigrationError("001_init", "column does not exist");
expect(err.message).toBe(
"Migration '001_init' failed: column does not exist"
);
});
it("exposes migration field", { timeout: 5000 }, () => {
const err = new MigrationError("002_add_users", "detail");
expect(err.migration).toBe("002_add_users");
});
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("underlying");
const err = new MigrationError("001_init", "failed", { cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,31 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import { DB_CODES, type DbCode } from "./db-codes.js";
export class DatabaseError extends SuaveplanError<DbCode> {
// biome-ignore lint/complexity/noUselessConstructor: SuaveplanError constructor is protected; this exposes a public entry point
public constructor(
code: DbCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
export class MigrationError extends DatabaseError {
public readonly migration: string;
public constructor(
migration: string,
detail: string,
options?: SuaveplanErrorOptions
) {
super(
DB_CODES.MIGRATION_FAILED,
`Migration '${migration}' failed: ${detail}`,
options
);
this.migration = migration;
this.name = "MigrationError";
}
}

View File

@@ -0,0 +1,11 @@
import { defineCodes } from "@suaveplan/error";
export const DB_CODES = defineCodes("DB", [
"CONNECTION_FAILED",
"NOT_CONNECTED",
"QUERY_FAILED",
"TRANSACTION_FAILED",
"MIGRATION_FAILED",
] as const);
export type DbCode = (typeof DB_CODES)[keyof typeof DB_CODES];

View File

@@ -0,0 +1,2 @@
export { DatabaseError, MigrationError } from "./database-error.js";
export { DB_CODES, type DbCode } from "./db-codes.js";

View File

@@ -0,0 +1,30 @@
export type { DatabaseAdapter, DbOperationOptions } from "./adapter/index.js";
export {
closeAllSingletons,
closeSingleton,
getSingleton,
hasSingleton,
} from "./connection/index.js";
export {
DatabaseError,
DB_CODES,
type DbCode,
MigrationError,
} from "./errors/index.js";
export type { MigrationRunner } from "./migration/migration-runner.js";
export {
recordConnectionDelta,
recordMigration,
recordQueryDuration,
recordTransaction,
resetInstruments,
withDbSpan,
} from "./telemetry/index.js";
export {
type DatabaseConfig,
DatabaseConfigSchema,
type MigrationConfig,
MigrationConfigSchema,
type MigrationResult,
type MigrationStatus,
} from "./types/index.js";

View File

@@ -0,0 +1,8 @@
export { MigrationError } from "../errors/index.js";
export type {
MigrationConfig,
MigrationResult,
MigrationStatus,
} from "../types/index.js";
export { MigrationConfigSchema } from "../types/index.js";
export type { MigrationRunner } from "./migration-runner.js";

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,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DbOperationOptions } from "../adapter/index.js";
import { MigrationError } from "../errors/index.js";
import type {
MigrationConfig,
MigrationResult,
MigrationStatus,
} from "../types/index.js";
import type { MigrationRunner } from "./migration-runner.js";
// Minimal stub implementation for contract verification
class StubMigrationRunner implements MigrationRunner {
private applied: Set<string> = new Set();
private migrationMap = new Map<string, string>();
shouldFail: string | undefined;
register(name: string, sql: string): void {
this.migrationMap.set(name, sql);
}
async pendingMigrations(
_config?: MigrationConfig
): Promise<MigrationStatus[]> {
return [...this.migrationMap.keys()]
.filter((name) => !this.applied.has(name))
.map((name) => ({ name, applied: false }));
}
async applyMigration(
name: string,
_sql: string,
opts?: DbOperationOptions
): Promise<void> {
if (opts?.signal?.aborted) throw new Error("aborted");
if (this.shouldFail === name) {
throw new MigrationError(name, "syntax error");
}
this.applied.add(name);
}
async run(
config?: MigrationConfig,
opts?: DbOperationOptions
): Promise<MigrationResult> {
const pending = await this.pendingMigrations(config);
const names: string[] = [];
for (const { name } of pending) {
const sql = this.migrationMap.get(name) ?? "";
await this.applyMigration(name, sql, opts);
names.push(name);
}
return { applied: names.length, migrations: names };
}
}
describe("MigrationRunner (StubMigrationRunner)", () => {
it("run applies all pending migrations", {
timeout: 30000,
}, async () => {
const runner = new StubMigrationRunner();
runner.register("001_init", "CREATE TABLE t (id INT)");
runner.register("002_add_col", "ALTER TABLE t ADD col TEXT");
const result = await runner.run();
expect(result.applied).toBe(2);
expect(result.migrations).toEqual(["001_init", "002_add_col"]);
});
it("pendingMigrations excludes already-applied", {
timeout: 30000,
}, async () => {
const runner = new StubMigrationRunner();
runner.register("001_init", "CREATE TABLE t (id INT)");
await runner.run();
const pending = await runner.pendingMigrations();
expect(pending).toHaveLength(0);
});
it("run returns empty result when nothing pending", {
timeout: 30000,
}, async () => {
const runner = new StubMigrationRunner();
const result = await runner.run();
expect(result).toEqual({ applied: 0, migrations: [] });
});
it("applyMigration throws MigrationError with correct migration field", {
timeout: 30000,
}, async () => {
const runner = new StubMigrationRunner();
runner.shouldFail = "001_init";
runner.register("001_init", "bad sql");
await expect(
runner.applyMigration("001_init", "bad sql")
).rejects.toThrow(MigrationError);
await expect(
runner.applyMigration("001_init", "bad sql")
).rejects.toMatchObject({ migration: "001_init" });
});
it("AbortSignal halts mid-run", { timeout: 30000 }, async () => {
const runner = new StubMigrationRunner();
runner.register("001_init", "CREATE TABLE t (id INT)");
runner.register("002_add_col", "ALTER TABLE t ADD col TEXT");
const ac = new AbortController();
// Abort before run starts
ac.abort();
await expect(
runner.run(undefined, { signal: ac.signal })
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,40 @@
import type { DbOperationOptions } from "../adapter/index.js";
import type {
MigrationConfig,
MigrationResult,
MigrationStatus,
} from "../types/index.js";
/**
* Contract for a migration runner.
*
* Concrete adapters delegate `DatabaseAdapter.migrate()` to an implementation
* of this interface. Keeping the runner separate lets adapters swap migration
* strategies without changing the top-level adapter API.
*/
export interface MigrationRunner {
/**
* Return all migrations that have not yet been applied according to the
* adapter's migration-tracking store.
*/
pendingMigrations(config?: MigrationConfig): Promise<MigrationStatus[]>;
/**
* Apply a single named migration by executing `sql` inside the adapter and
* recording its completion in the tracking store.
*/
applyMigration(
name: string,
sql: string,
opts?: DbOperationOptions
): Promise<void>;
/**
* Discover and apply all pending migrations in version order. Returns a
* summary of what was applied.
*/
run(
config?: MigrationConfig,
opts?: DbOperationOptions
): Promise<MigrationResult>;
}

View File

@@ -0,0 +1,8 @@
export {
recordConnectionDelta,
recordMigration,
recordQueryDuration,
recordTransaction,
resetInstruments,
} from "./instruments.js";
export { withDbSpan } from "./trace.js";

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,45 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
recordConnectionDelta,
recordMigration,
recordQueryDuration,
recordTransaction,
resetInstruments,
} from "./instruments.js";
describe("OTel instruments (no-op provider)", () => {
beforeEach(() => {
resetInstruments();
});
it("recordQueryDuration does not throw with no SDK registered", {
timeout: 5000,
}, () => {
expect(() => recordQueryDuration(42, "query")).not.toThrow();
expect(() => recordQueryDuration(10, "execute")).not.toThrow();
});
it("recordConnectionDelta does not throw", { timeout: 5000 }, () => {
expect(() => recordConnectionDelta(1, "main")).not.toThrow();
expect(() => recordConnectionDelta(-1, "main")).not.toThrow();
});
it("recordTransaction does not throw for both outcomes", {
timeout: 5000,
}, () => {
expect(() => recordTransaction("committed")).not.toThrow();
expect(() => recordTransaction("rolled_back")).not.toThrow();
});
it("recordMigration does not throw", { timeout: 5000 }, () => {
expect(() => recordMigration("001_init")).not.toThrow();
});
it("instruments are lazily initialised (no top-level getMeter)", {
timeout: 5000,
}, () => {
// Calling after resetInstruments re-initialises without error
resetInstruments();
expect(() => recordQueryDuration(1, "query")).not.toThrow();
});
});

View File

@@ -0,0 +1,60 @@
import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
interface DbInstruments {
queryDuration: Histogram;
connectionCount: UpDownCounter;
transactionCount: Counter;
migrationCount: Counter;
}
let instruments: DbInstruments | undefined;
function getInstruments(): DbInstruments {
if (instruments !== undefined) return instruments;
const meter = metrics.getMeter("@suaveplan/db");
instruments = {
queryDuration: meter.createHistogram("db.query.duration_ms", {
description: "Database query/execute duration in milliseconds",
unit: "ms",
}),
connectionCount: meter.createUpDownCounter("db.connection.count", {
description: "Number of active database connections",
}),
transactionCount: meter.createCounter("db.transaction.count", {
description: "Total database transactions by outcome",
}),
migrationCount: meter.createCounter("db.migration.count", {
description: "Total migrations applied",
}),
};
return instruments;
}
export function recordQueryDuration(
durationMs: number,
operation: "query" | "execute"
): void {
getInstruments().queryDuration.record(durationMs, {
"db.operation": operation,
});
}
export function recordConnectionDelta(delta: 1 | -1, dbName: string): void {
getInstruments().connectionCount.add(delta, { "db.name": dbName });
}
export function recordTransaction(outcome: "committed" | "rolled_back"): void {
getInstruments().transactionCount.add(1, { "db.outcome": outcome });
}
export function recordMigration(migrationName: string): void {
getInstruments().migrationCount.add(1, {
"db.migration.name": migrationName,
});
}
/** Reset the cached instruments (for use in tests). */
export function resetInstruments(): void {
instruments = undefined;
}

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,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withDbSpan } from "./trace.js";
describe("withDbSpan", () => {
it("returns resolved value on success", { timeout: 5000 }, async () => {
const result = await withDbSpan("test.op", async () => 42);
expect(result).toBe(42);
});
it("re-throws error from fn", { timeout: 5000 }, async () => {
await expect(
withDbSpan("test.op", async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("works with attributes", { timeout: 5000 }, async () => {
const result = await withDbSpan("test.op", async () => "ok", {
"db.name": "main",
});
expect(result).toBe("ok");
});
it("works with no-op OTel provider (no SDK registered)", {
timeout: 5000,
}, async () => {
// No SDK registered in test environment — must not throw
await expect(withDbSpan("db.query", async () => [])).resolves.toEqual(
[]
);
});
it("re-throws non-Error thrown values", { timeout: 5000 }, async () => {
await expect(
withDbSpan("test.op", async () => {
throw "string error";
})
).rejects.toBe("string error");
});
});

View File

@@ -0,0 +1,34 @@
import { type Attributes, SpanStatusCode, trace } from "@opentelemetry/api";
const tracer = trace.getTracer("@suaveplan/db");
/**
* Execute `fn` inside a named OTel span. Sets span status to `ERROR` and
* records the exception if `fn` throws, then always ends the span.
*/
export async function withDbSpan<T>(
name: string,
fn: () => Promise<T>,
attrs?: Attributes
): Promise<T> {
const span = tracer.startSpan(
name,
attrs !== undefined ? { attributes: attrs } : undefined
);
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
span.recordException(
err instanceof Error ? err : new Error(String(err))
);
throw err;
} finally {
span.end();
}
}

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.

View File

@@ -0,0 +1,11 @@
import z from "zod";
export const DatabaseConfigSchema = z
.object({
filename: z.string().optional(),
url: z.string().optional(),
readonlyMode: z.boolean().optional().default(false),
})
.strict();
export type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;

View File

@@ -0,0 +1,10 @@
export {
type DatabaseConfig,
DatabaseConfigSchema,
} from "./database-config.js";
export {
type MigrationConfig,
MigrationConfigSchema,
type MigrationResult,
type MigrationStatus,
} from "./migration-config.js";

View File

@@ -0,0 +1,21 @@
import z from "zod";
export const MigrationConfigSchema = z
.object({
migrationsFolder: z.string().optional().default("./drizzle"),
migrationsTable: z.string().optional().default("__drizzle_migrations"),
})
.strict();
export type MigrationConfig = z.infer<typeof MigrationConfigSchema>;
export interface MigrationStatus {
name: string;
applied: boolean;
appliedAt?: Date;
}
export interface MigrationResult {
applied: number;
migrations: string[];
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DatabaseConfigSchema, MigrationConfigSchema } from "./index.js";
describe("DatabaseConfigSchema", () => {
it("parses empty object with defaults", { timeout: 5000 }, () => {
const result = DatabaseConfigSchema.parse({});
expect(result.readonlyMode).toBe(false);
expect(result.filename).toBeUndefined();
expect(result.url).toBeUndefined();
});
it("parses all fields", { timeout: 5000 }, () => {
const result = DatabaseConfigSchema.parse({
filename: ":memory:",
url: "postgres://localhost/db",
readonlyMode: true,
});
expect(result.filename).toBe(":memory:");
expect(result.url).toBe("postgres://localhost/db");
expect(result.readonlyMode).toBe(true);
});
it("rejects unknown fields (strict)", { timeout: 5000 }, () => {
expect(() =>
DatabaseConfigSchema.parse({ unknown: "field" })
).toThrow();
});
it("rejects non-boolean readonlyMode", { timeout: 5000 }, () => {
expect(() =>
DatabaseConfigSchema.parse({ readonlyMode: "yes" })
).toThrow();
});
});
describe("MigrationConfigSchema", () => {
it("parses empty object with defaults", { timeout: 5000 }, () => {
const result = MigrationConfigSchema.parse({});
expect(result.migrationsFolder).toBe("./drizzle");
expect(result.migrationsTable).toBe("__drizzle_migrations");
});
it("parses custom values", { timeout: 5000 }, () => {
const result = MigrationConfigSchema.parse({
migrationsFolder: "./migrations",
migrationsTable: "_migrations",
});
expect(result.migrationsFolder).toBe("./migrations");
expect(result.migrationsTable).toBe("_migrations");
});
it("rejects unknown fields (strict)", { timeout: 5000 }, () => {
expect(() => MigrationConfigSchema.parse({ extra: true })).toThrow();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "src/__benchmarks__/**", "src/__tests__/**", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/db",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"src/__tests__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});