Initial Commit
This commit is contained in:
93
packages/persistence/database/db/README.md
Normal file
93
packages/persistence/database/db/README.md
Normal 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 |
|
||||
97
packages/persistence/database/db/package.json
Normal file
97
packages/persistence/database/db/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
106
packages/persistence/database/db/src/adapter/database-adapter.md
Normal file
106
packages/persistence/database/db/src/adapter/database-adapter.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
4
packages/persistence/database/db/src/adapter/index.ts
Normal file
4
packages/persistence/database/db/src/adapter/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
DatabaseAdapter,
|
||||
DbOperationOptions,
|
||||
} from "./database-adapter.js";
|
||||
6
packages/persistence/database/db/src/connection/index.ts
Normal file
6
packages/persistence/database/db/src/connection/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
closeAllSingletons,
|
||||
closeSingleton,
|
||||
getSingleton,
|
||||
hasSingleton,
|
||||
} from "./singleton.js";
|
||||
120
packages/persistence/database/db/src/connection/singleton.md
Normal file
120
packages/persistence/database/db/src/connection/singleton.md
Normal 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.
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/persistence/database/db/src/connection/singleton.ts
Normal file
89
packages/persistence/database/db/src/connection/singleton.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
107
packages/persistence/database/db/src/errors/database-error.md
Normal file
107
packages/persistence/database/db/src/errors/database-error.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
11
packages/persistence/database/db/src/errors/db-codes.ts
Normal file
11
packages/persistence/database/db/src/errors/db-codes.ts
Normal 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];
|
||||
2
packages/persistence/database/db/src/errors/index.ts
Normal file
2
packages/persistence/database/db/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DatabaseError, MigrationError } from "./database-error.js";
|
||||
export { DB_CODES, type DbCode } from "./db-codes.js";
|
||||
30
packages/persistence/database/db/src/index.ts
Normal file
30
packages/persistence/database/db/src/index.ts
Normal 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";
|
||||
8
packages/persistence/database/db/src/migration/index.ts
Normal file
8
packages/persistence/database/db/src/migration/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
8
packages/persistence/database/db/src/telemetry/index.ts
Normal file
8
packages/persistence/database/db/src/telemetry/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
recordConnectionDelta,
|
||||
recordMigration,
|
||||
recordQueryDuration,
|
||||
recordTransaction,
|
||||
resetInstruments,
|
||||
} from "./instruments.js";
|
||||
export { withDbSpan } from "./trace.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
76
packages/persistence/database/db/src/telemetry/trace.md
Normal file
76
packages/persistence/database/db/src/telemetry/trace.md
Normal 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.
|
||||
41
packages/persistence/database/db/src/telemetry/trace.test.ts
Normal file
41
packages/persistence/database/db/src/telemetry/trace.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
34
packages/persistence/database/db/src/telemetry/trace.ts
Normal file
34
packages/persistence/database/db/src/telemetry/trace.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
117
packages/persistence/database/db/src/types/database-config.md
Normal file
117
packages/persistence/database/db/src/types/database-config.md
Normal 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.
|
||||
@@ -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>;
|
||||
10
packages/persistence/database/db/src/types/index.ts
Normal file
10
packages/persistence/database/db/src/types/index.ts
Normal 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";
|
||||
@@ -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[];
|
||||
}
|
||||
55
packages/persistence/database/db/src/types/types.test.ts
Normal file
55
packages/persistence/database/db/src/types/types.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
packages/persistence/database/db/tsconfig.lib.json
Normal file
12
packages/persistence/database/db/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "src/__benchmarks__/**", "src/__tests__/**", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/persistence/database/db/vite.config.ts
Normal file
37
packages/persistence/database/db/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
38
packages/persistence/database/db/vitest.config.ts
Normal file
38
packages/persistence/database/db/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user