Initial Commit

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

View File

@@ -0,0 +1,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 -->

View File

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

View File

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

View File

@@ -0,0 +1,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"
]
}

View File

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

View File

@@ -0,0 +1,4 @@
// Bun-runtime entry point for the ./adapter conditional export.
export type { SqliteAdapterOptions } from "../schemas/index.js";
export { SqliteAdapter } from "./adapter.js";

View File

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

View File

@@ -0,0 +1,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 }
);
});
});

View File

@@ -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();
}
});
});

View 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"
);
}
}
}

View File

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

View File

@@ -0,0 +1,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";

View File

@@ -0,0 +1 @@
export { SqliteAdapter } from "./adapter.js";

View File

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

View File

@@ -0,0 +1,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);
}
)
);
});
});

View 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;
}
}

View File

@@ -0,0 +1,2 @@
export type { CacheEntry } from "./cache.js";
export { PreparedStatementCache } from "./cache.js";

View File

@@ -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:" });
},
});

View File

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

View File

@@ -0,0 +1,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();
}
});
});
}

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
export {
closeAllSqlite,
closeSqlite,
getDefaultSqlite,
hasSqlite,
} from "./connection.js";

View File

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

View File

@@ -0,0 +1,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",
});
});
});

View File

@@ -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();
},
};
}

View File

@@ -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";

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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 };
}

View File

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

View File

@@ -0,0 +1,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));
}
}
};

View File

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

View File

@@ -0,0 +1,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"
);
});
});

View 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;
}

View File

@@ -0,0 +1 @@
export type { SqliteCode } from "./errors.js";

View File

@@ -0,0 +1,6 @@
export type { SqliteCode } from "./errors.js";
export {
mapSqliteCode,
SQLITE_CODES,
SqliteError,
} from "./errors.js";

View 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";

View File

@@ -0,0 +1 @@
export { MigrationRunner } from "./migration.js";

View File

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

View File

@@ -0,0 +1,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 }
);
});
});

View File

@@ -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();
}
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export {
resetOtelInstruments,
sanitizeSql,
withQuerySpan,
} from "./otel.js";

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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();
}
}

View File

@@ -0,0 +1,12 @@
export type {
BunDriverOptions,
MigrationRunnerOptions,
SqliteAdapterOptions,
WasmDriverOptions,
} from "./schemas.js";
export {
BunDriverOptionsSchema,
MigrationRunnerOptionsSchema,
SqliteAdapterOptionsSchema,
WasmDriverOptionsSchema,
} from "./schemas.js";

View File

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

View File

@@ -0,0 +1,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();
});
});

View File

@@ -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
>;

View 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 }
);
}
}

View File

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

View File

@@ -0,0 +1,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();
}
});
});

View 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"
]
}

View 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),
},
},
});

View 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,
},
});

View File

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

View File

@@ -0,0 +1,97 @@
{
"name": "@suaveplan/db",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal relational database adapter interface: DatabaseAdapter (extends DatabaseLike), structured DB.* error codes, Zod-validated config types, injectable connection singletons, MigrationRunner interface, and full OTel instrumentation.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter/database-adapter": {
"types": "./dist/adapter/database-adapter.d.ts",
"default": "./dist/adapter/database-adapter.js"
},
"./connection/singleton": {
"types": "./dist/connection/singleton.d.ts",
"default": "./dist/connection/singleton.js"
},
"./errors/database-error": {
"types": "./dist/errors/database-error.d.ts",
"default": "./dist/errors/database-error.js"
},
"./migration/migration-runner": {
"types": "./dist/migration/migration-runner.d.ts",
"default": "./dist/migration/migration-runner.js"
},
"./telemetry/instruments": {
"types": "./dist/telemetry/instruments.d.ts",
"default": "./dist/telemetry/instruments.js"
},
"./telemetry/trace": {
"types": "./dist/telemetry/trace.d.ts",
"default": "./dist/telemetry/trace.js"
},
"./types/database-config": {
"types": "./dist/types/database-config.d.ts",
"default": "./dist/types/database-config.js"
},
"./types/migration-config": {
"types": "./dist/types/migration-config.d.ts",
"default": "./dist/types/migration-config.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:bench": "vitest bench",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View 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"
]
}

View File

@@ -0,0 +1,5 @@
export {
SurrealDbAdapter,
type SurrealDbAdapterConfig,
type SurrealInstance,
} from "./surrealdb.js";

View File

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

View File

@@ -0,0 +1,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