Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# @suaveplan/api-db-sqlite-memory — in-memory SQLite database adapter for the @suaveplan/api plugin host testing kit. Wraps bun:sqlite in `:memory:` mode (or an on-disk file for persistent test fixtures) behind the AdminDatabaseLike + TenantScopedDatabaseLike contracts, applies WAL + foreign_keys pragmas by default, ships a lexicographic migration runner with __plugin_migrations bookkeeping, and registers itself in the @suaveplan/api-db-adapter engine registry under the `sqlite-memory` key so `createDbAdapter({ engine: 'sqlite-memory' })` resolves transparently.
Stage-5 in-memory SQLite database adapter for the `@suaveplan/api` plugin host testing kit. Wraps `bun:sqlite` (in `:memory:` mode by default, or a file path for longer-lived fixtures) behind the `AdminDatabaseLike` + `TenantScopedDatabaseLike` contracts from `@suaveplan/api-types/db`, ships a lexicographic migration runner with `__plugin_migrations` bookkeeping, and self-registers in the `@suaveplan/api-db-adapter` engine registry under the `sqlite-memory` key so `createDbAdapter({ engine: "sqlite-memory" })` resolves transparently.
The adapter is production-grade — it happens to keep state in process memory. Real SQLite drives every query, transaction semantics match, foreign-key enforcement is on by default, and the migration runner is fully transactional. Tests boot in milliseconds without testcontainers or any other external service.
<!-- begin:auto-docs -->
## Documentation tree
- [engine](./src/engine/register-engine.md) — Registers the in-memory SQLite adapter under `engine: "sqlite-memory"` in the open `@suaveplan/api-db-adapter` engine r…
- [register-engine](./src/engine/register-engine.md) — Registers the in-memory SQLite adapter under `engine: "sqlite-memory"` in the open `@suaveplan/api-db-adapter` engine r…
- [error-codes](./src/error-codes/error-codes.md) — This module declares the stable error-code registry for `@suaveplan/api-db-sqlite-memory`. Every error thrown from the…
- [error-codes](./src/error-codes/error-codes.md) — This module declares the stable error-code registry for `@suaveplan/api-db-sqlite-memory`. Every error thrown from the…
- [create-sqlite-memory-adapter](./src/factory/create-sqlite-memory-adapter.md) — Opens a real `bun:sqlite` `Database` and wraps it as an `AdminDatabaseLike`. The default zero-argument call yields a `:…
- [create-sqlite-memory-db](./src/factory/create-sqlite-memory-db.md) — Stage-5 top-level testing factory. Combines `createSqliteMemoryAdapter` with the tenant-scoping wrapper from this packa…
- [migrations](./src/migrations/migrations.md) — Lexicographic forward-only migration runner for the in-memory SQLite adapter. Applies every `*.sql` file in a directory…
- [migrations](./src/migrations/migrations.md) — Lexicographic forward-only migration runner for the in-memory SQLite adapter. Applies every `*.sql` file in a directory…
- [options](./src/options/sqlite-memory-db-options.md) — Defines the Zod-validated options object accepted by `createSqliteMemoryDb`, `createSqliteMemoryAdapter`, and the engin…
- [sqlite-memory-db-options](./src/options/sqlite-memory-db-options.md) — Defines the Zod-validated options object accepted by `createSqliteMemoryDb`, `createSqliteMemoryAdapter`, and the engin…
- [tenant-scoping](./src/tenant-scoping/tenant-scoping.md) — Wraps an `AdminDatabaseLike` as a `TenantScopedDatabaseLike` that transparently injects the resolved tenant id into eve…
- [tenant-scoping](./src/tenant-scoping/tenant-scoping.md) — Wraps an `AdminDatabaseLike` as a `TenantScopedDatabaseLike` that transparently injects the resolved tenant id into eve…
- [testing](./src/testing/harness.md) — Convenience builder for plugin test files. Wraps `createSqliteMemoryDb` with an idempotent `cleanup()` so the canonical…
- [harness](./src/testing/harness.md) — Convenience builder for plugin test files. Wraps `createSqliteMemoryDb` with an idempotent `cleanup()` so the canonical…
<!-- end:auto-docs -->
## Connection string
The canonical connection string is `":memory:"` — the default. Any non-empty string passed as `filename` is forwarded straight to `bun:sqlite`'s `Database` constructor, so an absolute path opens a persistent file-backed fixture:
```ts
import { createSqliteMemoryAdapter } from "@suaveplan/api-db-sqlite-memory";
const ephemeral = createSqliteMemoryAdapter(); // ":memory:"
const persisted = createSqliteMemoryAdapter({ filename: "/tmp/fixture.sqlite" });
```
## Default pragmas
Every fresh connection has these pragmas applied at open time:
- `journal_mode = WAL` — write-ahead logging. No-op for `:memory:`, useful for file-backed fixtures.
- `foreign_keys = ON` — SQLite ships with FK enforcement disabled by default. The adapter flips it on so referential integrity is honoured.
Operators that want to override either default supply `pragmas` in the options object:
```ts
const adapter = createSqliteMemoryAdapter({
pragmas: { foreign_keys: "OFF", synchronous: "FULL" },
});
```
The final merged pragma map is `{ ...DEFAULT_PRAGMAS, ...options.pragmas }`, so any default can be overridden by repeating its key.
## Subpath exports
| Subpath | Purpose |
| --- | --- |
| `.` | Root barrel — re-exports every public symbol. |
| `./adapter` | `createSqliteMemoryAdapter` + OTel instrument hooks. |
| `./factory` | `createSqliteMemoryDb` returning `{ admin, scoped, close }`. |
| `./engine` | `registerSqliteMemoryEngine` for the api-db-adapter registry. |
| `./migrations` | `runSqliteMemoryMigrations` + `SqliteMigrationsFsReader`. |
| `./options` | Zod schema + `parseSqliteMemoryDbOptions` + `resolvePragmas`. |
| `./tenant-scoping` | `createTenantScopedSqliteWrapper` + pure SQL rewriters. |
| `./error-codes` | `DB_SQLITE_MEMORY_CODES` + `DbSqliteMemoryError`. |
| `./testing` | `createInMemorySqliteHarness` convenience builder. |
## Error codes
- `DB_SQLITE_MEMORY.OPEN_FAILED` — `bun:sqlite` constructor threw.
- `DB_SQLITE_MEMORY.INVALID_OPTIONS` — Zod validation failed.
- `DB_SQLITE_MEMORY.MIGRATION_FAILED` — a migration file threw; transaction rolled back.
- `DB_SQLITE_MEMORY.TENANT_SCOPE_VIOLATION` — cross-tenant write or unsupported SQL shape.
## License
MIT.

View File

@@ -0,0 +1,109 @@
{
"name": "@suaveplan/api-db-sqlite-memory",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-5 in-memory SQLite database adapter for the @suaveplan/api plugin host testing kit. Wraps bun:sqlite in `:memory:` mode (or an on-disk file for persistent test fixtures) behind the AdminDatabaseLike + TenantScopedDatabaseLike contracts, applies WAL + foreign_keys pragmas by default, ships a lexicographic migration runner with __plugin_migrations bookkeeping, and registers itself in the @suaveplan/api-db-adapter engine registry under the `sqlite-memory` key so `createDbAdapter({ engine: 'sqlite-memory' })` resolves transparently.",
"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": {
"import": {
"types": "./dist/factory/create-sqlite-memory-adapter.d.ts",
"default": "./dist/factory/create-sqlite-memory-adapter.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/create-sqlite-memory-db.d.ts",
"default": "./dist/factory/create-sqlite-memory-db.js"
}
},
"./engine": {
"import": {
"types": "./dist/engine/register-engine.d.ts",
"default": "./dist/engine/register-engine.js"
}
},
"./migrations": {
"import": {
"types": "./dist/migrations/migrations.d.ts",
"default": "./dist/migrations/migrations.js"
}
},
"./options": {
"import": {
"types": "./dist/options/sqlite-memory-db-options.d.ts",
"default": "./dist/options/sqlite-memory-db-options.js"
}
},
"./tenant-scoping": {
"import": {
"types": "./dist/tenant-scoping/tenant-scoping.d.ts",
"default": "./dist/tenant-scoping/tenant-scoping.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/harness.d.ts",
"default": "./dist/testing/harness.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",
"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/api-db-adapter": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "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 @@
export { registerSqliteMemoryEngine } from "./register-engine.js";

View File

@@ -0,0 +1,77 @@
# `registerSqliteMemoryEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Registers the in-memory SQLite adapter under `engine: "sqlite-memory"` in the open `@suaveplan/api-db-adapter` engine registry. Once called, any consumer that constructs an adapter via `createDbAdapter({ engine: "sqlite-memory", ... })` receives an instance produced by this package. The TypeScript-side declaration-merge into `DatabaseEngineRegistry` means the engine name is accepted as a string-literal-typed argument with no cast.
This is the bridge that lets `@suaveplan/api-host/testing`'s Stage-5 `createTestHost` boot a plugin entirely against an in-memory SQLite without changing any of The adapter code paths. The registration is idempotent — calling twice replaces the previous factory — and the function returns a disposer so tests can unregister the engine in `afterEach`.
## Features
- Type-level: `declare module` extends `DatabaseEngineRegistry` with `"sqlite-memory": true`.
- Runtime: `registerEngine("sqlite-memory", factory)` against the api-db-adapter registry.
- Factory honours `DbAdapterOptions.url` → `filename` so callers may write `engine: "sqlite-memory"` + `url: ":memory:"` (or a file path).
- Forwards `tenantColumn` + `autoInjectInserts` so The wrapper composes correctly on top.
- Returns a disposer `() => boolean` that unregisters the engine.
## Usage: Basic
```ts
import { createDbAdapter } from "@suaveplan/api-db-adapter";
import { registerSqliteMemoryEngine } from "@suaveplan/api-db-sqlite-memory/engine";
const dispose = registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
});
await adapter.admin.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
await adapter.close();
dispose();
```
## Usage: Advanced
Test lifecycle with deterministic registry teardown:
```ts
import {
afterEach,
beforeEach,
describe,
it,
} from "@suaveplan/testing/runner";
import { createDbAdapter } from "@suaveplan/api-db-adapter";
import { registerSqliteMemoryEngine } from "@suaveplan/api-db-sqlite-memory/engine";
describe("plugin install", () => {
let dispose: () => boolean;
beforeEach(() => {
dispose = registerSqliteMemoryEngine();
});
afterEach(() => {
dispose();
});
it("resolves the engine sentinel", { timeout: 5000 }, async () => {
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
tenantColumn: "tenant_id",
});
await adapter.close();
});
});
```
## API Reference
- `registerSqliteMemoryEngine()` — returns a disposer `() => boolean`.
## Implementation Notes
The declaration-merge happens inside this module so the type extension is only visible after the consumer imports from `@suaveplan/api-db-sqlite-memory/engine` (or the package root, which re-exports). That keeps the engine sentinel scoped to plugins that opt in, matching the design intent of the open `DatabaseEngineRegistry`. The factory normalises an empty or `"sqlite-memory:"` url to `":memory:"` because `createDbAdapter`'s Zod schema requires `url` to be non-empty — without this normalisation, callers would have to write `url: ":memory:"` redundantly even when the engine name already implies it.

View File

@@ -0,0 +1,75 @@
import {
createDbAdapter,
listEngines,
unregisterEngine,
} from "@suaveplan/api-db-adapter";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { registerSqliteMemoryEngine } from "./register-engine.js";
describe("registerSqliteMemoryEngine", () => {
afterEach(() => {
unregisterEngine("sqlite-memory");
});
it("registers under the sqlite-memory key", { timeout: 5000 }, () => {
registerSqliteMemoryEngine();
expect(listEngines()).toContain("sqlite-memory");
});
it("resolves via createDbAdapter", { timeout: 5000 }, async () => {
registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
});
try {
expect(adapter.admin.engine).toBe("sqlite-memory");
await adapter.admin.execute(
"CREATE TABLE r (id INTEGER PRIMARY KEY)"
);
await adapter.admin.execute("INSERT INTO r (id) VALUES (1)");
const rows = await adapter.admin.query("SELECT id FROM r");
expect(rows).toEqual([{ id: 1 }]);
} finally {
await adapter.close();
}
});
it("normalises sqlite-memory: url to :memory:", {
timeout: 5000,
}, async () => {
registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: "sqlite-memory:",
});
await adapter.close();
});
it("forwards tenantColumn + autoInjectInserts", {
timeout: 5000,
}, async () => {
registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
tenantColumn: "org_id",
autoInjectInserts: false,
});
try {
await adapter.admin.execute(
"CREATE TABLE x (id INTEGER PRIMARY KEY, org_id TEXT)"
);
} finally {
await adapter.close();
}
});
it("disposer returns true the first time + false the second", {
timeout: 5000,
}, () => {
const dispose = registerSqliteMemoryEngine();
expect(dispose()).toBe(true);
expect(dispose()).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { registerEngine, unregisterEngine } from "@suaveplan/api-db-adapter";
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
import { createSqliteMemoryAdapter } from "../factory/create-sqlite-memory-adapter.js";
/**
* Declaration-merge into the `DatabaseEngineRegistry` type so
* `engine: "sqlite-memory"` is accepted by `createDbAdapter(...)` callers
* without needing a string cast.
*/
declare module "@suaveplan/api-types/db" {
interface DatabaseEngineRegistry {
"sqlite-memory": true;
}
}
interface IncomingOptions {
readonly url?: string;
readonly tenantColumn?: string;
readonly autoInjectInserts?: boolean;
}
/**
* Register the in-memory SQLite adapter under `engine: "sqlite-memory"` in
* the open `@suaveplan/api-db-adapter` engine registry.
*
* Idempotent — re-registering replaces the previous factory. Returns a
* disposer that unregisters the engine; tests should call it in `afterEach`
* to keep the global registry clean.
*
* The factory honours a small subset of `DbAdapterOptions`:
* - `url` — passed through as `filename` (so callers may use `:memory:` or a
* file path like `file:/tmp/test.sqlite`).
* - `tenantColumn` + `autoInjectInserts` — forwarded so the higher-level
* `createDbAdapter` wrapper uses the same column convention end-to-end.
*/
export function registerSqliteMemoryEngine(): () => boolean {
registerEngine(
"sqlite-memory",
async (options): Promise<AdminDatabaseLike> => {
const incoming = options as IncomingOptions;
const url = incoming.url;
const filename =
url === undefined || url === "" || url === "sqlite-memory:"
? ":memory:"
: url;
return createSqliteMemoryAdapter({
filename,
tenantColumn: incoming.tenantColumn,
autoInjectInserts: incoming.autoInjectInserts,
});
}
);
return () => unregisterEngine("sqlite-memory");
}

View File

@@ -0,0 +1,64 @@
# `DB_SQLITE_MEMORY_*` error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the stable error-code registry for `@suaveplan/api-db-sqlite-memory`. Every error thrown from the adapter, the migration runner, the tenant-scoping wrapper, or the engine registration helper extends `DbSqliteMemoryError` so consumers can branch on either `instanceof` or on the fully-qualified code string.
The four-code registry intentionally mirrors `@suaveplan/api-db-adapter`'s `DB_ADAPTER_*` shape: `TENANT_SCOPE_VIOLATION`, `MIGRATION_FAILED`, `INVALID_OPTIONS`, and the SQLite-specific `OPEN_FAILED`. Tests assert each code is produced by at least one production path.
## Features
- Discriminated codes via `defineCodes("DB_SQLITE_MEMORY", [...])` — typed `${prefix}.${code}` template literal union.
- `DbSqliteMemoryError` extends `SuaveplanError<DbSqliteMemoryCode>` so `error.code` is narrowed to the union.
- Compatible with `@suaveplan/error`'s cause-chain traversal, redaction, and OTel span-event adapters.
## Usage: Basic
```ts
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "@suaveplan/api-db-sqlite-memory/error-codes";
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.INVALID_OPTIONS,
"filename must be ':memory:' or a non-empty string"
);
```
## Usage: Advanced
Pattern-match on the code union inside a typed error boundary:
```ts
try {
await createSqliteMemoryDb(opts);
} catch (err) {
if (err instanceof DbSqliteMemoryError) {
switch (err.code) {
case DB_SQLITE_MEMORY_CODES.OPEN_FAILED:
console.error("filesystem issue", err.cause);
break;
case DB_SQLITE_MEMORY_CODES.MIGRATION_FAILED:
console.error("schema migration aborted", err.context);
break;
default:
throw err;
}
}
}
```
## API Reference
- `DB_SQLITE_MEMORY_CODES` — frozen `Record<UpperCaseName, FullyQualifiedCode>`.
- `DbSqliteMemoryCode` — union of every fully-qualified code string.
- `DbSqliteMemoryError` — constructor `(code, message, options?)`.
## Implementation Notes
`defineCodes` namespaces every entry under `DB_SQLITE_MEMORY.` at runtime — the dot-separated form is what surfaces in logs and OTel span events, so log scrapers can pivot on `DB_SQLITE_MEMORY.OPEN_FAILED` to root-cause early-boot failures distinct from upstream `DB_ADAPTER.ENGINE_UNSUPPORTED` messages. Keeping the namespace distinct from `DB_ADAPTER` lets operators tell at a glance whether the failure was inside the SQLite engine or inside the adapter façade that hosts it.

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_SQLITE_MEMORY_CODES, DbSqliteMemoryError } from "./error-codes.js";
describe("DB_SQLITE_MEMORY_CODES", () => {
it("defines the four expected codes", { timeout: 5000 }, () => {
expect(DB_SQLITE_MEMORY_CODES.OPEN_FAILED).toBe(
"DB_SQLITE_MEMORY.OPEN_FAILED"
);
expect(DB_SQLITE_MEMORY_CODES.INVALID_OPTIONS).toBe(
"DB_SQLITE_MEMORY.INVALID_OPTIONS"
);
expect(DB_SQLITE_MEMORY_CODES.MIGRATION_FAILED).toBe(
"DB_SQLITE_MEMORY.MIGRATION_FAILED"
);
expect(DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION).toBe(
"DB_SQLITE_MEMORY.TENANT_SCOPE_VIOLATION"
);
});
it("constructs a DbSqliteMemoryError with cause + context", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.OPEN_FAILED,
"open failed",
{ cause, context: { filename: "/missing.sqlite" } }
);
expect(err).toBeInstanceOf(DbSqliteMemoryError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("DB_SQLITE_MEMORY.OPEN_FAILED");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ filename: "/missing.sqlite" });
expect(err.name).toBe("DbSqliteMemoryError");
});
});

View File

@@ -0,0 +1,41 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-db-sqlite-memory`.
*
* - `TENANT_SCOPE_VIOLATION` — a tenant-scoped surface observed a write whose
* `tenant_id` payload conflicts with the bound scope, or a query shape the
* wrapper cannot safely rewrite.
* - `MIGRATION_FAILED` — a migration file threw during application; the
* transaction was rolled back and the plugin's schema state is unchanged.
* - `INVALID_OPTIONS` — `SqliteMemoryDbOptions` failed Zod validation.
* - `OPEN_FAILED` — opening the underlying `bun:sqlite` `Database` failed at
* construction time (filesystem error, permission denied, etc.).
*/
export const DB_SQLITE_MEMORY_CODES = defineCodes("DB_SQLITE_MEMORY", [
"TENANT_SCOPE_VIOLATION",
"MIGRATION_FAILED",
"INVALID_OPTIONS",
"OPEN_FAILED",
] as const);
/** Union of every fully-qualified code in {@link DB_SQLITE_MEMORY_CODES}. */
export type DbSqliteMemoryCode =
(typeof DB_SQLITE_MEMORY_CODES)[keyof typeof DB_SQLITE_MEMORY_CODES];
/**
* Typed error class for `@suaveplan/api-db-sqlite-memory`. Extends
* {@link SuaveplanError} so callers may discriminate via either
* `instanceof DbSqliteMemoryError` or `error.code` literal comparison.
*/
export class DbSqliteMemoryError extends SuaveplanError<DbSqliteMemoryCode> {
public constructor(
code: DbSqliteMemoryCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DbSqliteMemoryError";
}
}

View File

@@ -0,0 +1,5 @@
export {
DB_SQLITE_MEMORY_CODES,
type DbSqliteMemoryCode,
DbSqliteMemoryError,
} from "./error-codes.js";

View File

@@ -0,0 +1,65 @@
# `createSqliteMemoryAdapter`
## Purpose
Opens a real `bun:sqlite` `Database` and wraps it as an `AdminDatabaseLike`. The default zero-argument call yields a `:memory:` connection with `journal_mode=WAL` and `foreign_keys=ON` applied at boot — the canonical Stage-5 testing fixture. Every prepared statement, transaction, query, and execute path runs against the real SQLite engine; nothing is emulated.
The adapter is the lowest layer in the package: it owns the `bun:sqlite` lifecycle, instruments the OTel histograms + error counter, and exposes the underlying `Database` handle via `getRawDatabase(admin)` so the migration runner can drive raw `BEGIN/COMMIT` when it needs to. Higher-level surfaces (`createSqliteMemoryDb`, the engine-registry factory, the `createInMemorySqliteHarness` builder) all delegate to this function.
## Features
- Real `bun:sqlite` `Database` — no shim, no interpreter.
- WAL + foreign-keys pragmas by default, every pragma overridable.
- `query` / `execute` / `prepare` / `transaction` all implemented.
- Real `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` so transaction semantics match production SQLite.
- OTel histograms `api.db.query_duration_ms`, `api.db.execute_duration_ms` and counter `api.db.errors_total`.
- Idempotent `close()` — repeated calls are a no-op.
- `DB_SQLITE_MEMORY.OPEN_FAILED` raised with the original error chained on `cause` when the `bun:sqlite` constructor throws.
## Usage: Basic
```ts
import { createSqliteMemoryAdapter } from "@suaveplan/api-db-sqlite-memory/adapter";
const admin = createSqliteMemoryAdapter();
await admin.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
await admin.execute("INSERT INTO users (name) VALUES (?)", ["alice"]);
const rows = await admin.query("SELECT * FROM users");
await admin.close();
```
## Usage: Advanced
Open a file-backed fixture, disable WAL, and drive a transaction:
```ts
import { createSqliteMemoryAdapter } from "@suaveplan/api-db-sqlite-memory/adapter";
const admin = createSqliteMemoryAdapter({
filename: "/tmp/fixture.sqlite",
pragmas: { journal_mode: "DELETE", foreign_keys: "ON" },
});
if (admin.transaction !== undefined) {
await admin.transaction(async (tx) => {
await tx.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
await tx.execute("INSERT INTO t (id) VALUES (1)");
const r = await tx.query<{ id: number }[]>("SELECT * FROM t");
if (r.length !== 1) throw new Error("rollback");
});
}
await admin.close();
```
## API Reference
- `createSqliteMemoryAdapter(input?)` — returns an `AdminDatabaseLike`.
- `SQLITE_MEMORY_ENGINE` — the literal `"sqlite-memory"` engine sentinel string.
- `getAdapterInstruments()` — lazily-cached OTel histograms + error counter.
- `resetAdapterInstruments()` — test helper to drop the cache so a new MeterProvider is picked up.
- `getRawDatabase(admin)` — accessor for the underlying `bun:sqlite` `Database` handle when present; returns `undefined` for any non-matching adapter.
## Implementation Notes
The adapter intentionally calls prepare-per-query rather than caching prepared statements — Stage-5 tests are short-lived, the call overhead is dominated by the per-test SQLite open, and a stale statement cache would surprise tests that mutate schema between queries. The transaction implementation uses `BEGIN IMMEDIATE` for the same reason `@suaveplan/db-sqlite` does: it locks the database for write at transaction start, so a concurrent test reader cannot observe a partial mid-transaction view. The SQLite `run` method is preferred for `BEGIN/COMMIT/ROLLBACK` because the call returns a `Statement.RunResult` and any SQLite-level error surfaces synchronously, whereas a batch-execute path would silently swallow malformed PRAGMA values.

View File

@@ -0,0 +1,213 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
createSqliteMemoryAdapter,
getRawDatabase,
resetAdapterInstruments,
SQLITE_MEMORY_ENGINE,
} from "./create-sqlite-memory-adapter.js";
describe("createSqliteMemoryAdapter", () => {
let admin: ReturnType<typeof createSqliteMemoryAdapter>;
beforeEach(() => {
resetAdapterInstruments();
admin = createSqliteMemoryAdapter();
});
afterEach(async () => {
await admin.close();
});
it("exposes the engine sentinel", { timeout: 5000 }, () => {
expect(admin.engine).toBe(SQLITE_MEMORY_ENGINE);
});
it("query + execute round-trip", { timeout: 5000 }, async () => {
await admin.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
);
const ins = await admin.execute("INSERT INTO t (name) VALUES (?)", [
"alice",
]);
expect(ins.rowsAffected).toBe(1);
const rows =
await admin.query<{ id: number; name: string }[]>(
"SELECT * FROM t"
);
expect(rows).toEqual([{ id: 1, name: "alice" }]);
});
it("PRAGMA foreign_keys is honoured by the real bun:sqlite database", {
timeout: 5000,
}, async () => {
await admin.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)");
await admin.execute(
"CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id))"
);
await expect(
admin.execute("INSERT INTO child (parent_id) VALUES (?)", [42])
).rejects.toThrow();
});
it("prepare returns a working prepared statement", {
timeout: 5000,
}, async () => {
await admin.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
await admin.execute("INSERT INTO t (id) VALUES (1)");
await admin.execute("INSERT INTO t (id) VALUES (2)");
const stmt = await admin.prepare!<{ id: number }[]>(
"SELECT id FROM t WHERE id > ?"
);
const rows = await stmt.execute([1]);
expect(rows).toEqual([{ id: 2 }]);
await stmt.finalize();
});
it("transaction commits + rolls back", { timeout: 5000 }, async () => {
await admin.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
await admin.transaction!(async (tx) => {
await tx.execute("INSERT INTO t (id) VALUES (1)");
const rows = await tx.query<{ id: number }[]>("SELECT id FROM t");
expect(rows).toEqual([{ id: 1 }]);
});
await expect(
admin.transaction!(async (tx) => {
await tx.execute("INSERT INTO t (id) VALUES (2)");
throw new Error("boom");
})
).rejects.toThrow("boom");
const rows = await admin.query<{ id: number }[]>("SELECT id FROM t");
expect(rows).toEqual([{ id: 1 }]);
});
it("close is idempotent", { timeout: 5000 }, async () => {
await admin.close();
await admin.close();
});
it("getRawDatabase returns the underlying handle", {
timeout: 5000,
}, () => {
expect(getRawDatabase(admin)).toBeDefined();
expect(
getRawDatabase({
engine: "x",
query: async () => [],
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
})
).toBeUndefined();
});
it("query against missing table surfaces the SQLite error", {
timeout: 5000,
}, async () => {
await expect(
admin.query("SELECT * FROM does_not_exist")
).rejects.toThrow();
});
it("execute against malformed SQL surfaces the SQLite error", {
timeout: 5000,
}, async () => {
await expect(admin.execute("THIS IS NOT SQL")).rejects.toThrow();
});
it("transaction error path catches a non-rollback failure", {
timeout: 5000,
}, async () => {
await admin.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
// Use a non-existent table inside the transaction to force a failure
// that still has an open transaction state when the catch runs.
await expect(
admin.transaction!(async (tx) => {
await tx.query("SELECT * FROM nope");
})
).rejects.toThrow();
});
it("prepare statement execute path records errors", {
timeout: 5000,
}, async () => {
await admin.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
const stmt = await admin.prepare!("INSERT INTO t (id) VALUES (?)");
await stmt.execute([1]);
await expect(stmt.execute([1])).rejects.toThrow();
await stmt.finalize();
});
it("query with record params is wrapped into a single-element array", {
timeout: 5000,
}, async () => {
await admin.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
);
await admin.execute("INSERT INTO t (id, name) VALUES (1, 'a')");
// Record params with $name binding — bun:sqlite supports it
// and our normaliser wraps the record in [record].
const rows = await admin.query<{ id: number; name: string }[]>(
"SELECT * FROM t WHERE name = $name",
{ $name: "a" }
);
expect(rows).toEqual([{ id: 1, name: "a" }]);
});
it("OPEN_FAILED surfaces when bun:sqlite throws", { timeout: 5000 }, () => {
try {
createSqliteMemoryAdapter({
filename:
"/this/path/does/not/exist/and/cannot/be/created.sqlite",
});
throw new Error("expected throw");
} catch (err) {
expect(err).toMatchObject({
code: "DB_SQLITE_MEMORY.OPEN_FAILED",
});
}
});
it("emits OTel histograms under a populated MeterProvider", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 10_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetAdapterInstruments();
const a = createSqliteMemoryAdapter();
try {
await a.execute("CREATE TABLE m (id INTEGER PRIMARY KEY)");
await a.execute("INSERT INTO m (id) VALUES (1)");
await a.query("SELECT * FROM m");
await reader.forceFlush();
const collected = exporter.getMetrics();
const names = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("api.db.query_duration_ms");
expect(names).toContain("api.db.execute_duration_ms");
} finally {
await a.close();
await reader.shutdown();
metrics.disable();
resetAdapterInstruments();
}
});
});

View File

@@ -0,0 +1,235 @@
import { Database } from "bun:sqlite";
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type {
AdminDatabaseLike,
DbParams,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
import {
parseSqliteMemoryDbOptions,
resolvePragmas,
type SqliteMemoryDbOptions,
type SqliteMemoryDbOptionsInput,
} from "../options/index.js";
/** Engine sentinel string surfaced on every adapter returned by this factory. */
export const SQLITE_MEMORY_ENGINE = "sqlite-memory" as const;
interface CachedInstruments {
readonly meter: Meter;
readonly queryDuration: Histogram;
readonly executeDuration: Histogram;
readonly errors: Counter;
}
let cached: CachedInstruments | undefined;
/** Lazily resolve OTel instruments. */
export function getAdapterInstruments(): CachedInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-db-sqlite-memory");
cached = {
meter,
queryDuration: meter.createHistogram("api.db.query_duration_ms", {
description: "Per-query execution time in ms.",
}),
executeDuration: meter.createHistogram("api.db.execute_duration_ms", {
description: "Per-execute statement run time in ms.",
}),
errors: meter.createCounter("api.db.errors_total", {
description: "Adapter-level errors raised by the SQLite engine.",
}),
};
return cached;
}
/** Reset cached instruments — test helper. */
export function resetAdapterInstruments(): void {
cached = undefined;
}
function normalizeParams(params: DbParams | undefined): ReadonlyArray<unknown> {
if (params === undefined) return [];
if (Array.isArray(params)) return params;
return [params];
}
function openDatabase(options: SqliteMemoryDbOptions): Database {
try {
return new Database(options.filename, {
create: true,
readonly: options.readonly,
});
} catch (cause) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.OPEN_FAILED,
`failed to open bun:sqlite database '${options.filename}'`,
{
cause: cause as Error,
context: { filename: options.filename },
}
);
}
}
function applyPragmas(db: Database, options: SqliteMemoryDbOptions): void {
const merged = resolvePragmas(options);
for (const [name, value] of Object.entries(merged)) {
db.run(`PRAGMA ${name} = ${value}`);
}
}
interface InternalAdapter extends AdminDatabaseLike {
readonly raw: Database;
}
function buildPrepared<R>(
db: Database,
sql: string,
instruments: CachedInstruments
): PreparedStatement<R> {
const stmt = db.prepare(sql);
return {
id: `stmt-${Math.random().toString(36).slice(2)}`,
execute: async (params) => {
const p = normalizeParams(params);
const start = performance.now();
try {
return stmt.all(...(p as never[])) as R;
} catch (cause) {
instruments.errors.add(1, { op: "prepare.execute" });
throw cause;
} finally {
instruments.queryDuration.record(performance.now() - start, {
op: "prepare.execute",
});
}
},
finalize: async () => {
stmt.finalize();
},
};
}
function buildTransaction(
db: Database,
instruments: CachedInstruments
): DbTransaction {
return {
query: async <R = unknown>(
sql: string,
params?: DbParams
): Promise<R> => {
const p = normalizeParams(params);
const start = performance.now();
try {
return db.prepare(sql).all(...(p as never[])) as R;
} finally {
instruments.queryDuration.record(performance.now() - start, {
op: "tx.query",
});
}
},
execute: async (sql: string, params?: DbParams) => {
const p = normalizeParams(params);
const start = performance.now();
try {
const result = db.prepare(sql).run(...(p as never[]));
return { rowsAffected: Number(result.changes ?? 0) };
} finally {
instruments.executeDuration.record(performance.now() - start, {
op: "tx.execute",
});
}
},
commit: async () => undefined,
rollback: async () => undefined,
};
}
/**
* Open a real `bun:sqlite` database and wrap it as an `AdminDatabaseLike`.
*/
export function createSqliteMemoryAdapter(
input?: SqliteMemoryDbOptionsInput
): AdminDatabaseLike {
const options = parseSqliteMemoryDbOptions(input);
const db = openDatabase(options);
applyPragmas(db, options);
const instruments = getAdapterInstruments();
let closed = false;
const adapter: InternalAdapter = {
engine: SQLITE_MEMORY_ENGINE,
raw: db,
query: async <R = unknown>(
sql: string,
params?: DbParams
): Promise<R> => {
const p = normalizeParams(params);
const start = performance.now();
try {
return db.prepare(sql).all(...(p as never[])) as R;
} catch (cause) {
instruments.errors.add(1, { op: "query" });
throw cause;
} finally {
instruments.queryDuration.record(performance.now() - start, {
op: "query",
});
}
},
execute: async (sql, params) => {
const p = normalizeParams(params);
const start = performance.now();
try {
const result = db.prepare(sql).run(...(p as never[]));
return { rowsAffected: Number(result.changes ?? 0) };
} catch (cause) {
instruments.errors.add(1, { op: "execute" });
throw cause;
} finally {
instruments.executeDuration.record(performance.now() - start, {
op: "execute",
});
}
},
prepare: async <R = unknown>(sql: string) =>
buildPrepared<R>(db, sql, instruments),
transaction: async <R>(fn: (tx: DbTransaction) => Promise<R>) => {
db.run("BEGIN IMMEDIATE");
try {
const result = await fn(buildTransaction(db, instruments));
db.run("COMMIT");
return result;
} catch (cause) {
try {
db.run("ROLLBACK");
} catch {
/* already rolled back */
}
throw cause;
}
},
close: async () => {
if (closed) return;
closed = true;
db.close();
},
};
return adapter;
}
/** Internal accessor used by the migration runner + tests. */
export function getRawDatabase(admin: AdminDatabaseLike): Database | undefined {
const candidate = (admin as { raw?: unknown }).raw;
return candidate instanceof Database ? candidate : undefined;
}

View File

@@ -0,0 +1,75 @@
# `createSqliteMemoryDb`
## Purpose
Stage-5 top-level testing factory. Combines `createSqliteMemoryAdapter` with the tenant-scoping wrapper from this package so callers get the canonical `{ admin, scoped, close }` triple in one call. This is the function the `@suaveplan/api-host/testing` adapter table reaches for when a plugin test asks for `engine: "sqlite-memory"`.
The `admin` surface is the bare `AdminDatabaseLike` returned by the adapter — unscoped, useful for migrations and operator-style work. `scoped(tenantId)` produces a fresh `TenantScopedDatabaseLike` over the same underlying connection so route handlers see a tenant-bound surface that auto-injects (or rejects) cross-tenant payloads. `close()` is the idempotent shutdown.
## Features
- One-call assembly of admin + scoped + close.
- Tenant column + auto-inject mode forwarded from `SqliteMemoryDbOptions`.
- Both surfaces share the same `bun:sqlite` connection — closing once frees everything.
- The `admin.engine` field is `"sqlite-memory"`; `scoped().engine` matches.
## Usage: Basic
```ts
import { createSqliteMemoryDb } from "@suaveplan/api-db-sqlite-memory";
import type { TenantId } from "@suaveplan/api-types/tenant";
const db = createSqliteMemoryDb();
await db.admin.execute(
"CREATE TABLE notes (id INTEGER PRIMARY KEY, tenant_id TEXT, body TEXT)"
);
const tenant = "acme" as TenantId;
await db
.scoped(tenant)
.execute("INSERT INTO notes (body) VALUES (?)", ["hello"]);
const rows = await db.scoped(tenant).query("SELECT body FROM notes");
await db.close();
```
## Usage: Advanced
Customise the tenant column and disable auto-inject so missing `org_id` payloads are rejected:
```ts
import { createSqliteMemoryDb } from "@suaveplan/api-db-sqlite-memory";
import type { TenantId } from "@suaveplan/api-types/tenant";
const db = createSqliteMemoryDb({
tenantColumn: "org_id",
autoInjectInserts: false,
});
await db.admin.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, org_id TEXT, name TEXT)"
);
await db
.scoped("acme" as TenantId)
.execute("INSERT INTO items (org_id, name) VALUES (?, ?)", [
"acme",
"widget",
]);
// Throws DB_SQLITE_MEMORY.TENANT_SCOPE_VIOLATION:
await db
.scoped("acme" as TenantId)
.execute("INSERT INTO items (name) VALUES (?)", ["broken"]);
await db.close();
```
## API Reference
- `createSqliteMemoryDb(input?)` — returns `SqliteMemoryDb`.
- `SqliteMemoryDb.admin` — `AdminDatabaseLike` from `createSqliteMemoryAdapter`.
- `SqliteMemoryDb.scoped(tenantId)` — fresh `TenantScopedDatabaseLike`.
- `SqliteMemoryDb.close()` — idempotent close of the underlying database.
## Implementation Notes
The wrapper does not cache scoped instances — each `scoped(tenantId)` call builds a fresh `TenantScopedDatabaseLike` over the same admin connection. Caching would risk leaking a stale tenant binding between tests, and the wrapper is cheap enough that per-request construction is fine. Closing the admin closes the SQLite handle directly, so any outstanding scoped wrappers stop working at the same instant — there is no second close pass needed.

View File

@@ -0,0 +1,107 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
createSqliteMemoryDb,
type SqliteMemoryDb,
} from "./create-sqlite-memory-db.js";
/**
* Self-conformance suite. `@suaveplan/api-db-adapter` does not currently ship a
* `runDbAdapterConformance(...)` harness — when it does, this suite will be
* replaced with a one-line `runDbAdapterConformance("sqlite-memory-direct", ...)`.
* Until then, the suite drives every public method on AdminDatabaseLike +
* TenantScopedDatabaseLike against a real `:memory:` adapter.
*/
describe("sqlite-memory-direct conforms to Db contract", () => {
let db: SqliteMemoryDb;
beforeEach(() => {
db = createSqliteMemoryDb();
});
afterEach(() => db.close());
it("admin.query", { timeout: 5000 }, async () => {
const rows = await db.admin.query("SELECT 1 AS n");
expect(rows).toEqual([{ n: 1 }]);
});
it("admin.execute returns rowsAffected", { timeout: 5000 }, async () => {
await db.admin.execute("CREATE TABLE c (id INTEGER PRIMARY KEY)");
const r = await db.admin.execute("INSERT INTO c (id) VALUES (1)");
expect(r.rowsAffected).toBe(1);
});
it("admin.prepare exists and works", { timeout: 5000 }, async () => {
await db.admin.execute("CREATE TABLE c (id INTEGER PRIMARY KEY)");
await db.admin.execute("INSERT INTO c (id) VALUES (1)");
const stmt = await db.admin.prepare!("SELECT * FROM c");
try {
const rows = await stmt.execute();
expect(rows).toEqual([{ id: 1 }]);
} finally {
await stmt.finalize();
}
});
it("admin.transaction commits", { timeout: 5000 }, async () => {
await db.admin.execute("CREATE TABLE c (id INTEGER PRIMARY KEY)");
await db.admin.transaction!(async (tx) => {
await tx.execute("INSERT INTO c (id) VALUES (1)");
});
const rows = await db.admin.query<{ id: number }[]>("SELECT id FROM c");
expect(rows).toEqual([{ id: 1 }]);
});
it("admin.transaction rolls back on throw", { timeout: 5000 }, async () => {
await db.admin.execute("CREATE TABLE c (id INTEGER PRIMARY KEY)");
await expect(
db.admin.transaction!(async (tx) => {
await tx.execute("INSERT INTO c (id) VALUES (99)");
throw new Error("rollback me");
})
).rejects.toThrow("rollback me");
const rows = await db.admin.query("SELECT id FROM c");
expect(rows).toEqual([]);
});
it("scoped.query injects tenant", { timeout: 5000 }, async () => {
await db.admin.execute(
"CREATE TABLE c (id INTEGER PRIMARY KEY, tenant_id TEXT)"
);
await db.admin.execute(
"INSERT INTO c (id, tenant_id) VALUES (1, 'acme')"
);
await db.admin.execute(
"INSERT INTO c (id, tenant_id) VALUES (2, 'bigcorp')"
);
const acme = db.scoped("acme" as TenantId);
const rows = await acme.query<{ id: number }[]>("SELECT id FROM c");
expect(rows).toEqual([{ id: 1 }]);
});
it("scoped.transaction wraps tx in scope", { timeout: 5000 }, async () => {
await db.admin.execute(
"CREATE TABLE c (id INTEGER PRIMARY KEY, tenant_id TEXT, body TEXT)"
);
const acme = db.scoped("acme" as TenantId);
await acme.transaction!(async (tx) => {
await tx.execute("INSERT INTO c (body) VALUES (?)", ["x"]);
});
const rows = await db.admin.query<{ tenant_id: string }[]>(
"SELECT tenant_id FROM c"
);
expect(rows).toEqual([{ tenant_id: "acme" }]);
});
it("close is observable from both surfaces", {
timeout: 5000,
}, async () => {
await db.close();
await expect(db.admin.query("SELECT 1")).rejects.toThrow();
});
});

View File

@@ -0,0 +1,63 @@
import type {
AdminDatabaseLike,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { createSqliteMemoryDb } from "./create-sqlite-memory-db.js";
describe("createSqliteMemoryDb", () => {
it("returns admin + scoped + close triple", { timeout: 5000 }, async () => {
const db = createSqliteMemoryDb();
try {
expect(db.admin.engine).toBe("sqlite-memory");
const scoped = db.scoped("acme" as TenantId);
expect(scoped.engine).toBe("sqlite-memory");
expect(scoped.tenantId).toBe("acme");
} finally {
await db.close();
}
});
it("admin + scoped surfaces are type-distinct", { timeout: 5000 }, () => {
const db = createSqliteMemoryDb();
try {
expectTypeOf(db.admin).toEqualTypeOf<AdminDatabaseLike>();
expectTypeOf(
db.scoped("acme" as TenantId)
).toEqualTypeOf<TenantScopedDatabaseLike>();
// Runtime sanity: admin has no tenantId field; scoped has it.
expect(
(db.admin as { tenantId?: unknown }).tenantId
).toBeUndefined();
expect(db.scoped("acme" as TenantId).tenantId).toBe("acme");
} finally {
db.close();
}
});
it("forwards tenantColumn into scoped wrapper", {
timeout: 5000,
}, async () => {
const db = createSqliteMemoryDb({ tenantColumn: "org_id" });
try {
await db.admin.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, org_id TEXT, name TEXT)"
);
const acme = db.scoped("acme" as TenantId);
await acme.execute("INSERT INTO items (name) VALUES (?)", ["w"]);
const rows = await db.admin.query<
{ name: string; org_id: string }[]
>("SELECT name, org_id FROM items");
expect(rows).toEqual([{ name: "w", org_id: "acme" }]);
} finally {
await db.close();
}
});
it("close is idempotent on the DB wrapper", { timeout: 5000 }, async () => {
const db = createSqliteMemoryDb();
await db.close();
await db.close();
});
});

View File

@@ -0,0 +1,44 @@
import type {
AdminDatabaseLike,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
parseSqliteMemoryDbOptions,
type SqliteMemoryDbOptionsInput,
} from "../options/index.js";
import { createTenantScopedSqliteWrapper } from "../tenant-scoping/index.js";
import { createSqliteMemoryAdapter } from "./create-sqlite-memory-adapter.js";
/**
* Return shape of {@link createSqliteMemoryDb}: the admin surface (used by
* migrations + operator tooling) plus a `scoped(tenantId)` factory that
* produces a `TenantScopedDatabaseLike` wrapper enforcing the configured
* tenant column.
*/
export interface SqliteMemoryDb {
readonly admin: AdminDatabaseLike;
scoped(tenantId: TenantId): TenantScopedDatabaseLike;
close(): Promise<void>;
}
/**
* Top-level Stage-5 testing factory. Parses + validates options, opens a
* real `bun:sqlite` `:memory:` (or file-backed) database, applies the
* configured pragmas, and returns the admin + scoped surfaces.
*/
export function createSqliteMemoryDb(
input?: SqliteMemoryDbOptionsInput
): SqliteMemoryDb {
const options = parseSqliteMemoryDbOptions(input);
const admin = createSqliteMemoryAdapter(options);
return {
admin,
scoped: (tenantId) =>
createTenantScopedSqliteWrapper(admin, tenantId, {
tenantColumn: options.tenantColumn,
autoInjectInserts: options.autoInjectInserts,
}),
close: () => admin.close(),
};
}

View File

@@ -0,0 +1,11 @@
export {
createSqliteMemoryAdapter,
getAdapterInstruments,
getRawDatabase,
resetAdapterInstruments,
SQLITE_MEMORY_ENGINE,
} from "./create-sqlite-memory-adapter.js";
export {
createSqliteMemoryDb,
type SqliteMemoryDb,
} from "./create-sqlite-memory-db.js";

View File

@@ -0,0 +1,40 @@
export { registerSqliteMemoryEngine } from "./engine/index.js";
export {
DB_SQLITE_MEMORY_CODES,
type DbSqliteMemoryCode,
DbSqliteMemoryError,
} from "./error-codes/index.js";
export {
createSqliteMemoryAdapter,
createSqliteMemoryDb,
getAdapterInstruments,
getRawDatabase,
resetAdapterInstruments,
SQLITE_MEMORY_ENGINE,
type SqliteMemoryDb,
} from "./factory/index.js";
export {
getMigrationInstruments,
type RunSqliteMemoryMigrationsOptions,
type RunSqliteMemoryMigrationsResult,
resetMigrationInstruments,
runSqliteMemoryMigrations,
type SqliteMigrationsFsReader,
} from "./migrations/index.js";
export {
DEFAULT_PRAGMAS,
parseSqliteMemoryDbOptions,
resolvePragmas,
type SqliteMemoryDbOptions,
type SqliteMemoryDbOptionsInput,
SqliteMemoryDbOptionsSchema,
} from "./options/index.js";
export {
createTenantScopedSqliteWrapper,
sqliteRewriters,
type TenantScopedSqliteWrapperOptions,
} from "./tenant-scoping/index.js";
export {
createInMemorySqliteHarness,
type InMemorySqliteHarness,
} from "./testing/index.js";

View File

@@ -0,0 +1,8 @@
export {
getMigrationInstruments,
type RunSqliteMemoryMigrationsOptions,
type RunSqliteMemoryMigrationsResult,
resetMigrationInstruments,
runSqliteMemoryMigrations,
type SqliteMigrationsFsReader,
} from "./migrations.js";

View File

@@ -0,0 +1,81 @@
# `runSqliteMemoryMigrations`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lexicographic forward-only migration runner for the in-memory SQLite adapter. Applies every `*.sql` file in a directory in sorted order against an `AdminDatabaseLike`, recording each successful application in a host-managed `__plugin_migrations` table keyed by `(plugin, version)`.
The runner is idempotent: re-running with no new files returns immediately. A failing migration throws `DB_SQLITE_MEMORY.MIGRATION_FAILED` with the offending file on `error.context.file`, and the transaction is rolled back so `__plugin_migrations` never reflects a half-applied state.
## Features
- Lexicographic ordering — `001_*.sql` runs before `002_*.sql`.
- Idempotent — re-running skips already-applied versions, returns them as `skipped`.
- Transactional per-file when the admin adapter supports `transaction()`; falls back to a plain `execute` pair when it does not.
- OTel `api.db.migrations_applied_total` counter + `api.db.migration_duration_ms` histogram emitted per applied file.
- Pluggable filesystem reader via `SqliteMigrationsFsReader` — tests pass an in-memory map; production uses `node:fs/promises`.
- Failed migrations throw `DB_SQLITE_MEMORY.MIGRATION_FAILED` with the original SQLite error chained on `cause`.
## Usage: Basic
```ts
import { readFile, readdir } from "node:fs/promises";
import { createSqliteMemoryAdapter } from "@suaveplan/api-db-sqlite-memory";
import { runSqliteMemoryMigrations } from "@suaveplan/api-db-sqlite-memory/migrations";
const admin = createSqliteMemoryAdapter();
const result = await runSqliteMemoryMigrations({
plugin: "users",
admin,
migrationsDir: "/abs/path/to/migrations",
fs: { readdir, readFile: (p) => readFile(p, "utf8") },
});
console.log(result.applied, result.skipped);
```
## Usage: Advanced
In-memory filesystem reader for tests:
```ts
import {
runSqliteMemoryMigrations,
type SqliteMigrationsFsReader,
} from "@suaveplan/api-db-sqlite-memory/migrations";
const files: Record<string, string> = {
"001_init.sql": "CREATE TABLE notes (id INTEGER PRIMARY KEY)",
"002_body.sql": "ALTER TABLE notes ADD COLUMN body TEXT",
};
const fs: SqliteMigrationsFsReader = {
readdir: async () => Object.keys(files),
readFile: async (p) => {
const base = p.split("/").pop() as string;
const f = files[base];
if (f === undefined) throw new Error(`missing: ${base}`);
return f;
},
};
await runSqliteMemoryMigrations({
plugin: "notes",
admin,
migrationsDir: "/virtual",
fs,
});
```
## API Reference
- `runSqliteMemoryMigrations(options)` — returns `{ applied, skipped }`.
- `SqliteMigrationsFsReader` — `readdir(dir)` + `readFile(path)` contract.
- `RunSqliteMemoryMigrationsOptions` — `plugin`, `admin`, `migrationsDir`, `fs`.
- `getMigrationInstruments()` / `resetMigrationInstruments()` — OTel cache hooks.
## Implementation Notes
The `__plugin_migrations` table uses a composite `(plugin, version)` primary key so multiple plugins can coexist in the same database without colliding on filenames. The runner queries the applied rows once per call and builds an in-memory `Set` rather than `SELECT EXISTS` per file — a plugin with a hundred migrations would otherwise run a hundred no-op queries on every restart. Lexicographic ordering is the same convention used by `@suaveplan/api-db-adapter`'s migration runner so plugin authors can move between in-memory and Postgres-backed engines without renaming migration files.

View File

@@ -0,0 +1,245 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
import { createSqliteMemoryAdapter } from "../factory/create-sqlite-memory-adapter.js";
import {
resetMigrationInstruments,
runSqliteMemoryMigrations,
type SqliteMigrationsFsReader,
} from "./migrations.js";
function fsFromMap(files: Record<string, string>): SqliteMigrationsFsReader {
return {
readdir: async () => Object.keys(files),
readFile: async (path) => {
const base = path.split("/").pop() as string;
const content = files[base];
if (content === undefined) {
throw new Error(`missing file: ${path}`);
}
return content;
},
};
}
describe("runSqliteMemoryMigrations", () => {
let admin: ReturnType<typeof createSqliteMemoryAdapter>;
beforeEach(() => {
resetMigrationInstruments();
admin = createSqliteMemoryAdapter();
});
afterEach(() => admin.close());
it("applies pending migrations in lexicographic order", {
timeout: 5000,
}, async () => {
const fs = fsFromMap({
"002_body.sql": "ALTER TABLE notes ADD COLUMN body TEXT",
"001_init.sql": "CREATE TABLE notes (id INTEGER PRIMARY KEY)",
"skip.txt": "ignored",
});
const result = await runSqliteMemoryMigrations({
plugin: "notes",
admin,
migrationsDir: "/v",
fs,
});
expect(result.applied).toEqual(["001_init.sql", "002_body.sql"]);
expect(result.skipped).toEqual([]);
const rows = await admin.query<{ version: string }[]>(
"SELECT version FROM __plugin_migrations WHERE plugin = ? ORDER BY version",
["notes"]
);
expect(rows.map((r) => r.version)).toEqual([
"001_init.sql",
"002_body.sql",
]);
});
it("is idempotent on re-run", { timeout: 5000 }, async () => {
const fs = fsFromMap({
"001_init.sql": "CREATE TABLE notes (id INTEGER PRIMARY KEY)",
});
await runSqliteMemoryMigrations({
plugin: "notes",
admin,
migrationsDir: "/v",
fs,
});
const second = await runSqliteMemoryMigrations({
plugin: "notes",
admin,
migrationsDir: "/v",
fs,
});
expect(second.applied).toEqual([]);
expect(second.skipped).toEqual(["001_init.sql"]);
});
it("MIGRATION_FAILED is thrown with the offending file", {
timeout: 5000,
}, async () => {
const fs = fsFromMap({
"001_bad.sql": "THIS IS NOT SQL",
});
try {
await runSqliteMemoryMigrations({
plugin: "x",
admin,
migrationsDir: "/v",
fs,
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(DbSqliteMemoryError);
expect((err as DbSqliteMemoryError).code).toBe(
DB_SQLITE_MEMORY_CODES.MIGRATION_FAILED
);
expect((err as DbSqliteMemoryError).context).toMatchObject({
file: "001_bad.sql",
plugin: "x",
});
}
});
it("failed migration rolls back __plugin_migrations row", {
timeout: 5000,
}, async () => {
const fs = fsFromMap({
"001_init.sql": "CREATE TABLE n (id INTEGER PRIMARY KEY)",
"002_bad.sql": "ALTER TABLE missing ADD COLUMN x TEXT",
});
await expect(
runSqliteMemoryMigrations({
plugin: "n",
admin,
migrationsDir: "/v",
fs,
})
).rejects.toBeInstanceOf(DbSqliteMemoryError);
const rows = await admin.query<{ version: string }[]>(
"SELECT version FROM __plugin_migrations WHERE plugin = ?",
["n"]
);
expect(rows.map((r) => r.version)).toEqual(["001_init.sql"]);
});
it("falls back to non-transactional path when admin has no transaction", {
timeout: 5000,
}, async () => {
const fakeAdmin = {
engine: "fake" as const,
query: async () => [] as ReadonlyArray<{ version: string }>,
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
};
const calls: string[] = [];
const wrapped = {
...fakeAdmin,
execute: async (sql: string) => {
calls.push(sql.split(/\s+/).slice(0, 2).join(" "));
return { rowsAffected: 0 };
},
};
const fs = fsFromMap({
"001_init.sql": "CREATE TABLE z (id INTEGER PRIMARY KEY)",
});
const result = await runSqliteMemoryMigrations({
plugin: "z",
admin: wrapped,
migrationsDir: "/v",
fs,
});
expect(result.applied).toEqual(["001_init.sql"]);
expect(calls.some((c) => c.startsWith("CREATE"))).toBe(true);
expect(calls.some((c) => c.startsWith("INSERT"))).toBe(true);
});
it("non-transactional failure throws MIGRATION_FAILED", {
timeout: 5000,
}, async () => {
const fakeAdmin = {
engine: "fake" as const,
query: async () => [] as ReadonlyArray<{ version: string }>,
execute: async (sql: string) => {
if (
sql.includes(
"CREATE TABLE IF NOT EXISTS __plugin_migrations"
)
) {
return { rowsAffected: 0 };
}
throw new Error("nope");
},
close: async () => undefined,
};
const fs = fsFromMap({
"001_bad.sql": "CREATE TABLE q (id INTEGER PRIMARY KEY)",
});
await expect(
runSqliteMemoryMigrations({
plugin: "q",
admin: fakeAdmin,
migrationsDir: "/v",
fs,
})
).rejects.toMatchObject({
code: DB_SQLITE_MEMORY_CODES.MIGRATION_FAILED,
});
});
it("emits migrations_applied_total counter", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 10_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetMigrationInstruments();
const a = createSqliteMemoryAdapter();
try {
const fs = fsFromMap({
"001_init.sql": "CREATE TABLE m (id INTEGER PRIMARY KEY)",
});
await runSqliteMemoryMigrations({
plugin: "m",
admin: a,
migrationsDir: "/v",
fs,
});
await reader.forceFlush();
const collected = exporter.getMetrics();
const names = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m2) => m2.descriptor.name);
expect(names).toContain("api.db.migrations_applied_total");
expect(names).toContain("api.db.migration_duration_ms");
} finally {
await a.close();
await reader.shutdown();
metrics.disable();
resetMigrationInstruments();
}
});
});

View File

@@ -0,0 +1,142 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
/**
* Filesystem-like read surface required by the migration runner. Keeps the
* runner testable without depending on `node:fs` at the type level.
*/
export interface SqliteMigrationsFsReader {
/** List entries in `dir` — basenames only. */
readdir(dir: string): Promise<ReadonlyArray<string>>;
/** Read a file's contents as UTF-8. */
readFile(path: string): Promise<string>;
}
/** Input to {@link runSqliteMemoryMigrations}. */
export interface RunSqliteMemoryMigrationsOptions {
/** Plugin name — row key in `__plugin_migrations`. */
readonly plugin: string;
/** Admin adapter to run migrations against. */
readonly admin: AdminDatabaseLike;
/** Directory containing `001_init.sql`, `002_*.sql`, ... */
readonly migrationsDir: string;
/** Filesystem reader. */
readonly fs: SqliteMigrationsFsReader;
}
/** Outcome of a {@link runSqliteMemoryMigrations} invocation. */
export interface RunSqliteMemoryMigrationsResult {
readonly applied: ReadonlyArray<string>;
readonly skipped: ReadonlyArray<string>;
}
interface CachedMigrationInstruments {
readonly meter: Meter;
readonly migrationsApplied: Counter;
readonly migrationDuration: Histogram;
}
let cached: CachedMigrationInstruments | undefined;
/** Lazily resolve OTel instruments. */
export function getMigrationInstruments(): CachedMigrationInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-db-sqlite-memory");
cached = {
meter,
migrationsApplied: meter.createCounter(
"api.db.migrations_applied_total",
{ description: "Plugin migration files applied successfully." }
),
migrationDuration: meter.createHistogram(
"api.db.migration_duration_ms",
{ description: "Per-migration application time in ms." }
),
};
return cached;
}
/** Reset cached instruments — test helper. */
export function resetMigrationInstruments(): void {
cached = undefined;
}
const CREATE_TABLE_SQL =
"CREATE TABLE IF NOT EXISTS __plugin_migrations (plugin TEXT NOT NULL, version TEXT NOT NULL, applied_at TEXT NOT NULL, PRIMARY KEY (plugin, version))";
/**
* Apply pending migrations from `migrationsDir` in lexicographic order,
* recording applied rows in `__plugin_migrations(plugin, version, applied_at)`.
* Idempotent: re-running with no new files is a no-op.
*
* Failures throw `DB_SQLITE_MEMORY.MIGRATION_FAILED` with the offending file
* on `error.context.file`. The transaction is rolled back so the
* `__plugin_migrations` row for the failing version is NOT recorded.
*/
export async function runSqliteMemoryMigrations(
options: RunSqliteMemoryMigrationsOptions
): Promise<RunSqliteMemoryMigrationsResult> {
const { plugin, admin, migrationsDir, fs } = options;
await admin.execute(CREATE_TABLE_SQL);
const entries = await fs.readdir(migrationsDir);
const sqlFiles = entries
.filter((name) => name.endsWith(".sql"))
.sort((a, b) => a.localeCompare(b));
const appliedRows = (await admin.query(
"SELECT version FROM __plugin_migrations WHERE plugin = ?",
[plugin]
)) as ReadonlyArray<{ version: string }>;
const alreadyApplied = new Set(appliedRows.map((r) => r.version));
const applied: string[] = [];
const skipped: string[] = [];
const instruments = getMigrationInstruments();
for (const file of sqlFiles) {
if (alreadyApplied.has(file)) {
skipped.push(file);
continue;
}
const sql = await fs.readFile(`${migrationsDir}/${file}`);
const start = performance.now();
try {
if (admin.transaction !== undefined) {
await admin.transaction(async (tx) => {
await tx.execute(sql);
await tx.execute(
"INSERT INTO __plugin_migrations (plugin, version, applied_at) VALUES (?, ?, ?)",
[plugin, file, new Date().toISOString()]
);
});
} else {
await admin.execute(sql);
await admin.execute(
"INSERT INTO __plugin_migrations (plugin, version, applied_at) VALUES (?, ?, ?)",
[plugin, file, new Date().toISOString()]
);
}
} catch (cause) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.MIGRATION_FAILED,
`migration ${file} failed for plugin ${plugin}`,
{
cause: cause as Error,
context: { file, plugin },
}
);
}
const elapsed = performance.now() - start;
instruments.migrationDuration.record(elapsed, { plugin });
instruments.migrationsApplied.add(1, { plugin });
applied.push(file);
}
return { applied, skipped };
}

View File

@@ -0,0 +1,8 @@
export {
DEFAULT_PRAGMAS,
parseSqliteMemoryDbOptions,
resolvePragmas,
type SqliteMemoryDbOptions,
type SqliteMemoryDbOptionsInput,
SqliteMemoryDbOptionsSchema,
} from "./sqlite-memory-db-options.js";

View File

@@ -0,0 +1,65 @@
# `SqliteMemoryDbOptions`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod-validated options object accepted by `createSqliteMemoryDb`, `createSqliteMemoryAdapter`, and the engine-registry factory. Every field is optional and carries a default — the zero-argument call yields the canonical `:memory:` connection that the api-host testing kit uses by convention.
The schema is the single source of truth for what a "valid in-memory SQLite test adapter" means inside Suaveplan. Both the direct factory and the engine registration helper pass raw caller input through `parseSqliteMemoryDbOptions` before doing any work, which means Zod issues surface as the same `DB_SQLITE_MEMORY.INVALID_OPTIONS` code no matter the entry point.
## Features
- `filename` defaults to `":memory:"`; any non-empty string accepted for file-backed fixtures.
- `tenantColumn` defaults to `"tenant_id"` — matches the column convention shared with `@suaveplan/api-db-adapter`.
- `migrationsDir` optional; consumed by `runSqliteMemoryMigrations`.
- `pragmas` merges atop `DEFAULT_PRAGMAS` (`journal_mode=WAL`, `foreign_keys=ON`).
- `autoInjectInserts` controls the tenant-scoping wrapper's strictness.
- `readonly` opens the underlying `bun:sqlite` `Database` in read-only mode.
- `resolvePragmas(options)` returns the effective pragma map without opening a connection — useful for tests + diagnostics.
## Usage: Basic
```ts
import { parseSqliteMemoryDbOptions } from "@suaveplan/api-db-sqlite-memory/options";
const opts = parseSqliteMemoryDbOptions();
// opts.filename === ":memory:"
// opts.pragmas effective = { journal_mode: "WAL", foreign_keys: "ON" }
```
## Usage: Advanced
Customise pragmas + override the tenant column while keeping every other default:
```ts
import {
parseSqliteMemoryDbOptions,
resolvePragmas,
} from "@suaveplan/api-db-sqlite-memory/options";
const opts = parseSqliteMemoryDbOptions({
filename: "/tmp/fixture.sqlite",
tenantColumn: "org_id",
pragmas: { foreign_keys: "OFF", synchronous: "FULL" },
autoInjectInserts: false,
});
const effective = resolvePragmas(opts);
// { journal_mode: "WAL", foreign_keys: "OFF", synchronous: "FULL" }
```
## API Reference
- `SqliteMemoryDbOptionsSchema` — the Zod schema object.
- `SqliteMemoryDbOptions` — `z.infer` of the schema.
- `SqliteMemoryDbOptionsInput` — `z.input` of the schema.
- `parseSqliteMemoryDbOptions(input?)` — parse + throw `DbSqliteMemoryError` on failure.
- `resolvePragmas(options)` — final merged pragma map.
- `DEFAULT_PRAGMAS` — `{ journal_mode: "WAL", foreign_keys: "ON" }`.
## Implementation Notes
The schema deliberately uses `z.record(z.string(), z.string())` for `pragmas` rather than a closed enum — SQLite ships dozens of pragmas and operators occasionally want to flip `synchronous`, `temp_store`, or `cache_size` for fixture warm-up. The merge order (`{ ...DEFAULT_PRAGMAS, ...options.pragmas }`) means a caller can disable any default by re-supplying the same key, so the policy is "safe defaults, full override". Field-level `.default(...)` calls run inside `.safeParse`, so `parseSqliteMemoryDbOptions(undefined)` is the same as passing `{}` — handy for the engine-registry path where the adapter url-fragment may legitimately be empty.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DbSqliteMemoryError } from "../error-codes/index.js";
import {
DEFAULT_PRAGMAS,
parseSqliteMemoryDbOptions,
resolvePragmas,
SqliteMemoryDbOptionsSchema,
} from "./sqlite-memory-db-options.js";
describe("SqliteMemoryDbOptions", () => {
it("zero-arg call yields :memory: defaults", { timeout: 5000 }, () => {
const opts = parseSqliteMemoryDbOptions();
expect(opts.filename).toBe(":memory:");
expect(opts.tenantColumn).toBe("tenant_id");
expect(opts.autoInjectInserts).toBe(true);
expect(opts.readonly).toBe(false);
expect(opts.pragmas).toEqual({});
});
it("passing {} is equivalent to undefined", { timeout: 5000 }, () => {
expect(parseSqliteMemoryDbOptions({})).toEqual(
parseSqliteMemoryDbOptions(undefined)
);
});
it("resolvePragmas merges defaults under caller overrides", {
timeout: 5000,
}, () => {
const opts = parseSqliteMemoryDbOptions({
pragmas: { foreign_keys: "OFF", synchronous: "FULL" },
});
expect(resolvePragmas(opts)).toEqual({
journal_mode: "WAL",
foreign_keys: "OFF",
synchronous: "FULL",
});
});
it("resolvePragmas with no overrides equals DEFAULT_PRAGMAS", {
timeout: 5000,
}, () => {
const opts = parseSqliteMemoryDbOptions();
expect(resolvePragmas(opts)).toEqual(DEFAULT_PRAGMAS);
});
it("rejects empty filename with INVALID_OPTIONS", { timeout: 5000 }, () => {
try {
parseSqliteMemoryDbOptions({ filename: "" });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(DbSqliteMemoryError);
expect((err as DbSqliteMemoryError).code).toBe(
"DB_SQLITE_MEMORY.INVALID_OPTIONS"
);
}
});
it("rejects empty tenantColumn", { timeout: 5000 }, () => {
expect(() => parseSqliteMemoryDbOptions({ tenantColumn: "" })).toThrow(
DbSqliteMemoryError
);
});
it("rejects empty migrationsDir", { timeout: 5000 }, () => {
expect(() => parseSqliteMemoryDbOptions({ migrationsDir: "" })).toThrow(
DbSqliteMemoryError
);
});
it("schema is exported for downstream composition", {
timeout: 5000,
}, () => {
expect(SqliteMemoryDbOptionsSchema.shape.filename).toBeDefined();
});
});

View File

@@ -0,0 +1,88 @@
import z from "zod";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
/**
* Default PRAGMA map applied to every freshly opened connection.
*
* - `journal_mode=WAL` — write-ahead logging; in `:memory:` mode this is a
* no-op but harmless, and it matters for file-backed test fixtures.
* - `foreign_keys=ON` — SQLite ships with FK enforcement disabled by default;
* the adapter flips it on so referential integrity is honoured.
*/
export const DEFAULT_PRAGMAS = {
journal_mode: "WAL",
foreign_keys: "ON",
} as const satisfies Readonly<Record<string, string>>;
/**
* Top-level options for `createSqliteMemoryDb`. Every field has a default —
* the zero-argument call yields an in-memory adapter with WAL + foreign-keys
* enforcement, a `tenant_id` scoping column, and strict insert mode.
*/
export const SqliteMemoryDbOptionsSchema = z.object({
/**
* `bun:sqlite` filename. Default `":memory:"`. Set to an absolute path
* for a persistent file-backed fixture; the file is created if missing.
*/
filename: z.string().min(1).default(":memory:"),
/**
* Column name used by the tenant-scoping wrapper. Default `"tenant_id"`.
*/
tenantColumn: z.string().min(1).default("tenant_id"),
/** Optional migrations directory passed to `runSqliteMemoryMigrations`. */
migrationsDir: z.string().min(1).optional(),
/**
* Pragmas applied at open time. Merged on top of {@link DEFAULT_PRAGMAS} —
* provide `{ foreign_keys: "OFF" }` to disable FK enforcement, etc.
*/
pragmas: z.record(z.string(), z.string()).default({}),
/**
* Strict-mode flag for `INSERT` payloads. When `true` (default) a payload
* lacking the tenant column is rejected; when `false` the wrapper
* auto-injects the resolved tenant id.
*/
autoInjectInserts: z.boolean().default(true),
/** Read-only connection. Default `false`. */
readonly: z.boolean().default(false),
});
/** Inferred adapter options (after parsing). */
export type SqliteMemoryDbOptions = z.infer<typeof SqliteMemoryDbOptionsSchema>;
/** Raw input shape (pre-parse). */
export type SqliteMemoryDbOptionsInput = z.input<
typeof SqliteMemoryDbOptionsSchema
>;
/**
* Parse + validate raw options. Any Zod failure is rethrown as a
* {@link DbSqliteMemoryError} with code `DB_SQLITE_MEMORY.INVALID_OPTIONS`
* and the issue list attached on `error.context.issues`.
*/
export function parseSqliteMemoryDbOptions(
input?: SqliteMemoryDbOptionsInput
): SqliteMemoryDbOptions {
const parsed = SqliteMemoryDbOptionsSchema.safeParse(input ?? {});
if (!parsed.success) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.INVALID_OPTIONS,
"invalid SqliteMemoryDbOptions",
{ context: { issues: parsed.error.issues } }
);
}
return parsed.data;
}
/**
* Final pragma map = defaults merged with caller overrides. Exposed so tests
* (and consumers that want to inspect what will be applied) can compute the
* effective set without opening a connection.
*/
export function resolvePragmas(
options: SqliteMemoryDbOptions
): Readonly<Record<string, string>> {
return { ...DEFAULT_PRAGMAS, ...options.pragmas };
}

View File

@@ -0,0 +1,5 @@
export {
createTenantScopedSqliteWrapper,
sqliteRewriters,
type TenantScopedSqliteWrapperOptions,
} from "./tenant-scoping.js";

View File

@@ -0,0 +1,81 @@
# `createTenantScopedSqliteWrapper`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps an `AdminDatabaseLike` as a `TenantScopedDatabaseLike` that transparently injects the resolved tenant id into every SELECT/UPDATE/DELETE and validates (or auto-injects) the same column on INSERTs. The wrapper is engine-agnostic in shape but ships with this package because its error code namespace is `DB_SQLITE_MEMORY.*` — the SQL-rewrite algorithm mirrors `@suaveplan/api-db-adapter` so behaviour is identical to The path.
The wrapper is what `RequestContext.db` resolves to inside route handlers when the host has installed the in-memory engine; tests using `createSqliteMemoryDb` go through `scoped(tenantId)` and never touch the admin connection directly.
## Features
- SELECT/UPDATE/DELETE: `WHERE tenant_id = ?` (or `AND tenant_id = ?` if a WHERE already exists), preserving `ORDER BY` / `LIMIT` / `GROUP BY` / `RETURNING` tail clauses.
- INSERT: auto-injects the tenant column when `autoInjectInserts: true`; rejects with `DB_SQLITE_MEMORY.TENANT_SCOPE_VIOLATION` when `false` and the column is missing.
- INSERT with a literal mismatched tenant value → `TENANT_SCOPE_VIOLATION`.
- INSERT with a bound `?` parameter whose value mismatches the bound tenant → `TENANT_SCOPE_VIOLATION`.
- DDL / non-CRUD statements rejected — use the admin surface for migrations.
- Record-style params (named binds) rejected — wrapper is positional only.
- `prepare` + `transaction` are forwarded with rewritten SQL.
## Usage: Basic
```ts
import { createSqliteMemoryAdapter } from "@suaveplan/api-db-sqlite-memory";
import { createTenantScopedSqliteWrapper } from "@suaveplan/api-db-sqlite-memory/tenant-scoping";
import type { TenantId } from "@suaveplan/api-types/tenant";
const admin = createSqliteMemoryAdapter();
await admin.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, tenant_id TEXT, name TEXT)"
);
const acme = createTenantScopedSqliteWrapper(admin, "acme" as TenantId);
await acme.execute("INSERT INTO users (name) VALUES (?)", ["alice"]);
// SQL rewritten to: INSERT INTO users (name, tenant_id) VALUES (?, ?)
const rows = await acme.query("SELECT * FROM users");
// SQL rewritten to: SELECT * FROM users WHERE tenant_id = ?
```
## Usage: Advanced
Strict mode + bound-param mismatch surfacing:
```ts
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "@suaveplan/api-db-sqlite-memory";
import { createTenantScopedSqliteWrapper } from "@suaveplan/api-db-sqlite-memory/tenant-scoping";
const strict = createTenantScopedSqliteWrapper(admin, "acme" as TenantId, {
autoInjectInserts: false,
});
try {
await strict.execute("INSERT INTO users (tenant_id, name) VALUES (?, ?)", [
"bigcorp",
"bob",
]);
} catch (err) {
if (
err instanceof DbSqliteMemoryError &&
err.code === DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION
) {
// bound tenant "bigcorp" does not match scope "acme"
}
}
```
## API Reference
- `createTenantScopedSqliteWrapper(admin, tenantId, options?)` — returns `TenantScopedDatabaseLike`.
- `TenantScopedSqliteWrapperOptions.tenantColumn` — default `"tenant_id"`.
- `TenantScopedSqliteWrapperOptions.autoInjectInserts` — default `true`.
- `sqliteRewriters.injectWhere` — pure helper exposed for unit tests.
- `sqliteRewriters.handleInsert` — pure helper exposed for unit tests.
## Implementation Notes
The rewriter does string parsing on the SQL because SQLite does not expose an AST visitor that would let us bolt-on a tenant predicate at the engine level. The closed grammar of supported shapes (single-row INSERT, simple SELECT/UPDATE/DELETE with optional trailing clauses) keeps the regex set small and auditable — anything outside that grammar throws `TENANT_SCOPE_VIOLATION` so callers cannot silently bypass the policy. Tail-clause detection finds the first of `RETURNING/ORDER BY/LIMIT/GROUP BY` and splices the predicate before it; without that, appending the predicate to the bare SQL would corrupt `LIMIT 10` into `LIMIT 10 WHERE ...`.

View File

@@ -0,0 +1,270 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
import { createSqliteMemoryAdapter } from "../factory/create-sqlite-memory-adapter.js";
import {
createTenantScopedSqliteWrapper,
sqliteRewriters,
} from "./tenant-scoping.js";
const ACME = "acme" as TenantId;
const BIG = "bigcorp" as TenantId;
describe("tenant-scoping wrapper", () => {
let admin: ReturnType<typeof createSqliteMemoryAdapter>;
beforeEach(async () => {
admin = createSqliteMemoryAdapter();
await admin.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, tenant_id TEXT, name TEXT)"
);
await admin.execute(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
[ACME as unknown as string, "alice"]
);
await admin.execute(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
[BIG as unknown as string, "bob"]
);
});
afterEach(() => admin.close());
it("admin reads across tenants", { timeout: 5000 }, async () => {
const rows = await admin.query<{ tenant_id: string }[]>(
"SELECT tenant_id FROM users"
);
expect(rows.map((r) => r.tenant_id).sort()).toEqual([
"acme",
"bigcorp",
]);
});
it("scoped SELECT injects WHERE", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
const rows = await scoped.query<{ name: string }[]>(
"SELECT name FROM users"
);
expect(rows).toEqual([{ name: "alice" }]);
});
it("scoped INSERT auto-injects tenant column", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await scoped.execute("INSERT INTO users (name) VALUES (?)", ["carol"]);
const rows = await admin.query<{ name: string; tenant_id: string }[]>(
"SELECT name, tenant_id FROM users WHERE name = ?",
["carol"]
);
expect(rows).toEqual([{ name: "carol", tenant_id: "acme" }]);
});
it("scoped rejects cross-tenant insert", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await expect(
scoped.execute(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
["bigcorp", "mallory"]
)
).rejects.toMatchObject({
code: DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("scoped rejects literal mismatched tenant", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await expect(
scoped.execute(
"INSERT INTO users (tenant_id, name) VALUES ('bigcorp', 'm')"
)
).rejects.toBeInstanceOf(DbSqliteMemoryError);
});
it("scoped accepts matching literal tenant", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await scoped.execute(
"INSERT INTO users (tenant_id, name) VALUES ('acme', 'dan')"
);
});
it("strict mode rejects insert missing tenant column", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME, {
autoInjectInserts: false,
});
await expect(
scoped.execute("INSERT INTO users (name) VALUES (?)", ["eve"])
).rejects.toMatchObject({
code: DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("strict mode accepts insert with matching tenant", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME, {
autoInjectInserts: false,
});
await scoped.execute(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
["acme", "frank"]
);
});
it("UPDATE has WHERE injected", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await scoped.execute("UPDATE users SET name = ? WHERE name = ?", [
"alice-v2",
"alice",
]);
const rows = await admin.query<{ name: string; tenant_id: string }[]>(
"SELECT name, tenant_id FROM users WHERE name = ?",
["alice-v2"]
);
expect(rows).toEqual([{ name: "alice-v2", tenant_id: "acme" }]);
});
it("DELETE has WHERE injected", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await scoped.execute("DELETE FROM users WHERE name = ?", ["alice"]);
const remaining = await admin.query<{ tenant_id: string }[]>(
"SELECT tenant_id FROM users"
);
expect(remaining.map((r) => r.tenant_id)).toEqual(["bigcorp"]);
});
it("DDL is rejected", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await expect(
scoped.execute("CREATE TABLE x (id INT)")
).rejects.toMatchObject({
code: DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("record params are rejected", { timeout: 5000 }, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await expect(
scoped.query("SELECT * FROM users WHERE name = :name", {
name: "alice",
})
).rejects.toMatchObject({
code: DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("scoped.prepare rewrites SQL at execute time", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
const stmt = await scoped.prepare!<{ name: string }[]>(
"SELECT name FROM users"
);
const rows = await stmt.execute();
expect(rows).toEqual([{ name: "alice" }]);
await stmt.finalize();
});
it("scoped.transaction wraps each tx call with rewriter", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await scoped.transaction!(async (tx) => {
await tx.execute("INSERT INTO users (name) VALUES (?)", ["gina"]);
const rows = await tx.query<{ name: string }[]>(
"SELECT name FROM users"
);
expect(rows.map((r) => r.name).sort()).toEqual(["alice", "gina"]);
});
});
it("scoped.transaction rolls back on throw and rejects via commit/rollback no-ops", {
timeout: 5000,
}, async () => {
const scoped = createTenantScopedSqliteWrapper(admin, ACME);
await expect(
scoped.transaction!(async (tx) => {
await tx.execute("INSERT INTO users (name) VALUES (?)", [
"ivan",
]);
await tx.rollback();
await tx.commit();
throw new Error("trip");
})
).rejects.toThrow("trip");
});
it("ORDER BY / LIMIT tail preserved by injectWhere", {
timeout: 5000,
}, () => {
const r = sqliteRewriters.injectWhere(
"SELECT * FROM users ORDER BY id LIMIT 5",
"tenant_id",
"acme",
[]
);
expect(r.sql).toContain("WHERE tenant_id = ?");
expect(r.sql).toContain("ORDER BY id LIMIT 5");
});
it("AND clause appended when WHERE already exists", {
timeout: 5000,
}, () => {
const r = sqliteRewriters.injectWhere(
"SELECT * FROM users WHERE name = ? ORDER BY id",
"tenant_id",
"acme",
["alice"]
);
expect(r.sql).toContain("AND tenant_id = ?");
expect(r.params).toEqual(["alice", "acme"]);
});
it("INSERT ... SELECT shape is rejected", { timeout: 5000 }, () => {
expect(() =>
sqliteRewriters.handleInsert(
"INSERT INTO t SELECT * FROM other",
"tenant_id",
"acme",
[],
true
)
).toThrow(DbSqliteMemoryError);
});
it("$N positional bind syntax is recognised", { timeout: 5000 }, () => {
const r = sqliteRewriters.handleInsert(
"INSERT INTO t (tenant_id, name) VALUES ($1, $2)",
"tenant_id",
"acme",
["acme", "x"],
false
);
expect(r.sql).toContain("INSERT");
});
it("$N bind with mismatched tenant throws", { timeout: 5000 }, () => {
expect(() =>
sqliteRewriters.handleInsert(
"INSERT INTO t (tenant_id, name) VALUES ($1, $2)",
"tenant_id",
"acme",
["bigcorp", "x"],
false
)
).toThrow(DbSqliteMemoryError);
});
});

View File

@@ -0,0 +1,285 @@
import type {
AdminDatabaseLike,
DbParams,
DbTransaction,
PreparedStatement,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
DB_SQLITE_MEMORY_CODES,
DbSqliteMemoryError,
} from "../error-codes/index.js";
/** Wrapper options. */
export interface TenantScopedSqliteWrapperOptions {
readonly tenantColumn?: string;
readonly autoInjectInserts?: boolean;
}
const SELECT_RE = /^\s*select\b/i;
const UPDATE_RE = /^\s*update\b/i;
const DELETE_RE = /^\s*delete\b/i;
const INSERT_PREFIX_RE = /^\s*insert\b/i;
const INSERT_SHAPE_RE =
/^\s*insert\s+into\s+(\w+)\s*\(([^)]*)\)\s*values\s*\(([^)]*)\)/i;
interface RewriteResult {
readonly sql: string;
readonly params: ReadonlyArray<unknown>;
}
/** Pure SQL-rewrite helpers — exposed for tests. */
export const sqliteRewriters = {
/**
* Inject `WHERE tenantColumn = ?` (or `AND tenantColumn = ?`) into
* SELECT / UPDATE / DELETE, preserving any trailing clause
* (`ORDER BY`, `LIMIT`, `GROUP BY`, `RETURNING`).
*/
injectWhere(
sql: string,
tenantColumn: string,
tenantId: string,
params: ReadonlyArray<unknown>
): RewriteResult {
const lower = sql.toLowerCase();
const idxWhere = lower.indexOf(" where ");
const idxReturning = lower.indexOf(" returning ");
const idxOrder = lower.indexOf(" order by ");
const idxLimit = lower.indexOf(" limit ");
const idxGroup = lower.indexOf(" group by ");
const tail = [idxReturning, idxOrder, idxLimit, idxGroup]
.filter((i) => i !== -1)
.sort((a, b) => a - b)[0];
let rewritten: string;
if (idxWhere !== -1) {
const whereEnd =
tail !== undefined && tail > idxWhere ? tail : sql.length;
const head = sql.slice(0, whereEnd).trimEnd();
const tailStr = sql.slice(whereEnd);
rewritten = `${head} AND ${tenantColumn} = ?${tailStr}`;
} else {
const cut = tail ?? sql.length;
const head = sql.slice(0, cut).trimEnd();
const tailStr = sql.slice(cut);
rewritten = `${head} WHERE ${tenantColumn} = ?${tailStr}`;
}
return { sql: rewritten, params: [...params, tenantId] };
},
/**
* Inject or verify the tenant column on an INSERT. Throws
* `DB_SQLITE_MEMORY.TENANT_SCOPE_VIOLATION` if the bound value conflicts
* with the resolved tenant or (with `autoInject: false`) the column is
* absent.
*/
handleInsert(
sql: string,
tenantColumn: string,
tenantId: string,
params: ReadonlyArray<unknown>,
autoInject: boolean
): RewriteResult {
const m = sql.match(INSERT_SHAPE_RE);
if (m === null) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
"INSERT shape not supported by tenant-scoped wrapper — use the admin surface for multi-row INSERT or INSERT ... SELECT",
{ context: { sql } }
);
}
const [, table, colList, valList] = m as unknown as [
string,
string,
string,
string,
];
const cols = colList.split(",").map((c) => c.trim());
const vals = valList.split(",").map((v) => v.trim());
const colIdx = cols.findIndex(
(c) => c.toLowerCase() === tenantColumn.toLowerCase()
);
if (colIdx === -1) {
if (!autoInject) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
`INSERT into '${table}' is missing tenant column '${tenantColumn}' and autoInjectInserts is false`,
{ context: { sql, table } }
);
}
const newCols = [...cols, tenantColumn].join(", ");
const newVals = [...vals, "?"].join(", ");
return {
sql: sql.replace(
INSERT_SHAPE_RE,
`INSERT INTO ${table} (${newCols}) VALUES (${newVals})`
),
params: [...params, tenantId],
};
}
const placeholder = vals[colIdx] as string;
if (placeholder === "?" || /^\$\d+$/.test(placeholder)) {
const paramVal = params[colIdx];
if (paramVal !== undefined && paramVal !== tenantId) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
`INSERT bound tenant '${String(paramVal)}' does not match scope '${tenantId}'`,
{ context: { sql, bound: paramVal, expected: tenantId } }
);
}
} else {
const literal = placeholder.replace(/^['"]|['"]$/g, "");
if (literal !== tenantId) {
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
`INSERT literal tenant '${literal}' does not match scope '${tenantId}'`,
{ context: { sql, literal, expected: tenantId } }
);
}
}
return { sql, params };
},
};
function normalizeParams(params: DbParams | undefined): ReadonlyArray<unknown> {
if (params === undefined) return [];
if (Array.isArray(params)) return params;
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
"tenant-scoped wrapper requires positional params; named-param SQL is not supported",
{ context: { paramsType: "record" } }
);
}
function dispatch(
sql: string,
params: DbParams | undefined,
tenantColumn: string,
tenantId: string,
autoInject: boolean
): RewriteResult {
const p = normalizeParams(params);
if (SELECT_RE.test(sql) || UPDATE_RE.test(sql) || DELETE_RE.test(sql)) {
return sqliteRewriters.injectWhere(sql, tenantColumn, tenantId, p);
}
if (INSERT_PREFIX_RE.test(sql)) {
return sqliteRewriters.handleInsert(
sql,
tenantColumn,
tenantId,
p,
autoInject
);
}
throw new DbSqliteMemoryError(
DB_SQLITE_MEMORY_CODES.TENANT_SCOPE_VIOLATION,
"tenant-scoped wrapper only accepts SELECT/INSERT/UPDATE/DELETE; use migrations for DDL or the admin surface",
{ context: { sql } }
);
}
/**
* Wrap an admin adapter as a tenant-scoped surface. Reads + UPDATE + DELETE
* have `WHERE tenantColumn = ?` appended; INSERTs are checked / auto-injected
* against the resolved tenant.
*/
export function createTenantScopedSqliteWrapper(
admin: AdminDatabaseLike,
tenantId: TenantId,
options: TenantScopedSqliteWrapperOptions = {}
): TenantScopedDatabaseLike {
const tenantColumn = options.tenantColumn ?? "tenant_id";
const autoInject = options.autoInjectInserts ?? true;
const tenantString = tenantId as unknown as string;
const wrappedTx = (tx: DbTransaction): DbTransaction => ({
query: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await tx.query(r.sql, r.params);
},
execute: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await tx.execute(r.sql, r.params);
},
commit: () => tx.commit(),
rollback: () => tx.rollback(),
});
const scoped: TenantScopedDatabaseLike = {
engine: admin.engine,
tenantId,
query: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await admin.query(r.sql, r.params);
},
execute: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await admin.execute(r.sql, r.params);
},
};
if (admin.prepare !== undefined) {
const adminPrepare = admin.prepare.bind(admin);
const scopedPrepare = async <R>(
sql: string
): Promise<PreparedStatement<R>> => {
const stmt = await adminPrepare<R>(sql);
return {
id: stmt.id,
execute: async (params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return (await admin.query(r.sql, r.params)) as R;
},
finalize: () => stmt.finalize(),
};
};
(scoped as { prepare?: typeof scopedPrepare }).prepare = scopedPrepare;
}
if (admin.transaction !== undefined) {
const adminTransaction = admin.transaction.bind(admin);
(
scoped as {
transaction?: <R>(
fn: (tx: DbTransaction) => Promise<R>
) => Promise<R>;
}
).transaction = async <R>(fn: (tx: DbTransaction) => Promise<R>) =>
await adminTransaction((tx) => fn(wrappedTx(tx)));
}
return scoped;
}

View File

@@ -0,0 +1,68 @@
# `createInMemorySqliteHarness`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Convenience builder for plugin test files. Wraps `createSqliteMemoryDb` with an idempotent `cleanup()` so the canonical `beforeEach` / `afterEach` pair stays one line each. The result conforms to `SqliteMemoryDb` so callers can treat the harness exactly like a direct factory return value, with the bonus that double-cleanup is safe — the second call observes the `closed` flag and returns without touching the database.
This is the entry point Stage-5 plugin tests should reach for. Direct construction via `createSqliteMemoryDb` works too, but the harness keeps the test boilerplate uniform across packages.
## Features
- Same shape as `SqliteMemoryDb` plus an extra `cleanup` method.
- Idempotent `cleanup` — safe to call from both an explicit teardown and a try/finally.
- Forwards every `SqliteMemoryDbOptions` field to `createSqliteMemoryDb`.
## Usage: Basic
```ts
import { afterEach, beforeEach, it } from "@suaveplan/testing/runner";
import {
createInMemorySqliteHarness,
type InMemorySqliteHarness,
} from "@suaveplan/api-db-sqlite-memory/testing";
let harness: InMemorySqliteHarness;
beforeEach(() => {
harness = createInMemorySqliteHarness();
});
afterEach(() => harness.cleanup());
it("inserts a row", { timeout: 5000 }, async () => {
await harness.admin.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, tenant_id TEXT)"
);
});
```
## Usage: Advanced
Customise the tenant column + open a file-backed fixture for a longer-lived test:
```ts
import { createInMemorySqliteHarness } from "@suaveplan/api-db-sqlite-memory/testing";
const harness = createInMemorySqliteHarness({
filename: "/tmp/long-fixture.sqlite",
tenantColumn: "org_id",
});
try {
await harness.admin.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, org_id TEXT)"
);
} finally {
await harness.cleanup();
}
```
## API Reference
- `createInMemorySqliteHarness(options?)` — returns `InMemorySqliteHarness`.
- `InMemorySqliteHarness` — extends `SqliteMemoryDb` with `cleanup(): Promise<void>`.
## Implementation Notes
`cleanup()` and `close()` share the same idempotency flag — the harness owns the `closed` boolean and the `close()` method on the embedded `SqliteMemoryDb` is exposed verbatim so legacy code that still calls `close` does not silently bypass the flag. This matters when a test file mixes a manual `close()` with an `afterEach` cleanup: the second of the two is a guaranteed no-op rather than a hard error from `bun:sqlite`'s "database already closed" path.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemorySqliteHarness } from "./harness.js";
describe("createInMemorySqliteHarness", () => {
it("returns the SqliteMemoryDb triple plus cleanup", {
timeout: 5000,
}, async () => {
const h = createInMemorySqliteHarness();
expect(h.admin.engine).toBe("sqlite-memory");
await h.cleanup();
});
it("cleanup is idempotent", { timeout: 5000 }, async () => {
const h = createInMemorySqliteHarness();
await h.cleanup();
await h.cleanup();
});
it("forwards options to createSqliteMemoryDb", {
timeout: 5000,
}, async () => {
const h = createInMemorySqliteHarness({ tenantColumn: "org_id" });
try {
await h.admin.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, org_id TEXT)"
);
} finally {
await h.cleanup();
}
});
});

View File

@@ -0,0 +1,41 @@
import {
createSqliteMemoryDb,
type SqliteMemoryDb,
} from "../factory/create-sqlite-memory-db.js";
import type { SqliteMemoryDbOptionsInput } from "../options/index.js";
/** Result of {@link createInMemorySqliteHarness}. */
export interface InMemorySqliteHarness extends SqliteMemoryDb {
/** Idempotent teardown — closes the underlying connection. */
cleanup(): Promise<void>;
}
/**
* Convenience builder for plugin tests. Opens a fresh `:memory:` adapter and
* returns the canonical admin + scoped + cleanup triple. The cleanup
* function is safe to call multiple times — the second call is a no-op.
*
* Use from `beforeEach` / `afterEach`:
*
* ```ts
* let harness: InMemorySqliteHarness;
* beforeEach(() => { harness = createInMemorySqliteHarness(); });
* afterEach(() => harness.cleanup());
* ```
*/
export function createInMemorySqliteHarness(
options?: SqliteMemoryDbOptionsInput
): InMemorySqliteHarness {
const db = createSqliteMemoryDb(options);
let closed = false;
return {
admin: db.admin,
scoped: db.scoped,
close: db.close,
cleanup: async () => {
if (closed) return;
closed = true;
await db.close();
},
};
}

View File

@@ -0,0 +1,4 @@
export {
createInMemorySqliteHarness,
type InMemorySqliteHarness,
} from "./harness.js";

View File

@@ -0,0 +1,67 @@
import { createDbAdapter, unregisterEngine } from "@suaveplan/api-db-adapter";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
registerSqliteMemoryEngine,
runSqliteMemoryMigrations,
type SqliteMigrationsFsReader,
} from "../../src/index.js";
/**
* Integration test mirroring how Stage-5 `createTestHost` will wire this
* adapter when the api-host helper lands: register the engine, construct a
* `DbAdapter` via the same `createDbAdapter` Stage-4 entry point production
* uses, drive migrations, and exercise the scoped surface.
*/
describe("api-db-sqlite-memory integration", () => {
afterEach(() => unregisterEngine("sqlite-memory"));
it('createTestHost resolves engine:"sqlite-memory" to this adapter', {
timeout: 5000,
}, async () => {
registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
});
try {
expect(adapter.admin.engine).toBe("sqlite-memory");
} finally {
await adapter.close();
}
});
it("end-to-end: migrations + scoped writes + admin reads", {
timeout: 5000,
}, async () => {
registerSqliteMemoryEngine();
const adapter = await createDbAdapter({
engine: "sqlite-memory",
url: ":memory:",
tenantColumn: "tenant_id",
});
try {
const fs: SqliteMigrationsFsReader = {
readdir: async () => ["001_init.sql"],
readFile: async () =>
"CREATE TABLE posts (id INTEGER PRIMARY KEY, tenant_id TEXT, title TEXT)",
};
await runSqliteMemoryMigrations({
plugin: "posts",
admin: adapter.admin,
migrationsDir: "/v",
fs,
});
const acme = adapter.scoped("acme" as TenantId);
await acme.execute("INSERT INTO posts (title) VALUES (?)", [
"hello",
]);
const rows = await adapter.admin.query<
{ title: string; tenant_id: string }[]
>("SELECT title, tenant_id FROM posts");
expect(rows).toEqual([{ title: "hello", tenant_id: "acme" }]);
} finally {
await adapter.close();
}
});
});

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", "bun"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "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/api-db-sqlite-memory",
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\//,
/^@opentelemetry\//,
/^node:/,
"bun:sqlite",
"tslib",
"zod",
],
},
},
});

View File

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