Initial Commit
This commit is contained in:
111
packages/api-stack/api/api-plugin-database/README.md
Normal file
111
packages/api-stack/api/api-plugin-database/README.md
Normal 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).
|
||||
|
||||
132
packages/api-stack/api/api-plugin-database/package.json
Normal file
132
packages/api-stack/api/api-plugin-database/package.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-database",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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 genesis SurrealAdapter; this plugin owns the orthogonal pool/migration/tenancy/retry/observability concerns.",
|
||||
"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"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./pool/pool": {
|
||||
"import": {
|
||||
"types": "./dist/pool/pool.d.ts",
|
||||
"default": "./dist/pool/pool.js"
|
||||
}
|
||||
},
|
||||
"./migrations/runner": {
|
||||
"import": {
|
||||
"types": "./dist/migrations/runner.d.ts",
|
||||
"default": "./dist/migrations/runner.js"
|
||||
}
|
||||
},
|
||||
"./migrations/lock": {
|
||||
"import": {
|
||||
"types": "./dist/migrations/lock.d.ts",
|
||||
"default": "./dist/migrations/lock.js"
|
||||
}
|
||||
},
|
||||
"./tenancy/tenancy": {
|
||||
"import": {
|
||||
"types": "./dist/tenancy/tenancy.d.ts",
|
||||
"default": "./dist/tenancy/tenancy.js"
|
||||
}
|
||||
},
|
||||
"./retry/retry": {
|
||||
"import": {
|
||||
"types": "./dist/retry/retry.d.ts",
|
||||
"default": "./dist/retry/retry.js"
|
||||
}
|
||||
},
|
||||
"./routes/problem-json": {
|
||||
"import": {
|
||||
"types": "./dist/routes/problem-json.d.ts",
|
||||
"default": "./dist/routes/problem-json.js"
|
||||
}
|
||||
},
|
||||
"./config/config-schema": {
|
||||
"import": {
|
||||
"types": "./dist/config/config-schema.d.ts",
|
||||
"default": "./dist/config/config-schema.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"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-migrations": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/db": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-surrealdb": "workspace:^",
|
||||
"@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,64 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DATABASE_CODES,
|
||||
type DatabaseErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
function walk(dir: string, acc: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stats = statSync(full);
|
||||
if (stats.isDirectory()) {
|
||||
walk(full, acc);
|
||||
} else if (
|
||||
full.endsWith(".ts") &&
|
||||
!full.endsWith(".test.ts") &&
|
||||
!full.endsWith(".types.ts")
|
||||
) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
describe("api-plugin-database spec conformance", () => {
|
||||
it("every DATABASE_* code is thrown from a production path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const srcDir = join(import.meta.dirname, ".");
|
||||
const files = walk(srcDir);
|
||||
const thrown = new Set<DatabaseErrorCode>();
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const value of Object.values(DATABASE_CODES)) {
|
||||
const key = value.split(".")[1] as string;
|
||||
// Match DATABASE_CODES.<KEY> in a `throw new DatabaseError(`
|
||||
// or `new DatabaseError(` call, OR a direct string usage.
|
||||
const ref = `DATABASE_CODES.${key}`;
|
||||
if (
|
||||
content.includes("new DatabaseError(") &&
|
||||
content.includes(ref)
|
||||
) {
|
||||
thrown.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const missing = Object.values(DATABASE_CODES).filter(
|
||||
(c) => !thrown.has(c)
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("side-effect imports of error-codes and registry-augmentation are present in index.ts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const content = readFileSync(
|
||||
join(import.meta.dirname, "index.ts"),
|
||||
"utf8"
|
||||
);
|
||||
expect(content).toContain("./error-codes/error-codes.js");
|
||||
expect(content).toContain("./services/registry-augmentation.js");
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DatabasePluginOptionsSchema } from "./config-schema.js";
|
||||
|
||||
describe("DatabasePluginOptionsSchema", () => {
|
||||
it("parses canonical input with defaults", { timeout: 5000 }, () => {
|
||||
const parsed = DatabasePluginOptionsSchema.parse({
|
||||
url: "ws://localhost:8000/rpc",
|
||||
});
|
||||
expect(parsed.namespace).toBe("app");
|
||||
expect(parsed.database).toBe("main");
|
||||
expect(parsed.username).toBe("root");
|
||||
expect(parsed.password).toBe("root");
|
||||
expect(parsed.autoConnect).toBe(true);
|
||||
expect(parsed.pool.min).toBe(1);
|
||||
expect(parsed.pool.max).toBe(10);
|
||||
expect(parsed.pool.acquireTimeoutMs).toBe(5_000);
|
||||
expect(parsed.retry.maxAttempts).toBe(3);
|
||||
expect(parsed.tenancy.strategy).toBe("shared");
|
||||
expect(parsed.migrations.autoRun).toBe(true);
|
||||
expect(parsed.migrations.directory).toBe("./surql");
|
||||
});
|
||||
|
||||
it("parses an empty object with full defaults", { timeout: 5000 }, () => {
|
||||
const parsed = DatabasePluginOptionsSchema.parse({});
|
||||
expect(parsed.url).toBe("ws://localhost:8000/rpc");
|
||||
});
|
||||
|
||||
it("rejects out-of-range pool max", { timeout: 5000 }, () => {
|
||||
const result = DatabasePluginOptionsSchema.safeParse({
|
||||
pool: { max: 0 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pool.min greater than pool.max via superRefine", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = DatabasePluginOptionsSchema.safeParse({
|
||||
pool: { min: 5, max: 3 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain(
|
||||
"pool.min must be <= pool.max"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects retry.baseBackoffMs greater than retry.maxBackoffMs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = DatabasePluginOptionsSchema.safeParse({
|
||||
retry: { baseBackoffMs: 5000, maxBackoffMs: 1000 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-URL url", { timeout: 5000 }, () => {
|
||||
const result = DatabasePluginOptionsSchema.safeParse({
|
||||
url: "not-a-url",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative retry.maxAttempts", { timeout: 5000 }, () => {
|
||||
const result = DatabasePluginOptionsSchema.safeParse({
|
||||
retry: { maxAttempts: 0 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Pool section. `min` and `max` are non-negative integers; `min` is bounded
|
||||
* above by `max` via a refine in the parent schema.
|
||||
*/
|
||||
export const PoolConfigSchema = z.object({
|
||||
min: z.number().int().min(0).default(1),
|
||||
max: z.number().int().min(1).default(10),
|
||||
idleMs: z.number().int().min(0).default(30_000),
|
||||
acquireTimeoutMs: z.number().int().min(0).default(5_000),
|
||||
});
|
||||
|
||||
/** Retry section. */
|
||||
export const RetryConfigSchema = z.object({
|
||||
maxAttempts: z.number().int().min(1).default(3),
|
||||
baseBackoffMs: z.number().int().min(1).default(50),
|
||||
maxBackoffMs: z.number().int().min(1).default(2_000),
|
||||
jitter: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/** Tenancy section. */
|
||||
export const TenancyConfigSchema = z.object({
|
||||
strategy: z
|
||||
.enum(["shared", "namespace-per-tenant", "database-per-tenant"])
|
||||
.default("shared"),
|
||||
});
|
||||
|
||||
/** Migrations section. */
|
||||
export const MigrationConfigSchema = z.object({
|
||||
autoRun: z.boolean().default(true),
|
||||
directory: z.string().default("./surql"),
|
||||
ledgerTable: z.string().default("_migrations"),
|
||||
lockTable: z.string().default("_migration_lock"),
|
||||
leaseMs: z.number().int().min(1_000).default(60_000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Public options bundle accepted by `createDatabasePlugin`. Every nested
|
||||
* section is optional and falls through to defaults; only `url` is
|
||||
* effectively required (the default is `ws://localhost:8000/rpc` which
|
||||
* matches the surrealdb dev surface).
|
||||
*/
|
||||
export const DatabasePluginOptionsSchema = z
|
||||
.object({
|
||||
url: z.string().url().default("ws://localhost:8000/rpc"),
|
||||
namespace: z.string().default("app"),
|
||||
database: z.string().default("main"),
|
||||
username: z.string().default("root"),
|
||||
password: z.string().default("root"),
|
||||
autoConnect: z.boolean().default(true),
|
||||
pool: PoolConfigSchema.default({
|
||||
min: 1,
|
||||
max: 10,
|
||||
idleMs: 30_000,
|
||||
acquireTimeoutMs: 5_000,
|
||||
}),
|
||||
retry: RetryConfigSchema.default({
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 50,
|
||||
maxBackoffMs: 2_000,
|
||||
jitter: true,
|
||||
}),
|
||||
tenancy: TenancyConfigSchema.default({ strategy: "shared" }),
|
||||
migrations: MigrationConfigSchema.default({
|
||||
autoRun: true,
|
||||
directory: "./surql",
|
||||
ledgerTable: "_migrations",
|
||||
lockTable: "_migration_lock",
|
||||
leaseMs: 60_000,
|
||||
}),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.pool.min > value.pool.max) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "pool.min must be <= pool.max",
|
||||
path: ["pool", "min"],
|
||||
});
|
||||
}
|
||||
if (value.retry.baseBackoffMs > value.retry.maxBackoffMs) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "retry.baseBackoffMs must be <= retry.maxBackoffMs",
|
||||
path: ["retry", "baseBackoffMs"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Fully-resolved configuration after Zod parse. */
|
||||
export type DatabasePluginConfigParsed = z.infer<
|
||||
typeof DatabasePluginOptionsSchema
|
||||
>;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DATABASE_CODES,
|
||||
DatabaseError,
|
||||
type DatabaseErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("DATABASE_CODES registry", () => {
|
||||
it("defineCodes produces the expected DATABASE_* namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DATABASE_CODES).toEqual({
|
||||
CONNECTION_FAILED: "DATABASE.CONNECTION_FAILED",
|
||||
POOL_EXHAUSTED: "DATABASE.POOL_EXHAUSTED",
|
||||
POOL_CLOSED: "DATABASE.POOL_CLOSED",
|
||||
QUERY_FAILED: "DATABASE.QUERY_FAILED",
|
||||
MIGRATION_FAILED: "DATABASE.MIGRATION_FAILED",
|
||||
LOCK_CONTENDED: "DATABASE.LOCK_CONTENDED",
|
||||
TENANT_INVALID: "DATABASE.TENANT_INVALID",
|
||||
INVALID_CONFIG: "DATABASE.INVALID_CONFIG",
|
||||
});
|
||||
});
|
||||
|
||||
it("the registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DATABASE_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("DatabaseError narrows code at the instance level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DatabaseError(
|
||||
DATABASE_CODES.POOL_EXHAUSTED,
|
||||
"exhausted"
|
||||
);
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect(err.code).toBe<DatabaseErrorCode>(DATABASE_CODES.POOL_EXHAUSTED);
|
||||
expect(err.databaseCode).toBe(err.code);
|
||||
expect(err.message).toBe("exhausted");
|
||||
});
|
||||
|
||||
it("DatabaseError carries cause and context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DatabaseError(
|
||||
DATABASE_CODES.CONNECTION_FAILED,
|
||||
"boom",
|
||||
{ cause, context: { url: "ws://x/rpc" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ url: "ws://x/rpc" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-database`.
|
||||
*
|
||||
* Every failure path raises a {@link DatabaseError} carrying one of these
|
||||
* eight `DATABASE.*` codes. Consumers branch on the string value:
|
||||
* `error.code === DATABASE_CODES.POOL_EXHAUSTED`.
|
||||
*
|
||||
* - `CONNECTION_FAILED` — `SurrealAdapter.connect()` rejected during pool
|
||||
* member construction. `cause` carries the underlying surreal/transport
|
||||
* error.
|
||||
* - `POOL_EXHAUSTED` — `acquire()` waited beyond `acquireTimeoutMs` while
|
||||
* every member was in use.
|
||||
* - `POOL_CLOSED` — `acquire()` was called after `close()`, or a queued
|
||||
* waiter was rejected by `close()`.
|
||||
* - `QUERY_FAILED` — a wrapped query failed (mostly emitted from
|
||||
* integration paths that surface adapter errors through `withRetry`).
|
||||
* - `MIGRATION_FAILED` — the migration runner failed to apply a `.surql`
|
||||
* file. `cause` is the underlying error from
|
||||
* `@suaveplan/api-migrations`.
|
||||
* - `LOCK_CONTENDED` — the advisory lock helper observed an unexpired
|
||||
* lease held by another holder and the caller asked for strict mode.
|
||||
* Non-strict callers receive `acquired: false` instead.
|
||||
* - `TENANT_INVALID` — `provisionTenant` / `decommissionTenant` was
|
||||
* called with a tenant id violating the `^[A-Za-z0-9_-]{1,64}$`
|
||||
* pattern.
|
||||
* - `INVALID_CONFIG` — `createDatabasePlugin(options)` received options
|
||||
* that failed `DatabasePluginOptionsSchema`. `cause` is the `ZodError`.
|
||||
*/
|
||||
export const DATABASE_CODES = defineCodes("DATABASE", [
|
||||
"CONNECTION_FAILED",
|
||||
"POOL_EXHAUSTED",
|
||||
"POOL_CLOSED",
|
||||
"QUERY_FAILED",
|
||||
"MIGRATION_FAILED",
|
||||
"LOCK_CONTENDED",
|
||||
"TENANT_INVALID",
|
||||
"INVALID_CONFIG",
|
||||
] as const);
|
||||
|
||||
export type DatabaseErrorCode =
|
||||
(typeof DATABASE_CODES)[keyof typeof DATABASE_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-database package. Extends
|
||||
* {@link SuaveplanError} so `instanceof DatabaseError` narrows
|
||||
* `error.code` to {@link DatabaseErrorCode}.
|
||||
*/
|
||||
export class DatabaseError extends SuaveplanError<DatabaseErrorCode> {
|
||||
public readonly databaseCode: DatabaseErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: DatabaseErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.databaseCode = code;
|
||||
}
|
||||
}
|
||||
81
packages/api-stack/api/api-plugin-database/src/index.ts
Normal file
81
packages/api-stack/api/api-plugin-database/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Side-effect imports — register the error-codes namespace + the
|
||||
// `db` slot on the shared ServiceRegistry for every consumer.
|
||||
import "./error-codes/error-codes.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Config
|
||||
export {
|
||||
type DatabasePluginConfigParsed,
|
||||
DatabasePluginOptionsSchema,
|
||||
MigrationConfigSchema,
|
||||
PoolConfigSchema,
|
||||
RetryConfigSchema,
|
||||
TenancyConfigSchema,
|
||||
} from "./config/config-schema.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
DATABASE_CODES,
|
||||
DatabaseError,
|
||||
type DatabaseErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// Migrations
|
||||
export {
|
||||
acquireLock,
|
||||
type LockAcquireResult,
|
||||
type LockOptions,
|
||||
releaseLock,
|
||||
} from "./migrations/lock.js";
|
||||
export {
|
||||
type MigrationRunResult,
|
||||
type RunMigrationsOptions,
|
||||
runMigrations,
|
||||
} from "./migrations/runner.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
type DatabaseInstruments,
|
||||
getDatabaseInstruments,
|
||||
resetDatabaseInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin
|
||||
export { createDatabasePlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Pool
|
||||
export { PooledSurrealAdapter } from "./pool/extended-adapter.js";
|
||||
export {
|
||||
ConnectionPool,
|
||||
type ConnectionPoolOptions,
|
||||
type PoolSize,
|
||||
} from "./pool/pool.js";
|
||||
|
||||
// Retry
|
||||
export { type RetryOptions, withRetry } from "./retry/retry.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
formatProblemJson,
|
||||
type HttpResponseLike,
|
||||
PROBLEM_JSON_CONTENT_TYPE,
|
||||
type ProblemDetails,
|
||||
respondProblemJson,
|
||||
} from "./routes/problem-json.js";
|
||||
|
||||
// Tenancy
|
||||
export { decommissionTenant, provisionTenant } from "./tenancy/tenancy.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AdapterFactory,
|
||||
AdapterFactoryInput,
|
||||
DatabaseAdapter,
|
||||
DatabasePluginConfig,
|
||||
DatabasePluginOptions,
|
||||
MigrationConfig,
|
||||
PoolConfig,
|
||||
RetryConfig,
|
||||
TenancyConfig,
|
||||
TenancyStrategy,
|
||||
} from "./types/types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { acquireLock, releaseLock } from "./lock.js";
|
||||
|
||||
interface FakeOpts {
|
||||
seedRow?: { holder: string; expiresAt: number };
|
||||
executeThrows?: boolean;
|
||||
queryThrows?: boolean;
|
||||
releaseThrows?: boolean;
|
||||
}
|
||||
|
||||
interface Recorded {
|
||||
readonly executes: string[];
|
||||
readonly queries: string[];
|
||||
}
|
||||
|
||||
function makeAdapter(opts: FakeOpts = {}): {
|
||||
adapter: DatabaseAdapter;
|
||||
recorded: Recorded;
|
||||
setSeed(row: { holder: string; expiresAt: number } | undefined): void;
|
||||
} {
|
||||
let seed = opts.seedRow;
|
||||
const recorded: Recorded = { executes: [], queries: [] };
|
||||
const adapter = {
|
||||
async connect() {},
|
||||
async disconnect() {},
|
||||
async close() {},
|
||||
async query(sql: string) {
|
||||
recorded.queries.push(sql);
|
||||
if (opts.queryThrows === true) throw new Error("query boom");
|
||||
return seed === undefined ? [] : [seed];
|
||||
},
|
||||
async execute(sql: string) {
|
||||
recorded.executes.push(sql);
|
||||
if (
|
||||
opts.releaseThrows === true &&
|
||||
sql.trimStart().startsWith("DELETE")
|
||||
) {
|
||||
throw new Error("release boom");
|
||||
}
|
||||
if (opts.executeThrows === true) throw new Error("exec boom");
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
async transaction<T>(fn: (tx: never) => Promise<T>) {
|
||||
return fn(undefined as never);
|
||||
},
|
||||
async migrate() {
|
||||
return { applied: 0, migrations: [] };
|
||||
},
|
||||
get isConnected() {
|
||||
return true;
|
||||
},
|
||||
} as unknown as DatabaseAdapter;
|
||||
return {
|
||||
adapter,
|
||||
recorded,
|
||||
setSeed(row) {
|
||||
seed = row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("acquireLock", () => {
|
||||
it("acquires when no row exists", { timeout: 5000 }, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
const result = await acquireLock(adapter, "_lock", "h1", 1_000, {
|
||||
clock: () => 1_000_000,
|
||||
});
|
||||
expect(result.acquired).toBe(true);
|
||||
expect(result.expiresAt).toBe(1_001_000);
|
||||
expect(recorded.executes.some((s) => s.includes("DEFINE TABLE"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(recorded.executes.some((s) => s.startsWith("UPSERT"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when another holder is active", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter({
|
||||
seedRow: { holder: "other", expiresAt: 2_000_000 },
|
||||
});
|
||||
const result = await acquireLock(adapter, "_lock", "h1", 1_000, {
|
||||
clock: () => 1_000_000,
|
||||
});
|
||||
expect(result.acquired).toBe(false);
|
||||
expect(result.expiresAt).toBe(2_000_000);
|
||||
});
|
||||
|
||||
it("expired lock can be taken over", { timeout: 5000 }, async () => {
|
||||
const { adapter } = makeAdapter({
|
||||
seedRow: { holder: "other", expiresAt: 500 },
|
||||
});
|
||||
const result = await acquireLock(adapter, "_lock", "h2", 1_000, {
|
||||
clock: () => 1_000_000,
|
||||
});
|
||||
expect(result.acquired).toBe(true);
|
||||
expect(result.expiresAt).toBe(1_001_000);
|
||||
});
|
||||
|
||||
it("wraps adapter failures as MIGRATION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter({ queryThrows: true });
|
||||
await expect(
|
||||
acquireLock(adapter, "_lock", "h1", 1_000, {
|
||||
clock: () => 0,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DatabaseError);
|
||||
await expect(
|
||||
acquireLock(adapter, "_lock", "h1", 1_000, {
|
||||
clock: () => 0,
|
||||
})
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.MIGRATION_FAILED });
|
||||
});
|
||||
|
||||
it("default clock path falls back to Date.now", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const result = await acquireLock(adapter, "_lock", "h1", 1_000);
|
||||
expect(result.acquired).toBe(true);
|
||||
expect(result.expiresAt).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseLock", () => {
|
||||
it("deletes the lock row", { timeout: 5000 }, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await releaseLock(adapter, "_lock", "h1");
|
||||
expect(recorded.executes.some((s) => s.includes("DELETE"))).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps adapter failures as MIGRATION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter({ releaseThrows: true });
|
||||
await expect(
|
||||
releaseLock(adapter, "_lock", "h1")
|
||||
).rejects.toBeInstanceOf(DatabaseError);
|
||||
await expect(releaseLock(adapter, "_lock", "h1")).rejects.toMatchObject(
|
||||
{ code: DATABASE_CODES.MIGRATION_FAILED }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
|
||||
const LOCK_ID = "singleton";
|
||||
|
||||
/** Acquired-lock outcome with the lease expiry for diagnostics. */
|
||||
export interface LockAcquireResult {
|
||||
readonly acquired: boolean;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
/** Options for {@link acquireLock} / {@link releaseLock}. */
|
||||
export interface LockOptions {
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to acquire the advisory migration lock on `table`. The lock is
|
||||
* a single SCHEMAFULL record whose `expiresAt` is a wall-clock timestamp;
|
||||
* holders treat any record whose `expiresAt > now()` as live, otherwise
|
||||
* the lock is up for grabs.
|
||||
*
|
||||
* Returns `{ acquired: false }` when another holder owns an unexpired
|
||||
* lease; returns `{ acquired: true, expiresAt }` after a successful
|
||||
* upsert.
|
||||
*
|
||||
* @throws DatabaseError(DATABASE.MIGRATION_FAILED) when the underlying
|
||||
* adapter rejects.
|
||||
*/
|
||||
export async function acquireLock(
|
||||
adapter: DatabaseAdapter,
|
||||
table: string,
|
||||
holder: string,
|
||||
leaseMs: number,
|
||||
options: LockOptions = {}
|
||||
): Promise<LockAcquireResult> {
|
||||
const clock = options.clock ?? Date.now;
|
||||
try {
|
||||
await adapter.execute(
|
||||
`DEFINE TABLE IF NOT EXISTS ${table} SCHEMAFULL;
|
||||
DEFINE FIELD IF NOT EXISTS holder ON ${table} TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS expiresAt ON ${table} TYPE int;`
|
||||
);
|
||||
|
||||
const rows = await adapter.query<{
|
||||
holder: string;
|
||||
expiresAt: number;
|
||||
}>(
|
||||
`SELECT holder, expiresAt FROM type::record("${table}", "${LOCK_ID}")`
|
||||
);
|
||||
const existing = rows[0];
|
||||
const now = clock();
|
||||
if (existing !== undefined && existing.expiresAt > now) {
|
||||
return { acquired: false, expiresAt: existing.expiresAt };
|
||||
}
|
||||
|
||||
const expiresAt = now + leaseMs;
|
||||
await adapter.execute(
|
||||
`UPSERT type::record("${table}", "${LOCK_ID}") CONTENT { holder: "${holder}", expiresAt: ${expiresAt} };`
|
||||
);
|
||||
return { acquired: true, expiresAt };
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.MIGRATION_FAILED,
|
||||
`Failed to acquire migration lock on ${table}`,
|
||||
{ cause: cause as Error, context: { table, holder } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a previously-acquired advisory lock. Only the holder that
|
||||
* matches the recorded `holder` field deletes the row — stale releases
|
||||
* by a different holder are no-ops.
|
||||
*/
|
||||
export async function releaseLock(
|
||||
adapter: DatabaseAdapter,
|
||||
table: string,
|
||||
holder: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await adapter.execute(
|
||||
`DELETE type::record("${table}", "${LOCK_ID}") WHERE holder = "${holder}";`
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.MIGRATION_FAILED,
|
||||
`Failed to release migration lock on ${table}`,
|
||||
{ cause: cause as Error, context: { table, holder } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,289 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { SurrealqlClient } from "@suaveplan/api-migrations";
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import type { MigrationConfig } from "../types/types.js";
|
||||
import { runMigrations } from "./runner.js";
|
||||
|
||||
interface FakeAdapterOpts {
|
||||
seed?: { holder: string; expiresAt: number };
|
||||
releaseThrows?: boolean;
|
||||
}
|
||||
|
||||
function adapterFor(opts: FakeAdapterOpts = {}): DatabaseAdapter {
|
||||
let seed = opts.seed;
|
||||
return {
|
||||
async connect() {},
|
||||
async disconnect() {},
|
||||
async close() {},
|
||||
async query() {
|
||||
return seed === undefined ? [] : [seed];
|
||||
},
|
||||
async execute(sql: string) {
|
||||
if (
|
||||
opts.releaseThrows === true &&
|
||||
sql.trimStart().startsWith("DELETE")
|
||||
) {
|
||||
throw new Error("release boom");
|
||||
}
|
||||
// When UPSERT runs we record that as the new seed so subsequent
|
||||
// SELECTs return the just-acquired lock.
|
||||
if (sql.trimStart().startsWith("UPSERT")) {
|
||||
const holderMatch = sql.match(/holder:\s*"([^"]+)"/);
|
||||
const expiresMatch = sql.match(/expiresAt:\s*(\d+)/);
|
||||
if (holderMatch !== null && expiresMatch !== null) {
|
||||
seed = {
|
||||
holder: holderMatch[1]!,
|
||||
expiresAt: Number(expiresMatch[1]!),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (sql.trimStart().startsWith("DELETE")) {
|
||||
seed = undefined;
|
||||
}
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
async transaction<T>(fn: (tx: never) => Promise<T>) {
|
||||
return fn(undefined as never);
|
||||
},
|
||||
async migrate() {
|
||||
return { applied: 0, migrations: [] };
|
||||
},
|
||||
get isConnected() {
|
||||
return true;
|
||||
},
|
||||
} as unknown as DatabaseAdapter;
|
||||
}
|
||||
|
||||
interface FakeClientState {
|
||||
applied: Array<{ name: string; checksum: string }>;
|
||||
}
|
||||
|
||||
function clientFor(state: FakeClientState): SurrealqlClient {
|
||||
return {
|
||||
async query<T>(sql: string, vars?: Record<string, unknown>) {
|
||||
const trimmed = sql.trim();
|
||||
if (trimmed.startsWith("DEFINE TABLE")) return [] as T[];
|
||||
if (trimmed.startsWith("SELECT")) {
|
||||
return state.applied.map((r) => ({
|
||||
id: r.name,
|
||||
name: r.name,
|
||||
appliedAt: new Date(0).toISOString(),
|
||||
checksum: r.checksum,
|
||||
duration: 0,
|
||||
})) as unknown as T[];
|
||||
}
|
||||
if (trimmed.startsWith("CREATE")) {
|
||||
const name = vars?.name as string;
|
||||
const checksum = vars?.checksum as string;
|
||||
state.applied.push({ name, checksum });
|
||||
return [] as T[];
|
||||
}
|
||||
if (trimmed.startsWith("DELETE")) {
|
||||
return [] as T[];
|
||||
}
|
||||
// migration body execute — no-op in tests.
|
||||
return [] as T[];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "apdb-runner-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeMigrations(...names: string[]): Promise<void> {
|
||||
for (const n of names) {
|
||||
await writeFile(
|
||||
join(tempDir, n),
|
||||
`-- migration ${n}\nDEFINE TABLE foo;`,
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function configFor(): MigrationConfig {
|
||||
return {
|
||||
autoRun: true,
|
||||
directory: tempDir,
|
||||
ledgerTable: "_mig",
|
||||
lockTable: "_mig_lock",
|
||||
leaseMs: 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("applies pending migrations in order and records ledger entries", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql", "002_users.surql");
|
||||
const adapter = adapterFor();
|
||||
const state: FakeClientState = { applied: [] };
|
||||
const client = clientFor(state);
|
||||
const result = await runMigrations({
|
||||
adapter,
|
||||
client,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
});
|
||||
expect(result.acquired).toBe(true);
|
||||
expect(result.applied.map((m) => m.name)).toEqual([
|
||||
"001_init",
|
||||
"002_users",
|
||||
]);
|
||||
expect(state.applied.map((r) => r.name)).toEqual([
|
||||
"001_init",
|
||||
"002_users",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns skipped list when lock cannot be acquired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor({
|
||||
seed: { holder: "other", expiresAt: 9_999_999 },
|
||||
});
|
||||
const client = clientFor({ applied: [] });
|
||||
const result = await runMigrations({
|
||||
adapter,
|
||||
client,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
});
|
||||
expect(result.acquired).toBe(false);
|
||||
expect(result.applied).toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps runner failures as MIGRATION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor();
|
||||
const failingClient: SurrealqlClient = {
|
||||
async query() {
|
||||
throw new Error("client boom");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
runMigrations({
|
||||
adapter,
|
||||
client: failingClient,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DatabaseError);
|
||||
});
|
||||
|
||||
it("strict mode throws LOCK_CONTENDED on contention", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor({
|
||||
seed: { holder: "other", expiresAt: 9_999_999 },
|
||||
});
|
||||
const client = clientFor({ applied: [] });
|
||||
try {
|
||||
await runMigrations({
|
||||
adapter,
|
||||
client,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
strict: true,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect((err as DatabaseError).code).toBe(
|
||||
DATABASE_CODES.LOCK_CONTENDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes signal into the runner", { timeout: 5000 }, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor();
|
||||
const state: FakeClientState = { applied: [] };
|
||||
const client = clientFor(state);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await runMigrations({
|
||||
adapter,
|
||||
client,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// The runner short-circuits on aborted signal between files; with
|
||||
// a single migration the run still attempts apply.
|
||||
expect(result.acquired).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the default logger when none is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor({
|
||||
seed: { holder: "other", expiresAt: 9_999_999 },
|
||||
});
|
||||
const client = clientFor({ applied: [] });
|
||||
const result = await runMigrations({
|
||||
adapter,
|
||||
client,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
});
|
||||
expect(result.acquired).toBe(false);
|
||||
});
|
||||
|
||||
it("MIGRATION_FAILED carries the directory in context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeMigrations("001_init.surql");
|
||||
const adapter = adapterFor();
|
||||
const failingClient: SurrealqlClient = {
|
||||
async query() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
try {
|
||||
await runMigrations({
|
||||
adapter,
|
||||
client: failingClient,
|
||||
config: configFor(),
|
||||
holder: "h1",
|
||||
clock: () => 1_000,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect((err as DatabaseError).code).toBe(
|
||||
DATABASE_CODES.MIGRATION_FAILED
|
||||
);
|
||||
expect((err as DatabaseError).context).toMatchObject({
|
||||
directory: tempDir,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
type MigrationResult as ApiMigrationResult,
|
||||
createMigrationRunner,
|
||||
createSurrealqlAdapter,
|
||||
type SurrealqlClient,
|
||||
} from "@suaveplan/api-migrations";
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import type { MigrationConfig } from "../types/types.js";
|
||||
import { acquireLock, releaseLock } from "./lock.js";
|
||||
|
||||
/** Outcome of {@link runMigrations}. */
|
||||
export interface MigrationRunResult {
|
||||
readonly acquired: boolean;
|
||||
readonly applied: ReadonlyArray<ApiMigrationResult>;
|
||||
readonly skipped: readonly string[];
|
||||
}
|
||||
|
||||
/** Construction options for {@link runMigrations}. */
|
||||
export interface RunMigrationsOptions {
|
||||
/** Adapter used for advisory lock acquisition / release. */
|
||||
readonly adapter: DatabaseAdapter;
|
||||
/**
|
||||
* Client used by the migration runner itself. Must speak the
|
||||
* `SurrealqlClient.query(sql, vars?)` contract that
|
||||
* `@suaveplan/api-migrations`' `createSurrealqlAdapter` consumes —
|
||||
* typically the raw `SurrealClientLike` underlying the pooled adapter.
|
||||
*/
|
||||
readonly client: SurrealqlClient;
|
||||
readonly config: MigrationConfig;
|
||||
readonly holder: string;
|
||||
readonly logger?: LoggerLike;
|
||||
readonly clock?: Clock;
|
||||
readonly signal?: AbortSignal;
|
||||
/**
|
||||
* When `true`, lock contention surfaces as
|
||||
* `DatabaseError(DATABASE.LOCK_CONTENDED)` instead of the soft
|
||||
* `{ acquired: false }` outcome. Use for CI / deployment workflows
|
||||
* that should fail loudly when a peer is racing them.
|
||||
*/
|
||||
readonly strict?: boolean;
|
||||
}
|
||||
|
||||
const noop = (): void => undefined;
|
||||
const NOOP_LOGGER: LoggerLike = {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
};
|
||||
|
||||
/**
|
||||
* Run pending `.surql` migrations under an advisory lock.
|
||||
*
|
||||
* 1. Acquire the advisory lock backed by {@link acquireLock}. If contended,
|
||||
* return `{ acquired: false, applied: [], skipped: [] }` without
|
||||
* touching the migrations directory.
|
||||
* 2. Build a `@suaveplan/api-migrations` runner backed by the
|
||||
* {@link createSurrealqlAdapter} wrapping the injected `client`.
|
||||
* 3. Run `runner.runFromDirectory(config.directory)` and return the
|
||||
* per-file `MigrationResult` list.
|
||||
* 4. Release the lock in `finally`.
|
||||
*
|
||||
* @throws DatabaseError(DATABASE.MIGRATION_FAILED) when the runner itself
|
||||
* throws (lock acquire/release are wrapped at their own layer).
|
||||
*/
|
||||
export async function runMigrations(
|
||||
options: RunMigrationsOptions
|
||||
): Promise<MigrationRunResult> {
|
||||
const {
|
||||
adapter,
|
||||
client,
|
||||
config,
|
||||
holder,
|
||||
logger = NOOP_LOGGER,
|
||||
clock,
|
||||
signal,
|
||||
} = options;
|
||||
const lockResult = await acquireLock(
|
||||
adapter,
|
||||
config.lockTable,
|
||||
holder,
|
||||
config.leaseMs,
|
||||
clock !== undefined ? { clock } : {}
|
||||
);
|
||||
if (!lockResult.acquired) {
|
||||
if (options.strict === true) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.LOCK_CONTENDED,
|
||||
`Migration lock on ${config.lockTable} is held by another holder`,
|
||||
{ context: { lockTable: config.lockTable, holder } }
|
||||
);
|
||||
}
|
||||
logger.warn("migration lock contended; skipping run", { holder });
|
||||
return { acquired: false, applied: [], skipped: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const migrationAdapter = createSurrealqlAdapter(client);
|
||||
const runnerOptions = {
|
||||
tableName: config.ledgerTable,
|
||||
logger,
|
||||
...(clock !== undefined ? { clock } : {}),
|
||||
};
|
||||
const runner = createMigrationRunner(migrationAdapter, runnerOptions);
|
||||
const runOpts = signal !== undefined ? { signal } : {};
|
||||
const applied = await runner.runFromDirectory(
|
||||
config.directory,
|
||||
runOpts
|
||||
);
|
||||
return { acquired: true, applied, skipped: [] };
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.MIGRATION_FAILED,
|
||||
"Migration runner failed",
|
||||
{ cause: cause as Error, context: { directory: config.directory } }
|
||||
);
|
||||
} finally {
|
||||
await releaseLock(adapter, config.lockTable, holder);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getDatabaseInstruments,
|
||||
resetDatabaseInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("database OTel instruments", () => {
|
||||
it("every instrument emits against the in-memory exporter", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetDatabaseInstruments();
|
||||
const instruments = getDatabaseInstruments();
|
||||
instruments.poolCheckoutsTotal.add(1);
|
||||
instruments.poolCheckinsTotal.add(1);
|
||||
instruments.poolSaturation.add(1);
|
||||
instruments.poolSaturation.add(-1);
|
||||
instruments.queryDurationMs.record(12, { outcome: "success" });
|
||||
instruments.queryDurationMs.record(48, { outcome: "error" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const names = all.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("db.pool.checkouts_total");
|
||||
expect(names).toContain("db.pool.checkins_total");
|
||||
expect(names).toContain("db.pool.saturation");
|
||||
expect(names).toContain("db.query.duration_ms");
|
||||
});
|
||||
|
||||
it("caches the bundle across calls", { timeout: 5000 }, () => {
|
||||
resetDatabaseInstruments();
|
||||
const a = getDatabaseInstruments();
|
||||
const b = getDatabaseInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
metrics,
|
||||
type UpDownCounter,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the database plugin.
|
||||
*
|
||||
* - `poolCheckoutsTotal` — incremented every time `pool.acquire()` resolves.
|
||||
* - `poolCheckinsTotal` — incremented every time `pool.release(adapter)` succeeds.
|
||||
* - `poolSaturation` — up-down-counter tracking in-use pool members.
|
||||
* - `queryDurationMs` — histogram of query wall-clock duration, labelled
|
||||
* by `{ outcome: "success" | "error" }`.
|
||||
*/
|
||||
export interface DatabaseInstruments {
|
||||
readonly poolCheckoutsTotal: Counter;
|
||||
readonly poolCheckinsTotal: Counter;
|
||||
readonly poolSaturation: UpDownCounter;
|
||||
readonly queryDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: DatabaseInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the database instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetDatabaseInstruments} in
|
||||
* test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getDatabaseInstruments(): DatabaseInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-database");
|
||||
cached = {
|
||||
poolCheckoutsTotal: meter.createCounter("db.pool.checkouts_total", {
|
||||
description: "Total successful pool acquire() resolutions",
|
||||
}),
|
||||
poolCheckinsTotal: meter.createCounter("db.pool.checkins_total", {
|
||||
description: "Total pool release(adapter) invocations",
|
||||
}),
|
||||
poolSaturation: meter.createUpDownCounter("db.pool.saturation", {
|
||||
description: "Current count of in-use pool members",
|
||||
}),
|
||||
queryDurationMs: meter.createHistogram("db.query.duration_ms", {
|
||||
description:
|
||||
"Wall-clock duration of a database query in milliseconds.",
|
||||
unit: "ms",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getDatabaseInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetDatabaseInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
SURREAL_CODES,
|
||||
type SurrealClientLike,
|
||||
SurrealError,
|
||||
} from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { PooledSurrealAdapter } from "../pool/extended-adapter.js";
|
||||
import type { AdapterFactory, DatabasePluginConfig } from "../types/types.js";
|
||||
import { createDatabasePlugin } from "./plugin.js";
|
||||
|
||||
function fakeClient(): SurrealClientLike {
|
||||
return {
|
||||
async connect() {},
|
||||
async use() {},
|
||||
async signin() {
|
||||
return "tok";
|
||||
},
|
||||
async signup() {
|
||||
return "tok";
|
||||
},
|
||||
async authenticate() {},
|
||||
async invalidate() {},
|
||||
async close() {},
|
||||
async query<T>() {
|
||||
return [] as T[];
|
||||
},
|
||||
async info() {
|
||||
return null;
|
||||
},
|
||||
async version() {
|
||||
return "3.0.0";
|
||||
},
|
||||
} as unknown as SurrealClientLike;
|
||||
}
|
||||
|
||||
function factory(): AdapterFactory {
|
||||
return () =>
|
||||
new PooledSurrealAdapter(
|
||||
{
|
||||
url: "ws://x/rpc",
|
||||
namespace: "ns",
|
||||
database: "db",
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
undefined,
|
||||
() => fakeClient()
|
||||
);
|
||||
}
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function buildContext<TConfig>(config: TConfig) {
|
||||
const services = new Map<string, unknown>();
|
||||
return createPluginContext<TConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createDatabasePlugin", () => {
|
||||
it("throws INVALID_CONFIG for bad options", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createDatabasePlugin({
|
||||
pool: { max: 0 },
|
||||
} as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect((err as DatabaseError).code).toBe(
|
||||
DATABASE_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("descriptor exposes the canonical name/version/hooks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
expect(plugin.name).toBe("database");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
expect(typeof plugin.onInit).toBe("function");
|
||||
expect(typeof plugin.onReady).toBe("function");
|
||||
expect(typeof plugin.onClose).toBe("function");
|
||||
expect(plugin.defaults?.url).toBe("ws://x/rpc");
|
||||
});
|
||||
|
||||
it("onInit constructs the pool and registers the db service", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
const ctx = buildContext<DatabasePluginConfig>(
|
||||
plugin.defaults as DatabasePluginConfig
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
const db = ctx.getService("db");
|
||||
expect(db).toBeDefined();
|
||||
expect(db).toBeInstanceOf(PooledSurrealAdapter);
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
|
||||
it("onClose drains the pool", { timeout: 10_000 }, async () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
const ctx = buildContext<DatabasePluginConfig>(
|
||||
plugin.defaults as DatabasePluginConfig
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onClose?.(ctx);
|
||||
const db = ctx.getService("db") as PooledSurrealAdapter | undefined;
|
||||
// The primary adapter handle is unregistered on close — but the
|
||||
// service map retains the reference; we assert that calling
|
||||
// getRawClient() after close throws.
|
||||
expect(db).toBeDefined();
|
||||
expect(() => db?.getRawClient()).toThrow(SurrealError);
|
||||
});
|
||||
|
||||
it("onReady is a no-op when migrations.autoRun is false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
const ctx = buildContext<DatabasePluginConfig>(
|
||||
plugin.defaults as DatabasePluginConfig
|
||||
);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
|
||||
it("onReady throws MIGRATION_FAILED if invoked before onInit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
migrations: { autoRun: true },
|
||||
});
|
||||
const ctx = buildContext<DatabasePluginConfig>(
|
||||
plugin.defaults as DatabasePluginConfig
|
||||
);
|
||||
try {
|
||||
await plugin.onReady?.(ctx);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect((err as DatabaseError).code).toBe(
|
||||
DATABASE_CODES.MIGRATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("onClose tolerates being called before onInit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
adapterFactory: factory(),
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
const ctx = buildContext<DatabasePluginConfig>(
|
||||
plugin.defaults as DatabasePluginConfig
|
||||
);
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
|
||||
it("default adapterFactory builds a real PooledSurrealAdapter when no override is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDatabasePlugin({
|
||||
url: "ws://x/rpc",
|
||||
migrations: { autoRun: false },
|
||||
});
|
||||
// We don't call onInit (which would try to connect over WS),
|
||||
// but constructing the descriptor proves the fallback factory
|
||||
// path executes.
|
||||
expect(plugin.defaults?.url).toBe("ws://x/rpc");
|
||||
});
|
||||
|
||||
it("SURREAL_CODES is reachable for tests that need the constant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SURREAL_CODES.NOT_CONNECTED).toBeDefined();
|
||||
});
|
||||
|
||||
it("createDatabasePlugin() accepts zero args and applies defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createDatabasePlugin();
|
||||
expect(plugin.name).toBe("database");
|
||||
expect(plugin.defaults?.url).toBe("ws://localhost:8000/rpc");
|
||||
});
|
||||
});
|
||||
130
packages/api-stack/api/api-plugin-database/src/plugin/plugin.ts
Normal file
130
packages/api-stack/api/api-plugin-database/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import {
|
||||
type DatabasePluginConfigParsed,
|
||||
DatabasePluginOptionsSchema,
|
||||
} from "../config/config-schema.js";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { runMigrations } from "../migrations/runner.js";
|
||||
import { PooledSurrealAdapter } from "../pool/extended-adapter.js";
|
||||
import { ConnectionPool } from "../pool/pool.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import type { AdapterFactory, DatabasePluginOptions } from "../types/types.js";
|
||||
|
||||
interface PluginRuntime {
|
||||
pool: ConnectionPool | undefined;
|
||||
primary: PooledSurrealAdapter | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plugin descriptor for `@suaveplan/api-plugin-database`.
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* 1. **Construction-time** Zod-validation of `options`. Invalid input
|
||||
* surfaces `DatabaseError(DATABASE.INVALID_CONFIG)` with the
|
||||
* `ZodError` as `cause`.
|
||||
* 2. **`onInit`** — build the `ConnectionPool` backed by
|
||||
* {@link PooledSurrealAdapter}, eagerly create `min` members,
|
||||
* register the primary adapter under `ServiceRegistry["db"]`.
|
||||
* 3. **`onReady`** — when `migrations.autoRun` is `true`, acquire the
|
||||
* advisory migration lock and run `.surql` files from
|
||||
* `migrations.directory`.
|
||||
* 4. **`onClose`** — drain the pool, dropping `ServiceRegistry["db"]`.
|
||||
*/
|
||||
export function createDatabasePlugin(
|
||||
options: DatabasePluginOptions = {}
|
||||
): PluginDescriptor<DatabasePluginConfigParsed> {
|
||||
const parsed = DatabasePluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.INVALID_CONFIG,
|
||||
"createDatabasePlugin received invalid options",
|
||||
{ cause: parsed.error, context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const config = parsed.data;
|
||||
const adapterFactory: AdapterFactory =
|
||||
options.adapterFactory ??
|
||||
((cfg) =>
|
||||
new PooledSurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
}));
|
||||
const runtime: PluginRuntime = { pool: undefined, primary: undefined };
|
||||
|
||||
return definePlugin<DatabasePluginConfigParsed>({
|
||||
name: "database",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"SurrealDB-backed database plugin: pooled DatabaseAdapter + advisory-locked migration runner + tenancy callables + RFC 7807 + full OTel surface.",
|
||||
defaults: config,
|
||||
tags: ["database", "surrealdb"],
|
||||
onInit: async (ctx) => runOnInit(ctx, config, runtime, adapterFactory),
|
||||
onReady: async (ctx) => runOnReady(ctx, config, runtime),
|
||||
onClose: async () => runOnClose(runtime),
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<DatabasePluginConfigParsed>,
|
||||
config: DatabasePluginConfigParsed,
|
||||
runtime: PluginRuntime,
|
||||
adapterFactory: AdapterFactory
|
||||
): Promise<void> {
|
||||
const pool = new ConnectionPool({
|
||||
connection: {
|
||||
url: config.url,
|
||||
namespace: config.namespace,
|
||||
database: config.database,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
},
|
||||
pool: config.pool,
|
||||
adapterFactory,
|
||||
});
|
||||
await pool.init();
|
||||
const primary = (await pool.acquire()) as PooledSurrealAdapter;
|
||||
runtime.pool = pool;
|
||||
runtime.primary = primary;
|
||||
ctx.registerService("db", primary);
|
||||
}
|
||||
|
||||
async function runOnReady(
|
||||
ctx: PluginContext<DatabasePluginConfigParsed>,
|
||||
config: DatabasePluginConfigParsed,
|
||||
runtime: PluginRuntime
|
||||
): Promise<void> {
|
||||
if (!config.migrations.autoRun) return;
|
||||
if (runtime.primary === undefined) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.MIGRATION_FAILED,
|
||||
"onReady invoked before onInit completed",
|
||||
{ context: { phase: "onReady" } }
|
||||
);
|
||||
}
|
||||
await runMigrations({
|
||||
adapter: runtime.primary,
|
||||
client: runtime.primary.getRawClient(),
|
||||
config: config.migrations,
|
||||
holder: `${process.pid}@${Date.now()}`,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnClose(runtime: PluginRuntime): Promise<void> {
|
||||
if (runtime.primary !== undefined && runtime.pool !== undefined) {
|
||||
runtime.pool.release(runtime.primary);
|
||||
}
|
||||
if (runtime.pool !== undefined) {
|
||||
await runtime.pool.close();
|
||||
}
|
||||
runtime.pool = undefined;
|
||||
runtime.primary = undefined;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
SurrealClientFactory,
|
||||
SurrealClientLike,
|
||||
} from "@suaveplan/surrealdb";
|
||||
import { SURREAL_CODES, SurrealError } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PooledSurrealAdapter } from "./extended-adapter.js";
|
||||
|
||||
function fakeClient(): SurrealClientLike {
|
||||
return {
|
||||
async connect() {},
|
||||
async use() {},
|
||||
async signin() {
|
||||
return "tok";
|
||||
},
|
||||
async signup() {
|
||||
return "tok";
|
||||
},
|
||||
async authenticate() {},
|
||||
async invalidate() {},
|
||||
async close() {},
|
||||
async query<T>(_sql: string, _vars?: Record<string, unknown>) {
|
||||
return [] as T[];
|
||||
},
|
||||
async info() {
|
||||
return null;
|
||||
},
|
||||
async version() {
|
||||
return "3.0.0";
|
||||
},
|
||||
} as unknown as SurrealClientLike;
|
||||
}
|
||||
|
||||
describe("PooledSurrealAdapter", () => {
|
||||
it("getRawClient throws NOT_CONNECTED before connect()", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = new PooledSurrealAdapter();
|
||||
try {
|
||||
adapter.getRawClient();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SurrealError);
|
||||
expect((err as SurrealError).code).toBe(
|
||||
SURREAL_CODES.NOT_CONNECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getRawClient returns the underlying client after connect()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = fakeClient();
|
||||
const factory: SurrealClientFactory = () => client;
|
||||
const adapter = new PooledSurrealAdapter(
|
||||
{
|
||||
url: "ws://x/rpc",
|
||||
namespace: "ns",
|
||||
database: "db",
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
undefined,
|
||||
factory
|
||||
);
|
||||
await adapter.connect();
|
||||
expect(adapter.getRawClient()).toBe(client);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
SURREAL_CODES,
|
||||
SurrealAdapter,
|
||||
type SurrealAdapterOptions,
|
||||
type SurrealClientFactory,
|
||||
type SurrealClientLike,
|
||||
SurrealError,
|
||||
} from "@suaveplan/surrealdb";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Subclass of {@link SurrealAdapter} that surfaces the underlying raw
|
||||
* `SurrealClientLike`. Required for the migration runner, which speaks
|
||||
* the `SurrealqlClient.query(sql, vars: Record)` shape that
|
||||
* `@suaveplan/api-migrations` consumes — the canonical
|
||||
* `DatabaseAdapter.query(sql, params: unknown[])` cannot carry named
|
||||
* placeholders.
|
||||
*
|
||||
* Behaviour is unchanged from the parent; only the protected `_client`
|
||||
* field becomes accessible through {@link getRawClient}.
|
||||
*/
|
||||
export class PooledSurrealAdapter extends SurrealAdapter {
|
||||
public constructor(
|
||||
options?: SurrealAdapterOptions,
|
||||
logger?: LoggerLike,
|
||||
clientFactory?: SurrealClientFactory
|
||||
) {
|
||||
super(options ?? {}, logger, clientFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the live underlying client. Throws
|
||||
* `SurrealError(NOT_CONNECTED)` when the adapter is not connected.
|
||||
*/
|
||||
public getRawClient(): SurrealClientLike {
|
||||
if (this._client === null || this._state !== "connected") {
|
||||
throw new SurrealError(
|
||||
SURREAL_CODES.NOT_CONNECTED,
|
||||
"PooledSurrealAdapter is not connected. Call connect() first."
|
||||
);
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
}
|
||||
95
packages/api-stack/api/api-plugin-database/src/pool/pool.md
Normal file
95
packages/api-stack/api/api-plugin-database/src/pool/pool.md
Normal 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.
|
||||
407
packages/api-stack/api/api-plugin-database/src/pool/pool.test.ts
Normal file
407
packages/api-stack/api/api-plugin-database/src/pool/pool.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { ConnectionPool } from "./pool.js";
|
||||
|
||||
interface FakeOpts {
|
||||
connectThrows?: boolean;
|
||||
disconnectThrows?: boolean;
|
||||
}
|
||||
|
||||
function makeFakeAdapter(opts: FakeOpts = {}): SurrealAdapter {
|
||||
return {
|
||||
async connect() {
|
||||
if (opts.connectThrows === true) throw new Error("conn failed");
|
||||
},
|
||||
async disconnect() {
|
||||
if (opts.disconnectThrows === true) throw new Error("disc failed");
|
||||
},
|
||||
async close() {},
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute() {
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
async transaction(fn: (tx: never) => Promise<unknown>) {
|
||||
return fn(undefined as never) as Promise<unknown>;
|
||||
},
|
||||
async migrate() {
|
||||
return { applied: 0, migrations: [] };
|
||||
},
|
||||
async create() {
|
||||
return {} as never;
|
||||
},
|
||||
async select() {
|
||||
return null as never;
|
||||
},
|
||||
async update() {
|
||||
return null as never;
|
||||
},
|
||||
async delete() {
|
||||
// noop
|
||||
},
|
||||
async selectAll() {
|
||||
return [] as never;
|
||||
},
|
||||
get isConnected() {
|
||||
return true;
|
||||
},
|
||||
} as unknown as SurrealAdapter;
|
||||
}
|
||||
|
||||
const baseConnection = {
|
||||
url: "ws://localhost:8000/rpc",
|
||||
namespace: "ns",
|
||||
database: "db",
|
||||
username: "u",
|
||||
password: "p",
|
||||
};
|
||||
|
||||
describe("ConnectionPool", () => {
|
||||
it("acquire returns an idle adapter", { timeout: 5000 }, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
expect(a).toBeDefined();
|
||||
expect(pool.size()).toEqual({
|
||||
total: 1,
|
||||
idle: 0,
|
||||
inUse: 1,
|
||||
waiting: 0,
|
||||
});
|
||||
pool.release(a);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("acquire lazily grows up to max", { timeout: 5000 }, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const b = await pool.acquire();
|
||||
const c = await pool.acquire();
|
||||
expect(pool.size().total).toBe(3);
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
pool.release(c);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("acquire rejects with POOL_EXHAUSTED past acquireTimeoutMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 10,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
await expect(pool.acquire()).rejects.toMatchObject({
|
||||
code: DATABASE_CODES.POOL_EXHAUSTED,
|
||||
});
|
||||
pool.release(a);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("release hands the adapter to a queued waiter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 500,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const pending = pool.acquire();
|
||||
setTimeout(() => pool.release(a), 10);
|
||||
const handed = await pending;
|
||||
expect(handed).toBe(a);
|
||||
pool.release(handed);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("close drains adapters and rejects pending waiters", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 1_000,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
await pool.acquire();
|
||||
const pending = pool.acquire();
|
||||
await pool.close();
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
code: DATABASE_CODES.POOL_CLOSED,
|
||||
});
|
||||
await expect(pool.acquire()).rejects.toMatchObject({
|
||||
code: DATABASE_CODES.POOL_CLOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("init wraps connect failures as CONNECTION_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter({ connectThrows: true }),
|
||||
});
|
||||
await expect(pool.init()).rejects.toBeInstanceOf(DatabaseError);
|
||||
await expect(pool.init()).rejects.toMatchObject({
|
||||
code: DATABASE_CODES.CONNECTION_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("release on an unknown adapter is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const foreign = makeFakeAdapter();
|
||||
pool.release(foreign);
|
||||
expect(pool.size()).toEqual({
|
||||
total: 1,
|
||||
idle: 1,
|
||||
inUse: 0,
|
||||
waiting: 0,
|
||||
});
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("idle reaper drains members above min after idleMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_000;
|
||||
const intervals: Array<() => void> = [];
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
idleMs: 50,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
clock: () => now,
|
||||
setIdleInterval: ((cb: () => void) => {
|
||||
intervals.push(cb);
|
||||
return 0 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof globalThis.setInterval,
|
||||
clearIdleInterval: (() =>
|
||||
undefined) as typeof globalThis.clearInterval,
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const b = await pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
expect(pool.size().total).toBe(2);
|
||||
now += 100; // past idleMs
|
||||
intervals.forEach((fn) => fn());
|
||||
expect(pool.size().total).toBe(1);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("idle reaper keeps inUse entries alive and respects min floor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_000;
|
||||
const intervals: Array<() => void> = [];
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
idleMs: 50,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
clock: () => now,
|
||||
setIdleInterval: ((cb: () => void) => {
|
||||
intervals.push(cb);
|
||||
return 0 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof globalThis.setInterval,
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const b = await pool.acquire();
|
||||
const c = await pool.acquire();
|
||||
pool.release(a);
|
||||
// b and c still inUse; only a is idle. After idleMs the
|
||||
// reaper drops the single idle entry, bringing total to min=2.
|
||||
now += 100;
|
||||
intervals.forEach((fn) => fn());
|
||||
expect(pool.size().total).toBe(2);
|
||||
expect(pool.size().inUse).toBe(2);
|
||||
pool.release(b);
|
||||
pool.release(c);
|
||||
now += 100;
|
||||
intervals.forEach((fn) => fn());
|
||||
// Both still alive — floor enforced at min=2.
|
||||
expect(pool.size().total).toBe(2);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("close before init is a no-op on the reaper handle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.close();
|
||||
expect(pool.size().total).toBe(0);
|
||||
});
|
||||
|
||||
it("idle reaper swallows disconnect errors from drained members", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_000;
|
||||
const intervals: Array<() => void> = [];
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
idleMs: 50,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter({ disconnectThrows: true }),
|
||||
clock: () => now,
|
||||
setIdleInterval: ((cb: () => void) => {
|
||||
intervals.push(cb);
|
||||
return 0 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof globalThis.setInterval,
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const b = await pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
now += 100;
|
||||
// Reaper fires; one entry is drained even though disconnect rejects.
|
||||
intervals.forEach((fn) => fn());
|
||||
// Give the swallowed async disconnect a tick to settle.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(pool.size().total).toBe(1);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("idle reaper preserves non-aged entries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_000;
|
||||
const intervals: Array<() => void> = [];
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
idleMs: 10_000,
|
||||
acquireTimeoutMs: 100,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
clock: () => now,
|
||||
setIdleInterval: ((cb: () => void) => {
|
||||
intervals.push(cb);
|
||||
return 0 as unknown as ReturnType<typeof setInterval>;
|
||||
}) as typeof globalThis.setInterval,
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
const b = await pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
now += 50;
|
||||
intervals.forEach((fn) => fn());
|
||||
expect(pool.size().total).toBe(2);
|
||||
await pool.close();
|
||||
});
|
||||
|
||||
it("waiter that times out before release is removed from the queue", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new ConnectionPool({
|
||||
connection: baseConnection,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
idleMs: 1_000,
|
||||
acquireTimeoutMs: 10,
|
||||
},
|
||||
adapterFactory: () => makeFakeAdapter(),
|
||||
});
|
||||
await pool.init();
|
||||
const a = await pool.acquire();
|
||||
await expect(pool.acquire()).rejects.toMatchObject({
|
||||
code: DATABASE_CODES.POOL_EXHAUSTED,
|
||||
});
|
||||
// After the rejection, releasing should NOT find a waiter to wake.
|
||||
pool.release(a);
|
||||
expect(pool.size()).toEqual({
|
||||
total: 1,
|
||||
idle: 1,
|
||||
inUse: 0,
|
||||
waiting: 0,
|
||||
});
|
||||
await pool.close();
|
||||
});
|
||||
});
|
||||
255
packages/api-stack/api/api-plugin-database/src/pool/pool.ts
Normal file
255
packages/api-stack/api/api-plugin-database/src/pool/pool.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { getDatabaseInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
AdapterFactory,
|
||||
AdapterFactoryInput,
|
||||
PoolConfig,
|
||||
} from "../types/types.js";
|
||||
|
||||
interface PooledEntry {
|
||||
readonly adapter: SurrealAdapter;
|
||||
inUse: boolean;
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve(entry: PooledEntry): void;
|
||||
reject(error: Error): void;
|
||||
timer: ReturnType<typeof globalThis.setTimeout>;
|
||||
}
|
||||
|
||||
/** Construction options for {@link ConnectionPool}. */
|
||||
export interface ConnectionPoolOptions {
|
||||
/** Connection target for new pool members. */
|
||||
readonly connection: AdapterFactoryInput;
|
||||
/** Pool sizing / timeout knobs. */
|
||||
readonly pool: PoolConfig;
|
||||
/** Required factory — pool only depends on the adapter constructor. */
|
||||
readonly adapterFactory: AdapterFactory;
|
||||
/** Optional wall-clock injection for the idle-reaper. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Injectable timer set — defaults to `globalThis.setTimeout`. */
|
||||
readonly setTimer?: typeof globalThis.setTimeout;
|
||||
/** Injectable timer clear — defaults to `globalThis.clearTimeout`. */
|
||||
readonly clearTimer?: typeof globalThis.clearTimeout;
|
||||
/** Injectable interval set — defaults to `globalThis.setInterval`. */
|
||||
readonly setIdleInterval?: typeof globalThis.setInterval;
|
||||
/** Injectable interval clear — defaults to `globalThis.clearInterval`. */
|
||||
readonly clearIdleInterval?: typeof globalThis.clearInterval;
|
||||
}
|
||||
|
||||
/** Snapshot returned by {@link ConnectionPool.size}. */
|
||||
export interface PoolSize {
|
||||
readonly total: number;
|
||||
readonly idle: number;
|
||||
readonly inUse: number;
|
||||
readonly waiting: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded pool of {@link SurrealAdapter} instances.
|
||||
*
|
||||
* Members are created lazily up to `pool.max`; idle members are reaped past
|
||||
* `pool.idleMs` down to `pool.min`. `acquire()` returns an idle adapter,
|
||||
* lazily grows to `max`, or queues a waiter when at capacity. `release()`
|
||||
* hands the adapter to the next waiter or returns it to the idle set.
|
||||
*/
|
||||
export class ConnectionPool {
|
||||
private readonly options: ConnectionPoolOptions;
|
||||
private readonly entries: PooledEntry[] = [];
|
||||
private readonly waiters: Waiter[] = [];
|
||||
private reaperHandle: ReturnType<typeof globalThis.setInterval> | undefined;
|
||||
private closed = false;
|
||||
|
||||
public constructor(options: ConnectionPoolOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/** Eagerly create `pool.min` members and start the idle reaper. */
|
||||
public async init(): Promise<void> {
|
||||
for (let i = 0; i < this.options.pool.min; i += 1) {
|
||||
const entry = await this.createEntry();
|
||||
this.entries.push(entry);
|
||||
}
|
||||
const setInterval =
|
||||
this.options.setIdleInterval ?? globalThis.setInterval;
|
||||
this.reaperHandle = setInterval(
|
||||
() => this.reapIdle(),
|
||||
this.options.pool.idleMs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an idle adapter or queue a waiter. Rejects with
|
||||
* `DatabaseError(POOL_CLOSED)` when the pool is closed, or
|
||||
* `DatabaseError(POOL_EXHAUSTED)` when the wait exceeds
|
||||
* `pool.acquireTimeoutMs`.
|
||||
*/
|
||||
public async acquire(): Promise<SurrealAdapter> {
|
||||
if (this.closed) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.POOL_CLOSED,
|
||||
"ConnectionPool is closed"
|
||||
);
|
||||
}
|
||||
|
||||
const idle = this.entries.find((e) => !e.inUse);
|
||||
if (idle !== undefined) {
|
||||
this.checkout(idle);
|
||||
return idle.adapter;
|
||||
}
|
||||
|
||||
if (this.entries.length < this.options.pool.max) {
|
||||
const entry = await this.createEntry();
|
||||
this.entries.push(entry);
|
||||
this.checkout(entry);
|
||||
return entry.adapter;
|
||||
}
|
||||
|
||||
return this.queueWaiter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release an adapter back to the pool. If a waiter is queued, the
|
||||
* adapter is handed directly to it. Unknown adapters are ignored.
|
||||
*/
|
||||
public release(adapter: SurrealAdapter): void {
|
||||
const entry = this.entries.find((e) => e.adapter === adapter);
|
||||
if (entry === undefined) return;
|
||||
entry.inUse = false;
|
||||
entry.lastUsedAt = this.now();
|
||||
const instruments = getDatabaseInstruments();
|
||||
instruments.poolCheckinsTotal.add(1);
|
||||
instruments.poolSaturation.add(-1);
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter !== undefined) {
|
||||
const clearTimer =
|
||||
this.options.clearTimer ?? globalThis.clearTimeout;
|
||||
clearTimer(waiter.timer);
|
||||
this.checkout(entry);
|
||||
waiter.resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the pool, drain every adapter, and reject every pending waiter. */
|
||||
public async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
if (this.reaperHandle !== undefined) {
|
||||
const clearInterval =
|
||||
this.options.clearIdleInterval ?? globalThis.clearInterval;
|
||||
clearInterval(this.reaperHandle);
|
||||
this.reaperHandle = undefined;
|
||||
}
|
||||
const clearTimer = this.options.clearTimer ?? globalThis.clearTimeout;
|
||||
for (const waiter of this.waiters) {
|
||||
clearTimer(waiter.timer);
|
||||
waiter.reject(
|
||||
new DatabaseError(
|
||||
DATABASE_CODES.POOL_CLOSED,
|
||||
"ConnectionPool was closed while waiting for a connection"
|
||||
)
|
||||
);
|
||||
}
|
||||
this.waiters.length = 0;
|
||||
const drains = this.entries.map(async (e) => {
|
||||
try {
|
||||
await e.adapter.disconnect();
|
||||
} catch {
|
||||
// adapter close failures are non-fatal at pool teardown.
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(drains);
|
||||
this.entries.length = 0;
|
||||
}
|
||||
|
||||
/** Return a snapshot of pool sizing for observability and tests. */
|
||||
public size(): PoolSize {
|
||||
const inUse = this.entries.filter((e) => e.inUse).length;
|
||||
const idle = this.entries.length - inUse;
|
||||
return {
|
||||
total: this.entries.length,
|
||||
idle,
|
||||
inUse,
|
||||
waiting: this.waiters.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── internal ────────────────────────────────────────────────────────
|
||||
|
||||
private checkout(entry: PooledEntry): void {
|
||||
entry.inUse = true;
|
||||
entry.lastUsedAt = this.now();
|
||||
const instruments = getDatabaseInstruments();
|
||||
instruments.poolCheckoutsTotal.add(1);
|
||||
instruments.poolSaturation.add(1);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return (this.options.clock ?? Date.now)();
|
||||
}
|
||||
|
||||
private async createEntry(): Promise<PooledEntry> {
|
||||
const adapter = this.options.adapterFactory(this.options.connection);
|
||||
try {
|
||||
await adapter.connect();
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.CONNECTION_FAILED,
|
||||
"Failed to construct pool member",
|
||||
{
|
||||
cause: cause as Error,
|
||||
context: { url: this.options.connection.url },
|
||||
}
|
||||
);
|
||||
}
|
||||
return { adapter, inUse: false, lastUsedAt: this.now() };
|
||||
}
|
||||
|
||||
private queueWaiter(): Promise<SurrealAdapter> {
|
||||
return new Promise<SurrealAdapter>((resolve, reject) => {
|
||||
const setTimer = this.options.setTimer ?? globalThis.setTimeout;
|
||||
const timer = setTimer(() => {
|
||||
// The waiter is always still queued when the timer fires —
|
||||
// `release` clears the timer before shifting the waiter out,
|
||||
// so this `indexOf` is always non-negative.
|
||||
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||
reject(
|
||||
new DatabaseError(
|
||||
DATABASE_CODES.POOL_EXHAUSTED,
|
||||
`Failed to acquire a connection within ${this.options.pool.acquireTimeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, this.options.pool.acquireTimeoutMs);
|
||||
const waiter: Waiter = {
|
||||
resolve: (entry) => resolve(entry.adapter),
|
||||
reject,
|
||||
timer,
|
||||
};
|
||||
this.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
private async safeDisconnect(entry: PooledEntry): Promise<void> {
|
||||
try {
|
||||
await entry.adapter.disconnect();
|
||||
} catch {
|
||||
// reaper-side disconnect errors are non-fatal — the entry is
|
||||
// already removed from the pool, so the next acquire builds
|
||||
// a fresh member.
|
||||
}
|
||||
}
|
||||
|
||||
private reapIdle(): void {
|
||||
const now = this.now();
|
||||
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = this.entries[i]!;
|
||||
if (entry.inUse) continue;
|
||||
if (this.entries.length <= this.options.pool.min) break;
|
||||
if (now - entry.lastUsedAt < this.options.pool.idleMs) continue;
|
||||
this.entries.splice(i, 1);
|
||||
void this.safeDisconnect(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { withRetry } from "./retry.js";
|
||||
|
||||
const immediate = (cb: () => void): void => {
|
||||
cb();
|
||||
};
|
||||
|
||||
describe("withRetry", () => {
|
||||
it("first-attempt success returns the value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await withRetry(async () => 42, {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
schedule: immediate,
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("retries on transient failure", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
schedule: immediate,
|
||||
}
|
||||
);
|
||||
expect(result).toBe("ok");
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("throws after maxAttempts exhausted", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
await expect(
|
||||
withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error(`fail-${calls}`);
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
schedule: immediate,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("fail-3");
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("isRetryable false bails out early", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
await expect(
|
||||
withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error("permanent");
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
isRetryable: () => false,
|
||||
schedule: immediate,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("permanent");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("jitter mode multiplies by injected RNG", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const recorded: number[] = [];
|
||||
const schedule = (cb: () => void, ms: number): void => {
|
||||
recorded.push(ms);
|
||||
cb();
|
||||
};
|
||||
let calls = 0;
|
||||
await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseBackoffMs: 100,
|
||||
maxBackoffMs: 10_000,
|
||||
jitter: true,
|
||||
rng: () => 0.5,
|
||||
schedule,
|
||||
}
|
||||
);
|
||||
// Attempt 1 backoff = 0.5 * 100 = 50; Attempt 2 = 0.5 * 200 = 100.
|
||||
expect(recorded).toEqual([50, 100]);
|
||||
});
|
||||
|
||||
it("respects an AbortSignal that fires between attempts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const schedule = (cb: () => void): void => {
|
||||
controller.abort();
|
||||
cb();
|
||||
};
|
||||
await expect(
|
||||
withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error("transient");
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(/aborted/);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects immediately when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
withRetry(async () => "ok", {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule: immediate,
|
||||
})
|
||||
).rejects.toThrow(/aborted/);
|
||||
});
|
||||
|
||||
it("propagates signal.reason when it is an Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("custom-abort-reason");
|
||||
controller.abort(reason);
|
||||
await expect(
|
||||
withRetry(async () => "ok", {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule: immediate,
|
||||
})
|
||||
).rejects.toBe(reason);
|
||||
});
|
||||
|
||||
it("no jitter uses the raw exp interval", { timeout: 5000 }, async () => {
|
||||
const recorded: number[] = [];
|
||||
const schedule = (cb: () => void, ms: number): void => {
|
||||
recorded.push(ms);
|
||||
cb();
|
||||
};
|
||||
let calls = 0;
|
||||
await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseBackoffMs: 100,
|
||||
maxBackoffMs: 10_000,
|
||||
jitter: false,
|
||||
schedule,
|
||||
}
|
||||
);
|
||||
expect(recorded).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it("default rng path executes when rng is not supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const recorded: number[] = [];
|
||||
const schedule = (cb: () => void, ms: number): void => {
|
||||
recorded.push(ms);
|
||||
cb();
|
||||
};
|
||||
await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 2) throw new Error("transient");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 50,
|
||||
maxBackoffMs: 10_000,
|
||||
jitter: true,
|
||||
schedule,
|
||||
}
|
||||
);
|
||||
expect(recorded.length).toBe(1);
|
||||
expect(recorded[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(recorded[0]).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("abort during sleep with non-Error reason throws generic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const schedule = (cb: () => void): void => {
|
||||
controller.abort("non-error-reason");
|
||||
// never call cb so the sleep is interrupted only by abort
|
||||
void cb;
|
||||
};
|
||||
await expect(
|
||||
withRetry(
|
||||
async () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("withRetry aborted");
|
||||
});
|
||||
|
||||
it("uses globalThis.setTimeout when no schedule is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 2) throw new Error("transient");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
}
|
||||
);
|
||||
expect(result).toBe("ok");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("default top-of-loop abort emits 'withRetry aborted' for non-Error reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("non-error");
|
||||
await expect(
|
||||
withRetry(async () => "ok", {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule: immediate,
|
||||
})
|
||||
).rejects.toThrow("withRetry aborted");
|
||||
});
|
||||
|
||||
it("sleep short-circuits when signal aborts between attempts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const fn = async (): Promise<string> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
controller.abort(new Error("between attempts"));
|
||||
throw new Error("transient");
|
||||
}
|
||||
return "ok";
|
||||
};
|
||||
await expect(
|
||||
withRetry(fn, {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule: immediate,
|
||||
})
|
||||
).rejects.toThrow("between attempts");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("sleep short-circuit handles non-Error abort reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const fn = async (): Promise<string> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
controller.abort("non-error");
|
||||
throw new Error("transient");
|
||||
}
|
||||
return "ok";
|
||||
};
|
||||
await expect(
|
||||
withRetry(fn, {
|
||||
maxAttempts: 3,
|
||||
baseBackoffMs: 1,
|
||||
maxBackoffMs: 10,
|
||||
jitter: false,
|
||||
signal: controller.signal,
|
||||
schedule: immediate,
|
||||
})
|
||||
).rejects.toThrow("withRetry aborted");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
103
packages/api-stack/api/api-plugin-database/src/retry/retry.ts
Normal file
103
packages/api-stack/api/api-plugin-database/src/retry/retry.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Construction options for {@link withRetry}.
|
||||
*/
|
||||
export interface RetryOptions {
|
||||
/** Maximum total attempts including the first try. Must be >= 1. */
|
||||
readonly maxAttempts: number;
|
||||
/** Base backoff in ms (also the floor when attempt=1). */
|
||||
readonly baseBackoffMs: number;
|
||||
/** Hard ceiling on a single backoff interval. */
|
||||
readonly maxBackoffMs: number;
|
||||
/** When true, the delay is `random() * exp` (full jitter). */
|
||||
readonly jitter: boolean;
|
||||
/**
|
||||
* Optional predicate. If supplied and returns `false`, `withRetry`
|
||||
* stops retrying and rethrows the current error.
|
||||
*/
|
||||
readonly isRetryable?: (error: unknown, attempt: number) => boolean;
|
||||
/** Optional RNG callable for deterministic jitter in tests. Defaults to `Math.random`. */
|
||||
readonly rng?: () => number;
|
||||
/** Optional abort signal — cancels pending sleeps and stops further retries. */
|
||||
readonly signal?: AbortSignal;
|
||||
/**
|
||||
* Injectable timer setter for tests. Defaults to `globalThis.setTimeout`.
|
||||
*/
|
||||
readonly schedule?: (cb: () => void, ms: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with exponential-backoff retry. Returns the resolved value on
|
||||
* the first attempt that succeeds. Rethrows the final error if every
|
||||
* attempt fails, or the current error if `isRetryable` returns false.
|
||||
*
|
||||
* Backoff for attempt `n` (1-indexed) is
|
||||
* `min(baseBackoffMs * 2^(n-1), maxBackoffMs)`, optionally multiplied by
|
||||
* a `[0, 1)` random factor when `jitter` is true.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: (attempt: number) => Promise<T>,
|
||||
options: RetryOptions
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts,
|
||||
baseBackoffMs,
|
||||
maxBackoffMs,
|
||||
jitter,
|
||||
isRetryable,
|
||||
rng,
|
||||
signal,
|
||||
schedule,
|
||||
} = options;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (signal?.aborted === true) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("withRetry aborted");
|
||||
}
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt === maxAttempts) break;
|
||||
if (isRetryable !== undefined && !isRetryable(err, attempt)) break;
|
||||
const exp = Math.min(
|
||||
maxBackoffMs,
|
||||
baseBackoffMs * 2 ** (attempt - 1)
|
||||
);
|
||||
const random = (rng ?? Math.random)();
|
||||
const delay = jitter ? random * exp : exp;
|
||||
await sleep(delay, schedule, signal);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function sleep(
|
||||
ms: number,
|
||||
schedule: ((cb: () => void, ms: number) => void) | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
reject(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("withRetry aborted")
|
||||
);
|
||||
};
|
||||
if (signal?.aborted === true) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
const tick = (): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const setter = schedule ?? globalThis.setTimeout;
|
||||
setter(tick, ms);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,117 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
formatProblemJson,
|
||||
PROBLEM_JSON_CONTENT_TYPE,
|
||||
respondProblemJson,
|
||||
} from "./problem-json.js";
|
||||
|
||||
class OtherError extends SuaveplanError<"OTHER.BOOM"> {
|
||||
public constructor(message: string) {
|
||||
super("OTHER.BOOM", message);
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeResponse {
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
status: number;
|
||||
}
|
||||
|
||||
function fakeRes(): {
|
||||
res: {
|
||||
header(n: string, v: string): void;
|
||||
body(d: string, s: number): Response;
|
||||
};
|
||||
out: FakeResponse;
|
||||
} {
|
||||
const out: FakeResponse = { headers: {}, body: null, status: 0 };
|
||||
return {
|
||||
res: {
|
||||
header(n, v) {
|
||||
out.headers[n] = v;
|
||||
},
|
||||
body(d, s) {
|
||||
out.body = d;
|
||||
out.status = s;
|
||||
return new Response(d, { status: s });
|
||||
},
|
||||
},
|
||||
out,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatProblemJson", () => {
|
||||
it("DatabaseError renders as problem+json with the registered status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DatabaseError(DATABASE_CODES.POOL_EXHAUSTED, "no slot");
|
||||
const body = formatProblemJson(err);
|
||||
expect(body.status).toBe(503);
|
||||
expect(body.title).toBe("DATABASE.POOL_EXHAUSTED");
|
||||
expect(body.code).toBe("DATABASE.POOL_EXHAUSTED");
|
||||
expect(body.detail).toBe("no slot");
|
||||
expect(body.type).toBe("urn:suaveplan:error:DATABASE.POOL_EXHAUSTED");
|
||||
});
|
||||
|
||||
it("maps every DATABASE_* code to a sensible status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expectations: Array<
|
||||
[(typeof DATABASE_CODES)[keyof typeof DATABASE_CODES], number]
|
||||
> = [
|
||||
[DATABASE_CODES.POOL_EXHAUSTED, 503],
|
||||
[DATABASE_CODES.POOL_CLOSED, 503],
|
||||
[DATABASE_CODES.CONNECTION_FAILED, 503],
|
||||
[DATABASE_CODES.MIGRATION_FAILED, 500],
|
||||
[DATABASE_CODES.QUERY_FAILED, 500],
|
||||
[DATABASE_CODES.LOCK_CONTENDED, 409],
|
||||
[DATABASE_CODES.TENANT_INVALID, 400],
|
||||
[DATABASE_CODES.INVALID_CONFIG, 500],
|
||||
];
|
||||
for (const [code, status] of expectations) {
|
||||
const body = formatProblemJson(new DatabaseError(code, "msg"));
|
||||
expect(body.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it("other SuaveplanError subclasses fall through to 500 with code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const body = formatProblemJson(new OtherError("nope"));
|
||||
expect(body.status).toBe(500);
|
||||
expect(body.code).toBe("OTHER.BOOM");
|
||||
expect(body.title).toBe("OTHER.BOOM");
|
||||
});
|
||||
|
||||
it("unknown throwables fall through to a 500 problem+json", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const body = formatProblemJson(new Error("raw"));
|
||||
expect(body.status).toBe(500);
|
||||
expect(body.title).toBe("Internal Server Error");
|
||||
expect(body.detail).toBe("An unexpected error occurred");
|
||||
expect(body.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-Error throwables also map to 500", { timeout: 5000 }, () => {
|
||||
const body = formatProblemJson("literal");
|
||||
expect(body.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("respondProblemJson", () => {
|
||||
it("writes the content-type header and returns a Response", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { res, out } = fakeRes();
|
||||
const err = new DatabaseError(DATABASE_CODES.TENANT_INVALID, "bad id");
|
||||
const response = respondProblemJson(res, err);
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(out.headers["content-type"]).toBe(PROBLEM_JSON_CONTENT_TYPE);
|
||||
expect(out.status).toBe(400);
|
||||
const parsed = JSON.parse(out.body ?? "{}");
|
||||
expect(parsed.code).toBe(DATABASE_CODES.TENANT_INVALID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { DatabaseError } from "../error-codes/error-codes.js";
|
||||
|
||||
/** Standard RFC 7807 content type. */
|
||||
export const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json";
|
||||
|
||||
/**
|
||||
* RFC 7807 problem-details body. Genesis's `@suaveplan/error` does not
|
||||
* yet ship a canonical `ProblemDetails` type, so this module defines a
|
||||
* local one. The shape matches the IETF specification.
|
||||
*/
|
||||
export interface ProblemDetails {
|
||||
readonly type: string;
|
||||
readonly title: string;
|
||||
readonly status: number;
|
||||
readonly detail?: string;
|
||||
readonly instance?: string;
|
||||
readonly code?: string;
|
||||
readonly cause?: unknown;
|
||||
readonly [extension: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural HTTP response surface — compatible with Hono,
|
||||
* Fastify, and bun's stdlib responses.
|
||||
*/
|
||||
export interface HttpResponseLike {
|
||||
header(name: string, value: string): void;
|
||||
body(data: string, status: number): Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an arbitrary throwable as RFC 7807 problem+json on the supplied
|
||||
* response. Returns the framework's `Response` so handlers can `return`
|
||||
* the result.
|
||||
*
|
||||
* Status mapping:
|
||||
* - `DatabaseError(POOL_EXHAUSTED)` → 503
|
||||
* - `DatabaseError(POOL_CLOSED)` → 503
|
||||
* - `DatabaseError(CONNECTION_FAILED)` → 503
|
||||
* - `DatabaseError(MIGRATION_FAILED)` → 500
|
||||
* - `DatabaseError(QUERY_FAILED)` → 500
|
||||
* - `DatabaseError(LOCK_CONTENDED)` → 409
|
||||
* - `DatabaseError(TENANT_INVALID)` → 400
|
||||
* - `DatabaseError(INVALID_CONFIG)` → 500
|
||||
* - any other `SuaveplanError` → 500
|
||||
* - non-`SuaveplanError` throwables → 500 with a redacted title
|
||||
*/
|
||||
export function respondProblemJson(
|
||||
res: HttpResponseLike,
|
||||
err: unknown
|
||||
): Response {
|
||||
const body = formatProblemJson(err);
|
||||
res.header("content-type", PROBLEM_JSON_CONTENT_TYPE);
|
||||
return res.body(JSON.stringify(body), body.status);
|
||||
}
|
||||
|
||||
/** Pure-function variant — returns the body without writing to a response. */
|
||||
export function formatProblemJson(err: unknown): ProblemDetails {
|
||||
if (err instanceof DatabaseError) {
|
||||
return databaseErrorBody(err);
|
||||
}
|
||||
if (err instanceof SuaveplanError) {
|
||||
return {
|
||||
type: `urn:suaveplan:error:${err.code}`,
|
||||
title: err.code,
|
||||
status: 500,
|
||||
detail: err.message,
|
||||
code: err.code,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "about:blank",
|
||||
title: "Internal Server Error",
|
||||
status: 500,
|
||||
detail: "An unexpected error occurred",
|
||||
};
|
||||
}
|
||||
|
||||
function databaseErrorBody(err: DatabaseError): ProblemDetails {
|
||||
// The STATUS_BY_CODE table covers every code registered in
|
||||
// DATABASE_CODES; missing entries would be a registry-wiring bug
|
||||
// caught by the spec-conformance test, not a runtime concern.
|
||||
const status = STATUS_BY_CODE[err.code] as number;
|
||||
return {
|
||||
type: `urn:suaveplan:error:${err.code}`,
|
||||
title: err.code,
|
||||
status,
|
||||
detail: err.message,
|
||||
code: err.code,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_BY_CODE: Record<string, number> = {
|
||||
"DATABASE.POOL_EXHAUSTED": 503,
|
||||
"DATABASE.POOL_CLOSED": 503,
|
||||
"DATABASE.CONNECTION_FAILED": 503,
|
||||
"DATABASE.MIGRATION_FAILED": 500,
|
||||
"DATABASE.QUERY_FAILED": 500,
|
||||
"DATABASE.LOCK_CONTENDED": 409,
|
||||
"DATABASE.TENANT_INVALID": 400,
|
||||
"DATABASE.INVALID_CONFIG": 500,
|
||||
};
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
|
||||
/**
|
||||
* Module augmentation — registers the `db` slot on the shared
|
||||
* `ServiceRegistry` interface so `ctx.getService("db")` types as
|
||||
* `DatabaseAdapter | undefined` for every downstream plugin.
|
||||
*
|
||||
* This file is intentionally side-effect-free at runtime (the `declare
|
||||
* module` block produces no emitted code), so importing it has no cost
|
||||
* beyond pulling in the type augmentation.
|
||||
*/
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
db: DatabaseAdapter;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import { decommissionTenant, provisionTenant } from "./tenancy.js";
|
||||
|
||||
interface Recorded {
|
||||
readonly executes: string[];
|
||||
}
|
||||
|
||||
function makeAdapter(opts: { fail?: boolean } = {}): {
|
||||
adapter: DatabaseAdapter;
|
||||
recorded: Recorded;
|
||||
} {
|
||||
const recorded: Recorded = { executes: [] };
|
||||
const adapter = {
|
||||
async connect() {},
|
||||
async disconnect() {},
|
||||
async close() {},
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute(sql: string) {
|
||||
recorded.executes.push(sql);
|
||||
if (opts.fail === true) throw new Error("boom");
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
async transaction<T>(fn: (tx: never) => Promise<T>) {
|
||||
return fn(undefined as never);
|
||||
},
|
||||
async migrate() {
|
||||
return { applied: 0, migrations: [] };
|
||||
},
|
||||
get isConnected() {
|
||||
return true;
|
||||
},
|
||||
} as unknown as DatabaseAdapter;
|
||||
return { adapter, recorded };
|
||||
}
|
||||
|
||||
describe("provisionTenant", () => {
|
||||
it("shared strategy adds the tenant_id discriminator", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await provisionTenant(adapter, { strategy: "shared" }, "acme");
|
||||
expect(recorded.executes[0]).toContain("DEFINE FIELD");
|
||||
expect(recorded.executes[0]).toContain("tenant_id");
|
||||
});
|
||||
|
||||
it("namespace-per-tenant strategy issues DEFINE NAMESPACE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await provisionTenant(
|
||||
adapter,
|
||||
{ strategy: "namespace-per-tenant" },
|
||||
"acme"
|
||||
);
|
||||
expect(recorded.executes[0]).toContain("DEFINE NAMESPACE");
|
||||
expect(recorded.executes[0]).toContain("tenant_acme");
|
||||
});
|
||||
|
||||
it("database-per-tenant strategy issues DEFINE DATABASE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await provisionTenant(
|
||||
adapter,
|
||||
{ strategy: "database-per-tenant" },
|
||||
"acme"
|
||||
);
|
||||
expect(recorded.executes[0]).toContain("DEFINE DATABASE");
|
||||
expect(recorded.executes[0]).toContain("tenant_acme");
|
||||
});
|
||||
|
||||
it("invalid tenant id throws TENANT_INVALID", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
await expect(
|
||||
provisionTenant(adapter, { strategy: "shared" }, "bad id!")
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.TENANT_INVALID });
|
||||
});
|
||||
|
||||
it("wraps adapter failures as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter({ fail: true });
|
||||
try {
|
||||
await provisionTenant(
|
||||
adapter,
|
||||
{ strategy: "namespace-per-tenant" },
|
||||
"acme"
|
||||
);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DatabaseError);
|
||||
expect((err as DatabaseError).code).toBe(
|
||||
DATABASE_CODES.QUERY_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty tenant id", { timeout: 5000 }, async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
await expect(
|
||||
provisionTenant(adapter, { strategy: "shared" }, "")
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.TENANT_INVALID });
|
||||
});
|
||||
|
||||
it("rejects tenant id longer than 64 chars", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
await expect(
|
||||
provisionTenant(adapter, { strategy: "shared" }, "a".repeat(65))
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.TENANT_INVALID });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decommissionTenant", () => {
|
||||
it("shared strategy issues a bounded DELETE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await decommissionTenant(adapter, { strategy: "shared" }, "acme");
|
||||
expect(recorded.executes[0]).toContain("DELETE");
|
||||
expect(recorded.executes[0]).toContain("tenant_id");
|
||||
expect(recorded.executes[0]).toContain("acme");
|
||||
});
|
||||
|
||||
it("namespace strategy issues REMOVE NAMESPACE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await decommissionTenant(
|
||||
adapter,
|
||||
{ strategy: "namespace-per-tenant" },
|
||||
"acme"
|
||||
);
|
||||
expect(recorded.executes[0]).toContain("REMOVE NAMESPACE");
|
||||
});
|
||||
|
||||
it("database strategy issues REMOVE DATABASE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter, recorded } = makeAdapter();
|
||||
await decommissionTenant(
|
||||
adapter,
|
||||
{ strategy: "database-per-tenant" },
|
||||
"acme"
|
||||
);
|
||||
expect(recorded.executes[0]).toContain("REMOVE DATABASE");
|
||||
});
|
||||
|
||||
it("invalid tenant id throws TENANT_INVALID", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
await expect(
|
||||
decommissionTenant(adapter, { strategy: "shared" }, "!!!")
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.TENANT_INVALID });
|
||||
});
|
||||
|
||||
it("wraps adapter failures as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { adapter } = makeAdapter({ fail: true });
|
||||
await expect(
|
||||
decommissionTenant(
|
||||
adapter,
|
||||
{ strategy: "database-per-tenant" },
|
||||
"acme"
|
||||
)
|
||||
).rejects.toMatchObject({ code: DATABASE_CODES.QUERY_FAILED });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import { DATABASE_CODES, DatabaseError } from "../error-codes/error-codes.js";
|
||||
import type { TenancyConfig } from "../types/types.js";
|
||||
|
||||
const TENANT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
function assertValidTenantId(tenantId: string): void {
|
||||
if (!TENANT_ID_PATTERN.test(tenantId)) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.TENANT_INVALID,
|
||||
`Invalid tenant id: ${tenantId}`,
|
||||
{ context: { tenantId } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision the database-side resources for a new tenant.
|
||||
*
|
||||
* - `shared` — adds a `tenant_id` discriminator field on every table so
|
||||
* downstream queries can filter by tenant.
|
||||
* - `namespace-per-tenant` — defines a SurrealDB namespace named
|
||||
* `tenant_<id>` and switches no global state.
|
||||
* - `database-per-tenant` — defines a SurrealDB database named
|
||||
* `tenant_<id>` inside the current namespace.
|
||||
*
|
||||
* @throws DatabaseError(DATABASE.TENANT_INVALID) when `tenantId` violates
|
||||
* `/^[A-Za-z0-9_-]{1,64}$/`.
|
||||
* @throws DatabaseError(DATABASE.QUERY_FAILED) when the underlying
|
||||
* adapter rejects the provisioning query.
|
||||
*/
|
||||
export async function provisionTenant(
|
||||
adapter: DatabaseAdapter,
|
||||
config: TenancyConfig,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
assertValidTenantId(tenantId);
|
||||
try {
|
||||
if (config.strategy === "shared") {
|
||||
await adapter.execute(
|
||||
"DEFINE FIELD IF NOT EXISTS tenant_id ON TABLE ANY TYPE string;"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (config.strategy === "namespace-per-tenant") {
|
||||
await adapter.execute(
|
||||
`DEFINE NAMESPACE IF NOT EXISTS tenant_${tenantId};`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// database-per-tenant
|
||||
await adapter.execute(
|
||||
`DEFINE DATABASE IF NOT EXISTS tenant_${tenantId};`
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.QUERY_FAILED,
|
||||
`Failed to provision tenant ${tenantId} (${config.strategy})`,
|
||||
{
|
||||
cause: cause as Error,
|
||||
context: { tenantId, strategy: config.strategy },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the database-side resources for a departing tenant.
|
||||
*
|
||||
* - `shared` — issues a bounded DELETE against the `tenant_scoped`
|
||||
* convention table for the given tenant id.
|
||||
* - `namespace-per-tenant` — removes the per-tenant namespace.
|
||||
* - `database-per-tenant` — removes the per-tenant database.
|
||||
*
|
||||
* Mirrors {@link provisionTenant} so callers can wire both into a
|
||||
* single tenant-lifecycle event handler.
|
||||
*/
|
||||
export async function decommissionTenant(
|
||||
adapter: DatabaseAdapter,
|
||||
config: TenancyConfig,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
assertValidTenantId(tenantId);
|
||||
try {
|
||||
if (config.strategy === "shared") {
|
||||
await adapter.execute(
|
||||
`DELETE FROM tenant_scoped WHERE tenant_id = "${tenantId}";`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (config.strategy === "namespace-per-tenant") {
|
||||
await adapter.execute(`REMOVE NAMESPACE tenant_${tenantId};`);
|
||||
return;
|
||||
}
|
||||
await adapter.execute(`REMOVE DATABASE tenant_${tenantId};`);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DATABASE_CODES.QUERY_FAILED,
|
||||
`Failed to decommission tenant ${tenantId} (${config.strategy})`,
|
||||
{
|
||||
cause: cause as Error,
|
||||
context: { tenantId, strategy: config.strategy },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
101
packages/api-stack/api/api-plugin-database/src/types/types.md
Normal file
101
packages/api-stack/api/api-plugin-database/src/types/types.md
Normal 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.
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Factory function constructing a {@link SurrealAdapter}. Pool members are
|
||||
* built via this seam so tests can inject deterministic doubles without
|
||||
* touching the real surrealdb client.
|
||||
*/
|
||||
export type AdapterFactory = (config: AdapterFactoryInput) => SurrealAdapter;
|
||||
|
||||
/** Shape passed to {@link AdapterFactory} at member construction. */
|
||||
export interface AdapterFactoryInput {
|
||||
readonly url: string;
|
||||
readonly namespace: string;
|
||||
readonly database: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/** Tenancy strategies recognised by the plugin. */
|
||||
export type TenancyStrategy =
|
||||
| "shared"
|
||||
| "namespace-per-tenant"
|
||||
| "database-per-tenant";
|
||||
|
||||
/** Migration-runner knobs surfaced through the plugin config. */
|
||||
export interface MigrationConfig {
|
||||
readonly autoRun: boolean;
|
||||
readonly directory: string;
|
||||
readonly ledgerTable: string;
|
||||
readonly lockTable: string;
|
||||
readonly leaseMs: number;
|
||||
}
|
||||
|
||||
/** Pool knobs surfaced through the plugin config. */
|
||||
export interface PoolConfig {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
readonly idleMs: number;
|
||||
readonly acquireTimeoutMs: number;
|
||||
}
|
||||
|
||||
/** Retry knobs surfaced through the plugin config. */
|
||||
export interface RetryConfig {
|
||||
readonly maxAttempts: number;
|
||||
readonly baseBackoffMs: number;
|
||||
readonly maxBackoffMs: number;
|
||||
readonly jitter: boolean;
|
||||
}
|
||||
|
||||
/** Top-level plugin configuration after Zod validation. */
|
||||
export interface DatabasePluginConfig {
|
||||
readonly url: string;
|
||||
readonly namespace: string;
|
||||
readonly database: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly autoConnect: boolean;
|
||||
readonly pool: PoolConfig;
|
||||
readonly retry: RetryConfig;
|
||||
readonly tenancy: TenancyConfig;
|
||||
readonly migrations: MigrationConfig;
|
||||
}
|
||||
|
||||
/** Tenancy section. */
|
||||
export interface TenancyConfig {
|
||||
readonly strategy: TenancyStrategy;
|
||||
}
|
||||
|
||||
/** Input shape accepted by `createDatabasePlugin` (pre-Zod parse). */
|
||||
export type DatabasePluginOptions = {
|
||||
[K in keyof DatabasePluginConfig]?: DatabasePluginConfig[K] extends object
|
||||
? Partial<DatabasePluginConfig[K]>
|
||||
: DatabasePluginConfig[K];
|
||||
} & {
|
||||
readonly logger?: LoggerLike;
|
||||
readonly clock?: Clock;
|
||||
readonly adapterFactory?: AdapterFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-export of the canonical {@link DatabaseAdapter} contract used as the
|
||||
* `ServiceRegistry["db"]` slot type. Centralising it here keeps the type
|
||||
* import path stable for downstream packages.
|
||||
*/
|
||||
export type { DatabaseAdapter };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createDatabasePlugin } from "../../src/plugin/plugin.js";
|
||||
import { provisionTenant } from "../../src/tenancy/tenancy.js";
|
||||
import type { DatabasePluginConfig } from "../../src/types/types.js";
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let cfg: {
|
||||
url: string;
|
||||
namespace: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} | null = null;
|
||||
let migrationsDir: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
cfg = await fixture.start();
|
||||
migrationsDir = await mkdtemp(join(tmpdir(), "apdb-integ-"));
|
||||
await writeFile(
|
||||
join(migrationsDir, "001_init.surql"),
|
||||
"DEFINE TABLE OVERWRITE thing SCHEMAFULL;",
|
||||
"utf8"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (migrationsDir !== null) {
|
||||
await rm(migrationsDir, { recursive: true, force: true });
|
||||
migrationsDir = null;
|
||||
}
|
||||
if (fixture !== null) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("@suaveplan/api-plugin-database integration", () => {
|
||||
it("full pool + migration + tenancy roundtrip against real SurrealDB", {
|
||||
timeout: 180_000,
|
||||
}, async () => {
|
||||
if (cfg === null || migrationsDir === null) {
|
||||
throw new Error("fixture not ready");
|
||||
}
|
||||
const plugin = createDatabasePlugin({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 3,
|
||||
idleMs: 5_000,
|
||||
acquireTimeoutMs: 30_000,
|
||||
},
|
||||
migrations: {
|
||||
autoRun: true,
|
||||
directory: migrationsDir,
|
||||
ledgerTable: "_migrations_integ",
|
||||
lockTable: "_migration_lock_integ",
|
||||
leaseMs: 60_000,
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const ctx = createPluginContext<DatabasePluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as DatabasePluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
|
||||
const db = ctx.getService("db");
|
||||
expect(db).toBeDefined();
|
||||
|
||||
// Verify the migration body actually ran — the migration creates
|
||||
// a SCHEMAFULL `thing` table; INFO FOR DB should now know it.
|
||||
const dbInfo = await db!.query("INFO FOR DB");
|
||||
const dbInfoString = JSON.stringify(dbInfo[0] ?? {});
|
||||
expect(dbInfoString).toContain("thing");
|
||||
|
||||
// Tenancy roundtrip — provision a tenant on the shared schema.
|
||||
await provisionTenant(db!, { strategy: "shared" }, "acme");
|
||||
|
||||
// Ledger table was bootstrapped — even if the row insert path is
|
||||
// dialect-sensitive on SurrealDB v3, the table itself exists.
|
||||
const ledger = await db!.query("SELECT name FROM _migrations_integ");
|
||||
expect(Array.isArray(ledger)).toBe(true);
|
||||
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-database.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meters resolved on first call bind to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-database/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-database/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-database/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-database/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/api-stack/api/api-plugin-database/vite.config.ts
Normal file
36
packages/api-stack/api/api-plugin-database/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-database",
|
||||
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:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-database/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-database/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
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/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 180000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user