Initial Commit
This commit is contained in:
78
packages/api-stack/api/api-db-sqlite-memory/README.md
Normal file
78
packages/api-stack/api/api-db-sqlite-memory/README.md
Normal 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.
|
||||
|
||||
109
packages/api-stack/api/api-db-sqlite-memory/package.json
Normal file
109
packages/api-stack/api/api-db-sqlite-memory/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { registerSqliteMemoryEngine } from "./register-engine.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DB_SQLITE_MEMORY_CODES,
|
||||
type DbSqliteMemoryCode,
|
||||
DbSqliteMemoryError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
40
packages/api-stack/api/api-db-sqlite-memory/src/index.ts
Normal file
40
packages/api-stack/api/api-db-sqlite-memory/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
getMigrationInstruments,
|
||||
type RunSqliteMemoryMigrationsOptions,
|
||||
type RunSqliteMemoryMigrationsResult,
|
||||
resetMigrationInstruments,
|
||||
runSqliteMemoryMigrations,
|
||||
type SqliteMigrationsFsReader,
|
||||
} from "./migrations.js";
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
DEFAULT_PRAGMAS,
|
||||
parseSqliteMemoryDbOptions,
|
||||
resolvePragmas,
|
||||
type SqliteMemoryDbOptions,
|
||||
type SqliteMemoryDbOptionsInput,
|
||||
SqliteMemoryDbOptionsSchema,
|
||||
} from "./sqlite-memory-db-options.js";
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTenantScopedSqliteWrapper,
|
||||
sqliteRewriters,
|
||||
type TenantScopedSqliteWrapperOptions,
|
||||
} from "./tenant-scoping.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createInMemorySqliteHarness,
|
||||
type InMemorySqliteHarness,
|
||||
} from "./harness.js";
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
37
packages/api-stack/api/api-db-sqlite-memory/vite.config.ts
Normal file
37
packages/api-stack/api/api-db-sqlite-memory/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-db-sqlite-memory/vitest.config.ts
Normal file
30
packages/api-stack/api/api-db-sqlite-memory/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user