first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 ...`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user