first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
# @suaveplan/api-plugin-database - SurrealDB-backed database plugin for @suaveplan/api. Pools @suaveplan/surrealdb's SurrealAdapter (implementing @suaveplan/db DatabaseAdapter), runs .surql migrations through @suaveplan/api-migrations under an advisory lock, provides tenant provisioning callables, retry/backoff, RFC 7807 problem-json rendering, and a full OTel surface. Path A wrapper — adapter surface delegates to suaveplan SurrealAdapter; this plugin owns the orthogonal pool/migration/tenancy/retry/observability concerns.
SurrealDB-backed database plugin for `@suaveplan/api`. Pools `@suaveplan/surrealdb`'s `SurrealAdapter` (implementing `@suaveplan/db`'s `DatabaseAdapter`), runs `.surql` migrations through `@suaveplan/api-migrations` under an advisory lock, ships tenant-provisioning callables for the three canonical multi-tenancy strategies, and emits a complete OpenTelemetry surface (pool checkouts / checkins / saturation + query duration histogram). RFC 7807 problem-json rendering rounds out the error path.
This is a **Path-A wrapper**: every adapter call delegates to the canonical `SurrealAdapter`; this package owns only the orthogonal pool / migration / tenancy / retry / observability concerns. Consumers obtain the `DatabaseAdapter` through `ctx.getService("db")` and call the suaveplan surface defined by `@suaveplan/db`.
<!-- begin:auto-docs -->
## Documentation tree
- [config](./src/config/config-schema.md) — Defines the Zod schemas that validate every public option bundle accepted by `@suaveplan/api-plugin-database`. The sche…
- [config-schema](./src/config/config-schema.md) — Defines the Zod schemas that validate every public option bundle accepted by `@suaveplan/api-plugin-database`. The sche…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `DATABASE_*` error-code registry consumed by every other module in the `@suaveplan/api-plugin-databa…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `DATABASE_*` error-code registry consumed by every other module in the `@suaveplan/api-plugin-databa…
- [lock](./src/migrations/lock.md) — Implements the advisory lock that prevents two concurrent migration runners from racing each other. The lock is a singl…
- [runner](./src/migrations/runner.md) — Implements `runMigrations`, the top-level orchestrator for the migration phase of `createDatabasePlugin`'s `onReady` ho…
- [otel](./src/otel/instruments.md) — Provides the lazily-cached bundle of OpenTelemetry instruments emitted by `@suaveplan/api-plugin-database`. Every count…
- [instruments](./src/otel/instruments.md) — Provides the lazily-cached bundle of OpenTelemetry instruments emitted by `@suaveplan/api-plugin-database`. Every count…
- [plugin](./src/plugin/plugin.md) — Exports `createDatabasePlugin`, the top-level factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-…
- [plugin](./src/plugin/plugin.md) — Exports `createDatabasePlugin`, the top-level factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-…
- [pool](./src/pool/pool.md) — Implements `ConnectionPool`, a bounded pool of `SurrealAdapter` instances that backs the `db` service registered by `cr…
- [extended-adapter](./src/pool/extended-adapter.md) — Defines `PooledSurrealAdapter`, a thin subclass of `@suaveplan/surrealdb`'s `SurrealAdapter` that exposes the underlyin…
- [pool](./src/pool/pool.md) — Implements `ConnectionPool`, a bounded pool of `SurrealAdapter` instances that backs the `db` service registered by `cr…
- [retry](./src/retry/retry.md) — Provides `withRetry`, a small exponential-backoff helper used by the connection pool, the migration runner, and any cal…
- [retry](./src/retry/retry.md) — Provides `withRetry`, a small exponential-backoff helper used by the connection pool, the migration runner, and any cal…
- [routes](./src/routes/problem-json.md) — Renders arbitrary throwables as RFC 7807 `application/problem+json` bodies for HTTP handlers that wrap database calls.…
- [problem-json](./src/routes/problem-json.md) — Renders arbitrary throwables as RFC 7807 `application/problem+json` bodies for HTTP handlers that wrap database calls.…
- [services](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` to register the `db` slot typed as `@suaveplan/db…
- [registry-augmentation](./src/services/registry-augmentation.md) — Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` to register the `db` slot typed as `@suaveplan/db…
- [tenancy](./src/tenancy/tenancy.md) — Exports the `provisionTenant` and `decommissionTenant` callables that downstream hosts (typically `@suaveplan/api-plugi…
- [tenancy](./src/tenancy/tenancy.md) — Exports the `provisionTenant` and `decommissionTenant` callables that downstream hosts (typically `@suaveplan/api-plugi…
- [types](./src/types/types.md) — Houses the internal type aliases and shared interfaces consumed by the rest of the package — the adapter factory seam,…
- [types](./src/types/types.md) — Houses the internal type aliases and shared interfaces consumed by the rest of the package — the adapter factory seam,…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-database
```
## Quick start
```ts
import { createPluginRegistry } from "@suaveplan/api-plugin";
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
const registry = createPluginRegistry();
registry.register(
createDatabasePlugin({
url: "ws://localhost:8000/rpc",
namespace: "app",
database: "main",
username: process.env.SURREAL_USER ?? "root",
password: process.env.SURREAL_PASS ?? "root",
pool: { min: 2, max: 20, idleMs: 30_000, acquireTimeoutMs: 5_000 },
migrations: {
autoRun: true,
directory: "./surql",
ledgerTable: "_migrations",
lockTable: "_migration_lock",
leaseMs: 60_000,
},
})
);
await registry.initialize();
// Inside any other plugin
const db = ctx.getService("db");
const users = await db?.query("SELECT * FROM user");
```
## Modules
- [`plugin/plugin.md`](./src/plugin/plugin.md) — top-level factory `createDatabasePlugin`.
- [`pool/pool.md`](./src/pool/pool.md) — bounded `ConnectionPool` over `SurrealAdapter`.
- [`pool/extended-adapter.md`](./src/pool/extended-adapter.md) — `PooledSurrealAdapter` subclass surfacing `getRawClient()`.
- [`migrations/runner.md`](./src/migrations/runner.md) — `.surql` runner under advisory lock.
- [`migrations/lock.md`](./src/migrations/lock.md) — advisory-lock primitive.
- [`tenancy/tenancy.md`](./src/tenancy/tenancy.md) — `provisionTenant` / `decommissionTenant` callables.
- [`retry/retry.md`](./src/retry/retry.md) — exponential-backoff helper.
- [`routes/problem-json.md`](./src/routes/problem-json.md) — RFC 7807 renderer.
- [`config/config-schema.md`](./src/config/config-schema.md) — Zod-validated configuration boundary.
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `DATABASE_*` stable error registry.
- [`otel/instruments.md`](./src/otel/instruments.md) — OpenTelemetry instrument bundle.
- [`services/registry-augmentation.md`](./src/services/registry-augmentation.md) — `ServiceRegistry["db"]` module augmentation.
## Tenancy
Three strategies are supported via `tenancy.strategy`:
- `shared` — single namespace + single database; rows carry a `tenant_id` discriminator.
- `namespace-per-tenant` — one SurrealDB namespace per tenant.
- `database-per-tenant` — one SurrealDB database per tenant inside the active namespace.
Tenant lifecycle is exposed as callable exports (`provisionTenant` / `decommissionTenant`) rather than descriptor hooks because the `PluginDescriptor` has no tenant-lifecycle slots. Wire them to your host's tenant events.
## Observability
Counters / histograms emitted under the `@suaveplan/api-plugin-database` meter:
- `db.pool.checkouts_total`
- `db.pool.checkins_total`
- `db.pool.saturation` (up-down-counter)
- `db.query.duration_ms` (histogram, `{ outcome }` label)
Migration metrics are emitted by `@suaveplan/api-migrations` under its own meter.
## Errors
Every failure path raises `DatabaseError` carrying one of eight `DATABASE_*` codes: `CONNECTION_FAILED`, `POOL_EXHAUSTED`, `POOL_CLOSED`, `QUERY_FAILED`, `MIGRATION_FAILED`, `LOCK_CONTENDED`, `TENANT_INVALID`, `INVALID_CONFIG`. See [`error-codes/error-codes.md`](./src/error-codes/error-codes.md).

View File

@@ -0,0 +1,69 @@
# `config-schema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schemas that validate every public option bundle accepted by `@suaveplan/api-plugin-database`. The schemas live in one place so the plugin factory, the pool, the migration runner, and the tenancy callables all parse their inputs through a single canonical surface. Defaults match production-sensible knobs (10-connection pool ceiling, 5-second acquire timeout, 60-second migration lease, jittered 3-attempt retry) so callers can pass only what they want to override.
The schemas use `z.object().default(...)` on every nested section so partial input is filled out completely. Two cross-section invariants are enforced via `superRefine`: `pool.min <= pool.max` and `retry.baseBackoffMs <= retry.maxBackoffMs`. Failed parses surface a `ZodError` that callers wrap into a `DatabaseError(DATABASE.INVALID_CONFIG)` with the underlying error attached as `cause`.
## Features
- Five nested sections: `pool`, `retry`, `tenancy`, `migrations`, plus top-level connection fields.
- Every leaf carries a default so `parse({})` succeeds end-to-end.
- Two `superRefine` invariants prevent nonsensical combinations.
- All numeric knobs are `z.number().int().min(...)` so floating-point values and negative budgets are rejected before they hit production code.
## Usage: Basic
```ts
import { DatabasePluginOptionsSchema } from "@suaveplan/api-plugin-database/config/config-schema";
const config = DatabasePluginOptionsSchema.parse({
url: "ws://localhost:8000/rpc",
});
console.log(config.pool.max); // 10 (default)
```
## Usage: Advanced
```ts
import {
DatabasePluginOptionsSchema,
type DatabasePluginConfigParsed,
} from "@suaveplan/api-plugin-database";
function loadConfig(json: unknown): DatabasePluginConfigParsed {
const result = DatabasePluginOptionsSchema.safeParse(json);
if (!result.success) {
throw new Error(
`Database config failed validation: ${result.error.message}`
);
}
return result.data;
}
```
## API Reference
### `PoolConfigSchema`, `RetryConfigSchema`, `TenancyConfigSchema`, `MigrationConfigSchema`
Section schemas with `.default(...)`-applied fields. Exported in case downstream packages want to compose them into a larger config.
### `DatabasePluginOptionsSchema`
Top-level schema. Accepts a partial input bundle; emits a fully populated `DatabasePluginConfigParsed`. Throws `ZodError` on invalid input. `superRefine` enforces `pool.min <= pool.max` and `retry.baseBackoffMs <= retry.maxBackoffMs`.
### `type DatabasePluginConfigParsed`
`z.infer` of the top-level schema — the canonical parsed shape consumed by every internal module.
## Implementation Notes
- The schemas live at the package boundary; internal modules consume `DatabasePluginConfigParsed`, not the raw options shape. This makes every internal call site cast-free.
- `url` defaults to `ws://localhost:8000/rpc` so the schema can be invoked with `parse({})` in tests and dev scaffolding without crashing on missing fields. Production deployments are expected to pass a real URL.
- `superRefine` runs after every section has been defaulted, so cross-section invariants always see fully populated values.
- The `zod` package is imported via the default-import form (`import z from "zod"`) per the suaveplan convention documented in `feedback_zod_import.md`.

View File

@@ -0,0 +1,80 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `DATABASE_*` error-code registry consumed by every other module in the `@suaveplan/api-plugin-database` package and by downstream observers that branch on `error.code`. The codes are registered through `defineCodes` from `@suaveplan/error` so they participate in the cross-package code namespace and inherit the canonical `SuaveplanError` wire-format, redaction, and OTel span-event integration.
The companion `DatabaseError` class extends `SuaveplanError<DatabaseErrorCode>` so `instanceof DatabaseError` narrows `error.code` to the literal union at every catch site. Consumers can write `if (error instanceof DatabaseError && error.code === DATABASE_CODES.POOL_EXHAUSTED)` and get exhaustive type checking from TypeScript.
## Features
- Eight stable codes spanning connection, pool, query, migration, lock, tenant, and configuration concerns.
- Literal-typed code values (`"DATABASE.POOL_EXHAUSTED"`, etc.) computed by `defineCodes`.
- `DatabaseError` carries `cause`, `context`, `timestamp`, and i18n message-key support inherited from `SuaveplanError`.
- Frozen registry object — runtime mutation throws.
## Usage: Basic
```ts
import {
DATABASE_CODES,
DatabaseError,
} from "@suaveplan/api-plugin-database/error-codes/error-codes";
throw new DatabaseError(
DATABASE_CODES.POOL_EXHAUSTED,
"no free connections available after 5s"
);
```
## Usage: Advanced
```ts
import {
DATABASE_CODES,
DatabaseError,
type DatabaseErrorCode,
} from "@suaveplan/api-plugin-database";
function isTransient(code: DatabaseErrorCode): boolean {
return (
code === DATABASE_CODES.POOL_EXHAUSTED ||
code === DATABASE_CODES.CONNECTION_FAILED
);
}
try {
await db.query("SELECT * FROM users");
} catch (err) {
if (err instanceof DatabaseError && isTransient(err.code)) {
// exponential backoff retry path
} else {
throw err;
}
}
```
## API Reference
### `DATABASE_CODES`
Frozen object mapping reason → fully-qualified code string. Exact keys: `CONNECTION_FAILED`, `POOL_EXHAUSTED`, `POOL_CLOSED`, `QUERY_FAILED`, `MIGRATION_FAILED`, `LOCK_CONTENDED`, `TENANT_INVALID`, `INVALID_CONFIG`.
### `DatabaseErrorCode`
Literal union of the eight code strings (e.g. `"DATABASE.POOL_EXHAUSTED"`).
### `class DatabaseError extends SuaveplanError<DatabaseErrorCode>`
- `new DatabaseError(code, message, options?)` — constructs an error carrying the supplied code. Options accept `cause`, `context`, and `clock` per the parent class.
- `databaseCode` — convenience alias of `code` typed at the literal union.
## Implementation Notes
- The registry intentionally avoids redundant `INTERNAL` / `UNKNOWN` slots. Every wrapper in this package uses one of the eight codes; anything truly unexpected propagates as the underlying `Error`.
- Adding a code is a breaking change for the API surface (the literal union widens) — bump the package version and add a `## MODIFIED Requirements` entry in the spec delta.
- `defineCodes` freezes the returned object; tests verify the surface against the registered list rather than the implementation.

View File

@@ -0,0 +1,77 @@
# `migrations/lock`
## Purpose
Implements the advisory lock that prevents two concurrent migration runners from racing each other. The lock is a single `SCHEMAFULL` record on a configurable table whose `expiresAt` field carries a wall-clock lease. Holders refresh / reclaim the lock by upserting; concurrent runners check the `expiresAt` and yield when it's in the future. Stale or crashed holders surrender the lock automatically once their lease expires.
This module deliberately mirrors the upstream lock semantics from `../suaveplan/packages/server/api-plugin-database/src/migrations/lock/lock.ts`, but speaks through the `DatabaseAdapter` interface (`query`, `execute`) instead of the raw `Surreal` client. That keeps the lock dialect-agnostic in principle, and lets tests inject a stub adapter without standing up a real surreal connection.
## Features
- Single-row, single-table advisory lock keyed at id `"singleton"`.
- Wall-clock lease via `expiresAt` (integer Unix ms).
- Pluggable `clock` for deterministic testing.
- Holder-aware release: `releaseLock(adapter, table, holder)` only deletes when `holder` matches.
- Errors wrapped as `DATABASE.MIGRATION_FAILED`.
## Usage: Basic
```ts
import {
acquireLock,
releaseLock,
} from "@suaveplan/api-plugin-database/migrations/lock";
const holder = `${process.pid}@${Date.now()}`;
const { acquired } = await acquireLock(db, "_migration_lock", holder, 60_000);
if (acquired) {
try {
// ...apply migrations
} finally {
await releaseLock(db, "_migration_lock", holder);
}
}
```
## Usage: Advanced
```ts
import { acquireLock, releaseLock } from "@suaveplan/api-plugin-database";
let now = 1_000;
const clock = () => now;
await acquireLock(adapter, "_migration_lock", "h1", 100, { clock });
const second = await acquireLock(adapter, "_migration_lock", "h2", 100, {
clock,
});
expect(second.acquired).toBe(false);
now += 1_000; // lease expired
const takeover = await acquireLock(adapter, "_migration_lock", "h2", 100, {
clock,
});
expect(takeover.acquired).toBe(true);
```
## API Reference
### `acquireLock(adapter, table, holder, leaseMs, options?): Promise<LockAcquireResult>`
Tries to acquire the lock. Returns `{ acquired: false, expiresAt }` when a peer holds an unexpired lease, otherwise `{ acquired: true, expiresAt }` after recording the new lease.
### `releaseLock(adapter, table, holder): Promise<void>`
Deletes the lock row when `holder` matches the recorded value. Foreign-holder releases are no-ops.
### `interface LockAcquireResult`
`{ acquired: boolean, expiresAt: number }` — `expiresAt` is `0` semantically meaningful only when `acquired` is `true`.
### `interface LockOptions`
`{ clock?: Clock }` — injectable wall-clock for tests.
## Implementation Notes
- The lock owns its own `DEFINE TABLE` / `DEFINE FIELD` DDL inside `acquireLock` so callers don't have to bootstrap the table separately. The DDL is `IF NOT EXISTS`, so repeated invocations are cheap.
- The lock semantics are intentionally cooperative: a holder that crashes before releasing surrenders the lock at the lease expiry, but in the worst case the next runner waits up to `leaseMs` before taking over. Choose `leaseMs` to bound that delay.
- The lock does NOT poll until acquired; the caller decides whether to back off and retry (`withRetry`) or skip the migration phase entirely.

View File

@@ -0,0 +1,74 @@
# `migrations/runner`
## Purpose
Implements `runMigrations`, the top-level orchestrator for the migration phase of `createDatabasePlugin`'s `onReady` hook. It composes the advisory lock from `./lock.ts` with `@suaveplan/api-migrations`' battle-tested SurrealQL adapter and runner: lock first, run if acquired, release in `finally`. Calling code never has to touch the migration discovery, ledger, or checksum-drift detection — those concerns live entirely inside `api-migrations`.
The runner is intentionally non-throwing for the contention case: when another holder already owns the lock, the function returns `{ acquired: false, applied: [], skipped: [] }` so the caller can log and move on. Other failures (adapter errors, file system errors, ledger drift) propagate as `DatabaseError(DATABASE.MIGRATION_FAILED)` with the underlying error attached as `cause`.
## Features
- Advisory-locked migration run via `acquireLock` + `releaseLock`.
- Delegates discovery, hashing, ledger, and drift detection to `@suaveplan/api-migrations`' `createMigrationRunner`.
- `AbortSignal` propagation into the runner.
- Pluggable `clock` and `logger`.
- Wrapped error surface: every internal failure surfaces `DATABASE.MIGRATION_FAILED`.
## Usage: Basic
```ts
import { runMigrations } from "@suaveplan/api-plugin-database/migrations/runner";
const result = await runMigrations({
adapter: db,
config: {
autoRun: true,
directory: "./surql",
ledgerTable: "_migrations",
lockTable: "_migration_lock",
leaseMs: 60_000,
},
holder: `${process.pid}@${Date.now()}`,
});
if (result.acquired) {
console.log(`Applied ${result.applied.length} migrations`);
}
```
## Usage: Advanced
```ts
import { runMigrations } from "@suaveplan/api-plugin-database";
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
let now = 1_000_000;
const result = await runMigrations({
adapter: pooledDb,
config: migrations,
holder: "ci-runner",
clock: () => now,
signal: controller.signal,
logger: pinoLogger,
});
```
## API Reference
### `runMigrations(options): Promise<MigrationRunResult>`
Acquires the advisory lock, runs `.surql` migrations through `api-migrations`, and releases the lock. Returns `{ acquired, applied, skipped }`.
### `interface RunMigrationsOptions`
`adapter: DatabaseAdapter`, `config: MigrationConfig`, `holder: string`, optional `logger`, `clock`, `signal`.
### `interface MigrationRunResult`
`{ acquired: boolean, applied: ApiMigrationResult[], skipped: string[] }`. `applied` is the runner's per-file outcome list; `skipped` is currently empty (the runner returns whichever files were applied or `[]`).
## Implementation Notes
- The intermediate `migrationAdapter` adapts the `DatabaseAdapter.query(sql, params: readonly unknown[])` signature to `api-migrations`' `SurrealqlClient.query(sql, vars: Record<string, unknown>)` shape — `vars` is normalised to positional `Object.values(vars)` since the surrealql adapter only uses `$name` placeholders in SurrealQL and the `query` resolves them through `paramsToRecord`.
- The lock is released in `finally`; an exception during release does not mask the original error because the runner phase already completed (or rethrew) at that point.
- `MigrationConfig.autoRun` is consulted by the plugin factory, not the runner — callers that invoke `runMigrations` directly should respect it themselves.

View File

@@ -0,0 +1,77 @@
# `otel/instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the lazily-cached bundle of OpenTelemetry instruments emitted by `@suaveplan/api-plugin-database`. Every counter and histogram is created against the global `MeterProvider` resolved by `@opentelemetry/api` at first call; tests install an in-memory provider through the package's setup file and then call `resetDatabaseInstruments()` to force a rebind. Centralising the instruments in one module avoids the surface drift that comes from each call site calling `metrics.getMeter(...)` independently.
The bundle covers the four cross-cutting observability concerns the plugin owns: pool checkouts, pool checkins, current saturation, and query duration. Migration metrics are not duplicated here — `@suaveplan/api-migrations` ships its own `migrations.applied_total` counter through `getMigrationInstruments()`, so the plugin simply lets that package emit when it triggers the runner.
## Features
- Four instruments: two counters (`db.pool.checkouts_total`, `db.pool.checkins_total`), one up-down-counter (`db.pool.saturation`), one histogram (`db.query.duration_ms`).
- Lazy creation so the bundle binds to whichever `MeterProvider` is global at first call.
- `resetDatabaseInstruments()` clears the cache for test reuse.
- Stable meter name `@suaveplan/api-plugin-database` so consumers can query by scope.
## Usage: Basic
```ts
import { getDatabaseInstruments } from "@suaveplan/api-plugin-database/otel/instruments";
const instruments = getDatabaseInstruments();
instruments.poolCheckoutsTotal.add(1);
```
## Usage: Advanced
```ts
import {
getDatabaseInstruments,
resetDatabaseInstruments,
} from "@suaveplan/api-plugin-database";
function instrumentedAcquire(pool, signal) {
const instruments = getDatabaseInstruments();
const start = performance.now();
return pool.acquire(signal).then(
(adapter) => {
instruments.poolCheckoutsTotal.add(1);
instruments.poolSaturation.add(1);
instruments.queryDurationMs.record(performance.now() - start, {
outcome: "success",
});
return adapter;
},
(err) => {
instruments.queryDurationMs.record(performance.now() - start, {
outcome: "error",
});
throw err;
}
);
}
```
## API Reference
### `getDatabaseInstruments()`
Returns `DatabaseInstruments`. First call binds against the global `MeterProvider`; subsequent calls return the same cached object (referential equality).
### `resetDatabaseInstruments()`
Drops the cache so the next `getDatabaseInstruments()` rebuilds. Call in test setup after installing an in-memory provider.
### `interface DatabaseInstruments`
Bundle of: `poolCheckoutsTotal: Counter`, `poolCheckinsTotal: Counter`, `poolSaturation: UpDownCounter`, `queryDurationMs: Histogram`.
## Implementation Notes
- The histogram is intentionally labelled at the call site (`{ outcome }`) rather than baked into the instrument name; this keeps cardinality bounded and the OpenTelemetry shape aligned with the W3C semantic conventions.
- The bundle does NOT include a migration counter — `@suaveplan/api-migrations` ships its own. Mixing them here would emit duplicate signals against the same meter name.
- `metrics.setGlobalMeterProvider(provider)` BEFORE the first call is mandatory in tests; the bundle is captured at first call and reused.

View File

@@ -0,0 +1,71 @@
# `plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports `createDatabasePlugin`, the top-level factory that produces the `PluginDescriptor` consumed by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory composes every other module in the package — `DatabasePluginOptionsSchema` validates options at the boundary, `ConnectionPool` owns lifecycle and concurrency control, `runMigrations` handles ledger-backed `.surql` application under an advisory lock, the `db` service slot is augmented onto `ServiceRegistry`, and the OTel instrument bundle is shared with the pool. This file is the single integration point — every consumer of the package builds its descriptor through this one function.
The descriptor binds three lifecycle hooks. `onInit` constructs the pool, eagerly acquires the primary adapter, and registers it under `ServiceRegistry["db"]`. `onReady` runs migrations under advisory lock when `migrations.autoRun` is `true`. `onClose` drains the pool. Construction-time Zod validation surfaces invalid options as `DatabaseError(DATABASE.INVALID_CONFIG)` so misconfigurations fail at registry intake rather than during the first request.
## Features
- Zod-validated options at construction time; invalid input surfaces `DATABASE.INVALID_CONFIG`.
- Three lifecycle hooks wired against a single `PluginRuntime` cell — no cross-hook globals.
- Injectable `adapterFactory` so tests run against a fake adapter without touching surrealdb.
- Auto-registers the canonical `db` service slot via module augmentation.
- Pool-acquired primary adapter exposed as `db` — every other plugin sees a fully connected `DatabaseAdapter`.
## Usage: Basic
```ts
import { createDatabasePlugin } from "@suaveplan/api-plugin-database/plugin/plugin";
import { createPluginRegistry } from "@suaveplan/api-plugin";
const registry = createPluginRegistry();
registry.register(
createDatabasePlugin({ url: "ws://localhost:8000/rpc" })
);
await registry.initialize();
```
## Usage: Advanced
```ts
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
import { PooledSurrealAdapter } from "@suaveplan/api-plugin-database";
const plugin = createDatabasePlugin({
url: process.env.SURREAL_URL,
namespace: "prod",
database: "main",
username: process.env.SURREAL_USER,
password: process.env.SURREAL_PASS,
pool: { min: 2, max: 50, idleMs: 60_000, acquireTimeoutMs: 5_000 },
retry: { maxAttempts: 5, baseBackoffMs: 100, maxBackoffMs: 10_000, jitter: true },
tenancy: { strategy: "namespace-per-tenant" },
migrations: {
autoRun: true,
directory: "./surql",
ledgerTable: "_migrations",
lockTable: "_migration_lock",
leaseMs: 60_000,
},
adapterFactory: (cfg) => new PooledSurrealAdapter({ ...cfg }),
});
```
## API Reference
### `createDatabasePlugin(options?): PluginDescriptor<DatabasePluginConfigParsed>`
Returns the plugin descriptor consumed by `PluginRegistry.register`. Throws `DatabaseError(DATABASE.INVALID_CONFIG)` synchronously if `options` fail Zod validation.
## Implementation Notes
- The factory keeps the pool, the primary adapter, and the runtime cell inside a closure so multiple `createDatabasePlugin` calls produce isolated runtimes. There is no module-level global state.
- `onReady` calls `getRawClient()` on the primary adapter to obtain the raw `SurrealClientLike` the migration runner needs. Because the primary adapter is acquired in `onInit` and held until `onClose`, the raw client is always live by the time `onReady` runs.
- `onClose` releases the primary adapter back to the pool before calling `close()` so the pool's drain phase isn't fighting an `inUse` member. The release is best-effort — `pool.release(adapter)` silently ignores unknown adapters, so multiple shutdown attempts are safe.
- The descriptor declares zero `dependencies` — consumers that need the database service register this plugin first.

View File

@@ -0,0 +1,61 @@
# `pool/extended-adapter`
## Purpose
Defines `PooledSurrealAdapter`, a thin subclass of `@suaveplan/surrealdb`'s `SurrealAdapter` that exposes the underlying `SurrealClientLike` via `getRawClient()`. The canonical `DatabaseAdapter.query(sql, params: unknown[])` only carries positional parameters (translated to `{ p0, p1, ... }` records inside the upstream adapter), which means the named `$name` / `$hash` / `$appliedAt` placeholders used by `@suaveplan/api-migrations`' SurrealQL adapter cannot ride over the canonical surface. The migration runner therefore needs access to the raw client.
Subclassing is the safest way to surface the otherwise-`protected` `_client` field without duplicating the parent's connection state machine. The class is otherwise transparent — every method inherited from `SurrealAdapter` works exactly the same, so pooled adapters continue to implement `DatabaseAdapter` and slot into `ServiceRegistry["db"]` unchanged.
## Features
- Extends `SurrealAdapter` 1:1 — no behaviour difference for `connect` / `disconnect` / `query` / `execute` / `transaction` / `create` / `select` / `update` / `delete` / `selectAll`.
- Adds `getRawClient(): SurrealClientLike` for the migration runner.
- Throws `SurrealError(NOT_CONNECTED)` when called before `connect()`.
## Usage: Basic
```ts
import { PooledSurrealAdapter } from "@suaveplan/api-plugin-database/pool/extended-adapter";
const adapter = new PooledSurrealAdapter({
url: "ws://localhost:8000/rpc",
namespace: "app",
database: "main",
username: "root",
password: "root",
});
await adapter.connect();
const client = adapter.getRawClient();
```
## Usage: Advanced
```ts
import { ConnectionPool, PooledSurrealAdapter } from "@suaveplan/api-plugin-database";
const pool = new ConnectionPool({
connection,
pool,
adapterFactory: (cfg) =>
new PooledSurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
}),
});
```
## API Reference
### `class PooledSurrealAdapter extends SurrealAdapter`
- `new PooledSurrealAdapter(options?, logger?, clientFactory?)` — pass-through constructor.
- `getRawClient(): SurrealClientLike` — returns the live underlying client; throws `SurrealError(NOT_CONNECTED)` when disconnected.
## Implementation Notes
- The subclass touches only `_client` and `_state` — both `protected` on the parent. No invariants of the parent class are altered.
- A future refactor could promote `getRawClient` onto `SurrealAdapter` itself; until then, this subclass localises the access to the one package that needs it.
- The class is intentionally NOT exposed through the default `db` slot — the slot type stays `DatabaseAdapter`. Consumers that need the raw client receive a `PooledSurrealAdapter` instance and call `getRawClient()` explicitly.

View File

@@ -0,0 +1,95 @@
# `pool`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `ConnectionPool`, a bounded pool of `SurrealAdapter` instances that backs the `db` service registered by `createDatabasePlugin`. The pool owns lifecycle (create, idle reap, drain) and concurrency control (waiter queue, acquire timeout); every adapter member implements the canonical `@suaveplan/db` `DatabaseAdapter` contract, so consumers receive a fully featured handle without any pool-specific wrapper type. This is the heart of the Path-A wrapper: instead of pooling raw `Surreal` clients (as the upstream package did), suaveplan pools whole adapters so each acquire yields a working, typed surface.
The pool emits OpenTelemetry instruments for every checkout, checkin, and saturation transition. Pool members are constructed through an injectable factory so tests can wire deterministic doubles without touching the surreal driver, and timers are injectable so the idle reaper and acquire-timeout paths can be exercised without `vi.useFakeTimers`.
## Features
- Bounded between `pool.min` and `pool.max`; lazy growth above `min` on demand.
- Waiter queue with `acquireTimeoutMs` reject; surfaces `DATABASE.POOL_EXHAUSTED`.
- Idle reaper drains members above `min` past `pool.idleMs` of inactivity.
- `close()` drains every member and rejects every waiter with `DATABASE.POOL_CLOSED`.
- OTel emission via `getDatabaseInstruments()` on every checkout / checkin / saturation event.
- Injectable `adapterFactory`, `clock`, `setTimer`/`clearTimer`, `setIdleInterval`/`clearIdleInterval`.
## Usage: Basic
```ts
import { ConnectionPool } from "@suaveplan/api-plugin-database/pool/pool";
import { SurrealAdapter } from "@suaveplan/surrealdb";
const pool = new ConnectionPool({
connection: {
url: "ws://localhost:8000/rpc",
namespace: "app",
database: "main",
username: "root",
password: "root",
},
pool: { min: 1, max: 10, idleMs: 30_000, acquireTimeoutMs: 5_000 },
adapterFactory: (cfg) =>
new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
}),
});
await pool.init();
const adapter = await pool.acquire();
try {
await adapter.query("SELECT 1");
} finally {
pool.release(adapter);
}
await pool.close();
```
## Usage: Advanced
```ts
import { ConnectionPool } from "@suaveplan/api-plugin-database";
let elapsed = 0;
const pool = new ConnectionPool({
connection: { url, namespace, database, username, password },
pool: { min: 2, max: 4, idleMs: 100, acquireTimeoutMs: 50 },
adapterFactory: () => new FakeAdapter(),
clock: () => elapsed,
setTimer: (cb, ms) => setTimeout(cb, ms),
setIdleInterval: (cb, ms) => setInterval(cb, ms),
});
await pool.init();
expect(pool.size()).toEqual({ total: 2, idle: 2, inUse: 0, waiting: 0 });
```
## API Reference
### `class ConnectionPool`
- `new ConnectionPool(options: ConnectionPoolOptions)` — builds an unstarted pool.
- `init(): Promise<void>` — eagerly creates `pool.min` members and starts the idle reaper.
- `acquire(): Promise<SurrealAdapter>` — returns an idle adapter, lazily grows up to `pool.max`, or queues a waiter (rejecting after `acquireTimeoutMs`).
- `release(adapter: SurrealAdapter): void` — returns the adapter to the pool; hands it to the next waiter if any.
- `close(): Promise<void>` — drains adapters and rejects every waiter with `DATABASE.POOL_CLOSED`.
- `size(): PoolSize` — snapshot `{ total, idle, inUse, waiting }`.
### `interface ConnectionPoolOptions`
`connection: AdapterFactoryInput`, `pool: PoolConfig`, `adapterFactory: AdapterFactory`, optional `clock`, `setTimer`, `clearTimer`, `setIdleInterval`, `clearIdleInterval`.
## Implementation Notes
- `release(adapter)` silently ignores unknown adapters so caller bugs (double-release, foreign adapter) don't poison the pool.
- The waiter queue is FIFO; the next `release` always wakes the oldest waiter.
- `close()` swallows adapter-disconnect errors — they are non-fatal at teardown and would only obscure the root cause of whatever triggered shutdown.
- The pool does NOT enforce a `signal` parameter on `acquire()`; callers wrap the call in `withRetry` (which honours `AbortSignal`) when they need cancellation.

View File

@@ -0,0 +1,85 @@
# `retry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `withRetry`, a small exponential-backoff helper used by the connection pool, the migration runner, and any caller-supplied wrapping of adapter calls. The helper is intentionally minimal: it owns the loop, the backoff calculation, the optional jitter, and the abort plumbing. Higher-level concerns (circuit breakers, dead-letter routing, OTel spans) are layered on top by the caller — keeping retry stateless makes it composable.
Backoff follows the canonical formula `min(baseBackoffMs * 2^(attempt-1), maxBackoffMs)`. Jittered mode multiplies the result by a `[0, 1)` random factor so concurrent callers don't synchronise their retry windows. Both the RNG and the `setTimeout` setter are injectable so tests run deterministically without time travel.
## Features
- Exponential backoff capped at `maxBackoffMs`.
- Optional full-jitter randomisation through an injectable `Rng`.
- `isRetryable(err, attempt)` predicate for bail-out on permanent failures.
- `AbortSignal` propagation cancels pending sleeps and the loop.
- Pure async — no global state, no side effects beyond the optional sleep.
## Usage: Basic
```ts
import { withRetry } from "@suaveplan/api-plugin-database/retry/retry";
const result = await withRetry(() => db.query("SELECT 1"), {
maxAttempts: 3,
baseBackoffMs: 50,
maxBackoffMs: 1_000,
jitter: true,
});
```
## Usage: Advanced
```ts
import { withRetry } from "@suaveplan/api-plugin-database";
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
const data = await withRetry(
async (attempt) => {
const adapter = await pool.acquire();
try {
return await adapter.query("SELECT * FROM users WHERE active");
} finally {
pool.release(adapter);
}
},
{
maxAttempts: 5,
baseBackoffMs: 100,
maxBackoffMs: 5_000,
jitter: true,
signal: controller.signal,
isRetryable: (err) =>
err instanceof Error && /timeout|reset/i.test(err.message),
}
);
```
## API Reference
### `withRetry<T>(fn, options): Promise<T>`
Invokes `fn(attempt)` (1-indexed) until success or budget exhaustion. Returns the resolved value or rethrows the final error.
### `interface RetryOptions`
- `maxAttempts` (number, >=1) — total attempt budget.
- `baseBackoffMs` (number, >=1) — base interval.
- `maxBackoffMs` (number, >=1) — interval ceiling.
- `jitter` (boolean) — multiply by `random()` for full jitter.
- `isRetryable?` (`(err, attempt) => boolean`) — bail predicate.
- `rng?` (`Rng`) — injectable random source.
- `signal?` (`AbortSignal`) — cancels in-flight sleep + loop.
- `schedule?` (`(cb, ms) => void`) — injectable `setTimeout` for tests.
## Implementation Notes
- Sleeps are interrupted promptly by `AbortSignal`; the inner promise rejects with the signal's `reason` (when an `Error`) or a generic `Error("withRetry aborted")`.
- Aborts at the top of the loop (before `fn(attempt)`) short-circuit the retry budget immediately.
- The helper does not catch or rewrap thrown values — callers see the underlying error. This keeps the helper independent of the package's error registry, which composes downstream.
- Helper functions are intentionally inline-sized; the file stays under the 300-line hard limit.

View File

@@ -0,0 +1,77 @@
# `routes/problem-json`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders arbitrary throwables as RFC 7807 `application/problem+json` bodies for HTTP handlers that wrap database calls. suaveplan's `@suaveplan/error` package does not (yet) ship a canonical problem-json formatter, so this module owns a small local renderer scoped to the codes registered by this plugin. The renderer is intentionally minimal — it picks a sensible HTTP status per `DATABASE_*` code, emits the `code` field as a stable string for client logic, and falls through to a generic 500 for unknown throwables.
The exported `formatProblemJson` is a pure function returning the body shape; `respondProblemJson` is the convenience variant that also sets the response content-type and emits the body through whatever HTTP framework the caller is using (Hono, Fastify, bun stdlib). Either form is safe to call from a catch block without leaking internals — error messages are passed through, but the `cause` chain and `context` are NOT serialised.
## Features
- RFC 7807-shaped output with `type`, `title`, `status`, `detail`, `code`.
- Per-code HTTP status mapping (503 for pool/connection, 409 for lock contention, 400 for tenant validation, 500 for migration/query/config, 500 for unknown).
- Framework-agnostic — pure `{ header, body }` structural response surface.
- Bare `SuaveplanError` subclasses surfaced with their code as title.
- Non-error throwables produce a redacted 500.
## Usage: Basic
```ts
import { respondProblemJson } from "@suaveplan/api-plugin-database/routes/problem-json";
app.get("/users", async (c) => {
try {
const db = c.get("db");
return c.json(await db.query("SELECT * FROM user"));
} catch (err) {
return respondProblemJson(c, err);
}
});
```
## Usage: Advanced
```ts
import {
formatProblemJson,
PROBLEM_JSON_CONTENT_TYPE,
} from "@suaveplan/api-plugin-database";
app.onError((err, req, res) => {
const body = formatProblemJson(err);
res.writeHead(body.status, { "content-type": PROBLEM_JSON_CONTENT_TYPE });
res.end(JSON.stringify(body));
});
```
## API Reference
### `respondProblemJson(res, err): Response`
Writes the content-type header and emits the JSON-serialised body through `res.body(string, status)`. Returns the framework's `Response`.
### `formatProblemJson(err): ProblemDetails`
Pure renderer — returns the body object without touching any I/O.
### `interface ProblemDetails`
`type: string; title: string; status: number; detail?: string; instance?: string; code?: string; cause?: unknown; [extension]: unknown`.
### `interface HttpResponseLike`
Structural minimum: `header(name, value): void` and `body(data, status): Response`.
### `PROBLEM_JSON_CONTENT_TYPE`
The IETF media type `"application/problem+json"`.
## Implementation Notes
- The status map is internal; adding a new database code requires adding a row to `STATUS_BY_CODE`. The table is kept here (rather than on the error class) so HTTP-specific concerns don't leak into the error registry.
- Non-`SuaveplanError` throwables produce a deliberately generic "Internal Server Error" title and no `detail` — leaking arbitrary error messages from non-typed throws would expose stack-trace-like information.
- The renderer does NOT walk the cause chain. Higher layers (logger, OTel span event) capture causes; the problem-json body is the client-facing surface and is intentionally narrow.

View File

@@ -0,0 +1,55 @@
# `services/registry-augmentation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Augments the `ServiceRegistry` interface from `@suaveplan/api-plugin` to register the `db` slot typed as `@suaveplan/db`'s `DatabaseAdapter`. Module augmentation is the canonical suaveplan pattern for cross-plugin typed services: it keeps `ctx.getService("db")` strongly typed without forcing every consumer to import a plugin-specific type and without leaking the implementation through the augmentation. Downstream plugins (e.g. `api-plugin-user`, future `api-plugin-tenant`) consume the slot purely by importing this module's barrel side effect.
The file is intentionally minimal: a single `declare module` block plus an empty `export {}` so module resolution treats it as a module rather than a script. Importing it from the package root barrel is a free type-only side effect at runtime.
## Features
- Adds `db: DatabaseAdapter` to the shared `ServiceRegistry`.
- Zero runtime cost — pure type augmentation.
- Re-imported via the package root barrel so consumers get the slot for free with `import "@suaveplan/api-plugin-database"`.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-database";
declare module "@suaveplan/api-plugin/service-registry" {
// db is already registered by the import above.
}
const db = ctx.getService("db"); // typed as DatabaseAdapter | undefined
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-database/services/registry-augmentation";
import type { DatabaseAdapter } from "@suaveplan/db";
async function withTx<T>(
ctx: PluginContext,
fn: (db: DatabaseAdapter) => Promise<T>
): Promise<T> {
const db = ctx.getService("db");
if (db === undefined) throw new Error("db service not registered");
return db.transaction(fn);
}
```
## API Reference
No runtime exports. The file augments `ServiceRegistry["db"]` at the type level.
## Implementation Notes
- The augmentation imports `DatabaseAdapter` as a type-only reference. The runtime cost of importing the module is zero.
- The plugin descriptor (in `plugin/plugin.ts`) imports this module as a side effect so consumers that pull the descriptor in get the augmentation transitively.
- Augmenting through a single canonical file (rather than each plugin) prevents duplicate-slot drift — any future package wanting to override or extend `db` would have to do so explicitly.

View File

@@ -0,0 +1,65 @@
# `tenancy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports the `provisionTenant` and `decommissionTenant` callables that downstream hosts (typically `@suaveplan/api-plugin-tenant`) invoke on their own tenant-lifecycle events. The functions live as plain async exports rather than as descriptor hooks because the `PluginDescriptor` has no tenant-lifecycle hook slots — placing tenant logic on the plugin descriptor was the upstream's choice; suaveplan routes it through the host event bus instead.
Both functions accept any `DatabaseAdapter` so they remain unit-testable against an in-process double, and both validate `tenantId` against the strict `/^[A-Za-z0-9_-]{1,64}$/` pattern before issuing any query so a hostile or malformed id cannot land in a SurrealQL `DEFINE NAMESPACE` statement.
## Features
- Three strategies: `shared`, `namespace-per-tenant`, `database-per-tenant`.
- Strict `tenantId` validation; invalid input surfaces `DATABASE.TENANT_INVALID`.
- Wraps adapter failures as `DATABASE.QUERY_FAILED` with the strategy and tenant id in `context`.
- Idempotent: every DDL uses `IF NOT EXISTS` so repeated provision calls are safe; `REMOVE` and `DELETE` are idempotent on missing rows in SurrealDB.
## Usage: Basic
```ts
import { provisionTenant, decommissionTenant } from "@suaveplan/api-plugin-database/tenancy/tenancy";
await provisionTenant(db, { strategy: "namespace-per-tenant" }, "acme");
// ...later
await decommissionTenant(db, { strategy: "namespace-per-tenant" }, "acme");
```
## Usage: Advanced
```ts
import {
provisionTenant,
decommissionTenant,
} from "@suaveplan/api-plugin-database";
eventBus.on("tenant:created", async ({ tenantId }) => {
const db = ctx.getService("db");
if (db === undefined) return;
await provisionTenant(db, config.tenancy, tenantId);
});
eventBus.on("tenant:deleted", async ({ tenantId }) => {
const db = ctx.getService("db");
if (db === undefined) return;
await decommissionTenant(db, config.tenancy, tenantId);
});
```
## API Reference
### `provisionTenant(adapter, config, tenantId): Promise<void>`
Issues the strategy-specific `DEFINE …` query against `adapter`. Throws `DatabaseError(DATABASE.TENANT_INVALID)` on bad ids; rejects with `DatabaseError(DATABASE.QUERY_FAILED)` on adapter failures.
### `decommissionTenant(adapter, config, tenantId): Promise<void>`
Mirror of `provisionTenant` — issues the strategy-specific `REMOVE …` (or bounded `DELETE …`) query.
## Implementation Notes
- The functions issue queries through `adapter.execute(sql)` with no parameters, because SurrealDB's `DEFINE NAMESPACE` and `REMOVE NAMESPACE` do not accept bind variables for the namespace name. The tenant id is validated against the regex before being interpolated into the SQL — there is no path by which an unvalidated value reaches the wire.
- The `shared` strategy assumes a single `tenant_scoped` convention table; consumers that name their tenant table differently should re-implement the decommission flow with their own table list. Issuing a wildcard `DELETE FROM *` (as upstream did) is intentionally NOT supported because it would also wipe non-tenant rows.
- The functions are exported individually so callers can wire only the half they need (e.g. provision on signup, but decommission via a soft-delete background job).

View File

@@ -0,0 +1,101 @@
# `types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Houses the internal type aliases and shared interfaces consumed by the rest of the package — the adapter factory seam, the strategy enum, the resolved configuration shape, and the public options bundle. Centralising the types here lets the pool, the plugin factory, the migration runner, and the tenancy callables all reference one canonical name without circular imports between feature folders. The file also re-exports `DatabaseAdapter` from `@suaveplan/db` so consumers of the package's barrel pick up the canonical contract through a stable path.
This file is intentionally narrow. Anything domain-specific (lock-row shape, migration record, pool entry) lives next to the feature that owns it; only types that span at least two features land here. That keeps the module graph flat and avoids the typical drift where shared types become a dumping ground.
## Features
- `AdapterFactory` / `AdapterFactoryInput` — injection seam used by the pool and the plugin factory.
- `TenancyStrategy` literal union and `TenancyConfig` interface.
- `PoolConfig`, `RetryConfig`, `MigrationConfig` section shapes mirroring the Zod schemas.
- `DatabasePluginConfig` — fully-resolved post-Zod configuration.
- `DatabasePluginOptions` — pre-Zod options bundle, with `logger`, `clock`, and `adapterFactory` escape hatches.
- Re-export of `DatabaseAdapter` from `@suaveplan/db` so consumers of this package's root barrel can name the slot type without importing a sibling.
## Usage: Basic
```ts
import type {
DatabasePluginConfig,
TenancyStrategy,
} from "@suaveplan/api-plugin-database/types/types";
function summarize(cfg: DatabasePluginConfig): string {
return `${cfg.namespace}/${cfg.database}@${cfg.url} (strategy=${cfg.tenancy.strategy satisfies TenancyStrategy})`;
}
```
## Usage: Advanced
```ts
import type {
AdapterFactory,
DatabaseAdapter,
} from "@suaveplan/api-plugin-database";
const fakeFactory: AdapterFactory = (cfg) => ({
/* ...fake DatabaseAdapter shape */
} as unknown as ReturnType<AdapterFactory>);
async function withDb<T>(
db: DatabaseAdapter,
fn: (tx: DatabaseAdapter) => Promise<T>
): Promise<T> {
return db.transaction(fn as never);
}
```
## API Reference
### `type AdapterFactory`
`(config: AdapterFactoryInput) => SurrealAdapter` — pool member constructor.
### `interface AdapterFactoryInput`
`{ url, namespace, database, username, password }` — connection bundle handed to each factory invocation.
### `type TenancyStrategy`
`"shared" | "namespace-per-tenant" | "database-per-tenant"`.
### `interface MigrationConfig`
`{ autoRun, directory, ledgerTable, lockTable, leaseMs }`.
### `interface PoolConfig`
`{ min, max, idleMs, acquireTimeoutMs }`.
### `interface RetryConfig`
`{ maxAttempts, baseBackoffMs, maxBackoffMs, jitter }`.
### `interface DatabasePluginConfig`
Fully-resolved configuration after Zod parse. Read-only.
### `interface TenancyConfig`
`{ strategy: TenancyStrategy }`.
### `type DatabasePluginOptions`
Partial recursive form of `DatabasePluginConfig` plus optional `logger`, `clock`, and `adapterFactory` injections.
### `type DatabaseAdapter`
Re-export of `@suaveplan/db`'s canonical `DatabaseAdapter`.
## Implementation Notes
- The file contains no runtime code; every export is a type. Importing it has zero footprint after tree-shaking.
- The recursive `Partial` in `DatabasePluginOptions` lets callers supply only the sections they want to override (`{ pool: { max: 50 } }`) without filling out the whole tree.
- `AdapterFactory` returns `SurrealAdapter` rather than `DatabaseAdapter` because the plugin's primary path needs `getRawClient()` from the `PooledSurrealAdapter` subclass. The pool itself is structurally typed and accepts any factory that produces a `SurrealAdapter`-shaped value.