Initial Commit
This commit is contained in:
17
packages/persistence/database/README.md
Normal file
17
packages/persistence/database/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# `database`
|
||||
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `database`
|
||||
|
||||
5 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/db`](./db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
|
||||
| [`@suaveplan/db-sqlite`](./db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
|
||||
| [`@suaveplan/migration`](./migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
|
||||
| [`@suaveplan/seed`](./seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, ide… |
|
||||
| [`@suaveplan/surrealdb`](./surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/persistence/database/_intro.md
Normal file
1
packages/persistence/database/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
110
packages/persistence/database/db-sqlite/README.md
Normal file
110
packages/persistence/database/db-sqlite/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# @suaveplan/db-sqlite - Universal SQLite adapter for Suaveplan: bun:sqlite on the Bun server, @sqlite.org/sqlite-wasm in the browser, unified behind a Drizzle-ORM surface with LRU prepared-statement cache, forward-only migration runner, OTel instrumentation, and structured SQLITE.* error codes.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a single `SqliteAdapter` that implements `DatabaseAdapter` from `@suaveplan/db`. Includes an LRU prepared-statement cache (128 entries by default), a forward-only ledger-tracked `MigrationRunner` with dry-run support, OpenTelemetry span instrumentation, structured `SQLITE_*` error codes, and a `./unsafe` subpath for raw queries.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
|
||||
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ - [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ - [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/db-sqlite
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createSqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const db = await createSqliteAdapter({ url: ":memory:" });
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.run("INSERT INTO users (id, email) VALUES (?, ?)", [
|
||||
"user-123",
|
||||
"alice@example.com",
|
||||
]);
|
||||
|
||||
const rows = await db.all<{ id: string; email: string }>(
|
||||
"SELECT id, email FROM users WHERE id = ?",
|
||||
["user-123"],
|
||||
);
|
||||
|
||||
await db.close();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [Adapter (universal)](./src/adapter/adapter.md) — `SqliteAdapter`, `createSqliteAdapter`, runtime selection
|
||||
- [Adapter (Bun)](./src/adapter/adapter.bun.md) — `bun:sqlite` driver binding
|
||||
- [Adapter (WASM)](./src/adapter/adapter.wasm.md) — browser/Worker `@sqlite.org/sqlite-wasm` binding
|
||||
- [Cache](./src/cache/cache.md) — `PreparedStatementCache` (LRU)
|
||||
- [Connection](./src/connection/connection.md) — connection helpers and singleton management
|
||||
- [Bun driver](./src/drivers/bun-driver.md) — low-level Bun SQLite driver
|
||||
- [WASM driver](./src/drivers/wasm-driver.md) — low-level WASM SQLite driver
|
||||
- [WASM worker](./src/drivers/wasm-worker.md) — Web Worker host for WASM driver
|
||||
- [Errors](./src/errors/errors.md) — `SqliteError`, `SQLITE_*` codes
|
||||
- [Migration](./src/migration/migration.md) — `MigrationRunner`, forward-only migration ledger
|
||||
- [OTel](./src/otel/otel.md) — span and metric instrumentation
|
||||
- [Schemas](./src/schemas/schemas.md) — Zod-validated config types
|
||||
- [Conformance](./src/conformance/conformance-suite.md) — shared `DatabaseAdapter` conformance suite
|
||||
- [Unsafe](./src/unsafe/unsafe.md) — `./unsafe` subpath for raw queries
|
||||
123
packages/persistence/database/db-sqlite/package.json
Normal file
123
packages/persistence/database/db-sqlite/package.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"name": "@suaveplan/db-sqlite",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal SQLite adapter for the genesis monorepo: bun:sqlite on the Bun server, @sqlite.org/sqlite-wasm in the browser, unified behind a Drizzle-ORM surface with LRU prepared-statement cache, forward-only migration runner, OTel instrumentation, and structured SQLITE.* error codes.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"bun": {
|
||||
"types": "./dist/adapter/adapter.bun.d.ts",
|
||||
"default": "./dist/adapter/adapter.bun.js"
|
||||
},
|
||||
"browser": {
|
||||
"types": "./dist/adapter/adapter.wasm.d.ts",
|
||||
"default": "./dist/adapter/adapter.wasm.js"
|
||||
},
|
||||
"default": {
|
||||
"types": "./dist/adapter/adapter.bun.d.ts",
|
||||
"default": "./dist/adapter/adapter.bun.js"
|
||||
}
|
||||
},
|
||||
"./unsafe": {
|
||||
"import": {
|
||||
"types": "./dist/unsafe/index.d.ts",
|
||||
"default": "./dist/unsafe/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./cache/cache": {
|
||||
"import": {
|
||||
"types": "./dist/cache/cache.d.ts",
|
||||
"default": "./dist/cache/cache.js"
|
||||
}
|
||||
},
|
||||
"./migration/migration": {
|
||||
"import": {
|
||||
"types": "./dist/migration/migration.d.ts",
|
||||
"default": "./dist/migration/migration.js"
|
||||
}
|
||||
},
|
||||
"./drivers/bun-driver": {
|
||||
"import": {
|
||||
"types": "./dist/drivers/bun-driver.d.ts",
|
||||
"default": "./dist/drivers/bun-driver.js"
|
||||
}
|
||||
},
|
||||
"./drivers/wasm-driver": {
|
||||
"import": {
|
||||
"types": "./dist/drivers/wasm-driver.d.ts",
|
||||
"default": "./dist/drivers/wasm-driver.js"
|
||||
}
|
||||
},
|
||||
"./connection/connection": {
|
||||
"import": {
|
||||
"types": "./dist/connection/connection.d.ts",
|
||||
"default": "./dist/connection/connection.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "bun test --timeout=30000",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --timeout=30000 --coverage --coverage-reporter=lcov --coverage-dir=coverage",
|
||||
"test:bench": "bun src/__benchmarks__/sqlite.bench.ts",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@sqlite.org/sqlite-wasm": "catalog:database",
|
||||
"@suaveplan/db": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"drizzle-orm": "catalog:database",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/bun": "catalog:",
|
||||
"@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,61 @@
|
||||
# adapter.bun
|
||||
|
||||
## Purpose
|
||||
|
||||
Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun runtime resolves `import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"bun"` condition in the package's `exports` field. It re-exports `SqliteAdapter` and the `SqliteAdapterOptions` type so consumers see a single, stable surface regardless of which environment condition matched.
|
||||
|
||||
The separation between adapter entry points prevents browser bundles from pulling in `bun:sqlite` references, and prevents Bun server bundles from pulling in WASM worker code. Each entry is a thin barrel that resolves at bundle time.
|
||||
|
||||
## Features
|
||||
|
||||
- Conditional export target for the `"bun"` condition in `package.json`
|
||||
- Re-exports `SqliteAdapter` from `./adapter.js`
|
||||
- Re-exports `SqliteAdapterOptions` type from `../schemas/index.js`
|
||||
- Zero runtime overhead — pure re-export barrel
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Resolved automatically by Bun via conditional exports
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const db = new SqliteAdapter();
|
||||
await db.connect();
|
||||
const rows = await db.query("SELECT 1 AS n");
|
||||
await db.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
import type { SqliteAdapterOptions } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const opts: SqliteAdapterOptions = {
|
||||
filename: "/var/data/app.db",
|
||||
readonly: false,
|
||||
};
|
||||
|
||||
const adapter = new SqliteAdapter(opts);
|
||||
await adapter.connect();
|
||||
|
||||
await adapter.migrate({ migrationsFolder: "./migrations" });
|
||||
|
||||
await adapter.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO users (name) VALUES (?)", ["Alice"]);
|
||||
});
|
||||
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`adapter.md`](./adapter.md) (`SqliteAdapter`) and [`../schemas/schemas.md`](../schemas/schemas.md) (`SqliteAdapterOptions`).
|
||||
|
||||
**Conditional export condition:** `"bun"` — selected when the consumer runs under the Bun runtime or when a bundler sets the `bun` condition (e.g. via `resolve.conditions: ["bun"]` in Vite).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file intentionally contains no logic. Any change to the Bun-specific adapter behaviour lives in `adapter.ts`, not here. This design keeps the conditional export system orthogonal to implementation: adding a new runtime condition (e.g. `"deno"`) only requires adding a new entry-point barrel and a new `exports` condition, with no changes to the core adapter code.
|
||||
|
||||
Vite excludes this file from the default ESM browser bundle because the `"browser"` condition resolves to `adapter.wasm.ts` instead.
|
||||
@@ -0,0 +1,4 @@
|
||||
// Bun-runtime entry point for the ./adapter conditional export.
|
||||
|
||||
export type { SqliteAdapterOptions } from "../schemas/index.js";
|
||||
export { SqliteAdapter } from "./adapter.js";
|
||||
113
packages/persistence/database/db-sqlite/src/adapter/adapter.md
Normal file
113
packages/persistence/database/db-sqlite/src/adapter/adapter.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
- adapter.wasm — Browser-runtime entry point for the…
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
## Purpose
|
||||
|
||||
`SqliteAdapter` is the main `DatabaseAdapter` implementation for `@suaveplan/db-sqlite`. It implements the full `DatabaseAdapter` contract from `@suaveplan/db`, backing it with a `bun:sqlite` database instance, an LRU `PreparedStatementCache`, a `MigrationRunner`, and OTel span instrumentation on every public operation.
|
||||
|
||||
The adapter is designed for the Bun server runtime only. Attempting to construct it outside Bun throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` immediately. Browser callers should use `createWasmDriver` directly via the `./adapter` conditional export, which resolves to `adapter.wasm.ts` in browser bundles.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `DatabaseAdapter` from `@suaveplan/db` — fully substitutable wherever `DatabaseLike` is expected
|
||||
- Idempotent `connect()` — concurrent callers share one in-flight connection promise
|
||||
- `PreparedStatementCache` (LRU, default 128 entries) reused across `query()` calls
|
||||
- `transaction()` uses `BEGIN IMMEDIATE` to prevent deadlocks on concurrent writes; auto-rollback on failure
|
||||
- `migrate()` delegates to `MigrationRunner` and invalidates the cache on success
|
||||
- `AbortSignal` respected on every public method — throws `SqliteError(SQLITE.*)` when aborted
|
||||
- All operations wrapped in `withQuerySpan` for OTel tracing and latency metrics
|
||||
- `sideEffects: false` — safe for tree-shaking
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
const adapter = new SqliteAdapter({ filename: ":memory:" });
|
||||
await adapter.connect();
|
||||
|
||||
const rows = await adapter.query<{ id: number }>(
|
||||
"SELECT id FROM users WHERE active = ?",
|
||||
[1]
|
||||
);
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
const adapter = new SqliteAdapter({ filename: "/data/app.db" });
|
||||
await adapter.connect();
|
||||
|
||||
// Transactional insert
|
||||
await adapter.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [1, "Alice"]);
|
||||
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [2, "Bob"]);
|
||||
});
|
||||
|
||||
// Run migrations
|
||||
const result = await adapter.migrate({
|
||||
migrationsFolder: "./db/migrations",
|
||||
});
|
||||
console.log(`Applied ${result.applied} migration(s)`);
|
||||
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SqliteAdapter(options?, schema?)`
|
||||
|
||||
Validates options, throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun.
|
||||
|
||||
### `connect(config?, opts?): Promise<void>`
|
||||
|
||||
Opens the SQLite file or `:memory:` database. Idempotent under concurrent calls — only one connection is opened.
|
||||
|
||||
### `disconnect(opts?): Promise<void>`
|
||||
|
||||
Closes the database, invalidates the prepared-statement cache, and resets connection state.
|
||||
|
||||
### `query<T>(sql, params?, opts?): Promise<T[]>`
|
||||
|
||||
Executes a `SELECT` query. Uses the prepared-statement cache for repeated SQL strings.
|
||||
|
||||
### `execute(sql, params?, opts?): Promise<{ rowsAffected: number }>`
|
||||
|
||||
Executes a `INSERT`/`UPDATE`/`DELETE` statement. Returns affected row count.
|
||||
|
||||
### `transaction<T>(fn, opts?): Promise<T>`
|
||||
|
||||
Runs `fn` inside a `BEGIN IMMEDIATE` transaction. Commits on success, rolls back on failure.
|
||||
|
||||
### `migrate(config?, opts?): Promise<MigrationResult>`
|
||||
|
||||
Delegates to `MigrationRunner` and invalidates the prepared-statement cache on success.
|
||||
|
||||
### `isConnected: boolean`
|
||||
|
||||
`true` after `connect()` and before `disconnect()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `_connectingPromise` field serialises concurrent `connect()` calls — the second caller awaits the same in-flight promise rather than opening a second connection. This is safe because `bun:sqlite` uses synchronous I/O under the covers and does not support concurrent connections to the same file.
|
||||
|
||||
`transaction()` casts `this` to `DatabaseLike` when passing it to `fn`. This is sound because `SqliteAdapter` implements all `DatabaseLike` methods. The cast avoids exposing internal `_sqlite` state to transaction callbacks while keeping the type constraint tight.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { PreparedStatementCache } from "../cache/index.js";
|
||||
import { SqliteAdapter } from "./adapter.js";
|
||||
|
||||
describe("SqliteAdapter property-based", () => {
|
||||
it("concurrent connect() calls never result in > 1 connection", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 8 }),
|
||||
async (concurrency) => {
|
||||
const adapter = new SqliteAdapter();
|
||||
const calls = Array.from({ length: concurrency }, () =>
|
||||
adapter.connect()
|
||||
);
|
||||
await Promise.all(calls);
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
await adapter.disconnect();
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
|
||||
it("cache.size never exceeds maxSize after arbitrary queries", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.integer({ min: 1, max: 20 }), {
|
||||
minLength: 1,
|
||||
maxLength: 30,
|
||||
}),
|
||||
async (ids) => {
|
||||
const adapter = new SqliteAdapter();
|
||||
// Swap in a small cache to test eviction
|
||||
adapter["_cache"] = new PreparedStatementCache(5);
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE prop_test (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
for (const id of ids) {
|
||||
// Each distinct id generates a different SQL string (non-parameterised)
|
||||
// to stress the cache eviction path
|
||||
await adapter.query(
|
||||
`SELECT ${id} AS id FROM prop_test`
|
||||
);
|
||||
}
|
||||
expect(adapter["_cache"].size).toBeLessThanOrEqual(5);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it("transaction isolation: committed rows visible after tx", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.integer({ min: 1, max: 1000 }), {
|
||||
minLength: 1,
|
||||
maxLength: 20,
|
||||
noNullPrototypes: true,
|
||||
}),
|
||||
async (insertIds) => {
|
||||
const unique = [...new Set(insertIds)];
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE iso_test (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
await adapter.transaction(async (tx) => {
|
||||
for (const id of unique) {
|
||||
await tx.execute(
|
||||
"INSERT INTO iso_test VALUES (?)",
|
||||
[id]
|
||||
);
|
||||
}
|
||||
});
|
||||
const rows = await adapter.query<{ id: number }>(
|
||||
"SELECT id FROM iso_test"
|
||||
);
|
||||
expect(rows).toHaveLength(unique.length);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 15 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SqliteError } from "../errors/index.js";
|
||||
import { SqliteAdapter } from "./adapter.js";
|
||||
|
||||
describe("SqliteAdapter", () => {
|
||||
it("isConnected is false before connect()", { timeout: 5000 }, () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("connects and sets isConnected = true", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
await adapter.disconnect();
|
||||
});
|
||||
|
||||
it("connect() is idempotent", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
await adapter.connect(); // second call — should not throw
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
await adapter.disconnect();
|
||||
});
|
||||
|
||||
it("concurrent connect() calls share one connection", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
// Fire two connects concurrently
|
||||
await Promise.all([adapter.connect(), adapter.connect()]);
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
await adapter.disconnect();
|
||||
});
|
||||
|
||||
it("disconnect() is idempotent", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
await adapter.disconnect();
|
||||
await adapter.disconnect(); // second call — no-op
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("close() disconnects", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
await adapter.close();
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("query throws NOT_CONNECTED before connect()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await expect(adapter.query("SELECT 1")).rejects.toMatchObject({
|
||||
code: "SQLITE.NOT_CONNECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("execute throws NOT_CONNECTED before connect()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await expect(
|
||||
adapter.execute("CREATE TABLE t (id INTEGER)")
|
||||
).rejects.toMatchObject({ code: "SQLITE.NOT_CONNECTED" });
|
||||
});
|
||||
|
||||
it("transaction throws NOT_CONNECTED before connect()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await expect(
|
||||
adapter.transaction(async () => undefined)
|
||||
).rejects.toMatchObject({ code: "SQLITE.NOT_CONNECTED" });
|
||||
});
|
||||
|
||||
it("migrate throws NOT_CONNECTED before connect()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await expect(adapter.migrate()).rejects.toMatchObject({
|
||||
code: "SQLITE.NOT_CONNECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("query returns rows", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE q_test (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
await adapter.execute("INSERT INTO q_test VALUES (?, ?)", [
|
||||
1,
|
||||
"hello",
|
||||
]);
|
||||
const rows = await adapter.query<{ id: number; val: string }>(
|
||||
"SELECT * FROM q_test"
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.val).toBe("hello");
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("execute returns rowsAffected", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE ra_test (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
await adapter.execute("INSERT INTO ra_test VALUES (1, 'a')");
|
||||
const result = await adapter.execute(
|
||||
"UPDATE ra_test SET val = ? WHERE id = ?",
|
||||
["b", 1]
|
||||
);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction commits on success", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE tx_test (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
await adapter.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO tx_test VALUES (?)", [1]);
|
||||
});
|
||||
const rows = await adapter.query<{ id: number }>(
|
||||
"SELECT * FROM tx_test"
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction rolls back on failure", { timeout: 30000 }, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE rollback_test (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
await expect(
|
||||
adapter.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
"INSERT INTO rollback_test VALUES (?)",
|
||||
[1]
|
||||
);
|
||||
throw new Error("intentional failure");
|
||||
})
|
||||
).rejects.toThrow();
|
||||
const rows = await adapter.query<{ id: number }>(
|
||||
"SELECT * FROM rollback_test"
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("query reuses prepared statements (cache hit)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.execute(
|
||||
"CREATE TABLE cache_test (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
await adapter.execute("INSERT INTO cache_test VALUES (1)");
|
||||
// Run the same query twice — second call must be a cache hit
|
||||
await adapter.query("SELECT * FROM cache_test");
|
||||
await adapter.query("SELECT * FROM cache_test");
|
||||
// 3 unique SQL strings cached (CREATE TABLE + INSERT + SELECT), not 4
|
||||
expect(adapter["_cache"].size).toBe(3);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrate invalidates cache", { timeout: 30000 }, async () => {
|
||||
const { mkdir, rm, writeFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
const folder = join(tmpdir(), `adapter-migrate-test-${Date.now()}`);
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "001_init.sql"),
|
||||
"CREATE TABLE migrated (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
// Populate cache with a query
|
||||
await adapter.execute(
|
||||
"CREATE TABLE pre_migrate (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
await adapter.query("SELECT * FROM pre_migrate");
|
||||
expect(adapter["_cache"].size).toBeGreaterThan(0);
|
||||
|
||||
await adapter.migrate({ migrationsFolder: folder });
|
||||
// Cache should be cleared after migration
|
||||
expect(adapter["_cache"].size).toBe(0);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("query aborted by AbortSignal throws QUERY_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.query("SELECT 1", [], { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "SQLITE.QUERY_FAILED" });
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("execute aborted by AbortSignal throws EXECUTE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.execute("SELECT 1", [], { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "SQLITE.EXECUTE_FAILED" });
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction aborted by AbortSignal throws TRANSACTION_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.transaction(async () => undefined, {
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "SQLITE.TRANSACTION_FAILED" });
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrate aborted by AbortSignal throws MIGRATION_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.migrate({}, { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "SQLITE.MIGRATION_FAILED" });
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("connect() aborted by AbortSignal throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.connect(undefined, { signal: ac.signal })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("disconnect() aborted by AbortSignal throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
adapter.disconnect({ signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: "SQLITE.NOT_CONNECTED" });
|
||||
// Cleanup without abort
|
||||
await adapter.disconnect();
|
||||
});
|
||||
|
||||
it("maps SqliteError from mapSqliteCode on query error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new SqliteAdapter();
|
||||
await adapter.connect();
|
||||
try {
|
||||
// Query a nonexistent table — bun:sqlite will throw
|
||||
await expect(
|
||||
adapter.query("SELECT * FROM nonexistent_table_xyz")
|
||||
).rejects.toBeInstanceOf(SqliteError);
|
||||
} finally {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
246
packages/persistence/database/db-sqlite/src/adapter/adapter.ts
Normal file
246
packages/persistence/database/db-sqlite/src/adapter/adapter.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
DatabaseConfig,
|
||||
MigrationConfig,
|
||||
MigrationResult,
|
||||
} from "@suaveplan/db";
|
||||
import type {
|
||||
DatabaseAdapter,
|
||||
DbOperationOptions,
|
||||
} from "@suaveplan/db/adapter/database-adapter";
|
||||
import type { DatabaseLike } from "@suaveplan/types";
|
||||
import { PreparedStatementCache } from "../cache/index.js";
|
||||
import { createBunDriver, isBunEnvironment } from "../drivers/bun-driver.js";
|
||||
import { mapSqliteCode, SQLITE_CODES, SqliteError } from "../errors/index.js";
|
||||
import { MigrationRunner } from "../migration/index.js";
|
||||
import { withQuerySpan } from "../otel/index.js";
|
||||
import {
|
||||
type SqliteAdapterOptions,
|
||||
SqliteAdapterOptionsSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
export class SqliteAdapter<
|
||||
TSchema extends Record<string, unknown> = Record<string, never>,
|
||||
> implements DatabaseAdapter
|
||||
{
|
||||
protected _options: SqliteAdapterOptions;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Database type varies by environment
|
||||
protected _sqlite: any | null = null;
|
||||
protected _connected = false;
|
||||
protected _connectingPromise: Promise<void> | null = null;
|
||||
protected _cache: PreparedStatementCache;
|
||||
|
||||
constructor(options?: SqliteAdapterOptions, _schema?: TSchema) {
|
||||
/* c8 ignore next 5 */
|
||||
if (!isBunEnvironment()) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.ENVIRONMENT_ERROR,
|
||||
"SqliteAdapter requires Bun runtime — use a WASM-based adapter in browser environments"
|
||||
);
|
||||
}
|
||||
this._options = SqliteAdapterOptionsSchema.parse(options ?? {});
|
||||
this._cache = new PreparedStatementCache();
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
async connect(
|
||||
config?: Partial<DatabaseConfig>,
|
||||
opts?: DbOperationOptions
|
||||
): Promise<void> {
|
||||
if (this._connectingPromise !== null) {
|
||||
return this._connectingPromise;
|
||||
}
|
||||
if (this._connected) return;
|
||||
|
||||
this._connectingPromise = this.#doConnect(config, opts);
|
||||
try {
|
||||
await this._connectingPromise;
|
||||
} finally {
|
||||
this._connectingPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #doConnect(
|
||||
config?: Partial<DatabaseConfig>,
|
||||
opts?: DbOperationOptions
|
||||
): Promise<void> {
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Connection aborted before it started"
|
||||
);
|
||||
}
|
||||
|
||||
const filename =
|
||||
config?.filename ?? this._options.filename ?? ":memory:";
|
||||
const readonly =
|
||||
config?.readonlyMode ?? this._options.readonly ?? false;
|
||||
|
||||
try {
|
||||
const { sqlite } = await createBunDriver({ filename, readonly });
|
||||
this._sqlite = sqlite;
|
||||
this._connected = true;
|
||||
} catch (err) {
|
||||
if (err instanceof SqliteError) throw err;
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.DRIVER_INIT_FAILED,
|
||||
`Failed to connect: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(opts?: DbOperationOptions): Promise<void> {
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Disconnect aborted"
|
||||
);
|
||||
}
|
||||
if (!this._connected || this._sqlite === null) return;
|
||||
this._cache.invalidate();
|
||||
this._sqlite.close();
|
||||
this._sqlite = null;
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
async query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: readonly unknown[],
|
||||
opts?: DbOperationOptions
|
||||
): Promise<T[]> {
|
||||
this._assertConnected();
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(SQLITE_CODES.QUERY_FAILED, "Query aborted");
|
||||
}
|
||||
|
||||
return withQuerySpan("db.query", sql, async () => {
|
||||
try {
|
||||
let stmt = this._cache.get(sql);
|
||||
if (stmt === undefined) {
|
||||
stmt = this._sqlite.prepare(sql);
|
||||
this._cache.set(sql, stmt);
|
||||
}
|
||||
const rows = stmt.all(...(params ?? [])) as T[];
|
||||
return rows;
|
||||
} catch (err) {
|
||||
if (err instanceof SqliteError) throw err;
|
||||
const code = mapSqliteCode(err);
|
||||
throw new SqliteError(
|
||||
code,
|
||||
`Query failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async execute(
|
||||
sql: string,
|
||||
params?: readonly unknown[],
|
||||
opts?: DbOperationOptions
|
||||
): Promise<{ rowsAffected: number }> {
|
||||
this._assertConnected();
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.EXECUTE_FAILED,
|
||||
"Execute aborted"
|
||||
);
|
||||
}
|
||||
|
||||
return withQuerySpan("db.execute", sql, async () => {
|
||||
try {
|
||||
let stmt = this._cache.get(sql);
|
||||
if (stmt === undefined) {
|
||||
stmt = this._sqlite.prepare(sql);
|
||||
this._cache.set(sql, stmt);
|
||||
}
|
||||
const result = stmt.run(...(params ?? []));
|
||||
return { rowsAffected: result.changes ?? 0 };
|
||||
} catch (err) {
|
||||
if (err instanceof SqliteError) throw err;
|
||||
const code = mapSqliteCode(err);
|
||||
throw new SqliteError(
|
||||
code,
|
||||
`Execute failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
fn: (tx: DatabaseLike) => Promise<T>,
|
||||
opts?: DbOperationOptions
|
||||
): Promise<T> {
|
||||
this._assertConnected();
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.TRANSACTION_FAILED,
|
||||
"Transaction aborted before start"
|
||||
);
|
||||
}
|
||||
|
||||
return withQuerySpan("db.transaction", "BEGIN IMMEDIATE", async () => {
|
||||
this._sqlite.run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = await fn(this as unknown as DatabaseLike);
|
||||
this._sqlite.run("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
this._sqlite.run("ROLLBACK");
|
||||
} catch {
|
||||
// ignore rollback errors
|
||||
}
|
||||
if (err instanceof SqliteError) throw err;
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.TRANSACTION_FAILED,
|
||||
`Transaction failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async migrate(
|
||||
config?: MigrationConfig,
|
||||
opts?: DbOperationOptions
|
||||
): Promise<MigrationResult> {
|
||||
this._assertConnected();
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.MIGRATION_FAILED,
|
||||
"Migration aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const migrationOpts: Record<string, string> = {};
|
||||
if (config?.migrationsFolder !== undefined) {
|
||||
migrationOpts["migrationsFolder"] = config.migrationsFolder;
|
||||
}
|
||||
if (config?.migrationsTable !== undefined) {
|
||||
migrationOpts["migrationsTable"] = config.migrationsTable;
|
||||
}
|
||||
const runner = new MigrationRunner(this._sqlite, migrationOpts);
|
||||
|
||||
const result = await runner.run();
|
||||
this._cache.invalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
protected _assertConnected(): void {
|
||||
if (!this._connected || this._sqlite === null) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Adapter is not connected — call connect() first"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# adapter.wasm
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler resolves `import { createWasmDriver } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"browser"` condition in the package's `exports` field. It re-exports the WASM-backed driver factory and its related types, giving browser consumers a clean surface that never references `bun:sqlite` or any Node.js-only API.
|
||||
|
||||
The separation between Bun and WASM adapter entry points keeps tree-shaking effective: a Bun server bundle never pulls in `@sqlite.org/sqlite-wasm` or the Web Worker, and a browser bundle never pulls in `bun:sqlite`. Each entry point is resolved at build time by the bundler's condition matching.
|
||||
|
||||
## Features
|
||||
|
||||
- Conditional export target for the `"browser"` condition in `package.json`
|
||||
- Re-exports `createWasmDriver`, `WasmDriverResult`, and `WasmProxyDb` from the WASM driver
|
||||
- Re-exports `WasmDriverOptions` type from the shared schemas
|
||||
- Zero runtime overhead — pure re-export barrel
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Resolved automatically by the browser bundler via conditional exports
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
const rows = await db.query("SELECT 1 AS n");
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
import type { WasmDriverOptions } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const opts: WasmDriverOptions = {
|
||||
filename: "app.db", // OPFS-backed persistent storage
|
||||
};
|
||||
|
||||
const { db, close } = await createWasmDriver(opts);
|
||||
|
||||
await db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
);
|
||||
await db.execute("INSERT INTO notes (body) VALUES (?)", ["Hello from the browser"]);
|
||||
|
||||
const rows = await db.query<{ id: number; body: string }>(
|
||||
"SELECT * FROM notes ORDER BY id"
|
||||
);
|
||||
console.log(rows);
|
||||
|
||||
await close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`../drivers/wasm-driver.md`](../drivers/wasm-driver.md).
|
||||
|
||||
**Conditional export condition:** `"browser"` — selected when a bundler uses browser-targeting conditions (e.g. Vite's default, Webpack's `target: "web"`).
|
||||
|
||||
**Default fallback:** When neither `"bun"` nor `"browser"` conditions are active (e.g. vanilla Node.js `require()`), the `exports.default` condition falls back to the Bun adapter entry point. The WASM path is never the fallback to avoid loading 1.5 MB of WASM in server-side contexts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WasmDriverOptions` is imported from `../schemas/index.js` rather than from `wasm-driver.js` because the Zod schemas live in the shared schemas module — keeping the driver files free of Zod as a direct import. This pattern is consistent across all driver options types in this package.
|
||||
|
||||
The WASM worker bundle (`wasm-worker.js`) is inlined as an asset URL by Vite at build time using `new URL("./wasm-worker.js", import.meta.url)`. The worker receives messages for `init`, `query`, `execute`, and `close`, and responds synchronously from SQLite's in-memory or OPFS-backed database.
|
||||
@@ -0,0 +1,6 @@
|
||||
// Browser/WASM entry point for the ./adapter conditional export.
|
||||
// Re-exports the WASM driver — full WASM adapter class coming in a future wave.
|
||||
|
||||
export type { WasmDriverResult, WasmProxyDb } from "../drivers/wasm-driver.js";
|
||||
export { createWasmDriver } from "../drivers/wasm-driver.js";
|
||||
export type { WasmDriverOptions } from "../schemas/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { SqliteAdapter } from "./adapter.js";
|
||||
88
packages/persistence/database/db-sqlite/src/cache/cache.md
vendored
Normal file
88
packages/persistence/database/db-sqlite/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PreparedStatementCache` is an LRU-bounded cache for `bun:sqlite` prepared statement objects, keyed by SQL text. Reusing prepared statements avoids the overhead of repeated SQL parsing and optimisation — a significant win for hot query paths. The cache is bounded to prevent unbounded memory growth in applications that generate many distinct SQL strings.
|
||||
|
||||
The cache integrates with OpenTelemetry to surface hit/miss/eviction counters at `sqlite.cache.hits`, `sqlite.cache.misses`, and `sqlite.cache.evictions`. These are no-ops when no OTel provider is registered, keeping the module safe for use in zero-dependency environments.
|
||||
|
||||
## Features
|
||||
|
||||
- LRU eviction when `size >= maxSize` (default `128`)
|
||||
- `get(sql)` — returns cached statement or `undefined`; updates LRU order
|
||||
- `set(sql, stmt)` — inserts or updates; evicts LRU entry when at capacity, calling `stmt.finalize()` on the evicted statement
|
||||
- `invalidate()` — clears all entries and calls `finalize()` on each held statement; called by `SqliteAdapter.migrate()` to prevent stale plan reuse after schema changes
|
||||
- `size` / `maxSize` read-only getters
|
||||
- OTel counters: `sqlite.cache.hits`, `sqlite.cache.misses`, `sqlite.cache.evictions`
|
||||
- `static resetInstruments()` — clears the OTel lazy singleton for test isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
|
||||
|
||||
const cache = new PreparedStatementCache();
|
||||
const stmt = sqlite.prepare("SELECT * FROM users WHERE id = ?");
|
||||
cache.set("SELECT * FROM users WHERE id = ?", stmt);
|
||||
|
||||
const hit = cache.get("SELECT * FROM users WHERE id = ?");
|
||||
// hit === stmt
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
|
||||
|
||||
// Small cache for testing eviction
|
||||
const cache = new PreparedStatementCache(3);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const sql = `SELECT ${i} FROM t`;
|
||||
cache.set(sql, { finalize: () => undefined }); // mock statement
|
||||
}
|
||||
|
||||
console.log(cache.size); // 3 — first entry evicted
|
||||
|
||||
cache.invalidate();
|
||||
console.log(cache.size); // 0 — all entries cleared
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new PreparedStatementCache(maxSize?: number)`
|
||||
|
||||
Constructs a new cache. `maxSize` defaults to `128`.
|
||||
|
||||
### `get(sql: string): Statement | undefined`
|
||||
|
||||
Returns the cached statement for `sql`, or `undefined` on miss. Increments `sqlite.cache.hits` or `sqlite.cache.misses`. Updates LRU order on hit.
|
||||
|
||||
### `set(sql: string, stmt: Statement): void`
|
||||
|
||||
Inserts or updates the cache entry for `sql`. On capacity overflow, evicts the least-recently-used entry, calling `stmt.finalize()` on it, and increments `sqlite.cache.evictions`.
|
||||
|
||||
### `invalidate(): void`
|
||||
|
||||
Clears all entries. Calls `stmt.finalize()` on each held statement before clearing. Called automatically by `SqliteAdapter.migrate()`.
|
||||
|
||||
### `size: number`
|
||||
|
||||
Current number of cached entries.
|
||||
|
||||
### `maxSize: number`
|
||||
|
||||
Maximum capacity as set at construction.
|
||||
|
||||
### `static resetInstruments(): void`
|
||||
|
||||
Clears the lazy OTel instruments singleton. Use in tests to ensure each test suite starts with a fresh counter baseline.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
LRU ordering is maintained with a parallel `string[]` array (`#lruOrder`) rather than a doubly-linked list. This is simpler and sufficient for cache sizes up to a few hundred entries — `Array.indexOf` and `splice` on an array of at most 128 strings add negligible overhead compared to a SQLite query round-trip. For much larger caches a linked-list implementation would be preferable.
|
||||
|
||||
`finalize()` is called defensively with a try/catch because `bun:sqlite` statements may already be finalised if the underlying database was closed before the cache was cleared.
|
||||
157
packages/persistence/database/db-sqlite/src/cache/cache.test.ts
vendored
Normal file
157
packages/persistence/database/db-sqlite/src/cache/cache.test.ts
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { PreparedStatementCache } from "./cache.js";
|
||||
|
||||
// Minimal mock statement
|
||||
function makeStmt(id = 0): { id: number; finalize: () => void } {
|
||||
return { id, finalize: vi.fn() };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
PreparedStatementCache.resetInstruments();
|
||||
});
|
||||
|
||||
describe("PreparedStatementCache", () => {
|
||||
it("returns undefined on cache miss", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
expect(cache.get("SELECT 1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns cached statement on hit", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
const stmt = makeStmt();
|
||||
cache.set("SELECT 1", stmt);
|
||||
expect(cache.get("SELECT 1")).toBe(stmt);
|
||||
});
|
||||
|
||||
it("size reflects current count", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
expect(cache.size).toBe(0);
|
||||
cache.set("SELECT 1", makeStmt());
|
||||
expect(cache.size).toBe(1);
|
||||
cache.set("SELECT 2", makeStmt());
|
||||
expect(cache.size).toBe(2);
|
||||
});
|
||||
|
||||
it("maxSize reflects constructor arg", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache(64);
|
||||
expect(cache.maxSize).toBe(64);
|
||||
});
|
||||
|
||||
it("evicts LRU entry when at capacity", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache(2);
|
||||
const s1 = makeStmt(1);
|
||||
const s2 = makeStmt(2);
|
||||
const s3 = makeStmt(3);
|
||||
cache.set("a", s1);
|
||||
cache.set("b", s2);
|
||||
cache.set("c", s3); // evicts "a" (LRU)
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
expect(cache.get("b")).toBe(s2);
|
||||
expect(cache.get("c")).toBe(s3);
|
||||
});
|
||||
|
||||
it("calls finalize() on evicted statement", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache(1);
|
||||
const s1 = makeStmt(1);
|
||||
const s2 = makeStmt(2);
|
||||
cache.set("a", s1);
|
||||
cache.set("b", s2); // evicts "a"
|
||||
expect(s1.finalize).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("get() updates LRU order", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache(2);
|
||||
const s1 = makeStmt(1);
|
||||
const s2 = makeStmt(2);
|
||||
const s3 = makeStmt(3);
|
||||
cache.set("a", s1);
|
||||
cache.set("b", s2);
|
||||
// Access "a" to make it MRU — now "b" is LRU
|
||||
cache.get("a");
|
||||
cache.set("c", s3); // should evict "b"
|
||||
expect(cache.get("a")).toBe(s1);
|
||||
expect(cache.get("b")).toBeUndefined();
|
||||
expect(cache.get("c")).toBe(s3);
|
||||
});
|
||||
|
||||
it("set() on existing key updates without growing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new PreparedStatementCache(3);
|
||||
const s1 = makeStmt(1);
|
||||
const s2 = makeStmt(2);
|
||||
cache.set("a", s1);
|
||||
cache.set("a", s2); // update, not insert
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get("a")).toBe(s2);
|
||||
});
|
||||
|
||||
it("invalidate() clears all entries and calls finalize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
const s1 = makeStmt(1);
|
||||
const s2 = makeStmt(2);
|
||||
cache.set("a", s1);
|
||||
cache.set("b", s2);
|
||||
cache.invalidate();
|
||||
expect(cache.size).toBe(0);
|
||||
expect(s1.finalize).toHaveBeenCalledOnce();
|
||||
expect(s2.finalize).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("invalidate() is safe on empty cache", { timeout: 5000 }, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
expect(() => cache.invalidate()).not.toThrow();
|
||||
});
|
||||
|
||||
it("tolerates finalize() throwing during eviction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new PreparedStatementCache(1);
|
||||
const badStmt = {
|
||||
finalize: () => {
|
||||
throw new Error("already closed");
|
||||
},
|
||||
};
|
||||
cache.set("a", badStmt);
|
||||
// Should not throw
|
||||
expect(() => cache.set("b", makeStmt())).not.toThrow();
|
||||
});
|
||||
|
||||
it("tolerates finalize() throwing during invalidate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new PreparedStatementCache();
|
||||
const badStmt = {
|
||||
finalize: () => {
|
||||
throw new Error("already closed");
|
||||
},
|
||||
};
|
||||
cache.set("a", badStmt);
|
||||
expect(() => cache.invalidate()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PreparedStatementCache property-based", () => {
|
||||
it("cache.size never exceeds maxSize", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.array(fc.string({ minLength: 1, maxLength: 50 }), {
|
||||
minLength: 0,
|
||||
maxLength: 50,
|
||||
}),
|
||||
(maxSize, keys) => {
|
||||
const cache = new PreparedStatementCache(maxSize);
|
||||
for (const key of keys) {
|
||||
cache.set(key, makeStmt());
|
||||
}
|
||||
expect(cache.size).toBeLessThanOrEqual(maxSize);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
126
packages/persistence/database/db-sqlite/src/cache/cache.ts
vendored
Normal file
126
packages/persistence/database/db-sqlite/src/cache/cache.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const DEFAULT_MAX_SIZE = 128;
|
||||
|
||||
let cacheInstruments:
|
||||
| {
|
||||
hits: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createCounter"]
|
||||
>;
|
||||
misses: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createCounter"]
|
||||
>;
|
||||
evictions: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createCounter"]
|
||||
>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function getCacheInstruments(): typeof cacheInstruments & object {
|
||||
if (cacheInstruments !== undefined) return cacheInstruments;
|
||||
const meter = metrics.getMeter("@suaveplan/db-sqlite");
|
||||
cacheInstruments = {
|
||||
hits: meter.createCounter("sqlite.cache.hits", {
|
||||
description: "Number of prepared-statement cache hits",
|
||||
}),
|
||||
misses: meter.createCounter("sqlite.cache.misses", {
|
||||
description: "Number of prepared-statement cache misses",
|
||||
}),
|
||||
evictions: meter.createCounter("sqlite.cache.evictions", {
|
||||
description: "Number of prepared-statement cache evictions",
|
||||
}),
|
||||
};
|
||||
return cacheInstruments;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Statement type varies by environment
|
||||
stmt: any;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class PreparedStatementCache {
|
||||
readonly #maxSize: number;
|
||||
readonly #entries = new Map<string, CacheEntry>();
|
||||
readonly #lruOrder: string[] = [];
|
||||
|
||||
constructor(maxSize: number = DEFAULT_MAX_SIZE) {
|
||||
this.#maxSize = maxSize;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Statement type varies by environment
|
||||
get(sql: string): any | undefined {
|
||||
const entry = this.#entries.get(sql);
|
||||
if (entry === undefined) {
|
||||
getCacheInstruments().misses.add(1);
|
||||
return undefined;
|
||||
}
|
||||
// Move to most-recently-used position
|
||||
const idx = this.#lruOrder.indexOf(sql);
|
||||
if (idx !== -1) {
|
||||
this.#lruOrder.splice(idx, 1);
|
||||
}
|
||||
this.#lruOrder.push(sql);
|
||||
getCacheInstruments().hits.add(1);
|
||||
return entry.stmt;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Statement type varies by environment
|
||||
set(sql: string, stmt: any): void {
|
||||
if (this.#entries.has(sql)) {
|
||||
// Update existing — refresh LRU position
|
||||
const idx = this.#lruOrder.indexOf(sql);
|
||||
if (idx !== -1) {
|
||||
this.#lruOrder.splice(idx, 1);
|
||||
}
|
||||
this.#lruOrder.push(sql);
|
||||
this.#entries.set(sql, { stmt, key: sql });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#entries.size >= this.#maxSize) {
|
||||
// Evict least-recently-used
|
||||
const lru = this.#lruOrder.shift();
|
||||
if (lru !== undefined) {
|
||||
const evicted = this.#entries.get(lru);
|
||||
this.#entries.delete(lru);
|
||||
if (evicted !== undefined) {
|
||||
try {
|
||||
evicted.stmt.finalize?.();
|
||||
} catch {
|
||||
// ignore finalize errors
|
||||
}
|
||||
getCacheInstruments().evictions.add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#entries.set(sql, { stmt, key: sql });
|
||||
this.#lruOrder.push(sql);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
for (const entry of this.#entries.values()) {
|
||||
try {
|
||||
entry.stmt.finalize?.();
|
||||
} catch {
|
||||
// ignore finalize errors
|
||||
}
|
||||
}
|
||||
this.#entries.clear();
|
||||
this.#lruOrder.length = 0;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#entries.size;
|
||||
}
|
||||
|
||||
get maxSize(): number {
|
||||
return this.#maxSize;
|
||||
}
|
||||
|
||||
/** Resets the OTel instruments singleton (for test isolation). */
|
||||
static resetInstruments(): void {
|
||||
cacheInstruments = undefined;
|
||||
}
|
||||
}
|
||||
2
packages/persistence/database/db-sqlite/src/cache/index.ts
vendored
Normal file
2
packages/persistence/database/db-sqlite/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { CacheEntry } from "./cache.js";
|
||||
export { PreparedStatementCache } from "./cache.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBunDriver } from "../drivers/bun-driver.js";
|
||||
import { runConformanceSuite } from "./conformance-suite.js";
|
||||
|
||||
runConformanceSuite("Bun driver", {
|
||||
async create() {
|
||||
return createBunDriver({ filename: ":memory:" });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# conformance-suite
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `runConformanceSuite`, a driver-agnostic test factory that accepts a `DriverFactory` callback and executes a standard suite of CRUD, transaction, and rollback assertions against any SQLite driver. Running the same suite against both the Bun driver and a WASM driver ensures that both runtimes satisfy the same behavioural contract, catching driver-specific regressions before they reach production.
|
||||
|
||||
The suite is intentionally minimal — it tests the lowest-level SQL operations that the adapter layer depends on. Higher-level tests (prepared-statement cache behaviour, migration runner idempotency, OTel counter increments) live in their respective unit test files.
|
||||
|
||||
## Features
|
||||
|
||||
- `runConformanceSuite(name, factory)` — registers a Vitest `describe` block named `${name} conformance`
|
||||
- Tests: table creation, SELECT, INSERT with parameters, transaction commit, transaction rollback, UPDATE, DELETE
|
||||
- `DriverFactory` interface — a single `create()` method returning `{ sqlite, close }`
|
||||
- Runs under the Bun driver via `bun-conformance.test.ts`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
runConformanceSuite("Bun driver", {
|
||||
async create() {
|
||||
return createBunDriver({ filename: ":memory:" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
|
||||
|
||||
// Custom driver shim for testing
|
||||
runConformanceSuite("Custom driver", {
|
||||
async create() {
|
||||
const driver = await MyCustomDriver.open(":memory:");
|
||||
return { sqlite: driver.raw, close: async () => driver.close() };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DriverFactory`
|
||||
|
||||
```ts
|
||||
interface DriverFactory {
|
||||
create(): Promise<{ sqlite: any; close: () => Promise<void> }>;
|
||||
}
|
||||
```
|
||||
|
||||
### `runConformanceSuite(name: string, factory: DriverFactory): void`
|
||||
|
||||
Registers a Vitest `describe` block with 5 test cases covering the core SQLite contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The conformance suite uses raw `sqlite.run()` / `sqlite.query().all()` calls rather than going through `SqliteAdapter` or Drizzle. This keeps the suite focused on driver-level semantics rather than adapter-layer concerns, making it useful for validating new driver implementations independently of the adapter code.
|
||||
|
||||
Each test creates fresh tables with unique names to avoid interference between tests within the same suite run.
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
export interface DriverFactory {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: driver shape varies by runtime
|
||||
create(): Promise<{ sqlite: any; close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver-agnostic conformance test suite. Accepts a DriverFactory and runs
|
||||
* a standard set of CRUD, transaction, and cache-behaviour assertions that
|
||||
* every SQLite driver must satisfy.
|
||||
*/
|
||||
export function runConformanceSuite(
|
||||
name: string,
|
||||
factory: DriverFactory
|
||||
): void {
|
||||
describe(`${name} conformance`, () => {
|
||||
it("can create and query a table", { timeout: 30000 }, async () => {
|
||||
const { sqlite, close } = await factory.create();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE conf_users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"
|
||||
);
|
||||
sqlite.run("INSERT INTO conf_users VALUES (1, 'Alice')");
|
||||
sqlite.run("INSERT INTO conf_users VALUES (2, 'Bob')");
|
||||
const rows = sqlite
|
||||
.query("SELECT name FROM conf_users ORDER BY id")
|
||||
.all() as { name: string }[];
|
||||
expect(rows.map((r) => r.name)).toEqual(["Alice", "Bob"]);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction commits all inserts on success", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await factory.create();
|
||||
try {
|
||||
sqlite.run("CREATE TABLE conf_tx (id INTEGER PRIMARY KEY)");
|
||||
sqlite.run("BEGIN IMMEDIATE");
|
||||
sqlite.run("INSERT INTO conf_tx VALUES (1)");
|
||||
sqlite.run("INSERT INTO conf_tx VALUES (2)");
|
||||
sqlite.run("COMMIT");
|
||||
const rows = sqlite.query("SELECT id FROM conf_tx").all() as {
|
||||
id: number;
|
||||
}[];
|
||||
expect(rows).toHaveLength(2);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction rolls back on failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await factory.create();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE conf_rollback (id INTEGER PRIMARY KEY)"
|
||||
);
|
||||
sqlite.run("BEGIN IMMEDIATE");
|
||||
sqlite.run("INSERT INTO conf_rollback VALUES (1)");
|
||||
sqlite.run("ROLLBACK");
|
||||
const rows = sqlite.query("SELECT id FROM conf_rollback").all();
|
||||
expect(rows).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports parameterised queries", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await factory.create();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE conf_params (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
sqlite.run("INSERT INTO conf_params VALUES (?, ?)", [
|
||||
1,
|
||||
"hello",
|
||||
]);
|
||||
const row = sqlite
|
||||
.query("SELECT val FROM conf_params WHERE id = ?")
|
||||
.get([1]) as { val: string } | null;
|
||||
expect(row?.val).toBe("hello");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("can update and delete rows", { timeout: 30000 }, async () => {
|
||||
const { sqlite, close } = await factory.create();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE conf_ud (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
sqlite.run("INSERT INTO conf_ud VALUES (1, 'a')");
|
||||
sqlite.run("UPDATE conf_ud SET val = ? WHERE id = ?", ["b", 1]);
|
||||
const updated = sqlite
|
||||
.query("SELECT val FROM conf_ud WHERE id = 1")
|
||||
.get() as { val: string } | null;
|
||||
expect(updated?.val).toBe("b");
|
||||
|
||||
sqlite.run("DELETE FROM conf_ud WHERE id = ?", [1]);
|
||||
const deleted = sqlite
|
||||
.query("SELECT 1 FROM conf_ud WHERE id = 1")
|
||||
.get();
|
||||
expect(deleted).toBeNull();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# connection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides thin singleton-management wrappers for `SqliteAdapter`, built on top of the `@suaveplan/db` singleton helpers. These functions let callers share a single connected `SqliteAdapter` instance across an application without manually tracking the singleton lifecycle or passing the adapter through every layer of the call stack.
|
||||
|
||||
The helpers default the singleton name to `"default"` so simple applications need only call `getDefaultSqlite()` with no arguments. More complex applications with multiple databases use explicit names.
|
||||
|
||||
## Features
|
||||
|
||||
- `getDefaultSqlite(options?, config?, name?)` — creates-or-returns a connected `SqliteAdapter` singleton
|
||||
- `closeSqlite(name?)` — disconnects and removes the named singleton
|
||||
- `closeAllSqlite()` — disconnects and removes all `SqliteAdapter` singletons
|
||||
- `hasSqlite(name?)` — returns `true` if the named singleton exists and is connected
|
||||
- Default name: `"default"` — single-database apps never need to supply a name
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDefaultSqlite } from "@suaveplan/db-sqlite/connection/connection";
|
||||
|
||||
const adapter = await getDefaultSqlite();
|
||||
const rows = await adapter.query("SELECT * FROM users");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
closeAllSqlite,
|
||||
closeSqlite,
|
||||
getDefaultSqlite,
|
||||
hasSqlite,
|
||||
} from "@suaveplan/db-sqlite/connection/connection";
|
||||
|
||||
// Primary database
|
||||
const primary = await getDefaultSqlite(
|
||||
{ filename: "/data/app.db" },
|
||||
undefined,
|
||||
"primary"
|
||||
);
|
||||
|
||||
// Analytics read-only shard
|
||||
const analytics = await getDefaultSqlite(
|
||||
{ filename: "/data/analytics.db", readonly: true },
|
||||
undefined,
|
||||
"analytics"
|
||||
);
|
||||
|
||||
console.log(hasSqlite("primary")); // true
|
||||
console.log(hasSqlite("analytics")); // true
|
||||
|
||||
await closeSqlite("analytics");
|
||||
console.log(hasSqlite("analytics")); // false
|
||||
|
||||
// Shutdown — closes all remaining singletons
|
||||
await closeAllSqlite();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDefaultSqlite(options?, config?, name?): Promise<SqliteAdapter>`
|
||||
|
||||
Creates a new `SqliteAdapter` using the given `options`, calls `connect(config)` on it, caches it under `name`, and returns it. Subsequent calls with the same `name` return the cached instance without reconnecting.
|
||||
|
||||
### `closeSqlite(name?): Promise<void>`
|
||||
|
||||
Disconnects and removes the singleton named `name`. No-op if no singleton by that name exists.
|
||||
|
||||
### `closeAllSqlite(): Promise<void>`
|
||||
|
||||
Disconnects and removes all singletons. Throws `AggregateError` if any disconnect fails.
|
||||
|
||||
### `hasSqlite(name?): boolean`
|
||||
|
||||
Returns `true` if a singleton exists under `name`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
These functions delegate entirely to `@suaveplan/db`'s `getSingleton`, `closeSingleton`, `closeAllSingletons`, and `hasSingleton`. The `SqliteAdapter`-specific names (`getDefaultSqlite`, `closeSqlite`, etc.) exist for discoverability — callers working with SQLite need not know the generic singleton API. The return type is narrowed from `DatabaseAdapter` to `SqliteAdapter` via a cast so callers have access to SQLite-specific properties.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SqliteAdapter } from "../adapter/adapter.js";
|
||||
import {
|
||||
closeAllSqlite,
|
||||
closeSqlite,
|
||||
getDefaultSqlite,
|
||||
hasSqlite,
|
||||
} from "./connection.js";
|
||||
|
||||
// Use unique name prefixes to avoid cross-test bleed with the module-level registry
|
||||
const PREFIX = "__connection_test__";
|
||||
let counter = 0;
|
||||
function uniqName(): string {
|
||||
return `${PREFIX}${counter++}`;
|
||||
}
|
||||
|
||||
describe("getDefaultSqlite", () => {
|
||||
it("creates and connects an adapter on first call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name = uniqName();
|
||||
const adapter = await getDefaultSqlite(undefined, undefined, name);
|
||||
try {
|
||||
expect(adapter).toBeInstanceOf(SqliteAdapter);
|
||||
expect(adapter.isConnected).toBe(true);
|
||||
} finally {
|
||||
await closeSqlite(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns same instance on second call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name = uniqName();
|
||||
const a1 = await getDefaultSqlite(undefined, undefined, name);
|
||||
const a2 = await getDefaultSqlite(undefined, undefined, name);
|
||||
try {
|
||||
expect(a1).toBe(a2);
|
||||
} finally {
|
||||
await closeSqlite(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSqlite", () => {
|
||||
it("returns false before getDefaultSqlite", { timeout: 5000 }, () => {
|
||||
expect(hasSqlite(uniqName())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after getDefaultSqlite", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name = uniqName();
|
||||
await getDefaultSqlite(undefined, undefined, name);
|
||||
try {
|
||||
expect(hasSqlite(name)).toBe(true);
|
||||
} finally {
|
||||
await closeSqlite(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false after closeSqlite", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name = uniqName();
|
||||
await getDefaultSqlite(undefined, undefined, name);
|
||||
await closeSqlite(name);
|
||||
expect(hasSqlite(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeSqlite", () => {
|
||||
it("disconnects and removes adapter from registry", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name = uniqName();
|
||||
const adapter = await getDefaultSqlite(undefined, undefined, name);
|
||||
await closeSqlite(name);
|
||||
expect(adapter.isConnected).toBe(false);
|
||||
expect(hasSqlite(name)).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op for unregistered name", { timeout: 30000 }, async () => {
|
||||
await expect(closeSqlite(uniqName())).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeAllSqlite", () => {
|
||||
it("disconnects all adapters and clears registry", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const name1 = uniqName();
|
||||
const name2 = uniqName();
|
||||
const a = await getDefaultSqlite(undefined, undefined, name1);
|
||||
const b = await getDefaultSqlite(undefined, undefined, name2);
|
||||
await closeAllSqlite();
|
||||
expect(a.isConnected).toBe(false);
|
||||
expect(b.isConnected).toBe(false);
|
||||
expect(hasSqlite(name1)).toBe(false);
|
||||
expect(hasSqlite(name2)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DatabaseConfig } from "@suaveplan/db";
|
||||
import {
|
||||
closeAllSingletons,
|
||||
closeSingleton,
|
||||
getSingleton,
|
||||
hasSingleton,
|
||||
} from "@suaveplan/db/connection/singleton";
|
||||
import { SqliteAdapter } from "../adapter/adapter.js";
|
||||
import type { SqliteAdapterOptions } from "../schemas/index.js";
|
||||
|
||||
const DEFAULT_SQLITE_NAME = "default";
|
||||
|
||||
export async function getDefaultSqlite(
|
||||
options?: SqliteAdapterOptions,
|
||||
config?: Partial<DatabaseConfig>,
|
||||
name: string = DEFAULT_SQLITE_NAME
|
||||
): Promise<SqliteAdapter> {
|
||||
return getSingleton(
|
||||
name,
|
||||
() => new SqliteAdapter(options),
|
||||
config
|
||||
) as Promise<SqliteAdapter>;
|
||||
}
|
||||
|
||||
export async function closeSqlite(
|
||||
name: string = DEFAULT_SQLITE_NAME
|
||||
): Promise<void> {
|
||||
return closeSingleton(name);
|
||||
}
|
||||
|
||||
export async function closeAllSqlite(): Promise<void> {
|
||||
return closeAllSingletons();
|
||||
}
|
||||
|
||||
export function hasSqlite(name: string = DEFAULT_SQLITE_NAME): boolean {
|
||||
return hasSingleton(name);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
closeAllSqlite,
|
||||
closeSqlite,
|
||||
getDefaultSqlite,
|
||||
hasSqlite,
|
||||
} from "./connection.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# bun-driver
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlite` (a built-in Bun module) and returns a thin wrapper holding the raw `Database` instance plus an idempotent `close()` function. The `isBunEnvironment()` guard ensures the driver is never accidentally called in a browser context — doing so raises `SQLITE.ENVIRONMENT_ERROR` immediately rather than surfacing a confusing module-not-found error at import time.
|
||||
|
||||
The dynamic `import("bun:sqlite")` pattern keeps the module evaluatable in non-Bun environments (so bundlers can tree-shake it out and browser builds do not fail), while still providing full type information from `@types/bun`.
|
||||
|
||||
## Features
|
||||
|
||||
- `createBunDriver(options?)` — opens an in-memory (default) or file-backed SQLite database via `bun:sqlite`
|
||||
- `isBunEnvironment()` — detects Bun runtime via `process.versions.bun`
|
||||
- `BunDriverResult` — `{ sqlite, close }` shape; `close()` is idempotent (safe to call multiple times)
|
||||
- Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` when called outside Bun
|
||||
- Throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if `Database` constructor throws (e.g. bad path, locked file)
|
||||
- `readonly` option forwarded to `bun:sqlite` `Database` constructor
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
const { sqlite, close } = await createBunDriver();
|
||||
// sqlite is a bun:sqlite Database instance — run raw SQL or pass to Drizzle
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver, isBunEnvironment } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
if (!isBunEnvironment()) {
|
||||
throw new Error("Server-side only path");
|
||||
}
|
||||
|
||||
const { sqlite, close } = await createBunDriver({
|
||||
filename: "/data/app.db",
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
try {
|
||||
sqlite.run("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)");
|
||||
sqlite.run("INSERT OR REPLACE INTO kv VALUES (?, ?)", ["hello", "world"]);
|
||||
} finally {
|
||||
await close(); // second call is a no-op
|
||||
await close(); // safe
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isBunEnvironment(): boolean`
|
||||
|
||||
Returns `true` when `process.versions.bun` is defined. Safe to call in any environment.
|
||||
|
||||
### `createBunDriver(options?: BunDriverOptions): Promise<BunDriverResult>`
|
||||
|
||||
Opens a SQLite database. Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun, `SqliteError(SQLITE.DRIVER_INIT_FAILED)` on open failure.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `options.filename` | `string` | `":memory:"` | File path or `":memory:"` |
|
||||
| `options.readonly` | `boolean` | `false` | Open in read-only mode |
|
||||
|
||||
### `BunDriverResult`
|
||||
|
||||
```ts
|
||||
interface BunDriverResult {
|
||||
sqlite: Database; // bun:sqlite Database instance
|
||||
close: () => Promise<void>; // idempotent — safe to call multiple times
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`close()` sets a `closed` flag so repeated calls are no-ops rather than throwing "database already closed". This is intentional: callers cleaning up in `finally` blocks should not have to track whether they already closed.
|
||||
|
||||
The `bun:sqlite` import is deferred via `await import("bun:sqlite")` inside the function body so that bundlers processing non-Bun targets (e.g. a Vite browser build) can externalize or ignore the module without causing a build error at parse time. The `isBunEnvironment()` guard before the import provides an early exit in the rare case this driver is invoked in a test environment that stubs the process object.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createBunDriver, isBunEnvironment } from "./bun-driver.js";
|
||||
|
||||
describe("isBunEnvironment", () => {
|
||||
it("returns true under Bun test runner", { timeout: 5000 }, () => {
|
||||
expect(isBunEnvironment()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunDriver", () => {
|
||||
it("creates an in-memory database by default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await createBunDriver();
|
||||
expect(sqlite).toBeDefined();
|
||||
await close();
|
||||
});
|
||||
|
||||
it("close() is idempotent", { timeout: 30000 }, async () => {
|
||||
const { close } = await createBunDriver();
|
||||
await close();
|
||||
await expect(close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("can run SQL after creation", { timeout: 30000 }, async () => {
|
||||
const { sqlite, close } = await createBunDriver();
|
||||
try {
|
||||
sqlite.run("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
|
||||
sqlite.run("INSERT INTO t VALUES (1, 'hello')");
|
||||
const row = sqlite.query("SELECT val FROM t WHERE id = 1").get();
|
||||
expect((row as { val: string }).val).toBe("hello");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a file-backed database when filename provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const path = `/tmp/db-sqlite-test-${Date.now()}.db`;
|
||||
const { sqlite, close } = await createBunDriver({ filename: path });
|
||||
try {
|
||||
sqlite.run("CREATE TABLE IF NOT EXISTS ping (id INTEGER)");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
// Clean up
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch {
|
||||
// ignore if already gone
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DRIVER_INIT_FAILED when path is in a nonexistent directory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bad = "/nonexistent/dir/that/does/not/exist/db.sqlite";
|
||||
await expect(createBunDriver({ filename: bad })).rejects.toMatchObject({
|
||||
code: "SQLITE.DRIVER_INIT_FAILED",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SQLITE_CODES, SqliteError } from "../errors/index.js";
|
||||
import type { BunDriverOptions } from "../schemas/index.js";
|
||||
|
||||
export interface BunDriverResult {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Database type varies by environment
|
||||
sqlite: any;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function isBunEnvironment(): boolean {
|
||||
return typeof Bun !== "undefined";
|
||||
}
|
||||
|
||||
export async function createBunDriver(
|
||||
options?: BunDriverOptions
|
||||
): Promise<BunDriverResult> {
|
||||
/* c8 ignore next 5 */
|
||||
if (!isBunEnvironment()) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.ENVIRONMENT_ERROR,
|
||||
"createBunDriver requires Bun runtime — use createWasmDriver in browser environments"
|
||||
);
|
||||
}
|
||||
|
||||
let sqlite: BunDriverResult["sqlite"];
|
||||
try {
|
||||
const { Database } = await import("bun:sqlite");
|
||||
const filename = options?.filename ?? ":memory:";
|
||||
sqlite = new Database(filename, {
|
||||
create: true,
|
||||
readonly: options?.readonly ?? false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.DRIVER_INIT_FAILED,
|
||||
`Failed to open SQLite database: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
|
||||
return {
|
||||
sqlite,
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sqlite.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { BunDriverResult } from "./bun-driver.js";
|
||||
export {
|
||||
createBunDriver,
|
||||
isBunEnvironment,
|
||||
} from "./bun-driver.js";
|
||||
export type { WasmDriverResult, WasmProxyDb } from "./wasm-driver.js";
|
||||
export { createWasmDriver } from "./wasm-driver.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
# wasm-driver
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running `wasm-worker.ts`, which loads `@sqlite.org/sqlite-wasm` and manages a SQLite database instance. Communication between the caller and the worker uses a message-passing protocol with sequential IDs mapped to `Promise` resolve/reject pairs, giving a fully async interface without blocking the main thread.
|
||||
|
||||
OPFS (Origin Private File System) persistence is attempted automatically when a non-`:memory:` filename is supplied and the worker context supports `sqlite3.oo1.OpfsDb`. When OPFS headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`) are absent, the worker falls back to in-memory storage with a console warning.
|
||||
|
||||
## Features
|
||||
|
||||
- `createWasmDriver(options?, workerFactory?)` — spawns worker, awaits ready handshake, returns `{ db, close }`
|
||||
- Injectable `workerFactory` parameter for test isolation — avoids real Worker spawning in Vitest
|
||||
- `WasmProxyDb` — `{ query(sql, params?), execute(sql, params?) }` interface returned by the driver
|
||||
- `close()` is idempotent and drains all pending requests with rejection before terminating the worker
|
||||
- Worker init failure terminates the worker and throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
|
||||
- Worker runtime errors reject all pending promises with `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
await db.execute("CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT)", []);
|
||||
await db.execute("INSERT INTO kv VALUES (?, ?)", ["hello", "world"]);
|
||||
const rows = await db.query("SELECT * FROM kv WHERE key = ?", ["hello"]);
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
|
||||
|
||||
// With OPFS persistence (requires COOP/COEP headers on the page)
|
||||
const { db, close } = await createWasmDriver({ filename: "app.db" });
|
||||
|
||||
try {
|
||||
await db.execute("BEGIN IMMEDIATE", []);
|
||||
await db.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"]);
|
||||
await db.execute("COMMIT", []);
|
||||
} catch (err) {
|
||||
await db.execute("ROLLBACK", []);
|
||||
throw err;
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createWasmDriver<TSchema>(options?, workerFactory?): Promise<WasmDriverResult<TSchema>>`
|
||||
|
||||
Spawns the WASM worker and awaits the `ready` handshake before resolving.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `options.filename` | `string` | `undefined` (→ `:memory:`) | OPFS file path or omit for in-memory |
|
||||
| `workerFactory` | `() => Worker` | `defaultWorkerFactory` | Injectable for testing |
|
||||
|
||||
**Throws** `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if the worker fails to initialise.
|
||||
|
||||
### `WasmProxyDb<TSchema>`
|
||||
|
||||
```ts
|
||||
interface WasmProxyDb<TSchema> {
|
||||
query(sql: string, params?: unknown[]): Promise<unknown[][]>;
|
||||
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### `WasmDriverResult<TSchema>`
|
||||
|
||||
```ts
|
||||
interface WasmDriverResult<TSchema> {
|
||||
db: WasmProxyDb<TSchema>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The worker URL is constructed with `new URL("./wasm-worker.js", import.meta.url)` so bundlers (Vite, webpack) can resolve it as a worker entry point and emit the correct asset path. The `.js` extension (not `.ts`) is used because the compiled output is what the browser loads at runtime.
|
||||
|
||||
The pending-promise map (`Map<id, {resolve, reject}>`) ensures that each in-flight request receives exactly one response, and that a stale response for a cancelled request is silently ignored. On `close()`, all pending promises are rejected before worker termination to prevent callers from hanging indefinitely.
|
||||
|
||||
OPFS support requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` response headers. The worker automatically falls back to in-memory storage without these headers rather than crashing, since detecting header presence from inside a Worker is unreliable.
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { SqliteError } from "../errors/index.js";
|
||||
import { createWasmDriver } from "./wasm-driver.js";
|
||||
|
||||
// Injectable worker factory avoids spawning real Workers in Node/Vitest environment
|
||||
function makeWorkerFactory(
|
||||
handler: (
|
||||
msg: unknown,
|
||||
post: (data: unknown) => void
|
||||
) => void | Promise<void>
|
||||
): () => Worker {
|
||||
return () => {
|
||||
let onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
let onerror: ((ev: ErrorEvent) => void) | null = null;
|
||||
|
||||
const post = (data: unknown) => {
|
||||
if (onmessage) {
|
||||
onmessage({ data } as MessageEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const worker = {
|
||||
get onmessage() {
|
||||
return onmessage;
|
||||
},
|
||||
set onmessage(fn: ((ev: MessageEvent) => void) | null) {
|
||||
onmessage = fn;
|
||||
},
|
||||
get onerror() {
|
||||
return onerror;
|
||||
},
|
||||
set onerror(fn: ((ev: ErrorEvent) => void) | null) {
|
||||
onerror = fn;
|
||||
},
|
||||
postMessage: (msg: unknown) => {
|
||||
void Promise.resolve().then(() => handler(msg, post));
|
||||
},
|
||||
terminate: vi.fn(),
|
||||
};
|
||||
|
||||
return worker as unknown as Worker;
|
||||
};
|
||||
}
|
||||
|
||||
function makeSuccessWorker(): () => Worker {
|
||||
return makeWorkerFactory((msg, post) => {
|
||||
const m = msg as {
|
||||
type: string;
|
||||
id: number;
|
||||
sql?: string;
|
||||
params?: unknown[];
|
||||
};
|
||||
if (m.type === "init") {
|
||||
post({ id: m.id, ok: true, result: { type: "ready" } });
|
||||
} else if (m.type === "query") {
|
||||
post({ id: m.id, ok: true, result: [["value1"], ["value2"]] });
|
||||
} else if (m.type === "execute") {
|
||||
post({ id: m.id, ok: true, result: { rowsAffected: 1 } });
|
||||
} else if (m.type === "close") {
|
||||
post({ id: m.id, ok: true, result: { type: "closed" } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("createWasmDriver", () => {
|
||||
it("creates driver and resolves on successful init", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { db, close } = await createWasmDriver(
|
||||
undefined,
|
||||
makeSuccessWorker()
|
||||
);
|
||||
expect(db).toBeDefined();
|
||||
await close();
|
||||
});
|
||||
|
||||
it("query returns rows from worker", { timeout: 30000 }, async () => {
|
||||
const { db, close } = await createWasmDriver(
|
||||
undefined,
|
||||
makeSuccessWorker()
|
||||
);
|
||||
const rows = await db.query("SELECT * FROM t");
|
||||
expect(rows).toEqual([["value1"], ["value2"]]);
|
||||
await close();
|
||||
});
|
||||
|
||||
it("execute returns rowsAffected from worker", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { db, close } = await createWasmDriver(
|
||||
undefined,
|
||||
makeSuccessWorker()
|
||||
);
|
||||
const result = await db.execute("INSERT INTO t VALUES (?)", [1]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
await close();
|
||||
});
|
||||
|
||||
it("close() is idempotent", { timeout: 30000 }, async () => {
|
||||
const { close } = await createWasmDriver(
|
||||
undefined,
|
||||
makeSuccessWorker()
|
||||
);
|
||||
await close();
|
||||
await expect(close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects pending requests when close() is called", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let postFn: ((data: unknown) => void) | null = null;
|
||||
|
||||
const slowWorker = makeWorkerFactory((msg, post) => {
|
||||
const m = msg as { type: string; id: number };
|
||||
if (m.type === "init") {
|
||||
post({ id: m.id, ok: true, result: { type: "ready" } });
|
||||
} else if (m.type === "close") {
|
||||
post({ id: m.id, ok: true, result: { type: "closed" } });
|
||||
}
|
||||
// query messages hang — capture postFn for manual resolution
|
||||
if (m.type === "query") {
|
||||
postFn = post;
|
||||
}
|
||||
});
|
||||
|
||||
const { db, close } = await createWasmDriver(undefined, slowWorker);
|
||||
|
||||
// Fire a query that will hang — capture the promise
|
||||
const queryPromise = db.query("SELECT 1");
|
||||
// Ensure the query is actually sent before closing
|
||||
await Promise.resolve();
|
||||
|
||||
// close() should drain pending
|
||||
await close();
|
||||
|
||||
// The hanging query should now reject
|
||||
await expect(queryPromise).rejects.toBeInstanceOf(SqliteError);
|
||||
|
||||
// Suppress unused variable warning
|
||||
void postFn;
|
||||
});
|
||||
|
||||
it("throws SqliteError on worker init failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const failWorker = makeWorkerFactory((msg, post) => {
|
||||
const m = msg as { type: string; id: number };
|
||||
if (m.type === "init") {
|
||||
post({
|
||||
id: m.id,
|
||||
ok: false,
|
||||
error: "sqlite-wasm init failed: load error",
|
||||
});
|
||||
} else if (m.type === "close") {
|
||||
post({ id: m.id, ok: true, result: { type: "closed" } });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
createWasmDriver(undefined, failWorker)
|
||||
).rejects.toBeInstanceOf(SqliteError);
|
||||
await expect(
|
||||
createWasmDriver(undefined, failWorker)
|
||||
).rejects.toMatchObject({ code: "SQLITE.DRIVER_INIT_FAILED" });
|
||||
});
|
||||
|
||||
it("worker error event rejects pending requests", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let fireError: ((ev: ErrorEvent) => void) | null = null;
|
||||
|
||||
const hangingWorker = makeWorkerFactory((msg, post) => {
|
||||
const m = msg as { type: string; id: number };
|
||||
if (m.type === "init") {
|
||||
post({ id: m.id, ok: true, result: { type: "ready" } });
|
||||
}
|
||||
// query messages hang — worker "crashes" below
|
||||
});
|
||||
|
||||
const patchedFactory = () => {
|
||||
const worker = hangingWorker();
|
||||
Object.defineProperty(worker, "onerror", {
|
||||
set: (fn: (ev: ErrorEvent) => void) => {
|
||||
fireError = fn;
|
||||
},
|
||||
get: () => fireError,
|
||||
configurable: true,
|
||||
});
|
||||
return worker;
|
||||
};
|
||||
|
||||
const { db } = await createWasmDriver(undefined, patchedFactory);
|
||||
|
||||
const queryPromise = db.query("SELECT 1");
|
||||
await Promise.resolve();
|
||||
|
||||
// Simulate worker crash — onerror should reject all pending promises
|
||||
fireError?.({ message: "Worker crashed" } as ErrorEvent);
|
||||
|
||||
await expect(queryPromise).rejects.toBeInstanceOf(SqliteError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { SQLITE_CODES, SqliteError } from "../errors/index.js";
|
||||
import type { WasmDriverOptions } from "../schemas/index.js";
|
||||
|
||||
export interface WasmDriverResult<TSchema = Record<string, never>> {
|
||||
db: WasmProxyDb<TSchema>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface WasmProxyDb<_TSchema = Record<string, never>> {
|
||||
query: (sql: string, params?: unknown[]) => Promise<unknown[][]>;
|
||||
execute: (
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
) => Promise<{ rowsAffected: number }>;
|
||||
}
|
||||
|
||||
type WorkerFactory = () => Worker;
|
||||
|
||||
interface WorkerResponse {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function defaultWorkerFactory(): Worker {
|
||||
return new Worker(new URL("./wasm-worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWasmDriver<TSchema = Record<string, never>>(
|
||||
options?: WasmDriverOptions,
|
||||
workerFactory: WorkerFactory = defaultWorkerFactory
|
||||
): Promise<WasmDriverResult<TSchema>> {
|
||||
const worker = workerFactory();
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
let nextId = 1;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const { id, ok, result, error } = event.data;
|
||||
const p = pending.get(id);
|
||||
if (p === undefined) return;
|
||||
pending.delete(id);
|
||||
if (ok) {
|
||||
p.resolve(result);
|
||||
} else {
|
||||
p.reject(
|
||||
new SqliteError(
|
||||
SQLITE_CODES.QUERY_FAILED,
|
||||
error ?? "Unknown worker error"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
const msg = event.message ?? "Worker error";
|
||||
for (const [id, p] of pending) {
|
||||
pending.delete(id);
|
||||
p.reject(new SqliteError(SQLITE_CODES.DRIVER_INIT_FAILED, msg));
|
||||
}
|
||||
};
|
||||
|
||||
function send<T>(
|
||||
type: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
worker.postMessage({ type, id, ...extra });
|
||||
});
|
||||
}
|
||||
|
||||
// Initialise the worker — await ready handshake
|
||||
try {
|
||||
await send("init", { filename: options?.filename });
|
||||
} catch (err) {
|
||||
worker.terminate();
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.DRIVER_INIT_FAILED,
|
||||
`WASM driver init failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
const db: WasmProxyDb<TSchema> = {
|
||||
query: async (sql, params) => {
|
||||
const rows = await send<unknown[][]>("query", { sql, params });
|
||||
return rows;
|
||||
},
|
||||
execute: async (sql, params) => {
|
||||
const result = await send<{ rowsAffected: number }>("execute", {
|
||||
sql,
|
||||
params,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
let closed = false;
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
// Drain pending with rejection
|
||||
for (const [id, p] of pending) {
|
||||
pending.delete(id);
|
||||
p.reject(
|
||||
new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Driver closed while request was pending"
|
||||
)
|
||||
);
|
||||
}
|
||||
try {
|
||||
await send("close");
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
worker.terminate();
|
||||
};
|
||||
|
||||
return { db, close };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# wasm-worker
|
||||
|
||||
## Purpose
|
||||
|
||||
Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `createWasmDriver` and handles all direct SQLite operations using `@sqlite.org/sqlite-wasm`. It communicates with the main thread via a simple message-passing protocol, keeping the WASM heap and SQLite connection completely isolated from the main thread's event loop.
|
||||
|
||||
Running SQLite operations in a worker prevents blocking the UI thread during expensive queries and migrations, and satisfies browser restrictions around synchronous WASM I/O on the main thread (required for OPFS-backed databases via `sqlite3_vfs`).
|
||||
|
||||
## Features
|
||||
|
||||
- Loads `@sqlite.org/sqlite-wasm` lazily on `init` message
|
||||
- Supports `init`, `query`, `execute`, `begin_tx`, `commit_tx`, `rollback_tx`, and `close` message types
|
||||
- Sequential request IDs for reliable request-response correlation
|
||||
- Returns structured `{ id, ok, result }` or `{ id, ok: false, error }` responses
|
||||
- Opens either an in-memory database (default) or a named database when `filename` is provided
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
This module is not imported directly. It is instantiated by `createWasmDriver` as a Web Worker:
|
||||
|
||||
```ts
|
||||
// Worker is created internally by the driver
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
||||
const rows = await db.query("SELECT * FROM t");
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For testing purposes, the worker's message protocol can be exercised directly by creating a mock worker that implements the same interface. The `createWasmDriver` function accepts an injectable `workerFactory` parameter for this:
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite";
|
||||
|
||||
function mockWorkerFactory(): Worker {
|
||||
let onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
const post = (data: unknown) => { if (onmessage) onmessage({ data } as MessageEvent); };
|
||||
|
||||
return {
|
||||
set onmessage(fn) { onmessage = fn; },
|
||||
postMessage(msg: unknown) {
|
||||
const m = msg as { type: string; id: number };
|
||||
if (m.type === "init") post({ id: m.id, ok: true, result: { type: "ready" } });
|
||||
if (m.type === "query") post({ id: m.id, ok: true, result: [] });
|
||||
},
|
||||
terminate() {},
|
||||
} as unknown as Worker;
|
||||
}
|
||||
|
||||
const { db, close } = await createWasmDriver(undefined, mockWorkerFactory);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Message Protocol (inbound → worker)
|
||||
|
||||
| `type` | Extra fields | Description |
|
||||
|---------------|----------------------------|-----------------------------------------|
|
||||
| `init` | `filename?: string` | Load WASM, open database, reply ready |
|
||||
| `query` | `sql: string`, `params?` | Run `all()`, reply with rows array |
|
||||
| `execute` | `sql: string`, `params?` | Run `run()`, reply with `rowsAffected` |
|
||||
| `begin_tx` | — | `BEGIN IMMEDIATE` |
|
||||
| `commit_tx` | — | `COMMIT` |
|
||||
| `rollback_tx` | — | `ROLLBACK` |
|
||||
| `close` | — | Close database, terminate worker |
|
||||
|
||||
### Message Protocol (outbound ← worker)
|
||||
|
||||
All responses carry `{ id: number, ok: boolean }`.
|
||||
|
||||
- Success: `{ id, ok: true, result: <payload> }`
|
||||
- Failure: `{ id, ok: false, error: "<message>" }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `/// <reference lib="webworker" />` triple-slash directive at the top of the file provides TypeScript access to `self`, `MessageEvent`, `postMessage`, and other Web Worker globals without requiring the file to be compiled with `"lib": ["WebWorker"]` globally — that would pollute the non-worker files.
|
||||
|
||||
The module uses the `@sqlite.org/sqlite-wasm` default export which is a zero-argument async factory: `const sqlite3 = await initSqlite()`. Previous versions of this package accepted a configuration argument; the current API does not.
|
||||
@@ -0,0 +1,176 @@
|
||||
/// <reference lib="webworker" />
|
||||
/**
|
||||
* Web Worker entry point for the @sqlite.org/sqlite-wasm driver.
|
||||
* Spawned by wasm-driver.ts; handles init, query, execute, and close messages.
|
||||
* Must not import any Node-only modules — runs in a browser Worker context.
|
||||
*/
|
||||
|
||||
interface InitMessage {
|
||||
type: "init";
|
||||
id: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface QueryMessage {
|
||||
type: "query";
|
||||
id: number;
|
||||
sql: string;
|
||||
params?: unknown[];
|
||||
}
|
||||
|
||||
interface ExecuteMessage {
|
||||
type: "execute";
|
||||
id: number;
|
||||
sql: string;
|
||||
params?: unknown[];
|
||||
}
|
||||
|
||||
interface BeginTxMessage {
|
||||
type: "begin_tx";
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface CommitTxMessage {
|
||||
type: "commit_tx";
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface RollbackTxMessage {
|
||||
type: "rollback_tx";
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface CloseMessage {
|
||||
type: "close";
|
||||
id: number;
|
||||
}
|
||||
|
||||
type WorkerMessage =
|
||||
| InitMessage
|
||||
| QueryMessage
|
||||
| ExecuteMessage
|
||||
| BeginTxMessage
|
||||
| CommitTxMessage
|
||||
| RollbackTxMessage
|
||||
| CloseMessage;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: sqlite OO1 API uses any
|
||||
let db: any = null;
|
||||
|
||||
function postOk(id: number, result?: unknown): void {
|
||||
self.postMessage({ id, ok: true, result });
|
||||
}
|
||||
|
||||
function postErr(id: number, message: string): void {
|
||||
self.postMessage({ id, ok: false, error: message });
|
||||
}
|
||||
|
||||
function runSql(sql: string, bind: unknown[]): void {
|
||||
// Using OO1 Database.exec() — this is sqlite3 OO1 API, not child_process
|
||||
db.exec({ sql, bind });
|
||||
}
|
||||
|
||||
function querySql(sql: string, bind: unknown[]): unknown[][] {
|
||||
const rows: unknown[][] = [];
|
||||
// Using OO1 Database.exec() — this is sqlite3 OO1 API, not child_process
|
||||
db.exec({ sql, bind, resultRows: rows, rowMode: "array" });
|
||||
return rows;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const msg = event.data;
|
||||
|
||||
if (msg.type === "init") {
|
||||
try {
|
||||
const initSqlite = (await import("@sqlite.org/sqlite-wasm"))
|
||||
.default;
|
||||
const sqlite3 = await initSqlite();
|
||||
|
||||
const filename = msg.filename ?? ":memory:";
|
||||
|
||||
if (
|
||||
filename !== ":memory:" &&
|
||||
typeof sqlite3.oo1.OpfsDb !== "undefined"
|
||||
) {
|
||||
try {
|
||||
db = new sqlite3.oo1.OpfsDb(filename);
|
||||
} catch {
|
||||
console.warn(
|
||||
"[wasm-worker] OPFS unavailable — falling back to in-memory SQLite"
|
||||
);
|
||||
db = new sqlite3.oo1.DB(":memory:");
|
||||
}
|
||||
} else {
|
||||
db = new sqlite3.oo1.DB(":memory:");
|
||||
}
|
||||
|
||||
postOk(msg.id, { type: "ready" });
|
||||
} catch (err) {
|
||||
postErr(
|
||||
msg.id,
|
||||
`sqlite-wasm init failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "close") {
|
||||
try {
|
||||
if (db !== null) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
postOk(msg.id, { type: "closed" });
|
||||
} catch (err) {
|
||||
postErr(
|
||||
msg.id,
|
||||
`close failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (db === null) {
|
||||
postErr(msg.id, "SQLITE.NOT_CONNECTED: database not initialised");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "query") {
|
||||
try {
|
||||
const rows = querySql(msg.sql, msg.params ?? []);
|
||||
postOk(msg.id, rows);
|
||||
} catch (err) {
|
||||
postErr(msg.id, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "execute") {
|
||||
try {
|
||||
runSql(msg.sql, msg.params ?? []);
|
||||
postOk(msg.id, { rowsAffected: db.changes() });
|
||||
} catch (err) {
|
||||
postErr(msg.id, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
msg.type === "begin_tx" ||
|
||||
msg.type === "commit_tx" ||
|
||||
msg.type === "rollback_tx"
|
||||
) {
|
||||
const sql =
|
||||
msg.type === "begin_tx"
|
||||
? "BEGIN IMMEDIATE"
|
||||
: msg.type === "commit_tx"
|
||||
? "COMMIT"
|
||||
: "ROLLBACK";
|
||||
try {
|
||||
runSql(sql, []);
|
||||
postOk(msg.id);
|
||||
} catch (err) {
|
||||
postErr(msg.id, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
93
packages/persistence/database/db-sqlite/src/errors/errors.md
Normal file
93
packages/persistence/database/db-sqlite/src/errors/errors.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error hierarchy for `@suaveplan/db-sqlite`. All runtime failures — connection problems, query errors, migration failures, environment mismatches — are reported as `SqliteError` instances carrying a stable `SQLITE.*` code from the `SQLITE_CODES` registry. This makes error handling programmatic and refactor-safe: callers switch on `err.code` rather than matching message strings.
|
||||
|
||||
The companion `mapSqliteCode` helper normalises raw error values thrown by `bun:sqlite` (which surfaces result-code strings like `SQLITE_BUSY` or `SQLITE_CONSTRAINT`) into the same stable code namespace, so adapters never leak driver-specific strings into calling code.
|
||||
|
||||
## Features
|
||||
|
||||
- `SQLITE_CODES` registry — 10 stable `SQLITE.*` code strings defined via `defineCodes`
|
||||
- `SqliteError extends SuaveplanError<SqliteCode>` — carries `code`, `message`, `cause`, and full `SuaveplanError` wire-format serialisation
|
||||
- `mapSqliteCode(err)` — maps raw `bun:sqlite` result codes and message substrings to stable `SqliteCode` values; falls back to `SQLITE.QUERY_FAILED`
|
||||
- Type alias `SqliteCode` — union of all valid code strings
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
|
||||
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Cannot query: adapter is not connected"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mapSqliteCode, SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
|
||||
|
||||
async function safeQuery(db: unknown, sql: string): Promise<unknown[]> {
|
||||
try {
|
||||
// ... execute query
|
||||
return [];
|
||||
} catch (raw) {
|
||||
const code = mapSqliteCode(raw);
|
||||
if (code === SQLITE_CODES.BUSY) {
|
||||
// caller can retry on BUSY
|
||||
throw new SqliteError(code, "Database is busy — retry after a moment", { cause: raw instanceof Error ? raw : undefined });
|
||||
}
|
||||
throw new SqliteError(code, `Query failed: ${raw instanceof Error ? raw.message : String(raw)}`, {
|
||||
cause: raw instanceof Error ? raw : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SQLITE_CODES`
|
||||
|
||||
Registry object produced by `defineCodes("SQLITE", [...])`. Keys and values:
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `NOT_CONNECTED` | `"SQLITE.NOT_CONNECTED"` |
|
||||
| `QUERY_FAILED` | `"SQLITE.QUERY_FAILED"` |
|
||||
| `EXECUTE_FAILED` | `"SQLITE.EXECUTE_FAILED"` |
|
||||
| `TRANSACTION_FAILED` | `"SQLITE.TRANSACTION_FAILED"` |
|
||||
| `MIGRATION_FAILED` | `"SQLITE.MIGRATION_FAILED"` |
|
||||
| `ENVIRONMENT_ERROR` | `"SQLITE.ENVIRONMENT_ERROR"` |
|
||||
| `BUSY` | `"SQLITE.BUSY"` |
|
||||
| `CONSTRAINT` | `"SQLITE.CONSTRAINT"` |
|
||||
| `CORRUPT` | `"SQLITE.CORRUPT"` |
|
||||
| `DRIVER_INIT_FAILED` | `"SQLITE.DRIVER_INIT_FAILED"` |
|
||||
|
||||
### `SqliteError`
|
||||
|
||||
```ts
|
||||
class SqliteError extends SuaveplanError<SqliteCode> {
|
||||
constructor(code: SqliteCode, message: string, options?: SuaveplanErrorOptions): SqliteError
|
||||
}
|
||||
```
|
||||
|
||||
Inherits `.code`, `.message`, `.cause`, `.name` from `SuaveplanError`. Fully serialisable to the `@suaveplan/error` wire format.
|
||||
|
||||
### `mapSqliteCode(err: unknown): SqliteCode`
|
||||
|
||||
Maps a raw thrown value from `bun:sqlite` to a stable `SqliteCode`. Checks `err.message` for known result-code strings (`SQLITE_BUSY`, `SQLITE_LOCKED`, `SQLITE_CONSTRAINT`, `SQLITE_CORRUPT`, `SQLITE_NOTADB`). Returns `SQLITE.QUERY_FAILED` as the safe default when no pattern matches.
|
||||
|
||||
### `SqliteCode`
|
||||
|
||||
Type alias: `(typeof SQLITE_CODES)[keyof typeof SQLITE_CODES]` — the union of all ten code strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `noUselessConstructor` biome rule is suppressed on `SqliteError` because `SuaveplanError` uses a `protected` constructor; the subclass constructor exists solely to expose a `public` entry point — without it TypeScript raises TS2674.
|
||||
|
||||
`mapSqliteCode` checks `err instanceof SqliteError` first so re-thrown errors are not re-wrapped. The result-code detection loop converts both the error message and the raw value to upper-case before matching, handling both `"SQLITE_BUSY: database is locked"` messages and bare result-code strings.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { mapSqliteCode, SQLITE_CODES, SqliteError } from "./errors.js";
|
||||
|
||||
describe("SQLITE_CODES", () => {
|
||||
it("has all ten stable codes", { timeout: 5000 }, () => {
|
||||
expect(SQLITE_CODES.NOT_CONNECTED).toBe("SQLITE.NOT_CONNECTED");
|
||||
expect(SQLITE_CODES.QUERY_FAILED).toBe("SQLITE.QUERY_FAILED");
|
||||
expect(SQLITE_CODES.EXECUTE_FAILED).toBe("SQLITE.EXECUTE_FAILED");
|
||||
expect(SQLITE_CODES.TRANSACTION_FAILED).toBe(
|
||||
"SQLITE.TRANSACTION_FAILED"
|
||||
);
|
||||
expect(SQLITE_CODES.MIGRATION_FAILED).toBe("SQLITE.MIGRATION_FAILED");
|
||||
expect(SQLITE_CODES.ENVIRONMENT_ERROR).toBe("SQLITE.ENVIRONMENT_ERROR");
|
||||
expect(SQLITE_CODES.BUSY).toBe("SQLITE.BUSY");
|
||||
expect(SQLITE_CODES.CONSTRAINT).toBe("SQLITE.CONSTRAINT");
|
||||
expect(SQLITE_CODES.CORRUPT).toBe("SQLITE.CORRUPT");
|
||||
expect(SQLITE_CODES.DRIVER_INIT_FAILED).toBe(
|
||||
"SQLITE.DRIVER_INIT_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteError", () => {
|
||||
it("is instanceof SuaveplanError", { timeout: 5000 }, () => {
|
||||
const err = new SqliteError(SQLITE_CODES.QUERY_FAILED, "query failed");
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err).toBeInstanceOf(SqliteError);
|
||||
});
|
||||
|
||||
it("has correct code", { timeout: 5000 }, () => {
|
||||
const err = new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"not connected"
|
||||
);
|
||||
expect(err.code).toBe("SQLITE.NOT_CONNECTED");
|
||||
});
|
||||
|
||||
it("has correct name", { timeout: 5000 }, () => {
|
||||
const err = new SqliteError(SQLITE_CODES.BUSY, "busy");
|
||||
expect(err.name).toBe("SqliteError");
|
||||
});
|
||||
|
||||
it("preserves cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("original");
|
||||
const err = new SqliteError(SQLITE_CODES.QUERY_FAILED, "wrapped", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("message is passed through directly", { timeout: 5000 }, () => {
|
||||
const err = new SqliteError(
|
||||
SQLITE_CODES.TRANSACTION_FAILED,
|
||||
"tx failed"
|
||||
);
|
||||
expect(err.message).toBe("tx failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSqliteCode", () => {
|
||||
it("returns code directly when already SqliteError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SqliteError(SQLITE_CODES.BUSY, "busy");
|
||||
expect(mapSqliteCode(err)).toBe("SQLITE.BUSY");
|
||||
});
|
||||
|
||||
it("maps SQLITE_BUSY message to BUSY", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
mapSqliteCode(new Error("SQLITE_BUSY: database is locked"))
|
||||
).toBe("SQLITE.BUSY");
|
||||
});
|
||||
|
||||
it("maps SQLITE_LOCKED message to BUSY", { timeout: 5000 }, () => {
|
||||
expect(mapSqliteCode(new Error("SQLITE_LOCKED: unable to lock"))).toBe(
|
||||
"SQLITE.BUSY"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps SQLITE_CONSTRAINT message to CONSTRAINT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
mapSqliteCode(
|
||||
new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed")
|
||||
)
|
||||
).toBe("SQLITE.CONSTRAINT");
|
||||
});
|
||||
|
||||
it("maps SQLITE_CORRUPT message to CORRUPT", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
mapSqliteCode(
|
||||
new Error("SQLITE_CORRUPT: database disk image is malformed")
|
||||
)
|
||||
).toBe("SQLITE.CORRUPT");
|
||||
});
|
||||
|
||||
it("maps SQLITE_NOTADB message to CORRUPT", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
mapSqliteCode(new Error("SQLITE_NOTADB: file is not a database"))
|
||||
).toBe("SQLITE.CORRUPT");
|
||||
});
|
||||
|
||||
it("falls back to QUERY_FAILED for unknown Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapSqliteCode(new Error("some random error"))).toBe(
|
||||
"SQLITE.QUERY_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", { timeout: 5000 }, () => {
|
||||
expect(mapSqliteCode("SQLITE_BUSY: locked")).toBe("SQLITE.BUSY");
|
||||
});
|
||||
|
||||
it("falls back to QUERY_FAILED for unknown non-Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapSqliteCode("something unexpected")).toBe(
|
||||
"SQLITE.QUERY_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
54
packages/persistence/database/db-sqlite/src/errors/errors.ts
Normal file
54
packages/persistence/database/db-sqlite/src/errors/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const SQLITE_CODES = defineCodes("SQLITE", [
|
||||
"NOT_CONNECTED",
|
||||
"QUERY_FAILED",
|
||||
"EXECUTE_FAILED",
|
||||
"TRANSACTION_FAILED",
|
||||
"MIGRATION_FAILED",
|
||||
"ENVIRONMENT_ERROR",
|
||||
"BUSY",
|
||||
"CONSTRAINT",
|
||||
"CORRUPT",
|
||||
"DRIVER_INIT_FAILED",
|
||||
] as const);
|
||||
|
||||
export type SqliteCode = (typeof SQLITE_CODES)[keyof typeof SQLITE_CODES];
|
||||
|
||||
export class SqliteError extends SuaveplanError<SqliteCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: SuaveplanError constructor is protected; exposes public entry point
|
||||
public constructor(
|
||||
code: SqliteCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
const RESULT_CODE_MAP: Record<string, SqliteCode> = {
|
||||
SQLITE_BUSY: SQLITE_CODES.BUSY,
|
||||
SQLITE_LOCKED: SQLITE_CODES.BUSY,
|
||||
SQLITE_CONSTRAINT: SQLITE_CODES.CONSTRAINT,
|
||||
SQLITE_CORRUPT: SQLITE_CODES.CORRUPT,
|
||||
SQLITE_NOTADB: SQLITE_CODES.CORRUPT,
|
||||
};
|
||||
|
||||
export function mapSqliteCode(err: unknown): SqliteCode {
|
||||
if (err instanceof SqliteError) return err.code;
|
||||
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message.toUpperCase()
|
||||
: String(err).toUpperCase();
|
||||
|
||||
for (const [pattern, code] of Object.entries(RESULT_CODE_MAP)) {
|
||||
if (msg.includes(pattern)) return code;
|
||||
}
|
||||
|
||||
return SQLITE_CODES.QUERY_FAILED;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { SqliteCode } from "./errors.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { SqliteCode } from "./errors.js";
|
||||
export {
|
||||
mapSqliteCode,
|
||||
SQLITE_CODES,
|
||||
SqliteError,
|
||||
} from "./errors.js";
|
||||
16
packages/persistence/database/db-sqlite/src/index.ts
Normal file
16
packages/persistence/database/db-sqlite/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// errors + schemas
|
||||
|
||||
// adapter
|
||||
export * from "./adapter/index.js";
|
||||
// cache
|
||||
export * from "./cache/index.js";
|
||||
// connection helpers
|
||||
export * from "./connection/index.js";
|
||||
// drivers
|
||||
export * from "./drivers/index.js";
|
||||
export * from "./errors/index.js";
|
||||
// migration runner
|
||||
export * from "./migration/index.js";
|
||||
// otel utilities
|
||||
export * from "./otel/index.js";
|
||||
export * from "./schemas/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { MigrationRunner } from "./migration.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# migration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationRunner` applies forward-only SQL migration files to a SQLite database. It maintains a ledger table (`__sqlite_migrations` by default) that records which migration files have been applied, making runs idempotent — re-running the same set of migration files produces the same database state regardless of how many times it is called.
|
||||
|
||||
Each migration is executed inside a `BEGIN IMMEDIATE` transaction. A failure mid-migration triggers an automatic `ROLLBACK`, ensuring the database is never left in a partially-applied state. A `dryRun()` mode reads and validates files but makes no database changes, letting callers preview what would be applied.
|
||||
|
||||
## Features
|
||||
|
||||
- Forward-only, filename-ordered migration application (`.sql` files sorted lexicographically)
|
||||
- Idempotent: skips already-applied migrations via ledger table lookup
|
||||
- Transactional: each migration runs in `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK`
|
||||
- Dry-run mode: previews pending migrations without modifying the database
|
||||
- Injectable `Clock` for deterministic `applied_at` timestamps in tests
|
||||
- Configurable `migrationsFolder`, `migrationsTable`, and `dryRun` via `MigrationRunnerOptionsSchema`
|
||||
- Throws `SqliteError(SQLITE.MIGRATION_FAILED)` with original cause on any failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
|
||||
|
||||
const { sqlite, close } = await createBunDriver({ filename: "app.db" });
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, { migrationsFolder: "./db/migrations" });
|
||||
const result = await runner.run();
|
||||
console.log(`Applied ${result.applied} migration(s):`, result.migrations);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
|
||||
|
||||
// Dry-run to preview without applying
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: "./db/migrations",
|
||||
migrationsTable: "custom_ledger",
|
||||
});
|
||||
|
||||
const preview = await runner.dryRun();
|
||||
console.log("Would apply:", preview.migrations);
|
||||
|
||||
if (preview.applied > 0) {
|
||||
const result = await runner.run();
|
||||
console.log("Applied:", result.migrations);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MigrationRunner(sqlite, options?, clock?)`
|
||||
|
||||
Constructs a `MigrationRunner`. Validates `options` via `MigrationRunnerOptionsSchema`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `sqlite` | `bun:sqlite Database` | Database instance to migrate |
|
||||
| `options` | `Partial<MigrationRunnerOptions>` | Folder, table, dry-run settings |
|
||||
| `clock` | `Clock` | Injectable clock for `applied_at` (default: `Date.now`) |
|
||||
|
||||
### `run(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
|
||||
|
||||
Reads `.sql` files from `migrationsFolder`, skips already-applied entries, applies each in order, records to ledger. Returns `{ applied: number, migrations: string[] }`.
|
||||
|
||||
### `dryRun(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
|
||||
|
||||
Same as `run()` but makes no database changes. Useful for change-preview UIs and CI checks.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Migrations are sorted lexicographically by filename. Conventional naming (`001_init.sql`, `002_add_users.sql`) produces chronological order. Non-`.sql` files in the folder are silently ignored.
|
||||
|
||||
The `#isMigrationApplied` check uses a raw SQL query rather than a prepared statement to avoid circular dependency with `PreparedStatementCache` — the migration runner runs before the adapter is fully connected and the cache is available.
|
||||
|
||||
The `applied_at` column stores a Unix millisecond timestamp as `INTEGER` rather than ISO 8601 text. This keeps sorting and comparison simple and avoids timezone ambiguity. Consumers that need a `Date` object divide by 1000 or use `new Date(applied_at)`.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { MigrationRunner } from "./migration.js";
|
||||
|
||||
async function makeDb(): Promise<{
|
||||
sqlite: import("bun:sqlite").Database;
|
||||
close: () => void;
|
||||
}> {
|
||||
const { Database } = await import("bun:sqlite");
|
||||
const sqlite = new Database(":memory:");
|
||||
return { sqlite, close: () => sqlite.close() };
|
||||
}
|
||||
|
||||
describe("MigrationRunner property-based", () => {
|
||||
it("ledger entries are monotonically sorted by filename after any run", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.stringMatching(/^[0-9]{3}_[a-z]{3}$/), {
|
||||
minLength: 1,
|
||||
maxLength: 6,
|
||||
noNullPrototypes: true,
|
||||
}),
|
||||
async (baseNames) => {
|
||||
const unique = [...new Set(baseNames)].sort();
|
||||
const { sqlite, close } = await makeDb();
|
||||
const folder = join(
|
||||
tmpdir(),
|
||||
`migration-prop-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
await mkdir(folder, { recursive: true });
|
||||
|
||||
for (const name of unique) {
|
||||
const safe = name.replace(/[^a-z0-9_]/gi, "_");
|
||||
await writeFile(
|
||||
join(folder, `${safe}.sql`),
|
||||
`CREATE TABLE IF NOT EXISTS t_${safe} (id INTEGER PRIMARY KEY)`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const result = await runner.run();
|
||||
expect(result.migrations).toEqual(
|
||||
[...result.migrations].sort()
|
||||
);
|
||||
} finally {
|
||||
close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
|
||||
it("re-running is idempotent: applied count equals 0 on second run", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.integer({ min: 1, max: 4 }), async (n) => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const folder = join(
|
||||
tmpdir(),
|
||||
`migration-idem-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
await mkdir(folder, { recursive: true });
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
await writeFile(
|
||||
join(folder, `${String(i).padStart(3, "0")}_step.sql`),
|
||||
`CREATE TABLE IF NOT EXISTS step_${i} (id INTEGER PRIMARY KEY)`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const first = await runner.run();
|
||||
const second = await runner.run();
|
||||
expect(first.applied).toBe(n);
|
||||
expect(second.applied).toBe(0);
|
||||
expect(second.migrations).toEqual([]);
|
||||
} finally {
|
||||
close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SqliteError } from "../errors/index.js";
|
||||
import { MigrationRunner } from "./migration.js";
|
||||
|
||||
// Minimal in-memory sqlite shim using real bun:sqlite
|
||||
async function makeDb(): Promise<{
|
||||
sqlite: import("bun:sqlite").Database;
|
||||
close: () => void;
|
||||
}> {
|
||||
const { Database } = await import("bun:sqlite");
|
||||
const sqlite = new Database(":memory:");
|
||||
return { sqlite, close: () => sqlite.close() };
|
||||
}
|
||||
|
||||
async function makeMigrationDir(
|
||||
files: Record<string, string>
|
||||
): Promise<{ folder: string; cleanup: () => Promise<void> }> {
|
||||
const folder = join(tmpdir(), `migration-test-${Date.now()}`);
|
||||
await mkdir(folder, { recursive: true });
|
||||
for (const [name, sql] of Object.entries(files)) {
|
||||
await writeFile(join(folder, name), sql, "utf8");
|
||||
}
|
||||
return {
|
||||
folder,
|
||||
cleanup: () => rm(folder, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MigrationRunner", () => {
|
||||
it("applies pending migrations and returns result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql":
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const result = await runner.run();
|
||||
expect(result.applied).toBe(1);
|
||||
expect(result.migrations).toEqual(["001_init.sql"]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent — second run applies nothing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql":
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
await runner.run();
|
||||
const second = await runner.run();
|
||||
expect(second.applied).toBe(0);
|
||||
expect(second.migrations).toEqual([]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies migrations in sorted filename order", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"002_add_col.sql": "ALTER TABLE t ADD COLUMN val TEXT",
|
||||
"001_create.sql": "CREATE TABLE t (id INTEGER PRIMARY KEY)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const result = await runner.run();
|
||||
expect(result.migrations).toEqual([
|
||||
"001_create.sql",
|
||||
"002_add_col.sql",
|
||||
]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dry-run does not modify the database", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql": "CREATE TABLE dry_test (id INTEGER PRIMARY KEY)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const result = await runner.dryRun();
|
||||
expect(result.applied).toBe(1);
|
||||
// Table should NOT exist — dry run made no changes
|
||||
expect(() =>
|
||||
sqlite.query("SELECT 1 FROM dry_test").get()
|
||||
).toThrow();
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws MIGRATION_FAILED when folder is missing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: "/nonexistent/path/migrations",
|
||||
});
|
||||
await expect(runner.run()).rejects.toBeInstanceOf(SqliteError);
|
||||
await expect(runner.run()).rejects.toMatchObject({
|
||||
code: "SQLITE.MIGRATION_FAILED",
|
||||
});
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws MIGRATION_FAILED when SQL is invalid", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_bad.sql": "THIS IS NOT VALID SQL !!@#$",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
await expect(runner.run()).rejects.toBeInstanceOf(SqliteError);
|
||||
await expect(runner.run()).rejects.toMatchObject({
|
||||
code: "SQLITE.MIGRATION_FAILED",
|
||||
});
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses injectable clock for applied_at timestamps", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql": "CREATE TABLE ts_test (id INTEGER PRIMARY KEY)",
|
||||
});
|
||||
const fixedNow = 1_000_000_000;
|
||||
const clock = () => fixedNow;
|
||||
try {
|
||||
const runner = new MigrationRunner(
|
||||
sqlite,
|
||||
{ migrationsFolder: folder },
|
||||
clock
|
||||
);
|
||||
await runner.run();
|
||||
const row = sqlite
|
||||
.query(
|
||||
"SELECT applied_at FROM __sqlite_migrations WHERE id = ?"
|
||||
)
|
||||
.get(["001_init.sql"]) as { applied_at: number } | null;
|
||||
expect(row?.applied_at).toBe(fixedNow);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips non-sql files in migrations folder", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql": "CREATE TABLE skip_test (id INTEGER PRIMARY KEY)",
|
||||
"README.md": "# migrations",
|
||||
".gitkeep": "",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
});
|
||||
const result = await runner.run();
|
||||
expect(result.applied).toBe(1);
|
||||
expect(result.migrations).toEqual(["001_init.sql"]);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports custom migrationsTable name", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql":
|
||||
"CREATE TABLE custom_table_test (id INTEGER PRIMARY KEY)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: folder,
|
||||
migrationsTable: "custom_ledger",
|
||||
});
|
||||
await runner.run();
|
||||
const row = sqlite.query("SELECT 1 FROM custom_ledger").get();
|
||||
expect(row).not.toBeNull();
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("run() accepts per-call option overrides", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
const { folder, cleanup } = await makeMigrationDir({
|
||||
"001_init.sql":
|
||||
"CREATE TABLE override_test (id INTEGER PRIMARY KEY)",
|
||||
});
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: "/nonexistent",
|
||||
});
|
||||
// Override with valid folder at call site
|
||||
const result = await runner.run({ migrationsFolder: folder });
|
||||
expect(result.applied).toBe(1);
|
||||
} finally {
|
||||
await cleanup();
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { MigrationResult } from "@suaveplan/db";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { SQLITE_CODES, SqliteError } from "../errors/index.js";
|
||||
import type { MigrationRunnerOptions } from "../schemas/index.js";
|
||||
import { MigrationRunnerOptionsSchema } from "../schemas/index.js";
|
||||
|
||||
const DEFAULT_CLOCK: Clock = () => Date.now();
|
||||
|
||||
export class MigrationRunner {
|
||||
readonly #options: Required<MigrationRunnerOptions>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Database type varies by environment
|
||||
readonly #sqlite: any;
|
||||
readonly #clock: Clock;
|
||||
|
||||
constructor(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: bun:sqlite Database type varies by environment
|
||||
sqlite: any,
|
||||
options?: Partial<MigrationRunnerOptions>,
|
||||
clock: Clock = DEFAULT_CLOCK
|
||||
) {
|
||||
const parsed = MigrationRunnerOptionsSchema.parse(options ?? {});
|
||||
this.#options = parsed as Required<MigrationRunnerOptions>;
|
||||
this.#sqlite = sqlite;
|
||||
this.#clock = clock;
|
||||
}
|
||||
|
||||
async run(
|
||||
options?: Partial<MigrationRunnerOptions>
|
||||
): Promise<MigrationResult> {
|
||||
const opts =
|
||||
options !== undefined
|
||||
? (MigrationRunnerOptionsSchema.parse({
|
||||
...this.#options,
|
||||
...options,
|
||||
}) as Required<MigrationRunnerOptions>)
|
||||
: this.#options;
|
||||
return this.#runWithOptions(opts);
|
||||
}
|
||||
|
||||
async dryRun(
|
||||
options?: Partial<MigrationRunnerOptions>
|
||||
): Promise<MigrationResult> {
|
||||
const base =
|
||||
options !== undefined
|
||||
? (MigrationRunnerOptionsSchema.parse({
|
||||
...this.#options,
|
||||
...options,
|
||||
}) as Required<MigrationRunnerOptions>)
|
||||
: this.#options;
|
||||
return this.#runWithOptions({ ...base, dryRun: true });
|
||||
}
|
||||
|
||||
async #runWithOptions(
|
||||
opts: Required<MigrationRunnerOptions>
|
||||
): Promise<MigrationResult> {
|
||||
this.#ensureLedgerTable(opts.migrationsTable);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
const entries = await readdir(opts.migrationsFolder);
|
||||
files = entries.filter((f) => f.endsWith(".sql")).sort();
|
||||
} catch (err) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.MIGRATION_FAILED,
|
||||
`Cannot read migrations folder '${opts.migrationsFolder}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
const applied: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const isApplied = this.#isMigrationApplied(
|
||||
opts.migrationsTable,
|
||||
file
|
||||
);
|
||||
if (isApplied) continue;
|
||||
|
||||
const filePath = join(opts.migrationsFolder, file);
|
||||
let sql: string;
|
||||
try {
|
||||
sql = await readFile(filePath, "utf8");
|
||||
} catch (err) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.MIGRATION_FAILED,
|
||||
`Cannot read migration file '${file}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
this.#sqlite.run("BEGIN IMMEDIATE");
|
||||
this.#sqlite.run(sql);
|
||||
this.#sqlite.run(
|
||||
`INSERT INTO ${opts.migrationsTable} (id, applied_at) VALUES (?, ?)`,
|
||||
[file, this.#clock()]
|
||||
);
|
||||
this.#sqlite.run("COMMIT");
|
||||
} catch (err) {
|
||||
try {
|
||||
this.#sqlite.run("ROLLBACK");
|
||||
} catch {
|
||||
// ignore rollback errors
|
||||
}
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.MIGRATION_FAILED,
|
||||
`Migration '${file}' failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
applied.push(file);
|
||||
}
|
||||
|
||||
return { applied: applied.length, migrations: applied };
|
||||
}
|
||||
|
||||
#ensureLedgerTable(table: string): void {
|
||||
try {
|
||||
this.#sqlite.run(`
|
||||
CREATE TABLE IF NOT EXISTS ${table} (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
} catch (err) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.MIGRATION_FAILED,
|
||||
`Failed to create migrations ledger table: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#isMigrationApplied(table: string, id: string): boolean {
|
||||
const row = this.#sqlite
|
||||
.query(`SELECT 1 FROM ${table} WHERE id = ?`)
|
||||
.get([id]);
|
||||
return row !== null && row !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
resetOtelInstruments,
|
||||
sanitizeSql,
|
||||
withQuerySpan,
|
||||
} from "./otel.js";
|
||||
65
packages/persistence/database/db-sqlite/src/otel/otel.md
Normal file
65
packages/persistence/database/db-sqlite/src/otel/otel.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation for `@suaveplan/db-sqlite`. The `withQuerySpan` function wraps any async database operation in a named OTel span, setting `db.system=sqlite` and `db.statement` (sanitised) as span attributes, recording exceptions on failure, and emitting latency and count metrics. It is a no-op when no OTel SDK is registered — the `@opentelemetry/api` no-op provider handles all calls silently.
|
||||
|
||||
`sanitizeSql` strips string literals and numeric values from SQL strings before attaching them to span attributes, preventing PII leakage into telemetry backends. Parameterised queries still record their structure (`SELECT * FROM users WHERE id = ?`) without revealing actual parameter values.
|
||||
|
||||
## Features
|
||||
|
||||
- `withQuerySpan(operationName, rawSql, fn)` — wraps fn in a named span with `db.system=sqlite` and sanitised `db.statement`
|
||||
- `sanitizeSql(sql)` — replaces string literals, quoted identifiers, and bare numbers with `?` placeholders
|
||||
- Histograms: `sqlite.query.duration_ms` (labels: `operation`)
|
||||
- Counters: `sqlite.query.count`, `sqlite.error.count` (labels: `operation`)
|
||||
- `resetOtelInstruments()` — clears the lazy instruments singleton for test isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withQuerySpan } from "@suaveplan/db-sqlite/otel";
|
||||
|
||||
const rows = await withQuerySpan(
|
||||
"db.query",
|
||||
"SELECT * FROM users WHERE id = 42",
|
||||
async () => sqlite.query("SELECT * FROM users WHERE id = ?").all([42])
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sanitizeSql, withQuerySpan } from "@suaveplan/db-sqlite/otel";
|
||||
|
||||
// Preview sanitisation before instrumentation
|
||||
const safe = sanitizeSql("SELECT * FROM t WHERE name = 'Alice' AND age = 30");
|
||||
// => "SELECT * FROM t WHERE name = ? AND age = ?"
|
||||
|
||||
const result = await withQuerySpan("db.execute", safe, async () => {
|
||||
sqlite.run("INSERT INTO events VALUES (?, ?)", ["login", 1234567890]);
|
||||
return { rowsAffected: 1 };
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withQuerySpan<T>(operationName, rawSql, fn): Promise<T>`
|
||||
|
||||
Starts a span named `operationName`, sets `db.system=sqlite` and `db.statement=sanitizeSql(rawSql)`, then calls `fn()`. On success records latency and increments `sqlite.query.count`. On failure records exception, sets `ERROR` status, and increments `sqlite.error.count`. Always ends the span.
|
||||
|
||||
### `sanitizeSql(sql: string): string`
|
||||
|
||||
Replaces single-quoted strings, double-quoted strings, and numeric literals (integer and decimal) with `?`. Safe to call on arbitrary SQL strings — does not parse SQL, only applies a regex substitution.
|
||||
|
||||
### `resetOtelInstruments(): void`
|
||||
|
||||
Clears the lazy OTel instruments singleton. Use in tests so each suite starts with fresh counter baselines. Also called internally by `SqliteAdapter.disconnect()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments lazy singleton is initialised on the first `withQuerySpan` call rather than at module load to ensure the OTel provider is registered before the meter is obtained. Tests that call `resetOtelInstruments()` between suites prevent counter state from leaking across test files.
|
||||
|
||||
`sanitizeSql` uses a single-pass regex and is intentionally simple — it does not understand SQL syntax, so complex nested strings or edge-case identifiers may not be fully sanitised. This is acceptable for telemetry purposes where approximate sanitisation is sufficient.
|
||||
102
packages/persistence/database/db-sqlite/src/otel/otel.test.ts
Normal file
102
packages/persistence/database/db-sqlite/src/otel/otel.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { resetOtelInstruments, sanitizeSql, withQuerySpan } from "./otel.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetOtelInstruments();
|
||||
});
|
||||
|
||||
describe("sanitizeSql", () => {
|
||||
it("strips single-quoted string literals", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql("SELECT * FROM t WHERE name = 'Alice'")).toBe(
|
||||
"SELECT * FROM t WHERE name = ?"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips double-quoted identifiers/strings", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql('SELECT "col" FROM t WHERE id = "val"')).toBe(
|
||||
"SELECT ? FROM t WHERE id = ?"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips integer literals", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql("SELECT * FROM t WHERE id = 42")).toBe(
|
||||
"SELECT * FROM t WHERE id = ?"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips decimal literals", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql("SELECT * FROM t WHERE score > 3.14")).toBe(
|
||||
"SELECT * FROM t WHERE score > ?"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips multiple literals in one query", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
sanitizeSql("SELECT * FROM t WHERE name = 'Bob' AND age = 30")
|
||||
).toBe("SELECT * FROM t WHERE name = ? AND age = ?");
|
||||
});
|
||||
|
||||
it("leaves plain SQL keywords untouched", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql("SELECT id FROM users")).toBe(
|
||||
"SELECT id FROM users"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty string", { timeout: 5000 }, () => {
|
||||
expect(sanitizeSql("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withQuerySpan", () => {
|
||||
it("returns resolved value on success", { timeout: 5000 }, async () => {
|
||||
const result = await withQuerySpan(
|
||||
"test.query",
|
||||
"SELECT 1",
|
||||
async () => 42
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("re-throws errors from fn", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withQuerySpan("test.query", "SELECT 1", async () => {
|
||||
throw new Error("query error");
|
||||
})
|
||||
).rejects.toThrow("query error");
|
||||
});
|
||||
|
||||
it("re-throws non-Error thrown values", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
withQuerySpan("test.query", "SELECT 1", async () => {
|
||||
throw "string error";
|
||||
})
|
||||
).rejects.toBe("string error");
|
||||
});
|
||||
|
||||
it("works with no-op OTel provider (no SDK registered)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
withQuerySpan("test.query", "SELECT 1", async () => [])
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("sanitises SQL for span attributes", { timeout: 5000 }, async () => {
|
||||
// Verify it doesn't throw when SQL contains literals
|
||||
const result = await withQuerySpan(
|
||||
"test.query",
|
||||
"SELECT * FROM t WHERE name = 'sensitive'",
|
||||
async () => "ok"
|
||||
);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("works after resetOtelInstruments()", { timeout: 5000 }, async () => {
|
||||
await withQuerySpan("test.query", "SELECT 1", async () => 1);
|
||||
resetOtelInstruments();
|
||||
// Should reinitialise instruments without throwing
|
||||
await expect(
|
||||
withQuerySpan("test.query", "SELECT 1", async () => 2)
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
89
packages/persistence/database/db-sqlite/src/otel/otel.ts
Normal file
89
packages/persistence/database/db-sqlite/src/otel/otel.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type Attributes,
|
||||
metrics,
|
||||
SpanStatusCode,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/db-sqlite";
|
||||
|
||||
let otelInstruments:
|
||||
| {
|
||||
queryDuration: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createHistogram"]
|
||||
>;
|
||||
queryCount: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createCounter"]
|
||||
>;
|
||||
errorCount: ReturnType<
|
||||
ReturnType<typeof metrics.getMeter>["createCounter"]
|
||||
>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function getInstruments(): typeof otelInstruments & object {
|
||||
if (otelInstruments !== undefined) return otelInstruments;
|
||||
const meter = metrics.getMeter(TRACER_NAME);
|
||||
otelInstruments = {
|
||||
queryDuration: meter.createHistogram("sqlite.query.duration_ms", {
|
||||
description: "Duration of SQLite query execution in milliseconds",
|
||||
unit: "ms",
|
||||
}),
|
||||
queryCount: meter.createCounter("sqlite.query.count", {
|
||||
description: "Number of SQLite queries executed",
|
||||
}),
|
||||
errorCount: meter.createCounter("sqlite.error.count", {
|
||||
description: "Number of SQLite query errors",
|
||||
}),
|
||||
};
|
||||
return otelInstruments;
|
||||
}
|
||||
|
||||
export function resetOtelInstruments(): void {
|
||||
otelInstruments = undefined;
|
||||
}
|
||||
|
||||
const LITERAL_PATTERN = /'[^']*'|"[^"]*"|\b\d+\.?\d*\b/g;
|
||||
|
||||
export function sanitizeSql(sql: string): string {
|
||||
return sql.replace(LITERAL_PATTERN, "?");
|
||||
}
|
||||
|
||||
export async function withQuerySpan<T>(
|
||||
operationName: string,
|
||||
rawSql: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const sanitised = sanitizeSql(rawSql);
|
||||
const attrs: Attributes = {
|
||||
"db.system": "sqlite",
|
||||
"db.statement": sanitised,
|
||||
};
|
||||
const span = tracer.startSpan(operationName, { attributes: attrs });
|
||||
const instruments = getInstruments();
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
const elapsed = Date.now() - start;
|
||||
instruments.queryDuration.record(elapsed, { operation: operationName });
|
||||
instruments.queryCount.add(1, { operation: operationName });
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - start;
|
||||
instruments.queryDuration.record(elapsed, { operation: operationName });
|
||||
instruments.errorCount.add(1, { operation: operationName });
|
||||
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();
|
||||
}
|
||||
}
|
||||
12
packages/persistence/database/db-sqlite/src/schemas/index.ts
Normal file
12
packages/persistence/database/db-sqlite/src/schemas/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
BunDriverOptions,
|
||||
MigrationRunnerOptions,
|
||||
SqliteAdapterOptions,
|
||||
WasmDriverOptions,
|
||||
} from "./schemas.js";
|
||||
export {
|
||||
BunDriverOptionsSchema,
|
||||
MigrationRunnerOptionsSchema,
|
||||
SqliteAdapterOptionsSchema,
|
||||
WasmDriverOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod schemas and inferred TypeScript types for all `@suaveplan/db-sqlite` configuration objects. Centralising validation in one module ensures that every entry point — `createBunDriver`, `createWasmDriver`, `SqliteAdapter`, `MigrationRunner` — applies the same rules with the same error messages, rather than duplicating ad-hoc checks across modules.
|
||||
|
||||
All schemas use `.strict()` where applicable so extra unknown properties are rejected at runtime, preventing silent misconfiguration from typos in option objects.
|
||||
|
||||
## Features
|
||||
|
||||
- `BunDriverOptionsSchema` — validates `filename` (optional string) and `readonly` (optional boolean, default `false`)
|
||||
- `WasmDriverOptionsSchema` — validates `filename` (optional string) for OPFS persistence
|
||||
- `SqliteAdapterOptionsSchema` — alias for `BunDriverOptionsSchema`; adapter and driver share the same shape
|
||||
- `MigrationRunnerOptionsSchema` — validates `migrationsFolder` (default `"./migrations"`), `migrationsTable` (default `"__sqlite_migrations"`), `dryRun` (default `false`)
|
||||
- Inferred TypeScript types exported for all four schemas
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BunDriverOptionsSchema } from "@suaveplan/db-sqlite/schemas";
|
||||
|
||||
const opts = BunDriverOptionsSchema.parse({ filename: ":memory:" });
|
||||
// opts.readonly === false (default applied)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunnerOptionsSchema } from "@suaveplan/db-sqlite/schemas";
|
||||
|
||||
const opts = MigrationRunnerOptionsSchema.parse({
|
||||
migrationsFolder: "./db/migrations",
|
||||
dryRun: true,
|
||||
});
|
||||
// opts.migrationsTable === "__sqlite_migrations" (default applied)
|
||||
// opts.dryRun === true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BunDriverOptionsSchema`
|
||||
|
||||
`z.object({ filename?: string, readonly?: boolean }).strict()` — `readonly` defaults to `false`.
|
||||
|
||||
### `WasmDriverOptionsSchema`
|
||||
|
||||
`z.object({ filename?: string }).strict()` — no defaults; absence means in-memory.
|
||||
|
||||
### `SqliteAdapterOptionsSchema`
|
||||
|
||||
Alias for `BunDriverOptionsSchema`. Both driver and adapter accept the same option shape.
|
||||
|
||||
### `MigrationRunnerOptionsSchema`
|
||||
|
||||
`z.object({ migrationsFolder?: string, migrationsTable?: string, dryRun?: boolean }).strict()`.
|
||||
|
||||
Defaults: `migrationsFolder = "./migrations"`, `migrationsTable = "__sqlite_migrations"`, `dryRun = false`.
|
||||
|
||||
### Type aliases
|
||||
|
||||
- `BunDriverOptions = z.infer<typeof BunDriverOptionsSchema>`
|
||||
- `WasmDriverOptions = z.infer<typeof WasmDriverOptionsSchema>`
|
||||
- `SqliteAdapterOptions = BunDriverOptions`
|
||||
- `MigrationRunnerOptions = z.infer<typeof MigrationRunnerOptionsSchema>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SqliteAdapterOptions` is deliberately an alias for `BunDriverOptions` rather than a separate schema so that passing an `SqliteAdapterOptions` object to `createBunDriver` is type-safe without casting. If the WASM adapter ever gains `readonly` support the alias can be split without a breaking change to the option names callers already use.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BunDriverOptionsSchema,
|
||||
MigrationRunnerOptionsSchema,
|
||||
SqliteAdapterOptionsSchema,
|
||||
WasmDriverOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("BunDriverOptionsSchema", () => {
|
||||
it("accepts empty object and applies defaults", { timeout: 5000 }, () => {
|
||||
const result = BunDriverOptionsSchema.parse({});
|
||||
expect(result.readonly).toBe(false);
|
||||
expect(result.filename).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts filename and readonly", { timeout: 5000 }, () => {
|
||||
const result = BunDriverOptionsSchema.parse({
|
||||
filename: ":memory:",
|
||||
readonly: true,
|
||||
});
|
||||
expect(result.filename).toBe(":memory:");
|
||||
expect(result.readonly).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
BunDriverOptionsSchema.parse({ filename: ":memory:", extra: true })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WasmDriverOptionsSchema", () => {
|
||||
it("accepts empty object", { timeout: 5000 }, () => {
|
||||
const result = WasmDriverOptionsSchema.parse({});
|
||||
expect(result.filename).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts filename", { timeout: 5000 }, () => {
|
||||
const result = WasmDriverOptionsSchema.parse({ filename: "app.db" });
|
||||
expect(result.filename).toBe("app.db");
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
WasmDriverOptionsSchema.parse({ filename: "a", extra: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteAdapterOptionsSchema", () => {
|
||||
it("is structurally identical to BunDriverOptionsSchema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = SqliteAdapterOptionsSchema.parse({
|
||||
filename: "test.db",
|
||||
});
|
||||
expect(result.filename).toBe("test.db");
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MigrationRunnerOptionsSchema", () => {
|
||||
it("applies all defaults", { timeout: 5000 }, () => {
|
||||
const result = MigrationRunnerOptionsSchema.parse({});
|
||||
expect(result.migrationsFolder).toBe("./migrations");
|
||||
expect(result.migrationsTable).toBe("__sqlite_migrations");
|
||||
expect(result.dryRun).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom values", { timeout: 5000 }, () => {
|
||||
const result = MigrationRunnerOptionsSchema.parse({
|
||||
migrationsFolder: "./db/migrations",
|
||||
migrationsTable: "custom_migrations",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(result.migrationsFolder).toBe("./db/migrations");
|
||||
expect(result.migrationsTable).toBe("custom_migrations");
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
MigrationRunnerOptionsSchema.parse({ unknown: "value" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import z from "zod";
|
||||
|
||||
export const BunDriverOptionsSchema = z
|
||||
.object({
|
||||
filename: z.string().optional(),
|
||||
readonly: z.boolean().optional().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type BunDriverOptions = z.infer<typeof BunDriverOptionsSchema>;
|
||||
|
||||
export const WasmDriverOptionsSchema = z
|
||||
.object({
|
||||
filename: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type WasmDriverOptions = z.infer<typeof WasmDriverOptionsSchema>;
|
||||
|
||||
export const SqliteAdapterOptionsSchema = BunDriverOptionsSchema;
|
||||
|
||||
export type SqliteAdapterOptions = BunDriverOptions;
|
||||
|
||||
export const MigrationRunnerOptionsSchema = z
|
||||
.object({
|
||||
migrationsFolder: z.string().optional().default("./migrations"),
|
||||
migrationsTable: z.string().optional().default("__sqlite_migrations"),
|
||||
dryRun: z.boolean().optional().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MigrationRunnerOptions = z.infer<
|
||||
typeof MigrationRunnerOptionsSchema
|
||||
>;
|
||||
25
packages/persistence/database/db-sqlite/src/unsafe/index.ts
Normal file
25
packages/persistence/database/db-sqlite/src/unsafe/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SQLITE_CODES, SqliteError } from "../errors/index.js";
|
||||
|
||||
/**
|
||||
* Unsafe — bypasses Drizzle ORM; no SQL injection protection.
|
||||
* Use only in migration scripts or low-level tooling where parameterised
|
||||
* queries are not available. Never pass user-supplied strings as `sql`.
|
||||
*/
|
||||
export async function rawQuery<T = Record<string, unknown>>(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts bun:sqlite Database or any query-capable object
|
||||
db: any,
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
const rows: T[] = stmt.all(...(params ?? []));
|
||||
return rows;
|
||||
} catch (err) {
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.QUERY_FAILED,
|
||||
`rawQuery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
packages/persistence/database/db-sqlite/src/unsafe/unsafe.md
Normal file
68
packages/persistence/database/db-sqlite/src/unsafe/unsafe.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# unsafe
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `unsafe` subpath exports `rawQuery` — a direct SQL execution function that bypasses the Drizzle ORM layer entirely. It is intentionally segregated behind the `@suaveplan/db-sqlite/unsafe` subpath import to make its use explicit and auditable: any import of this path stands out in code review as a deliberate bypass of the normal query builder.
|
||||
|
||||
**Security warning:** `rawQuery` provides no SQL injection protection. Never pass user-supplied or externally-sourced strings as the `sql` argument. Use only for migration scripts, low-level tooling, or read-only administrative queries where the SQL is fully under developer control.
|
||||
|
||||
## Features
|
||||
|
||||
- `rawQuery<T>(db, sql, params?)` — executes a prepared statement on any `bun:sqlite`-compatible Database instance
|
||||
- Returns rows typed as `T[]` (caller asserts the row shape)
|
||||
- Maps execution errors to `SqliteError(SQLITE.QUERY_FAILED)`
|
||||
- Supports parameterised queries via the `params` array — always use `?` placeholders and pass values in `params` rather than string-interpolating them
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
|
||||
|
||||
const rows = await rawQuery<{ count: number }>(
|
||||
sqlite,
|
||||
"SELECT count(*) AS count FROM sqlite_master WHERE type = ?",
|
||||
["table"]
|
||||
);
|
||||
console.log(rows[0]?.count);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
|
||||
|
||||
// Used in a migration helper to read schema state before Drizzle is set up
|
||||
async function tableExists(sqlite: unknown, name: string): Promise<boolean> {
|
||||
const rows = await rawQuery<{ found: number }>(
|
||||
sqlite,
|
||||
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
[name]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rawQuery<T>(db, sql, params?): Promise<T[]>`
|
||||
|
||||
Executes `sql` as a prepared statement on `db`. Throws `SqliteError(SQLITE.QUERY_FAILED)` on error.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `db` | `any` | A `bun:sqlite` Database instance |
|
||||
| `sql` | `string` | The SQL to execute — must not contain user input |
|
||||
| `params` | `unknown[]` | Bind parameters for `?` placeholders |
|
||||
|
||||
**Returns** `Promise<T[]>` — rows shaped as `T`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`rawQuery` uses `db.prepare(sql).all(params)` rather than `db.query(sql).all(params)` to be consistent with the caching strategy used by `SqliteAdapter`. The `params` spread form (`stmt.all(...params)`) passes each parameter as a positional argument, matching `bun:sqlite`'s expected calling convention.
|
||||
|
||||
The `db` parameter is typed as `any` to accept both `bun:sqlite Database` instances and test doubles without requiring a concrete import of `bun:sqlite` types from this module.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SqliteError } from "../errors/index.js";
|
||||
import { rawQuery } from "./index.js";
|
||||
|
||||
async function makeDb(): Promise<{
|
||||
sqlite: import("bun:sqlite").Database;
|
||||
close: () => void;
|
||||
}> {
|
||||
const { Database } = await import("bun:sqlite");
|
||||
const sqlite = new Database(":memory:");
|
||||
return { sqlite, close: () => sqlite.close() };
|
||||
}
|
||||
|
||||
describe("rawQuery", () => {
|
||||
it("executes query and returns rows", { timeout: 30000 }, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE rq_test (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
sqlite.run("INSERT INTO rq_test VALUES (1, 'hello')");
|
||||
const rows = await rawQuery<{ id: number; val: string }>(
|
||||
sqlite,
|
||||
"SELECT * FROM rq_test"
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.val).toBe("hello");
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("supports parameterised queries", { timeout: 30000 }, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
try {
|
||||
sqlite.run(
|
||||
"CREATE TABLE param_test (id INTEGER PRIMARY KEY, val TEXT)"
|
||||
);
|
||||
sqlite.run("INSERT INTO param_test VALUES (1, 'world')");
|
||||
const rows = await rawQuery<{ val: string }>(
|
||||
sqlite,
|
||||
"SELECT val FROM param_test WHERE id = ?",
|
||||
[1]
|
||||
);
|
||||
expect(rows[0]?.val).toBe("world");
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array for no matching rows", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
try {
|
||||
sqlite.run("CREATE TABLE empty_test (id INTEGER PRIMARY KEY)");
|
||||
const rows = await rawQuery(sqlite, "SELECT * FROM empty_test");
|
||||
expect(rows).toEqual([]);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws SqliteError on invalid SQL", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { sqlite, close } = await makeDb();
|
||||
try {
|
||||
await expect(
|
||||
rawQuery(sqlite, "THIS IS NOT SQL !!!!")
|
||||
).rejects.toBeInstanceOf(SqliteError);
|
||||
await expect(
|
||||
rawQuery(sqlite, "THIS IS NOT SQL !!!!")
|
||||
).rejects.toMatchObject({ code: "SQLITE.QUERY_FAILED" });
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
20
packages/persistence/database/db-sqlite/tsconfig.lib.json
Normal file
20
packages/persistence/database/db-sqlite/tsconfig.lib.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"src/__tests__/**",
|
||||
"src/conformance/**/*.test.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
51
packages/persistence/database/db-sqlite/vite.config.ts
Normal file
51
packages/persistence/database/db-sqlite/vite.config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/db-sqlite",
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"unsafe/index": "src/unsafe/index.ts",
|
||||
"adapter/adapter.bun": "src/adapter/adapter.bun.ts",
|
||||
"adapter/adapter.wasm": "src/adapter/adapter.wasm.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: (id) =>
|
||||
id.startsWith("@suaveplan/") ||
|
||||
id.startsWith("node:") ||
|
||||
id.startsWith("drizzle-orm") ||
|
||||
[
|
||||
"bun:sqlite",
|
||||
"@sqlite.org/sqlite-wasm",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
].includes(id),
|
||||
},
|
||||
},
|
||||
});
|
||||
50
packages/persistence/database/db-sqlite/vitest.config.ts
Normal file
50
packages/persistence/database/db-sqlite/vitest.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"tests/**/*.test.ts",
|
||||
],
|
||||
exclude: [
|
||||
"src/**/*.wasm.test.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
server: {
|
||||
deps: {
|
||||
// Allow bun:* builtins to pass through to the native runtime
|
||||
external: [/^bun:/],
|
||||
},
|
||||
},
|
||||
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__/**",
|
||||
"src/conformance/**",
|
||||
"src/drivers/wasm-worker.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
58
packages/persistence/database/migration/README.md
Normal file
58
packages/persistence/database/migration/README.md
Normal file
File diff suppressed because one or more lines are too long
117
packages/persistence/database/migration/package.json
Normal file
117
packages/persistence/database/migration/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@suaveplan/migration",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Database-agnostic migration runner with ledger tracking, dry-run, checksums, lock adapters, and 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"
|
||||
}
|
||||
},
|
||||
"./errors/migration-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/migration-error.d.ts",
|
||||
"default": "./dist/errors/migration-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/migration-error.d.ts",
|
||||
"default": "./dist/errors/migration-error.js"
|
||||
}
|
||||
},
|
||||
"./planner/migration-planner": {
|
||||
"import": {
|
||||
"types": "./dist/planner/migration-planner/migration-planner.d.ts",
|
||||
"default": "./dist/planner/migration-planner/migration-planner.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/planner/migration-planner/migration-planner.d.ts",
|
||||
"default": "./dist/planner/migration-planner/migration-planner.js"
|
||||
}
|
||||
},
|
||||
"./runner/migration-runner": {
|
||||
"import": {
|
||||
"types": "./dist/runner/migration-runner/migration-runner.d.ts",
|
||||
"default": "./dist/runner/migration-runner/migration-runner.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/runner/migration-runner/migration-runner.d.ts",
|
||||
"default": "./dist/runner/migration-runner/migration-runner.js"
|
||||
}
|
||||
},
|
||||
"./store/memory-store": {
|
||||
"import": {
|
||||
"types": "./dist/store/memory-store/memory-store.d.ts",
|
||||
"default": "./dist/store/memory-store/memory-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/store/memory-store/memory-store.d.ts",
|
||||
"default": "./dist/store/memory-store/memory-store.js"
|
||||
}
|
||||
},
|
||||
"./store/database-store": {
|
||||
"import": {
|
||||
"types": "./dist/store/database-store/database-store.d.ts",
|
||||
"default": "./dist/store/database-store/database-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/store/database-store/database-store.d.ts",
|
||||
"default": "./dist/store/database-store/database-store.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/db": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"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,5 @@
|
||||
export {
|
||||
SurrealDbAdapter,
|
||||
type SurrealDbAdapterConfig,
|
||||
type SurrealInstance,
|
||||
} from "./surrealdb.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# surrealdb-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SurrealDbAdapter` adapts a SurrealDB instance to the minimal `execute`/`query` interface required by `DatabaseMigrationStore` and migration context objects. Because SurrealDB uses named query variables rather than positional parameters, this adapter translates positional `params` arrays to `{ p0, p1, … }` named variable maps automatically. This allows the same migration SQL templates to work across SQLite (positional `?`) and SurrealDB (named `$p0`) by using a consistent calling convention.
|
||||
|
||||
## Features
|
||||
|
||||
- `query<T>(sql, params?)` — executes a SurrealDB query and returns typed results.
|
||||
- `execute(sql, params?)` — executes a non-query statement; always returns `{ rowsAffected: 0 }` (SurrealDB does not expose row counts).
|
||||
- Translates positional params array to named vars (`p0`, `p1`, …).
|
||||
- Wraps errors in `MigrationError(MIGRATION.STORE_ERROR)` or `MIGRATION.FAILED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SurrealDbAdapter } from "@suaveplan/migration";
|
||||
import { DatabaseMigrationStore } from "@suaveplan/migration";
|
||||
import Surreal from "surrealdb";
|
||||
|
||||
const db = new Surreal();
|
||||
await db.connect("http://localhost:8000");
|
||||
|
||||
const adapter = new SurrealDbAdapter({ db });
|
||||
const store = new DatabaseMigrationStore({ adapter });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SurrealDbAdapter } from "@suaveplan/migration";
|
||||
import { MigrationRunner, MemoryMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const adapter = new SurrealDbAdapter({ db: surrealInstance });
|
||||
const store = new MemoryMigrationStore(); // or DatabaseMigrationStore
|
||||
const runner = new MigrationRunner({ migrations, store });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SurrealDbAdapter`
|
||||
|
||||
**Constructor**: `(config: SurrealDbAdapterConfig)`
|
||||
|
||||
- `config.db` — object implementing `query<T>(sql, vars?): Promise<T[]>`.
|
||||
|
||||
### `query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>`
|
||||
|
||||
Translates params to named vars and delegates to `db.query`. Returns an empty array if the result is not an array.
|
||||
|
||||
### `execute(sql: string, params?: readonly unknown[]): Promise<{ rowsAffected: number }>`
|
||||
|
||||
Executes SQL via `db.query`. Always returns `{ rowsAffected: 0 }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SurrealDB's `query` method returns an array of result sets per SQL statement. This adapter flattens the outer array and returns the first result set. For multi-statement SQL, callers should split statements and call `query` for each.
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MigrationError } from "../../errors/migration-error.js";
|
||||
import { SurrealDbAdapter, type SurrealInstance } from "./surrealdb.js";
|
||||
|
||||
class FakeSurreal implements SurrealInstance {
|
||||
protected results: unknown[] = [];
|
||||
protected shouldThrow = false;
|
||||
protected lastSql = "";
|
||||
protected lastVars: Record<string, unknown> = {};
|
||||
|
||||
setResults(r: unknown[]) {
|
||||
this.results = r;
|
||||
}
|
||||
setThrow(t: boolean) {
|
||||
this.shouldThrow = t;
|
||||
}
|
||||
getLastSql() {
|
||||
return this.lastSql;
|
||||
}
|
||||
getLastVars() {
|
||||
return this.lastVars;
|
||||
}
|
||||
|
||||
async query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]> {
|
||||
if (this.shouldThrow) throw new Error("surreal error");
|
||||
this.lastSql = sql;
|
||||
this.lastVars = vars ?? {};
|
||||
return this.results as T[];
|
||||
}
|
||||
}
|
||||
|
||||
describe("SurrealDbAdapter", () => {
|
||||
it("query returns results", { timeout: 5000 }, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setResults([{ id: "1" }]);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
const results = await adapter.query("SELECT * FROM users");
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("query translates positional params to named vars", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setResults([]);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
await adapter.query("SELECT * FROM users WHERE id = $p0", ["abc"]);
|
||||
expect(surreal.getLastVars()).toEqual({ p0: "abc" });
|
||||
});
|
||||
|
||||
it("query returns empty array when result is not array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setResults([]);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
const results = await adapter.query("SELECT 1");
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it("query wraps errors in MigrationError", { timeout: 5000 }, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setThrow(true);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
await expect(adapter.query("SELECT 1")).rejects.toBeInstanceOf(
|
||||
MigrationError
|
||||
);
|
||||
});
|
||||
|
||||
it("execute returns rowsAffected 0", { timeout: 5000 }, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setResults([]);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
const result = await adapter.execute("CREATE TABLE users");
|
||||
expect(result.rowsAffected).toBe(0);
|
||||
});
|
||||
|
||||
it("execute wraps errors in MigrationError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setThrow(true);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
await expect(
|
||||
adapter.execute("CREATE TABLE users")
|
||||
).rejects.toBeInstanceOf(MigrationError);
|
||||
});
|
||||
|
||||
it("execute with no params works", { timeout: 5000 }, async () => {
|
||||
const surreal = new FakeSurreal();
|
||||
surreal.setResults([]);
|
||||
const adapter = new SurrealDbAdapter({ db: surreal });
|
||||
const result = await adapter.execute("CREATE TABLE users");
|
||||
expect(result.rowsAffected).toBe(0);
|
||||
expect(surreal.getLastVars()).toEqual({});
|
||||
});
|
||||
|
||||
it("query returns empty array for non-array result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Simulate a SurrealDB that returns a non-array
|
||||
const adapter = new SurrealDbAdapter({
|
||||
db: {
|
||||
async query<T>(): Promise<T[]> {
|
||||
return null as unknown as T[];
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await adapter.query("SELECT 1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("query wraps non-Error throws in MigrationError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SurrealDbAdapter({
|
||||
db: {
|
||||
async query<T>(): Promise<T[]> {
|
||||
throw "string error";
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(adapter.query("SELECT 1")).rejects.toBeInstanceOf(
|
||||
MigrationError
|
||||
);
|
||||
});
|
||||
|
||||
it("execute wraps non-Error throws in MigrationError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SurrealDbAdapter({
|
||||
db: {
|
||||
async query<T>(): Promise<T[]> {
|
||||
throw "string error";
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(adapter.execute("CREATE TABLE t")).rejects.toBeInstanceOf(
|
||||
MigrationError
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user