first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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.