Initial Commit
This commit is contained in:
112
packages/api-stack/api/api-db-worker-proxy/README.md
Normal file
112
packages/api-stack/api/api-db-worker-proxy/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# @suaveplan/api-db-worker-proxy - Worker-side proxy for the api `db` capability. Marshals AdminDatabaseLike / TenantScopedDatabaseLike calls — including transactions and cursor streams — over @suaveplan/api-worker-rpc MessagePort frames. Tenant scoping is enforced main-side; this package is the transport.
|
||||
|
||||
Worker-side proxy for the api `db` capability. Marshals
|
||||
`AdminDatabaseLike` and `TenantScopedDatabaseLike` calls — including
|
||||
transactions and cursor streams — over `@suaveplan/api-worker-rpc`
|
||||
`MessagePort` frames.
|
||||
|
||||
The main side mounts a real DB adapter and exposes it over a port;
|
||||
the worker side imports a proxy whose API surface is identical to a
|
||||
direct adapter. Worker plugin code never touches a real connection
|
||||
and never sees plaintext credentials.
|
||||
|
||||
**Tenant scoping is enforced on the main side**, NOT in this package.
|
||||
`wrapTenantScoped(adminProxy, tenantId)` is purely a type-level facade
|
||||
that prevents an admin reference from being passed where a tenant
|
||||
reference is required by the type system.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `DB_PROXY.*` stable error-code registry for `@suaveplan/api-db-worker-proxy`. Every failure that c…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `DB_PROXY.*` stable error-code registry for `@suaveplan/api-db-worker-proxy`. Every failure that c…
|
||||
- [main-side](./src/main-side/main-side.md) — This module exposes `createDbProxyServer(port, db)` — the trusted-side endpoint of the worker DB proxy. It mounts an `A…
|
||||
- [main-side](./src/main-side/main-side.md) — This module exposes `createDbProxyServer(port, db)` — the trusted-side endpoint of the worker DB proxy. It mounts an `A…
|
||||
- [tenant-scoped](./src/tenant-scoped/tenant-scoped.md) — This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a worker-side type-level facade that re-presents an admi…
|
||||
- [tenant-scoped](./src/tenant-scoped/tenant-scoped.md) — This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a worker-side type-level facade that re-presents an admi…
|
||||
- [testing](./src/testing/testing.md) — This module provides the in-memory test harness for `@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb…
|
||||
- [testing](./src/testing/testing.md) — This module provides the in-memory test harness for `@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb…
|
||||
- [worker-side](./src/worker-side/worker-side.md) — This module exposes `createDbProxyClient(port, { engine })` — the untrusted-side endpoint of the worker DB proxy. It re…
|
||||
- [worker-side](./src/worker-side/worker-side.md) — This module exposes `createDbProxyClient(port, { engine })` — the untrusted-side endpoint of the worker DB proxy. It re…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why
|
||||
|
||||
Per the `api-plugin` foundation design (§5.1, §5.2a, §6) every
|
||||
capability ships in two surfaces: a direct in-process binding for
|
||||
trusted plugins and a `MessagePort`-marshalled binding for
|
||||
isolated / sandboxed plugins. The `db` capability is special because
|
||||
|
||||
1. its operations return result sets that can be huge (favouring
|
||||
streaming over buffering),
|
||||
2. its operations are transactional (transaction handles MUST be
|
||||
bound to a single connection lifetime), and
|
||||
3. tenant-scoping is a security boundary the worker MUST NOT be able
|
||||
to circumvent.
|
||||
|
||||
This package is Stage-3 worker-isolation infrastructure: every
|
||||
Stage-4 DB adapter (`api-db-adapter`, `api-db-sqlite-memory`, …) MUST
|
||||
pass conformance under BOTH the direct surface AND this proxy
|
||||
surface before can be declared complete.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-db-worker-proxy
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
// main side
|
||||
import { createDbProxyServer } from "@suaveplan/api-db-worker-proxy/main-side";
|
||||
import { MessageChannel, Worker } from "node:worker_threads";
|
||||
|
||||
const channel = new MessageChannel();
|
||||
createDbProxyServer(channel.port1, realAdminAdapter);
|
||||
|
||||
const worker = new Worker("./plugin-worker.js");
|
||||
worker.postMessage({ port: channel.port2, engine: "sqlite" }, [channel.port2]);
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker side
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import { createDbProxyClient } from "@suaveplan/api-db-worker-proxy/worker-side";
|
||||
import { wrapTenantScoped } from "@suaveplan/api-db-worker-proxy/tenant-scoped";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
|
||||
parentPort?.once("message", (boot) => {
|
||||
const admin = createDbProxyClient(boot.port, { engine: boot.engine });
|
||||
|
||||
// request scope
|
||||
const tenantScoped = wrapTenantScoped(admin, "acme" as TenantId);
|
||||
void runHandler(tenantScoped);
|
||||
});
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
- `./main-side` — `createDbProxyServer(port, db)`: serves an
|
||||
`AdminDatabaseLike` over `@suaveplan/api-worker-rpc`. Returns a
|
||||
`DbProxyServer` with `close()`, `activeTransactionCount()`,
|
||||
`activePreparedCount()`.
|
||||
- `./worker-side` — `createDbProxyClient(port, { engine })`: returns
|
||||
an `AdminDatabaseLike`-shaped object plus `cursor(sql, params)`
|
||||
for streaming row iteration and `dispose()` for abrupt teardown.
|
||||
- `./tenant-scoped` — `wrapTenantScoped(adminProxy, tenantId)`:
|
||||
type-level facade. Tenant scoping is enforced main-side.
|
||||
- `./error-codes` — `DB_PROXY.*` codes:
|
||||
`UNSAFE_NOT_PERMITTED`, `TRANSACTION_LOST`, `STREAM_INTERRUPTED`,
|
||||
`PROXY_DISPOSED`.
|
||||
- `./testing` — `createInMemoryAdminDb` + `createDbProxyHarness` for
|
||||
downstream conformance suites.
|
||||
|
||||
## Co-located docs
|
||||
|
||||
- [`src/main-side/main-side.md`](./src/main-side/main-side.md)
|
||||
- [`src/worker-side/worker-side.md`](./src/worker-side/worker-side.md)
|
||||
- [`src/tenant-scoped/tenant-scoped.md`](./src/tenant-scoped/tenant-scoped.md)
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md)
|
||||
- [`src/testing/testing.md`](./src/testing/testing.md)
|
||||
|
||||
92
packages/api-stack/api/api-db-worker-proxy/package.json
Normal file
92
packages/api-stack/api/api-db-worker-proxy/package.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@suaveplan/api-db-worker-proxy",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Worker-side proxy for the api `db` capability. Marshals AdminDatabaseLike / TenantScopedDatabaseLike calls — including transactions and cursor streams — over @suaveplan/api-worker-rpc MessagePort frames. Tenant scoping is enforced main-side; this package is the transport.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./main-side": {
|
||||
"import": {
|
||||
"types": "./dist/main-side/main-side.d.ts",
|
||||
"default": "./dist/main-side/main-side.js"
|
||||
}
|
||||
},
|
||||
"./worker-side": {
|
||||
"import": {
|
||||
"types": "./dist/worker-side/worker-side.d.ts",
|
||||
"default": "./dist/worker-side/worker-side.js"
|
||||
}
|
||||
},
|
||||
"./tenant-scoped": {
|
||||
"import": {
|
||||
"types": "./dist/tenant-scoped/tenant-scoped.d.ts",
|
||||
"default": "./dist/tenant-scoped/tenant-scoped.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/testing.d.ts",
|
||||
"default": "./dist/testing/testing.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/api-worker-rpc": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "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,81 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the `DB_PROXY.*` stable error-code registry for
|
||||
`@suaveplan/api-db-worker-proxy`. Every failure that crosses the public
|
||||
surface — proxy disposed, transaction lost, cursor stream interrupted,
|
||||
unsafe-call attempt against a tenant-scoped wrapper — is raised as a
|
||||
`DbProxyError` carrying one of these four codes so consumers can
|
||||
discriminate without string-matching messages.
|
||||
|
||||
The codes are stable across the package lifecycle: once published, the
|
||||
literal value is frozen. New codes are added by extending the
|
||||
`defineCodes` tuple; never by renaming.
|
||||
|
||||
## Features
|
||||
|
||||
- `DB_PROXY_CODES` — frozen registry of fully-qualified code strings.
|
||||
- `DbProxyCode` — string-literal union of every registered code.
|
||||
- `DbProxyError` — typed subclass of `SuaveplanError<DbProxyCode>`
|
||||
carrying the code, cause chain, and Zod-wire-safe serialisation
|
||||
inherited from `@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DB_PROXY_CODES,
|
||||
DbProxyError,
|
||||
} from "@suaveplan/api-db-worker-proxy/error-codes";
|
||||
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.PROXY_DISPOSED,
|
||||
"cannot call .query() after .close()"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Branch on the code, not the message. The code is part of the public
|
||||
contract; messages are free text and change without notice.
|
||||
|
||||
```ts
|
||||
try {
|
||||
await proxy.query("SELECT 1");
|
||||
} catch (err) {
|
||||
if (err instanceof DbProxyError) {
|
||||
switch (err.code) {
|
||||
case DB_PROXY_CODES.PROXY_DISPOSED:
|
||||
return reopenProxy();
|
||||
case DB_PROXY_CODES.TRANSACTION_LOST:
|
||||
return retryUnit();
|
||||
case DB_PROXY_CODES.STREAM_INTERRUPTED:
|
||||
return reissueCursor();
|
||||
case DB_PROXY_CODES.UNSAFE_NOT_PERMITTED:
|
||||
throw err; // never retry an unsafe path.
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DB_PROXY_CODES: { UNSAFE_NOT_PERMITTED, TRANSACTION_LOST, STREAM_INTERRUPTED, PROXY_DISPOSED }` — frozen mapping of short names to fully-qualified code strings (`"DB_PROXY.<NAME>"`).
|
||||
- `type DbProxyCode = (typeof DB_PROXY_CODES)[keyof typeof DB_PROXY_CODES]`.
|
||||
- `class DbProxyError extends SuaveplanError<DbProxyCode>` — constructor `(code, message, options?)` where `options` accepts a `cause` and arbitrary `details`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built via `defineCodes("DB_PROXY", [...] as const)` so
|
||||
that adding a code without updating both the runtime registry and the
|
||||
type union is a compile error. `DbProxyError`'s `name` is set to
|
||||
`"DbProxyError"` so structured logs and `errorToEnvelope` round-trip
|
||||
correctly across the worker boundary — `@suaveplan/api-worker-rpc`'s
|
||||
error-reconstruction layer will reconstruct the class by `code` even
|
||||
when the original prototype isn't reachable on the other side.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "./error-codes.js";
|
||||
|
||||
describe("DB_PROXY error codes", () => {
|
||||
it("exposes the four documented codes", { timeout: 5000 }, () => {
|
||||
expect(DB_PROXY_CODES.UNSAFE_NOT_PERMITTED).toBe(
|
||||
"DB_PROXY.UNSAFE_NOT_PERMITTED"
|
||||
);
|
||||
expect(DB_PROXY_CODES.TRANSACTION_LOST).toBe(
|
||||
"DB_PROXY.TRANSACTION_LOST"
|
||||
);
|
||||
expect(DB_PROXY_CODES.STREAM_INTERRUPTED).toBe(
|
||||
"DB_PROXY.STREAM_INTERRUPTED"
|
||||
);
|
||||
expect(DB_PROXY_CODES.PROXY_DISPOSED).toBe("DB_PROXY.PROXY_DISPOSED");
|
||||
expect(Object.keys(DB_PROXY_CODES)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("DbProxyError carries the code, name and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DbProxyError(
|
||||
DB_PROXY_CODES.PROXY_DISPOSED,
|
||||
"proxy is disposed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err).toBeInstanceOf(DbProxyError);
|
||||
expect(err.name).toBe("DbProxyError");
|
||||
expect(err.code).toBe("DB_PROXY.PROXY_DISPOSED");
|
||||
expect(err.message).toBe("proxy is disposed");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-db-worker-proxy`.
|
||||
*
|
||||
* Every public failure path raises a {@link DbProxyError} carrying one
|
||||
* of these namespaced `DB_PROXY.*` codes. Consumers branch on the
|
||||
* literal value (e.g. `error.code === DB_PROXY_CODES.PROXY_DISPOSED`).
|
||||
*
|
||||
* - `UNSAFE_NOT_PERMITTED` — a worker attempted to invoke an unscoped /
|
||||
* admin-only method through a tenant-scoped wrapper, or otherwise to
|
||||
* smuggle past the main-side capability gate.
|
||||
* - `TRANSACTION_LOST` — the main-side transaction handle was lost
|
||||
* (port closed mid-tx, main side disposed, network blip). Consumers
|
||||
* should treat the in-flight unit of work as rolled back and retry.
|
||||
* - `STREAM_INTERRUPTED` — a cursor stream terminated before completion
|
||||
* without a clean `stream-end ok` frame. Surfaced through the
|
||||
* `AsyncIterable` returned by `query`/cursor methods.
|
||||
* - `PROXY_DISPOSED` — a method was called on a proxy after its `close()`
|
||||
* was invoked or the underlying port was closed.
|
||||
*/
|
||||
export const DB_PROXY_CODES = defineCodes("DB_PROXY", [
|
||||
"UNSAFE_NOT_PERMITTED",
|
||||
"TRANSACTION_LOST",
|
||||
"STREAM_INTERRUPTED",
|
||||
"PROXY_DISPOSED",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code in {@link DB_PROXY_CODES}. */
|
||||
export type DbProxyCode = (typeof DB_PROXY_CODES)[keyof typeof DB_PROXY_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for `@suaveplan/api-db-worker-proxy`. Extends
|
||||
* {@link SuaveplanError} so callers may discriminate via either
|
||||
* `instanceof DbProxyError` or by code string.
|
||||
*/
|
||||
export class DbProxyError extends SuaveplanError<DbProxyCode> {
|
||||
public constructor(
|
||||
code: DbProxyCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "DbProxyError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DB_PROXY_CODES,
|
||||
type DbProxyCode,
|
||||
DbProxyError,
|
||||
} from "./error-codes.js";
|
||||
23
packages/api-stack/api/api-db-worker-proxy/src/index.ts
Normal file
23
packages/api-stack/api/api-db-worker-proxy/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
DB_PROXY_CODES,
|
||||
type DbProxyCode,
|
||||
DbProxyError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
createDbProxyServer,
|
||||
type DbProxyServer,
|
||||
} from "./main-side/index.js";
|
||||
export { wrapTenantScoped } from "./tenant-scoped/index.js";
|
||||
export {
|
||||
createDbProxyHarness,
|
||||
createInMemoryAdminDb,
|
||||
type DbProxyHarness,
|
||||
type InMemoryAdminDb,
|
||||
type InMemoryAdminDbOptions,
|
||||
type MemoryRow,
|
||||
} from "./testing/index.js";
|
||||
export {
|
||||
createDbProxyClient,
|
||||
type DbProxyClient,
|
||||
type DbProxyClientOptions,
|
||||
} from "./worker-side/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createDbProxyServer,
|
||||
type DbProxyServer,
|
||||
} from "./main-side.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
# `main-side`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createDbProxyServer(port, db)` — the trusted-side
|
||||
endpoint of the worker DB proxy. It mounts an `AdminDatabaseLike`
|
||||
adapter on a `MessagePort` and turns every worker-issued RPC frame
|
||||
into a real call on the wrapped adapter. The main side is the only
|
||||
side that touches the underlying DB driver; the worker code that
|
||||
imported `@suaveplan/api-db-worker-proxy/worker-side` never sees a
|
||||
real connection, never sees plaintext credentials, and cannot
|
||||
fabricate a tenant identity.
|
||||
|
||||
The server keeps a registry of live transaction handles and prepared
|
||||
statements keyed by id so the worker can drive them across multiple
|
||||
round-trips without leaking driver handles into the worker's address
|
||||
space.
|
||||
|
||||
## Features
|
||||
|
||||
- Marshals `query`, `execute`, `prepare`, `transaction`, `cursor`,
|
||||
`engine`, and `close` over `@suaveplan/api-worker-rpc`.
|
||||
- Streams cursor rows as `stream-chunk` frames; worker-side break
|
||||
triggers `cancel` which the handler observes via its `AbortSignal`.
|
||||
- Bounds transaction lifetimes by port: closing the server (or
|
||||
receiving `goodbye`) raises `TRANSACTION_LOST` on every dangling
|
||||
handle rather than leaking driver state.
|
||||
- Exposes diagnostic counters `activeTransactionCount` and
|
||||
`activePreparedCount`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDbProxyServer } from "@suaveplan/api-db-worker-proxy/main-side";
|
||||
import { Worker, MessageChannel } from "node:worker_threads";
|
||||
|
||||
const channel = new MessageChannel();
|
||||
const server = createDbProxyServer(channel.port1, realAdapter);
|
||||
|
||||
const worker = new Worker("./worker.js");
|
||||
worker.postMessage({ port: channel.port2 }, [channel.port2]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When the host needs to scope the adapter per request, pass an
|
||||
already-tenant-scoped adapter to a per-request server. The proxy
|
||||
itself does NOT rewrite SQL — that responsibility lives in the
|
||||
adapter the host hands in here. `wrapTenantScoped` on the worker
|
||||
side is purely a type-level facade.
|
||||
|
||||
```ts
|
||||
const scoped = realAdapter.scopedFor(tenantId); // host responsibility
|
||||
const server = createDbProxyServer(port, scoped);
|
||||
// shut down at request end:
|
||||
server.close("request finished");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDbProxyServer(port: PortLike, db: AdminDatabaseLike): DbProxyServer`.
|
||||
- `DbProxyServer.close(reason?: string): void` — closes the server,
|
||||
rolling back live transactions and finalising prepared statements.
|
||||
- `DbProxyServer.activeTransactionCount(): number`.
|
||||
- `DbProxyServer.activePreparedCount(): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cursor handler returns an `AsyncGenerator`; `serveWorkerRpc`'s
|
||||
streaming path interprets the return as `AsyncIterable<unknown>` and
|
||||
emits one `stream-chunk` per yield, then `stream-end ok`. Cancellation
|
||||
from the worker comes in as an `AbortSignal.aborted = true` on the
|
||||
handler's `signal`; the generator throws `STREAM_INTERRUPTED` which
|
||||
the server marshals as `stream-end ok=false` carrying the
|
||||
`DB_PROXY.STREAM_INTERRUPTED` code.
|
||||
|
||||
Transactions are implemented by holding the adapter's `transaction()`
|
||||
promise open until either `tx.commit` or `tx.rollback` fires from the
|
||||
worker. Rolling back is signalled by throwing inside the adapter
|
||||
callback, which mirrors the adapter's own rollback semantics without
|
||||
adding a parallel state machine.
|
||||
@@ -0,0 +1,501 @@
|
||||
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
|
||||
import { createDbProxyHarness } from "../testing/testing.js";
|
||||
import { createDbProxyServer } from "./main-side.js";
|
||||
|
||||
describe("createDbProxyServer", () => {
|
||||
it("forwards query → adapter → response", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: [
|
||||
["a", { id: "a", v: 1 }],
|
||||
["b", { id: "b", v: 2 }],
|
||||
],
|
||||
});
|
||||
try {
|
||||
const rows =
|
||||
await h.client.query<ReadonlyArray<unknown>>("SELECT *");
|
||||
expect(rows).toHaveLength(2);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards execute and returns rowsAffected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
const result = await h.client.execute("INSERT a", { id: "a" });
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(h.db.rows.get("a")).toEqual({ id: "a" });
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates adapter errors as DbProxyError-shaped envelopes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await expect(h.client.query("THROW")).rejects.toThrow();
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close handler closes the underlying adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.close();
|
||||
expect(h.db.closed()).toBe(true);
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prepare when adapter does not implement it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
// Surgery: strip prepare from underlying adapter so the server path
|
||||
// returns UNSAFE_NOT_PERMITTED.
|
||||
(h.db as unknown as { prepare?: unknown }).prepare = undefined;
|
||||
try {
|
||||
await expect(h.client.prepare?.("SELECT *")).rejects.toThrow(
|
||||
/UNSAFE_NOT_PERMITTED|not support prepared/
|
||||
);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects transaction when adapter does not implement it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
(h.db as unknown as { transaction?: unknown }).transaction = undefined;
|
||||
try {
|
||||
await expect(
|
||||
h.client.transaction?.(async () => undefined)
|
||||
).rejects.toThrow(/UNSAFE_NOT_PERMITTED|not support transactions/);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close on the server empties the active-transactions registry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
// Start a transaction; assert server tracks it; then close
|
||||
// the server and verify the tracking map is emptied so the
|
||||
// main side does not leak transaction handles.
|
||||
let txGate: (() => void) | undefined;
|
||||
const callbackDone = new Promise<void>((resolve) => {
|
||||
txGate = resolve;
|
||||
});
|
||||
const txPromise = h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { id: "a" });
|
||||
expect(h.server.activeTransactionCount()).toBe(1);
|
||||
txGate?.();
|
||||
// Hold the callback open until the test signals.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
await callbackDone;
|
||||
h.server.close("forced");
|
||||
expect(h.server.activeTransactionCount()).toBe(0);
|
||||
// The hanging auto-commit will reject when the client port
|
||||
// is disposed below; attach a swallow so the unhandled
|
||||
// rejection does not leak across tests.
|
||||
txPromise?.catch(() => undefined);
|
||||
} finally {
|
||||
h.client.dispose();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prepare lifecycle: open, execute, finalize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
const stmt = await h.client.prepare?.("SELECT *");
|
||||
expect(stmt).toBeDefined();
|
||||
if (stmt === undefined) throw new Error("prepare returned");
|
||||
const rows = await stmt.execute();
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
await stmt.finalize();
|
||||
await stmt.finalize(); // idempotent on the worker side
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("activeTransactionCount and activePreparedCount expose live handles", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
expect(h.server.activeTransactionCount()).toBe(0);
|
||||
expect(h.server.activePreparedCount()).toBe(0);
|
||||
const stmt = await h.client.prepare?.("SELECT *");
|
||||
expect(h.server.activePreparedCount()).toBe(1);
|
||||
await stmt?.finalize();
|
||||
expect(h.server.activePreparedCount()).toBe(0);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor streams rows in order", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => [String(i), { i }] as const
|
||||
),
|
||||
});
|
||||
try {
|
||||
const collected: number[] = [];
|
||||
for await (const row of h.client.cursor("SELECT-CURSOR *")) {
|
||||
collected.push((row as { i: number }).i);
|
||||
}
|
||||
expect(collected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor cancellation aborts the main-side iterator", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => [String(i), { i }] as const
|
||||
),
|
||||
cursorDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
let count = 0;
|
||||
try {
|
||||
for await (const _row of h.client.cursor(
|
||||
"SELECT-CURSOR *",
|
||||
undefined,
|
||||
{ signal: ac.signal }
|
||||
)) {
|
||||
count += 1;
|
||||
if (count >= 3) ac.abort();
|
||||
}
|
||||
} catch {
|
||||
// Expected: stream is cancelled.
|
||||
}
|
||||
expect(count).toBeGreaterThanOrEqual(3);
|
||||
expect(count).toBeLessThan(50);
|
||||
} finally {
|
||||
await h.client.close().catch(() => undefined);
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor on scalar result yields a single chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
const seen: unknown[] = [];
|
||||
for await (const row of h.client.cursor("SELECT-SCALAR")) {
|
||||
seen.push(row);
|
||||
}
|
||||
expect(seen).toEqual([1]);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor on sync iterable yields each row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Construct a DB whose query returns a plain sync iterable.
|
||||
const dbWithSyncIter: AdminDatabaseLike = {
|
||||
engine: "sqlite",
|
||||
query: async () => [1, 2, 3][Symbol.iterator](),
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
close: async () => undefined,
|
||||
};
|
||||
const { createInProcessPortPair } = await import(
|
||||
"@suaveplan/api-worker-rpc"
|
||||
);
|
||||
const ports = createInProcessPortPair();
|
||||
const { createDbProxyServer } = await import("./main-side.js");
|
||||
const { createDbProxyClient } = await import(
|
||||
"../worker-side/worker-side.js"
|
||||
);
|
||||
const server = createDbProxyServer(ports.serverPort, dbWithSyncIter);
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: "sqlite",
|
||||
});
|
||||
try {
|
||||
const collected: unknown[] = [];
|
||||
for await (const row of client.cursor("SELECT-CURSOR *")) {
|
||||
collected.push(row);
|
||||
}
|
||||
expect(collected).toEqual([1, 2, 3]);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor on null result emits no chunks", { timeout: 5000 }, async () => {
|
||||
const dbWithNull: AdminDatabaseLike = {
|
||||
engine: "sqlite",
|
||||
query: async () => null as unknown as never,
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
close: async () => undefined,
|
||||
};
|
||||
const { createInProcessPortPair } = await import(
|
||||
"@suaveplan/api-worker-rpc"
|
||||
);
|
||||
const ports = createInProcessPortPair();
|
||||
const { createDbProxyServer } = await import("./main-side.js");
|
||||
const { createDbProxyClient } = await import(
|
||||
"../worker-side/worker-side.js"
|
||||
);
|
||||
const server = createDbProxyServer(ports.serverPort, dbWithNull);
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: "sqlite",
|
||||
});
|
||||
try {
|
||||
const collected: unknown[] = [];
|
||||
for await (const row of client.cursor("SELECT-CURSOR *")) {
|
||||
collected.push(row);
|
||||
}
|
||||
expect(collected).toEqual([]);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("server side throws DB_PROXY error for sync-iterable abort", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// sanity check DbProxyError export round-trips
|
||||
const err = new DbProxyError(DB_PROXY_CODES.STREAM_INTERRUPTED, "test");
|
||||
expect(err.code).toBe(DB_PROXY_CODES.STREAM_INTERRUPTED);
|
||||
});
|
||||
|
||||
it("server ensureTransaction rejects unknown id with TRANSACTION_LOST", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createInProcessPortPair, createRawWorkerRpcClient } =
|
||||
await import("@suaveplan/api-worker-rpc");
|
||||
const db = (
|
||||
await import("../testing/testing.js")
|
||||
).createInMemoryAdminDb();
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createDbProxyServer(ports.serverPort, db);
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
try {
|
||||
await expect(
|
||||
client.call("tx.query", [9999, "SELECT *", undefined])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
|
||||
await expect(
|
||||
client.call("tx.execute", [9999, "INSERT a", { v: 1 }])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
|
||||
await expect(
|
||||
client.call("tx.commit", [9999])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
|
||||
await expect(
|
||||
client.call("tx.rollback", [9999])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("server ensurePrepared rejects unknown id with PROXY_DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createInProcessPortPair, createRawWorkerRpcClient } =
|
||||
await import("@suaveplan/api-worker-rpc");
|
||||
const db = (
|
||||
await import("../testing/testing.js")
|
||||
).createInMemoryAdminDb();
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createDbProxyServer(ports.serverPort, db);
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
try {
|
||||
await expect(
|
||||
client.call("prepare.execute", [9999, {}])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.PROXY_DISPOSED });
|
||||
await expect(
|
||||
client.call("prepare.finalize", [9999])
|
||||
).rejects.toMatchObject({ code: DB_PROXY_CODES.PROXY_DISPOSED });
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("worker calls engine handler when no engine option provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { createInProcessPortPair, createRawWorkerRpcClient } =
|
||||
await import("@suaveplan/api-worker-rpc");
|
||||
const { createInMemoryAdminDb } = await import("../testing/testing.js");
|
||||
const db = createInMemoryAdminDb({ engine: "postgres" });
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createDbProxyServer(ports.serverPort, db);
|
||||
const client = createRawWorkerRpcClient(ports.clientPort);
|
||||
try {
|
||||
expect(await client.call("engine", [])).toBe("postgres");
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close handler swallows prepared.finalize failures", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Custom adapter whose prepare returns a stmt whose finalize
|
||||
// throws; the server's close handler MUST silently absorb that
|
||||
// error so cleanup of remaining handles still happens.
|
||||
const { createInProcessPortPair } = await import(
|
||||
"@suaveplan/api-worker-rpc"
|
||||
);
|
||||
let dbClosed = false;
|
||||
const adapter: AdminDatabaseLike = {
|
||||
engine: "sqlite",
|
||||
query: async () => undefined,
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
close: async () => {
|
||||
dbClosed = true;
|
||||
},
|
||||
prepare: async () => ({
|
||||
id: "s1",
|
||||
execute: async () => undefined,
|
||||
finalize: async () => {
|
||||
throw new Error("finalize boom");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createDbProxyServer(ports.serverPort, adapter);
|
||||
const { createDbProxyClient } = await import(
|
||||
"../worker-side/worker-side.js"
|
||||
);
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: "sqlite",
|
||||
});
|
||||
try {
|
||||
// Open a prepared statement so the server tracks it,
|
||||
// then call close on the client. The server's close
|
||||
// handler iterates and finalises each, swallowing the
|
||||
// explosion.
|
||||
await client.prepare?.("SELECT *");
|
||||
await client.close();
|
||||
expect(dbClosed).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close handler rolls back live transactions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
// Start a tx + capture so the server's transactions Map is
|
||||
// non-empty, then call .close() on the worker proxy. The
|
||||
// server's close handler iterates and settles each entry.
|
||||
let txGate: (() => void) | undefined;
|
||||
const txReady = new Promise<void>((r) => {
|
||||
txGate = r;
|
||||
});
|
||||
const promise = h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
txGate?.();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
await txReady;
|
||||
// close() forwards to server which iterates active txs.
|
||||
const closePromise = h.client.close();
|
||||
await Promise.race([
|
||||
closePromise,
|
||||
new Promise((r) => setTimeout(r, 100)),
|
||||
]);
|
||||
void promise?.catch(() => undefined);
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("commit twice surfaces TRANSACTION_LOST from server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await expect(
|
||||
h.client.transaction?.(async (tx) => {
|
||||
await tx.commit();
|
||||
await tx.commit();
|
||||
})
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await h.client.close().catch(() => undefined);
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rollback works as a first-class exit", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { id: "a" });
|
||||
await tx.rollback();
|
||||
});
|
||||
// adapter is a no-op rollback so the row stays; that's expected.
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close on server also finalises preprared statements", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.prepare?.("SELECT *");
|
||||
expect(h.server.activePreparedCount()).toBe(1);
|
||||
await h.client.close();
|
||||
expect(h.db.closed()).toBe(true);
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
DbParams,
|
||||
DbTransaction,
|
||||
PreparedStatement,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { PortLike } from "@suaveplan/api-worker-rpc";
|
||||
import {
|
||||
serveWorkerRpc,
|
||||
type WorkerRpcHandlers,
|
||||
type WorkerRpcServer,
|
||||
} from "@suaveplan/api-worker-rpc";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Mounted server handle returned by {@link createDbProxyServer}.
|
||||
*
|
||||
* Calling `close()` tears down the underlying worker-rpc server and
|
||||
* aborts every in-flight transaction / prepared statement / cursor so
|
||||
* that the worker side cleanly observes `TRANSACTION_LOST` /
|
||||
* `STREAM_INTERRUPTED` / `PROXY_DISPOSED` rather than hanging.
|
||||
*/
|
||||
export interface DbProxyServer extends WorkerRpcServer {
|
||||
/** Diagnostic count of live transaction handles bound to this port. */
|
||||
readonly activeTransactionCount: () => number;
|
||||
/** Diagnostic count of live prepared-statement handles. */
|
||||
readonly activePreparedCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount an {@link AdminDatabaseLike} on a `MessagePort` so that a
|
||||
* `@suaveplan/api-db-worker-proxy` client on the other side can drive
|
||||
* it. Tenant scoping is NOT enforced here — the caller MUST pass an
|
||||
* already-tenant-scoped adapter when this server backs a tenant
|
||||
* request context. Pass the raw admin adapter only for plugin-context
|
||||
* (migration / admin) work.
|
||||
*
|
||||
* The server marshals these methods:
|
||||
*
|
||||
* - `query(sql, params)` → result (regular RPC call).
|
||||
* - `execute(sql, params)` → `{ rowsAffected }`.
|
||||
* - `prepare(sql)` → handle id; subsequent `prepare.execute(id, params)`
|
||||
* and `prepare.finalize(id)` are routed to the same statement.
|
||||
* - `transaction()` → opens a transaction; returns a handle id which
|
||||
* the worker side uses for `tx.query` / `tx.execute` / `tx.commit` /
|
||||
* `tx.rollback`. The handle is bound to this port's lifetime.
|
||||
* - `cursor(sql, params)` → opens a streaming cursor; rows flow as
|
||||
* `stream-chunk` frames. Cursor close on the worker side (break out
|
||||
* of `for await`) propagates as cancel and finalises the cursor.
|
||||
* - `close()` → closes the underlying admin adapter and the port.
|
||||
* - `engine` is sent as a hello-ish reply on the `engine` method.
|
||||
*/
|
||||
export function createDbProxyServer(
|
||||
port: PortLike,
|
||||
db: AdminDatabaseLike
|
||||
): DbProxyServer {
|
||||
let txCounter = 0;
|
||||
let preparedCounter = 0;
|
||||
const transactions = new Map<
|
||||
number,
|
||||
{
|
||||
readonly tx: DbTransaction;
|
||||
/** Resolves the transaction-runner promise when commit/rollback fires. */
|
||||
readonly settle: (kind: "commit" | "rollback") => void;
|
||||
/** Tracks whether commit/rollback already fired so re-use returns LOST. */
|
||||
disposed: boolean;
|
||||
}
|
||||
>();
|
||||
const prepared = new Map<number, PreparedStatement>();
|
||||
|
||||
const ensureTransaction = (
|
||||
id: number
|
||||
): { readonly tx: DbTransaction; readonly disposed: boolean } => {
|
||||
const entry = transactions.get(id);
|
||||
if (entry === undefined || entry.disposed) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
`transaction ${id} is no longer valid`
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const ensurePrepared = (id: number): PreparedStatement => {
|
||||
const stmt = prepared.get(id);
|
||||
if (stmt === undefined) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.PROXY_DISPOSED,
|
||||
`prepared statement ${id} has been finalised`
|
||||
);
|
||||
}
|
||||
return stmt;
|
||||
};
|
||||
|
||||
const handlers: WorkerRpcHandlers = {
|
||||
engine: () => db.engine,
|
||||
query: async (params: ReadonlyArray<unknown>) => {
|
||||
const [sql, sqlParams] = params as [string, DbParams | undefined];
|
||||
return await db.query(sql, sqlParams);
|
||||
},
|
||||
execute: async (params: ReadonlyArray<unknown>) => {
|
||||
const [sql, sqlParams] = params as [string, DbParams | undefined];
|
||||
return await db.execute(sql, sqlParams);
|
||||
},
|
||||
"prepare.open": async (params: ReadonlyArray<unknown>) => {
|
||||
if (db.prepare === undefined) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
|
||||
"adapter does not support prepared statements"
|
||||
);
|
||||
}
|
||||
const [sql] = params as [string];
|
||||
const stmt = await db.prepare(sql);
|
||||
preparedCounter += 1;
|
||||
const id = preparedCounter;
|
||||
prepared.set(id, stmt);
|
||||
return { id };
|
||||
},
|
||||
"prepare.execute": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id, sqlParams] = params as [number, DbParams | undefined];
|
||||
const stmt = ensurePrepared(id);
|
||||
return await stmt.execute(sqlParams);
|
||||
},
|
||||
"prepare.finalize": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id] = params as [number];
|
||||
const stmt = ensurePrepared(id);
|
||||
await stmt.finalize();
|
||||
prepared.delete(id);
|
||||
return null;
|
||||
},
|
||||
"tx.begin": async () => {
|
||||
if (db.transaction === undefined) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
|
||||
"adapter does not support transactions"
|
||||
);
|
||||
}
|
||||
txCounter += 1;
|
||||
const id = txCounter;
|
||||
let settle: (kind: "commit" | "rollback") => void;
|
||||
const runTransaction = db.transaction;
|
||||
const txReady = new Promise<DbTransaction>((resolveTx) => {
|
||||
void runTransaction(async (tx) => {
|
||||
const finished = new Promise<"commit" | "rollback">(
|
||||
(resolveSettle) => {
|
||||
settle = resolveSettle;
|
||||
}
|
||||
);
|
||||
resolveTx(tx);
|
||||
const kind = await finished;
|
||||
if (kind === "rollback") {
|
||||
// re-throw so the adapter's transaction() rolls back.
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
"rollback requested"
|
||||
);
|
||||
}
|
||||
}).catch(() => {
|
||||
// adapter's transaction() rejects on rollback; the
|
||||
// worker has already been told via the response
|
||||
// frame so this catch only prevents an unhandled.
|
||||
});
|
||||
});
|
||||
const tx = await txReady;
|
||||
transactions.set(id, {
|
||||
tx,
|
||||
settle: (kind) => {
|
||||
settle(kind);
|
||||
},
|
||||
disposed: false,
|
||||
});
|
||||
return { id };
|
||||
},
|
||||
"tx.query": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id, sql, sqlParams] = params as [
|
||||
number,
|
||||
string,
|
||||
DbParams | undefined,
|
||||
];
|
||||
const entry = ensureTransaction(id);
|
||||
return await entry.tx.query(sql, sqlParams);
|
||||
},
|
||||
"tx.execute": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id, sql, sqlParams] = params as [
|
||||
number,
|
||||
string,
|
||||
DbParams | undefined,
|
||||
];
|
||||
const entry = ensureTransaction(id);
|
||||
return await entry.tx.execute(sql, sqlParams);
|
||||
},
|
||||
"tx.commit": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id] = params as [number];
|
||||
const entry = transactions.get(id);
|
||||
if (entry === undefined || entry.disposed) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
`transaction ${id} is no longer valid`
|
||||
);
|
||||
}
|
||||
entry.disposed = true;
|
||||
transactions.delete(id);
|
||||
await entry.tx.commit();
|
||||
entry.settle("commit");
|
||||
return null;
|
||||
},
|
||||
"tx.rollback": async (params: ReadonlyArray<unknown>) => {
|
||||
const [id] = params as [number];
|
||||
const entry = transactions.get(id);
|
||||
if (entry === undefined || entry.disposed) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
`transaction ${id} is no longer valid`
|
||||
);
|
||||
}
|
||||
entry.disposed = true;
|
||||
transactions.delete(id);
|
||||
await entry.tx.rollback();
|
||||
entry.settle("rollback");
|
||||
return null;
|
||||
},
|
||||
cursor: async function* (
|
||||
params: ReadonlyArray<unknown>,
|
||||
signal: AbortSignal
|
||||
): AsyncIterable<unknown> {
|
||||
const [sql, sqlParams] = params as [string, DbParams | undefined];
|
||||
// The adapter's `query` returns a value of unknown shape. Treat
|
||||
// it as iterable (sync or async) and stream rows; non-iterable
|
||||
// results are yielded as a single chunk.
|
||||
const result = await db.query(sql, sqlParams);
|
||||
if (result === null || result === undefined) return;
|
||||
const asyncIter = (
|
||||
result as {
|
||||
[Symbol.asyncIterator]?: () => AsyncIterator<unknown>;
|
||||
}
|
||||
)[Symbol.asyncIterator];
|
||||
const syncIter = (
|
||||
result as { [Symbol.iterator]?: () => Iterator<unknown> }
|
||||
)[Symbol.iterator];
|
||||
if (typeof asyncIter === "function") {
|
||||
// Async iteration honours abort by checking the signal
|
||||
// before every yield; sync iteration runs to completion
|
||||
// (it is too fast to meaningfully cancel from outside).
|
||||
for await (const row of result as AsyncIterable<unknown>) {
|
||||
if (signal.aborted) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.STREAM_INTERRUPTED,
|
||||
"cursor cancelled by worker"
|
||||
);
|
||||
}
|
||||
yield row;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof syncIter === "function") {
|
||||
for (const row of result as Iterable<unknown>) yield row;
|
||||
return;
|
||||
}
|
||||
yield result;
|
||||
},
|
||||
close: async () => {
|
||||
for (const entry of transactions.values()) {
|
||||
entry.disposed = true;
|
||||
entry.settle("rollback");
|
||||
}
|
||||
transactions.clear();
|
||||
for (const stmt of prepared.values()) {
|
||||
await stmt.finalize().catch(() => undefined);
|
||||
}
|
||||
prepared.clear();
|
||||
await db.close();
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const inner = serveWorkerRpc(port, handlers);
|
||||
|
||||
return {
|
||||
close(reason?: string): void {
|
||||
for (const entry of transactions.values()) {
|
||||
entry.disposed = true;
|
||||
entry.settle("rollback");
|
||||
}
|
||||
transactions.clear();
|
||||
inner.close(reason);
|
||||
},
|
||||
activeTransactionCount: () => transactions.size,
|
||||
activePreparedCount: () => prepared.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { wrapTenantScoped } from "./tenant-scoped.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `tenant-scoped`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a
|
||||
worker-side type-level facade that re-presents an admin-surface
|
||||
`AdminDatabaseLike` as a `TenantScopedDatabaseLike`. The facade
|
||||
matters because the two surface types are deliberately
|
||||
non-assignable in `@suaveplan/api-types`: a request handler declared
|
||||
to accept `TenantScopedDatabaseLike` MUST NOT accidentally receive
|
||||
an unscoped admin proxy, and vice versa.
|
||||
|
||||
**Tenant scoping is enforced on the main side.** The actual SQL
|
||||
rewrite / row-level security / connection routing all live in the
|
||||
host's adapter. This wrapper does NOT add policy; it asserts a
|
||||
shape so the type system can guarantee the slot in the request
|
||||
context has been narrowed to a tenant.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh facade object on every call so the same admin
|
||||
proxy can back many concurrent request scopes.
|
||||
- Exposes `tenantId` on the returned object for audit logging.
|
||||
- Preserves the optional `prepare` / `transaction` capabilities only
|
||||
if the admin proxy advertised them; absent capabilities throw
|
||||
`DB_PROXY.UNSAFE_NOT_PERMITTED`.
|
||||
- `close` is intentionally omitted because
|
||||
`TenantScopedDatabaseLike` does not expose it — the admin proxy
|
||||
retains lifecycle ownership.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { wrapTenantScoped } from "@suaveplan/api-db-worker-proxy/tenant-scoped";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
|
||||
const tenantId = "acme" as TenantId;
|
||||
const scoped = wrapTenantScoped(adminProxy, tenantId);
|
||||
const rows = await scoped.query("SELECT * FROM invoices");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the facade inside a request middleware that materialises
|
||||
`RequestContext.db`. The middleware constructs the wrapper from the
|
||||
already-resolved `tenantId` so route handlers only ever see the
|
||||
tenant-scoped type.
|
||||
|
||||
```ts
|
||||
function attachDb(adminProxy: AdminDatabaseLike) {
|
||||
return async (c: Context, next: Next): Promise<void> => {
|
||||
const tenantId = c.get("tenantId") as TenantId;
|
||||
const scoped = wrapTenantScoped(adminProxy, tenantId);
|
||||
c.set("db", scoped);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `wrapTenantScoped(adminProxy: AdminDatabaseLike, tenantId: TenantId): TenantScopedDatabaseLike`.
|
||||
- Throws `DB_PROXY.UNSAFE_NOT_PERMITTED` from `prepare` / `transaction`
|
||||
when the underlying admin proxy doesn't expose that capability.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper rebinds `prepare` and `transaction` so the underlying
|
||||
admin proxy's `this`-context survives the indirection (the methods
|
||||
are looked up off the proxy each call, not captured eagerly, so
|
||||
admin-side disposal is observed on the next call rather than at wrap
|
||||
time).
|
||||
|
||||
The `tenantId` field is set on the returned object purely so audit
|
||||
loggers and `RequestContext` consumers can read it directly. The
|
||||
field name and type match `TenantScopedDatabaseLike.tenantId`
|
||||
exactly; both are `TenantId` (a branded string).
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
DbTransaction,
|
||||
PreparedStatement,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { wrapTenantScoped } from "./tenant-scoped.js";
|
||||
|
||||
const fakeAdmin = (): AdminDatabaseLike => ({
|
||||
engine: "sqlite",
|
||||
query: async () => "ok",
|
||||
execute: async () => ({ rowsAffected: 1 }),
|
||||
prepare: async () =>
|
||||
({
|
||||
id: "p",
|
||||
execute: async () => "row",
|
||||
finalize: async () => undefined,
|
||||
}) as PreparedStatement<unknown>,
|
||||
transaction: async (fn) =>
|
||||
await fn({
|
||||
query: async () => "tx-row",
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
commit: async () => undefined,
|
||||
rollback: async () => undefined,
|
||||
} as DbTransaction),
|
||||
close: async () => undefined,
|
||||
});
|
||||
|
||||
describe("wrapTenantScoped", () => {
|
||||
it("returns a TenantScopedDatabaseLike with the resolved tenantId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const admin = fakeAdmin();
|
||||
const tid = "acme" as TenantId;
|
||||
const scoped = wrapTenantScoped(admin, tid);
|
||||
expect(scoped.engine).toBe("sqlite");
|
||||
expect(scoped.tenantId).toBe(tid);
|
||||
});
|
||||
|
||||
it("forwards query and execute to the admin proxy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = fakeAdmin();
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
expect(await scoped.query("SELECT *")).toBe("ok");
|
||||
expect(await scoped.execute("INSERT a", {})).toEqual({
|
||||
rowsAffected: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards prepare when admin exposes it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = fakeAdmin();
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
const stmt = await scoped.prepare?.("SELECT *");
|
||||
expect(stmt).toBeDefined();
|
||||
expect(await stmt?.execute()).toBe("row");
|
||||
await stmt?.finalize();
|
||||
});
|
||||
|
||||
it("forwards transaction when admin exposes it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = fakeAdmin();
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
const result = await scoped.transaction?.(async (tx) => {
|
||||
return (await tx.query("SELECT *")) as string;
|
||||
});
|
||||
expect(result).toBe("tx-row");
|
||||
});
|
||||
|
||||
it("omits prepare entirely when admin lacks it", { timeout: 5000 }, () => {
|
||||
const admin = fakeAdmin();
|
||||
(admin as { prepare?: unknown }).prepare = undefined;
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
expect(scoped.prepare).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits transaction entirely when admin lacks it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const admin = fakeAdmin();
|
||||
(admin as { transaction?: unknown }).transaction = undefined;
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
expect(scoped.transaction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a fresh facade for each call", { timeout: 5000 }, () => {
|
||||
const admin = fakeAdmin();
|
||||
const a = wrapTenantScoped(admin, "x" as TenantId);
|
||||
const b = wrapTenantScoped(admin, "x" as TenantId);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("late-removed prepare on admin surfaces through prepared facade", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// wrapping with a present prepare returns a closure that rebinds
|
||||
// on every call; if the admin's prepare is later removed, the
|
||||
// closure observes UNSAFE_NOT_PERMITTED.
|
||||
const admin = fakeAdmin();
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
(admin as { prepare?: unknown }).prepare = undefined;
|
||||
await expect(scoped.prepare?.("SELECT *")).rejects.toThrow(
|
||||
/UNSAFE_NOT_PERMITTED|not support/
|
||||
);
|
||||
});
|
||||
|
||||
it("late-removed transaction on admin surfaces through facade", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const admin = fakeAdmin();
|
||||
const scoped = wrapTenantScoped(admin, "x" as TenantId);
|
||||
(admin as { transaction?: unknown }).transaction = undefined;
|
||||
await expect(
|
||||
scoped.transaction?.(async () => undefined)
|
||||
).rejects.toThrow(/UNSAFE_NOT_PERMITTED|not support/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
DbTransaction,
|
||||
PreparedStatement,
|
||||
TenantScopedDatabaseLike,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Wrap an admin-surface proxy as a {@link TenantScopedDatabaseLike}.
|
||||
*
|
||||
* **Tenant scoping is enforced on the main side.** This wrapper is
|
||||
* the worker-side type-level facade that prevents an admin reference
|
||||
* from being passed where a tenant-scoped one is expected. The
|
||||
* resolved tenant id is reachable on the returned object so handlers
|
||||
* can include it in audit logs, but the wrapper does NOT rewrite SQL
|
||||
* — that is the main-side adapter's job.
|
||||
*
|
||||
* `close()` is intentionally absent from the returned object because
|
||||
* `TenantScopedDatabaseLike` does not expose it. The wrapper's
|
||||
* lifetime is bound to the underlying admin proxy; the host disposes
|
||||
* the admin proxy when the request scope ends.
|
||||
*
|
||||
* `wrapTenantScoped(adminProxy, tenantId)` always returns a fresh
|
||||
* facade — the same admin proxy may back many tenant scopes (one per
|
||||
* request) without interference because the wrapper is stateless.
|
||||
*/
|
||||
export function wrapTenantScoped(
|
||||
adminProxy: AdminDatabaseLike,
|
||||
tenantId: TenantId
|
||||
): TenantScopedDatabaseLike {
|
||||
const ensurePrepare = (): NonNullable<AdminDatabaseLike["prepare"]> => {
|
||||
if (adminProxy.prepare === undefined) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
|
||||
"underlying adapter does not support prepared statements"
|
||||
);
|
||||
}
|
||||
return adminProxy.prepare.bind(adminProxy);
|
||||
};
|
||||
const ensureTransaction = (): NonNullable<
|
||||
AdminDatabaseLike["transaction"]
|
||||
> => {
|
||||
if (adminProxy.transaction === undefined) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
|
||||
"underlying adapter does not support transactions"
|
||||
);
|
||||
}
|
||||
return adminProxy.transaction.bind(adminProxy);
|
||||
};
|
||||
|
||||
const prepareFn = adminProxy.prepare
|
||||
? async <R = unknown>(sql: string): Promise<PreparedStatement<R>> =>
|
||||
await ensurePrepare()<R>(sql)
|
||||
: undefined;
|
||||
|
||||
const transactionFn = adminProxy.transaction
|
||||
? async <R>(fn: (tx: DbTransaction) => Promise<R>): Promise<R> =>
|
||||
await ensureTransaction()(fn)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
engine: adminProxy.engine,
|
||||
tenantId,
|
||||
query: (sql, params) => adminProxy.query(sql, params),
|
||||
execute: (sql, params) => adminProxy.execute(sql, params),
|
||||
...(prepareFn !== undefined ? { prepare: prepareFn } : {}),
|
||||
...(transactionFn !== undefined ? { transaction: transactionFn } : {}),
|
||||
} as TenantScopedDatabaseLike;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createDbProxyHarness,
|
||||
createInMemoryAdminDb,
|
||||
type DbProxyHarness,
|
||||
type InMemoryAdminDb,
|
||||
type InMemoryAdminDbOptions,
|
||||
type MemoryRow,
|
||||
} from "./testing.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# `testing`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides the in-memory test harness for
|
||||
`@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb`
|
||||
— a deterministic `AdminDatabaseLike` backed by a `Map` — and
|
||||
`createDbProxyHarness`, which pairs that adapter with a real
|
||||
`MessageChannel`, a `createDbProxyServer` on one port, and a
|
||||
`createDbProxyClient` on the other. Tests can drive the full
|
||||
client-port-server-adapter chain end-to-end without a real DB and
|
||||
without any mocking framework.
|
||||
|
||||
The harness exists in `src/testing/` (rather than `tests/`) so it
|
||||
can be imported by downstream Stage-4 adapter packages from the
|
||||
package's `"./testing"` subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- `createInMemoryAdminDb({ engine?, seed?, cursorDelayMs? })` — a
|
||||
full `AdminDatabaseLike` implementation including `prepare`,
|
||||
`transaction`, and a synthetic `SELECT-CURSOR *` that returns an
|
||||
`AsyncIterable<MemoryRow>` for cursor streaming tests.
|
||||
- `createDbProxyHarness(opts)` — bundles adapter + ports + server +
|
||||
client into one disposable handle.
|
||||
- Tiny command DSL (`INSERT <id>`, `DELETE <id>`, `SELECT *`,
|
||||
`SELECT-CURSOR *`, `COUNT`, `SELECT-SCALAR`, `THROW`) chosen so
|
||||
every code path in the proxy can be exercised without parsing real
|
||||
SQL.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDbProxyHarness } from "@suaveplan/api-db-worker-proxy/testing";
|
||||
|
||||
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
expect(await h.client.query("SELECT *")).toEqual([{ v: 1 }]);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
To assert that cursor cancellation propagates, set a per-row delay
|
||||
on the in-memory cursor and abort partway through:
|
||||
|
||||
```ts
|
||||
const h = createDbProxyHarness({
|
||||
seed: Array.from({ length: 100 }, (_, i) => [String(i), { i }]),
|
||||
cursorDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
let n = 0;
|
||||
for await (const _row of h.client.cursor("SELECT-CURSOR *", undefined, {
|
||||
signal: ac.signal,
|
||||
})) {
|
||||
if (++n >= 3) {
|
||||
ac.abort();
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(n).toBe(3);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInMemoryAdminDb(opts?): InMemoryAdminDb` — `AdminDatabaseLike`
|
||||
plus `rows: ReadonlyMap<string, MemoryRow>` and `closed(): boolean`
|
||||
for assertions.
|
||||
- `createDbProxyHarness(opts?): DbProxyHarness` — `{ db, server,
|
||||
client, ports, close() }`.
|
||||
- `InMemoryAdminDbOptions` — `{ engine?, seed?, cursorDelayMs? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory adapter ignores parameter placeholders for `INSERT` /
|
||||
`DELETE` and instead encodes the row key in the SQL string. This is
|
||||
intentional — the harness exists to exercise the proxy plumbing, not
|
||||
to validate a real query language. Concrete adapters (Stage 4) ship
|
||||
their own conformance suites under their own engine semantics.
|
||||
|
||||
The harness's `close()` does NOT call `client.close()` so tests can
|
||||
choose between observing graceful close (`await client.close()`) and
|
||||
abrupt disconnect (`client.dispose()`, then `harness.close()`). Both
|
||||
paths are first-class.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createDbProxyHarness, createInMemoryAdminDb } from "./testing.js";
|
||||
|
||||
describe("createInMemoryAdminDb", () => {
|
||||
it("accepts INSERT params as array (positional)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await db.execute("INSERT x", [{ v: 9 }]);
|
||||
expect(db.rows.get("x")).toEqual({ v: 9 });
|
||||
});
|
||||
|
||||
it("accepts INSERT with no params", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await db.execute("INSERT y");
|
||||
expect(db.rows.get("y")).toEqual({});
|
||||
});
|
||||
|
||||
it("supports INSERT / DELETE / COUNT / SELECT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
expect(await db.execute("INSERT a", { v: 1 })).toEqual({
|
||||
rowsAffected: 1,
|
||||
});
|
||||
expect(await db.execute("COUNT")).toEqual({ rowsAffected: 1 });
|
||||
expect(await db.query("SELECT *")).toEqual([{ v: 1 }]);
|
||||
expect(await db.execute("DELETE a")).toEqual({ rowsAffected: 1 });
|
||||
expect(await db.execute("DELETE a")).toEqual({ rowsAffected: 0 });
|
||||
});
|
||||
|
||||
it("throws on THROW directive (query and execute)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await expect(db.query("THROW")).rejects.toThrow();
|
||||
await expect(db.execute("THROW")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws on unknown sql", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await expect(db.query("UNKNOWN")).rejects.toThrow();
|
||||
await expect(db.execute("UNKNOWN")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("close marks closed and guards further use", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await db.close();
|
||||
expect(db.closed()).toBe(true);
|
||||
await expect(db.query("SELECT *")).rejects.toThrow();
|
||||
await expect(db.execute("COUNT")).rejects.toThrow();
|
||||
await expect(db.prepare?.("SELECT *")).rejects.toThrow();
|
||||
await expect(db.transaction?.(async () => undefined)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("SELECT-CURSOR yields each seeded row", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb({
|
||||
seed: [
|
||||
["a", { v: 1 }],
|
||||
["b", { v: 2 }],
|
||||
],
|
||||
});
|
||||
const iter = (await db.query(
|
||||
"SELECT-CURSOR *"
|
||||
)) as AsyncIterable<unknown>;
|
||||
const collected: unknown[] = [];
|
||||
for await (const row of iter) collected.push(row);
|
||||
expect(collected).toEqual([{ v: 1 }, { v: 2 }]);
|
||||
});
|
||||
|
||||
it("SELECT-CURSOR honours cursorDelayMs", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb({
|
||||
seed: [["a", { v: 1 }]],
|
||||
cursorDelayMs: 1,
|
||||
});
|
||||
const iter = (await db.query(
|
||||
"SELECT-CURSOR *"
|
||||
)) as AsyncIterable<unknown>;
|
||||
const t0 = Date.now();
|
||||
for await (const _row of iter) void _row;
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("SELECT-SCALAR returns row count", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb({ seed: [["a", { v: 1 }]] });
|
||||
expect(await db.query("SELECT-SCALAR")).toBe(1);
|
||||
});
|
||||
|
||||
it("prepare on INSERT routes through execute path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
const stmt = await db.prepare?.("INSERT a");
|
||||
expect(stmt).toBeDefined();
|
||||
const res = await stmt?.execute({ v: 9 });
|
||||
expect(res).toEqual({ rowsAffected: 1 });
|
||||
await stmt?.finalize();
|
||||
await expect(stmt?.execute({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("prepare on SELECT routes through query path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = createInMemoryAdminDb({ seed: [["a", { v: 1 }]] });
|
||||
const stmt = await db.prepare?.("SELECT *");
|
||||
expect(Array.isArray(await stmt?.execute())).toBe(true);
|
||||
await stmt?.finalize();
|
||||
});
|
||||
|
||||
it("transaction provides a working tx", { timeout: 5000 }, async () => {
|
||||
const db = createInMemoryAdminDb();
|
||||
await db.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
const rows = (await tx.query("SELECT *")) as ReadonlyArray<unknown>;
|
||||
expect(rows).toHaveLength(1);
|
||||
await tx.commit();
|
||||
await tx.rollback();
|
||||
});
|
||||
});
|
||||
|
||||
it("custom engine is honoured", { timeout: 5000 }, () => {
|
||||
const db = createInMemoryAdminDb({ engine: "postgres" });
|
||||
expect(db.engine).toBe("postgres");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDbProxyHarness", () => {
|
||||
it("wires the in-memory db to a real proxy round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
expect(h.client.engine).toBe("sqlite");
|
||||
expect(await h.client.query("SELECT *")).toEqual([{ v: 1 }]);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close on harness does not require closing the client first", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const h = createDbProxyHarness();
|
||||
h.close();
|
||||
// No assertion needed: must not throw.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
DatabaseEngine,
|
||||
DbParams,
|
||||
DbTransaction,
|
||||
PreparedStatement,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import {
|
||||
createInProcessPortPair,
|
||||
type InProcessPortPair,
|
||||
} from "@suaveplan/api-worker-rpc";
|
||||
import {
|
||||
createDbProxyServer,
|
||||
type DbProxyServer,
|
||||
} from "../main-side/main-side.js";
|
||||
import {
|
||||
createDbProxyClient,
|
||||
type DbProxyClient,
|
||||
} from "../worker-side/worker-side.js";
|
||||
|
||||
/** Row shape used by {@link createInMemoryAdminDb}. */
|
||||
export interface MemoryRow {
|
||||
readonly [column: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A trivial `AdminDatabaseLike` backed by a `Map<string, MemoryRow>`.
|
||||
*
|
||||
* Supports a tiny command DSL chosen for tests, not real SQL:
|
||||
*
|
||||
* - `"SELECT *"` — returns every row as an array.
|
||||
* - `"SELECT-CURSOR *"` — returns an `AsyncIterable<MemoryRow>` so the
|
||||
* server's `cursor` handler can exercise streaming.
|
||||
* - `"INSERT <id>"` with params `[row]` — inserts a row at key `<id>`.
|
||||
* - `"DELETE <id>"` — deletes the row at `<id>`.
|
||||
* - `"COUNT"` — returns `{ rowsAffected }` reflecting current size.
|
||||
* - `"THROW"` — throws so tests can drive the failure path.
|
||||
*/
|
||||
export interface InMemoryAdminDb extends AdminDatabaseLike {
|
||||
/** Direct read access to the underlying store (for assertions). */
|
||||
readonly rows: ReadonlyMap<string, MemoryRow>;
|
||||
/** Closed flag (for assertions). */
|
||||
readonly closed: () => boolean;
|
||||
}
|
||||
|
||||
export interface InMemoryAdminDbOptions {
|
||||
readonly engine?: DatabaseEngine;
|
||||
/** Per-row delay used by `SELECT-CURSOR *` (ms). Default 0. */
|
||||
readonly cursorDelayMs?: number;
|
||||
/** Pre-populate rows. */
|
||||
readonly seed?: ReadonlyArray<readonly [string, MemoryRow]>;
|
||||
}
|
||||
|
||||
export function createInMemoryAdminDb(
|
||||
opts: InMemoryAdminDbOptions = {}
|
||||
): InMemoryAdminDb {
|
||||
const rows = new Map<string, MemoryRow>();
|
||||
for (const [k, v] of opts.seed ?? []) rows.set(k, v);
|
||||
let isClosed = false;
|
||||
const engine = opts.engine ?? "sqlite";
|
||||
const cursorDelay = opts.cursorDelayMs ?? 0;
|
||||
|
||||
const guard = (): void => {
|
||||
if (isClosed) throw new Error("memory-db: closed");
|
||||
};
|
||||
|
||||
const runExecute = async (
|
||||
sql: string,
|
||||
params?: DbParams
|
||||
): Promise<{ rowsAffected: number }> => {
|
||||
guard();
|
||||
if (sql.startsWith("INSERT ")) {
|
||||
const id = sql.slice(7).trim();
|
||||
const payload =
|
||||
params !== undefined && Array.isArray(params)
|
||||
? (params[0] as MemoryRow)
|
||||
: ((params as MemoryRow | undefined) ?? ({} as MemoryRow));
|
||||
rows.set(id, payload);
|
||||
return { rowsAffected: 1 };
|
||||
}
|
||||
if (sql.startsWith("DELETE ")) {
|
||||
const id = sql.slice(7).trim();
|
||||
const had = rows.delete(id);
|
||||
return { rowsAffected: had ? 1 : 0 };
|
||||
}
|
||||
if (sql === "COUNT") return { rowsAffected: rows.size };
|
||||
if (sql === "THROW")
|
||||
throw new Error("memory-db: explicit THROW for tests");
|
||||
throw new Error(`memory-db: unknown execute sql ${sql}`);
|
||||
};
|
||||
|
||||
const runQuery = async <R = unknown>(
|
||||
sql: string,
|
||||
_params?: DbParams
|
||||
): Promise<R> => {
|
||||
guard();
|
||||
if (sql === "SELECT *") return [...rows.values()] as unknown as R;
|
||||
if (sql === "SELECT-CURSOR *") {
|
||||
const snapshot = [...rows.values()];
|
||||
const delay = cursorDelay;
|
||||
const iter: AsyncIterable<MemoryRow> = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const row of snapshot) {
|
||||
if (delay > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
yield row;
|
||||
}
|
||||
},
|
||||
};
|
||||
return iter as unknown as R;
|
||||
}
|
||||
if (sql === "SELECT-SCALAR") return rows.size as unknown as R;
|
||||
if (sql === "THROW")
|
||||
throw new Error("memory-db: explicit THROW for tests");
|
||||
throw new Error(`memory-db: unknown query sql ${sql}`);
|
||||
};
|
||||
|
||||
const prepare = async <R = unknown>(
|
||||
sql: string
|
||||
): Promise<PreparedStatement<R>> => {
|
||||
guard();
|
||||
let finalised = false;
|
||||
return {
|
||||
id: `stmt:${sql}`,
|
||||
execute: async (params) => {
|
||||
if (finalised) throw new Error("memory-db: stmt finalised");
|
||||
if (sql.startsWith("INSERT") || sql.startsWith("DELETE")) {
|
||||
return (await runExecute(sql, params)) as unknown as R;
|
||||
}
|
||||
return await runQuery<R>(sql, params);
|
||||
},
|
||||
finalize: async () => {
|
||||
finalised = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const transaction = async <R>(
|
||||
fn: (tx: DbTransaction) => Promise<R>
|
||||
): Promise<R> => {
|
||||
guard();
|
||||
const tx: DbTransaction = {
|
||||
query: (sql, params) => runQuery(sql, params),
|
||||
execute: (sql, params) => runExecute(sql, params),
|
||||
commit: async () => undefined,
|
||||
rollback: async () => undefined,
|
||||
};
|
||||
return await fn(tx);
|
||||
};
|
||||
|
||||
return {
|
||||
get engine() {
|
||||
return engine;
|
||||
},
|
||||
get rows() {
|
||||
return rows as ReadonlyMap<string, MemoryRow>;
|
||||
},
|
||||
closed: () => isClosed,
|
||||
query: runQuery,
|
||||
execute: runExecute,
|
||||
prepare,
|
||||
transaction,
|
||||
close: async () => {
|
||||
isClosed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paired in-process server + worker-side proxy backed by a fresh
|
||||
* {@link createInMemoryAdminDb}. The harness owns the port pair and
|
||||
* the server; the caller owns the returned {@link DbProxyClient}.
|
||||
*
|
||||
* Typical usage in a test:
|
||||
*
|
||||
* ```ts
|
||||
* const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
* try {
|
||||
* const rows = await h.client.query("SELECT *");
|
||||
* expect(rows).toEqual([{ v: 1 }]);
|
||||
* } finally {
|
||||
* await h.client.close();
|
||||
* h.close();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DbProxyHarness {
|
||||
readonly db: InMemoryAdminDb;
|
||||
readonly server: DbProxyServer;
|
||||
readonly client: DbProxyClient;
|
||||
readonly ports: InProcessPortPair;
|
||||
/** Closes the server and the port pair (does NOT close the client). */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function createDbProxyHarness(
|
||||
opts: InMemoryAdminDbOptions = {}
|
||||
): DbProxyHarness {
|
||||
const db = createInMemoryAdminDb(opts);
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createDbProxyServer(ports.serverPort, db);
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: db.engine,
|
||||
});
|
||||
return {
|
||||
db,
|
||||
server,
|
||||
client,
|
||||
ports,
|
||||
close: (): void => {
|
||||
server.close("harness teardown");
|
||||
ports.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createDbProxyClient,
|
||||
type DbProxyClient,
|
||||
type DbProxyClientOptions,
|
||||
} from "./worker-side.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# `worker-side`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes `createDbProxyClient(port, { engine })` — the
|
||||
untrusted-side endpoint of the worker DB proxy. It returns an
|
||||
`AdminDatabaseLike`-shaped object whose methods marshal across a
|
||||
`MessagePort` to the main side's `createDbProxyServer`. Worker plugin
|
||||
code uses the returned proxy exactly as it would use a direct adapter
|
||||
— `await db.query(...)`, `await db.transaction(async (tx) => ...)` —
|
||||
without knowing the real connection lives in another process.
|
||||
|
||||
The factory is synchronous because `AdminDatabaseLike.engine` is a
|
||||
synchronous getter; the caller supplies the engine identifier
|
||||
up-front (the host knows which engine it has provisioned, so this is
|
||||
free information).
|
||||
|
||||
## Features
|
||||
|
||||
- Full `AdminDatabaseLike` surface: `query`, `execute`, `prepare`,
|
||||
`transaction`, `close`, `engine`.
|
||||
- Bonus `cursor(sql, params)` returns an `AsyncIterable<unknown>`
|
||||
backed by `@suaveplan/api-worker-rpc` streaming; consumer `break`
|
||||
cancels the main-side cursor.
|
||||
- `transaction(fn)` auto-commits on resolve and auto-rolls-back on
|
||||
reject; the inner `tx` handle becomes invalid after the callback
|
||||
resolves and any subsequent call throws `DB_PROXY.TRANSACTION_LOST`.
|
||||
- `dispose()` (alongside the interface's `close`) tears down the
|
||||
underlying port without invoking the main-side `close()` handler.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDbProxyClient } from "@suaveplan/api-db-worker-proxy/worker-side";
|
||||
|
||||
const db = createDbProxyClient(port, { engine: "sqlite" });
|
||||
const rows = await db.query<MyRow[]>("SELECT * FROM users");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Streaming a large cursor without buffering everything in worker
|
||||
memory:
|
||||
|
||||
```ts
|
||||
const ac = new AbortController();
|
||||
let seen = 0;
|
||||
for await (const row of db.cursor("SELECT-CURSOR *", undefined, {
|
||||
signal: ac.signal,
|
||||
})) {
|
||||
if (++seen >= 100) {
|
||||
ac.abort();
|
||||
break;
|
||||
}
|
||||
process(row);
|
||||
}
|
||||
```
|
||||
|
||||
`db.transaction` wires commit/rollback through the same port, so the
|
||||
main-side transaction is bound to the worker's logical scope:
|
||||
|
||||
```ts
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute("INSERT user-1", { name: "Alice" });
|
||||
await tx.execute("INSERT user-2", { name: "Bob" });
|
||||
}); // auto-commit
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDbProxyClient(port, opts): DbProxyClient` — extends
|
||||
`AdminDatabaseLike` with `cursor(...)` and `dispose()`.
|
||||
- `DbProxyClientOptions.engine: DatabaseEngine` — supplied by host.
|
||||
- `DbProxyClient.cursor(sql, params?, { signal? }): AsyncIterable<unknown>`.
|
||||
- `DbProxyClient.dispose(): void` — close port without server round-trip.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Cursor iteration wraps `streamRequest` from
|
||||
`@suaveplan/api-worker-rpc` so the same backpressure policy (default
|
||||
64-frame bounded queue) governs row delivery. The wrapper translates
|
||||
any non-`DbProxyError` exception coming up from the stream into
|
||||
`DB_PROXY.STREAM_INTERRUPTED` so consumers can branch uniformly on
|
||||
the proxy's error namespace.
|
||||
|
||||
`transaction(fn)` uses a closure-flag (`inScope`) to gate every `tx`
|
||||
method; after the callback resolves and the proxy issues `tx.commit`,
|
||||
the flag flips and any leaked reference to `tx` throws
|
||||
`DB_PROXY.TRANSACTION_LOST`. The auto-commit is suppressed if the
|
||||
callback already invoked `tx.commit` itself (the second commit
|
||||
would otherwise raise `TRANSACTION_LOST` from the main side, which
|
||||
this layer catches and silences).
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
TenantScopedDatabaseLike,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { wrapTenantScoped } from "../tenant-scoped/tenant-scoped.js";
|
||||
import type { DbProxyClient } from "./worker-side.js";
|
||||
|
||||
describe("admin and tenant-scoped surfaces are distinct (type-level)", () => {
|
||||
it("DbProxyClient is assignable to AdminDatabaseLike", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<DbProxyClient>().toMatchTypeOf<AdminDatabaseLike>();
|
||||
});
|
||||
|
||||
it("DbProxyClient is NOT assignable to TenantScopedDatabaseLike", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// AdminDatabaseLike has `close`, TenantScopedDatabaseLike does NOT
|
||||
// — but more importantly the latter has a required `tenantId`. The
|
||||
// admin surface cannot satisfy the tenant-scoped slot.
|
||||
expectTypeOf<DbProxyClient>().not.toMatchTypeOf<TenantScopedDatabaseLike>();
|
||||
});
|
||||
|
||||
it("wrapTenantScoped output is TenantScopedDatabaseLike, NOT AdminDatabaseLike", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
type Scoped = ReturnType<typeof wrapTenantScoped>;
|
||||
expectTypeOf<Scoped>().toMatchTypeOf<TenantScopedDatabaseLike>();
|
||||
expectTypeOf<Scoped>().not.toMatchTypeOf<AdminDatabaseLike>();
|
||||
});
|
||||
|
||||
it("wrapTenantScoped requires a TenantId-branded string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf(wrapTenantScoped).parameter(1).toEqualTypeOf<TenantId>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
|
||||
import { createDbProxyServer } from "../main-side/main-side.js";
|
||||
import {
|
||||
createDbProxyHarness,
|
||||
createInMemoryAdminDb,
|
||||
} from "../testing/testing.js";
|
||||
import { createDbProxyClient } from "./worker-side.js";
|
||||
|
||||
describe("createDbProxyClient", () => {
|
||||
it("synchronous factory returns AdminDatabaseLike", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ports = createInProcessPortPair();
|
||||
const db = createInMemoryAdminDb();
|
||||
const server = createDbProxyServer(ports.serverPort, db);
|
||||
try {
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: "sqlite",
|
||||
});
|
||||
expect(client.engine).toBe("sqlite");
|
||||
client.dispose();
|
||||
} finally {
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispose() rejects subsequent calls with PROXY_DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
h.client.dispose();
|
||||
await expect(h.client.query("SELECT *")).rejects.toThrow();
|
||||
// dispose is idempotent
|
||||
h.client.dispose();
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close() forwards to server then disposes locally", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.close();
|
||||
expect(h.db.closed()).toBe(true);
|
||||
await expect(h.client.query("SELECT *")).rejects.toThrow();
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction auto-commits when callback resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
const result = await h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
const rows = (await tx.query(
|
||||
"SELECT *"
|
||||
)) as ReadonlyArray<unknown>;
|
||||
expect(rows).toHaveLength(1);
|
||||
return "ok";
|
||||
});
|
||||
expect(result).toBe("ok");
|
||||
expect(h.db.rows.get("a")).toEqual({ v: 1 });
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction auto-rolls-back when callback throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await expect(
|
||||
h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-rollback path swallows rollback errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Custom adapter whose `transaction()` returns a tx that throws on
|
||||
// rollback; the worker-side wrapper MUST swallow the rollback
|
||||
// failure and surface the original callback error instead.
|
||||
const ports = createInProcessPortPair();
|
||||
const { createDbProxyServer } = await import(
|
||||
"../main-side/main-side.js"
|
||||
);
|
||||
const adapter = {
|
||||
engine: "sqlite" as const,
|
||||
query: async () => undefined,
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
close: async () => undefined,
|
||||
transaction: async <R>(
|
||||
fn: (tx: {
|
||||
query: (s: string) => Promise<unknown>;
|
||||
execute: (s: string) => Promise<{ rowsAffected: number }>;
|
||||
commit: () => Promise<void>;
|
||||
rollback: () => Promise<void>;
|
||||
}) => Promise<R>
|
||||
): Promise<R> =>
|
||||
await fn({
|
||||
query: async () => undefined,
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
commit: async () => undefined,
|
||||
rollback: async () => {
|
||||
throw new Error("rollback explosion");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const server = createDbProxyServer(ports.serverPort, adapter);
|
||||
const client = createDbProxyClient(ports.clientPort, {
|
||||
engine: "sqlite",
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
client.transaction?.(async () => {
|
||||
throw new Error("original");
|
||||
})
|
||||
).rejects.toThrow("original");
|
||||
} finally {
|
||||
client.dispose();
|
||||
server.close();
|
||||
ports.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("tx handle leaked outside callback throws TRANSACTION_LOST", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
let leaked: unknown = null;
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
leaked = tx;
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
});
|
||||
const tx = leaked as {
|
||||
query: (sql: string) => Promise<unknown>;
|
||||
};
|
||||
await expect(tx.query("SELECT *")).rejects.toMatchObject({
|
||||
code: DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
});
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("tx.commit inside callback then resolve does not double-commit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
await tx.commit();
|
||||
});
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("tx leaked-after-callback rejects rollback too", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
let leaked: unknown = null;
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
leaked = tx;
|
||||
});
|
||||
const tx = leaked as { rollback: () => Promise<void> };
|
||||
await expect(tx.rollback()).rejects.toMatchObject({
|
||||
code: DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
});
|
||||
// also assert it IS a DbProxyError
|
||||
await tx.rollback().catch((e) => {
|
||||
expect(e).toBeInstanceOf(DbProxyError);
|
||||
});
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("tx execute / commit outside scope rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
let leaked: unknown = null;
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
leaked = tx;
|
||||
});
|
||||
const tx = leaked as {
|
||||
execute: (s: string) => Promise<unknown>;
|
||||
commit: () => Promise<void>;
|
||||
};
|
||||
await expect(tx.execute("INSERT b")).rejects.toThrow();
|
||||
await expect(tx.commit()).rejects.toThrow();
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prepare execute returns rows; finalize is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
const stmt = await h.client.prepare?.("SELECT *");
|
||||
expect(stmt).toBeDefined();
|
||||
if (stmt === undefined) throw new Error("prepare returned");
|
||||
const rows = await stmt.execute();
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
await stmt.finalize();
|
||||
await expect(stmt.execute()).rejects.toThrow();
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("query when port closed surfaces PROXY_DISPOSED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
h.client.dispose();
|
||||
try {
|
||||
await expect(h.client.execute("INSERT a", {})).rejects.toThrow();
|
||||
await expect(h.client.prepare?.("SELECT *")).rejects.toThrow();
|
||||
await expect(
|
||||
h.client.transaction?.(async () => undefined)
|
||||
).rejects.toThrow();
|
||||
expect(() => h.client.cursor("SELECT-CURSOR *")).toThrow();
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor with signal already aborted yields no rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: [["a", { v: 1 }]],
|
||||
});
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const collected: unknown[] = [];
|
||||
try {
|
||||
for await (const row of h.client.cursor(
|
||||
"SELECT-CURSOR *",
|
||||
undefined,
|
||||
{ signal: ac.signal }
|
||||
)) {
|
||||
collected.push(row);
|
||||
}
|
||||
} catch {
|
||||
// pre-aborted signal may surface as error; that's fine.
|
||||
}
|
||||
expect(collected.length).toBeLessThanOrEqual(1);
|
||||
} finally {
|
||||
await h.client.close().catch(() => undefined);
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor iterator return() finalises cleanly when reused", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: [["a", { v: 1 }]],
|
||||
});
|
||||
try {
|
||||
const iter = h.client
|
||||
.cursor("SELECT-CURSOR *")
|
||||
[Symbol.asyncIterator]();
|
||||
const r1 = await iter.next();
|
||||
expect(r1.done).toBe(false);
|
||||
const ret = await iter.return?.();
|
||||
expect(ret?.done).toBe(true);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
AdminDatabaseLike,
|
||||
DatabaseEngine,
|
||||
DbParams,
|
||||
DbTransaction,
|
||||
PreparedStatement,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { PortLike } from "@suaveplan/api-worker-rpc";
|
||||
import {
|
||||
createRawWorkerRpcClient,
|
||||
StreamDispatcher,
|
||||
streamRequest,
|
||||
type WorkerRpcClient,
|
||||
} from "@suaveplan/api-worker-rpc";
|
||||
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Worker-side proxy returned by {@link createDbProxyClient}. Beyond the
|
||||
* standard {@link AdminDatabaseLike} surface this object adds:
|
||||
*
|
||||
* - `cursor(sql, params)` — streaming row iterator backed by the
|
||||
* worker-rpc stream channel; consumer cancellation propagates to
|
||||
* the main side.
|
||||
* - `dispose()` — tears the proxy down, rejecting in-flight calls
|
||||
* with `DB_PROXY.PROXY_DISPOSED`.
|
||||
*
|
||||
* The engine identity is fetched once during construction so the
|
||||
* `engine` getter is synchronous as the type demands.
|
||||
*/
|
||||
export interface DbProxyClient extends AdminDatabaseLike {
|
||||
/**
|
||||
* Streaming cursor. Yields rows one at a time over the worker-rpc
|
||||
* stream channel; early `break` propagates cancel to the main side
|
||||
* which finalises the underlying iterator.
|
||||
*/
|
||||
cursor(
|
||||
sql: string,
|
||||
params?: DbParams,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): AsyncIterable<unknown>;
|
||||
/** Sever the underlying port; subsequent calls throw `PROXY_DISPOSED`. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createDbProxyClient}. */
|
||||
export interface DbProxyClientOptions {
|
||||
/**
|
||||
* The engine identifier advertised by the remote adapter. The
|
||||
* caller knows this at construction time because the host wires
|
||||
* the adapter — re-fetching it would force a round-trip and turn
|
||||
* the factory async (breaking the synchronous `engine` getter
|
||||
* mandated by {@link AdminDatabaseLike}).
|
||||
*/
|
||||
readonly engine: DatabaseEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a worker-side proxy. The caller MUST pass a {@link PortLike}
|
||||
* already paired with a {@link createDbProxyServer} on the main side.
|
||||
*
|
||||
* `engine` is provided up-front because {@link AdminDatabaseLike}'s
|
||||
* `engine` is a synchronous getter; the host knows which engine it
|
||||
* has provisioned and propagates the value through the worker's boot
|
||||
* envelope.
|
||||
*/
|
||||
export function createDbProxyClient(
|
||||
port: PortLike,
|
||||
opts: DbProxyClientOptions
|
||||
): DbProxyClient {
|
||||
const dispatcher = new StreamDispatcher();
|
||||
const client = createRawWorkerRpcClient(port, {
|
||||
onStreamFrame: (frame) => dispatcher.dispatch(frame),
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
const guard = (): void => {
|
||||
if (disposed) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.PROXY_DISPOSED,
|
||||
"db proxy has been disposed"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const callRemote = async <R>(
|
||||
method: string,
|
||||
params: ReadonlyArray<unknown>
|
||||
): Promise<R> => {
|
||||
guard();
|
||||
return await client.call<R>(method, params);
|
||||
};
|
||||
|
||||
const engine = opts.engine;
|
||||
|
||||
const prepare = async <R = unknown>(
|
||||
sql: string
|
||||
): Promise<PreparedStatement<R>> => {
|
||||
const { id } = await callRemote<{ readonly id: number }>(
|
||||
"prepare.open",
|
||||
[sql]
|
||||
);
|
||||
const handleId = String(id);
|
||||
let finalised = false;
|
||||
return {
|
||||
id: handleId,
|
||||
execute: async (params) => {
|
||||
if (finalised) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.PROXY_DISPOSED,
|
||||
`prepared statement ${handleId} has been finalised`
|
||||
);
|
||||
}
|
||||
return (await callRemote("prepare.execute", [id, params])) as R;
|
||||
},
|
||||
finalize: async () => {
|
||||
if (finalised) return;
|
||||
finalised = true;
|
||||
await callRemote<null>("prepare.finalize", [id]);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const transaction = async <R>(
|
||||
fn: (tx: DbTransaction) => Promise<R>
|
||||
): Promise<R> => {
|
||||
const { id } = await callRemote<{ readonly id: number }>(
|
||||
"tx.begin",
|
||||
[]
|
||||
);
|
||||
let inScope = true;
|
||||
const guardScope = (): void => {
|
||||
if (!inScope) {
|
||||
throw new DbProxyError(
|
||||
DB_PROXY_CODES.TRANSACTION_LOST,
|
||||
"transaction handle used outside its callback scope"
|
||||
);
|
||||
}
|
||||
};
|
||||
const tx: DbTransaction = {
|
||||
query: async (sql, params) => {
|
||||
guardScope();
|
||||
return await callRemote("tx.query", [id, sql, params]);
|
||||
},
|
||||
execute: async (sql, params) => {
|
||||
guardScope();
|
||||
return (await callRemote("tx.execute", [id, sql, params])) as {
|
||||
readonly rowsAffected: number;
|
||||
};
|
||||
},
|
||||
commit: async () => {
|
||||
guardScope();
|
||||
inScope = false;
|
||||
await callRemote<null>("tx.commit", [id]);
|
||||
},
|
||||
rollback: async () => {
|
||||
guardScope();
|
||||
inScope = false;
|
||||
await callRemote<null>("tx.rollback", [id]);
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await fn(tx);
|
||||
// Auto-commit if the callback didn't explicitly commit/rollback.
|
||||
if (inScope) {
|
||||
inScope = false;
|
||||
await callRemote<null>("tx.commit", [id]);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (inScope) {
|
||||
inScope = false;
|
||||
await callRemote<null>("tx.rollback", [id]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const proxy: DbProxyClient = {
|
||||
engine,
|
||||
query: async (sql, params) => {
|
||||
return (await callRemote("query", [sql, params])) as never;
|
||||
},
|
||||
execute: async (sql, params) => {
|
||||
return (await callRemote("execute", [sql, params])) as {
|
||||
readonly rowsAffected: number;
|
||||
};
|
||||
},
|
||||
prepare,
|
||||
transaction,
|
||||
cursor: (sql, params, opts) => {
|
||||
guard();
|
||||
return wrapCursorStream(
|
||||
client,
|
||||
dispatcher,
|
||||
sql,
|
||||
params,
|
||||
opts?.signal
|
||||
);
|
||||
},
|
||||
close: async () => {
|
||||
try {
|
||||
await callRemote<null>("close", []);
|
||||
} finally {
|
||||
disposed = true;
|
||||
client.close("closed by client");
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
client.close("disposed");
|
||||
},
|
||||
};
|
||||
return proxy;
|
||||
}
|
||||
|
||||
function wrapCursorStream(
|
||||
client: WorkerRpcClient,
|
||||
dispatcher: StreamDispatcher,
|
||||
sql: string,
|
||||
params: DbParams | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): AsyncIterable<unknown> {
|
||||
// `streamRequest` already surfaces stream errors as
|
||||
// reconstructed exceptions (via `envelopeToError`); we delegate
|
||||
// straight through so the consumer's `for await` sees the same
|
||||
// shape it would see from a direct adapter. Stream-interrupted
|
||||
// failures from the main side already carry the
|
||||
// `DB_PROXY.STREAM_INTERRUPTED` code through the error envelope.
|
||||
return streamRequest(
|
||||
client,
|
||||
dispatcher,
|
||||
"cursor",
|
||||
[sql, params],
|
||||
signal !== undefined ? { signal } : {}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* §16 conformance proof — the worker-proxy package consumes the
|
||||
* `@suaveplan/api-worker-rpc` contract directly (client + server +
|
||||
* MessagePort transport). This test exercises the canonical
|
||||
* `runWorkerRpcConformance` harness with a minimal handler set to
|
||||
* prove the consumer is wire-compatible end-to-end (request frame,
|
||||
* response frame, abort propagation) using the same port transport
|
||||
* the package builds on top of in `src/main-side` / `src/worker-side`.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
|
||||
|
||||
const handles: Array<{ dispose(): void }> = [];
|
||||
afterEach(() => {
|
||||
while (handles.length > 0) handles.pop()?.dispose();
|
||||
});
|
||||
|
||||
describe("api-worker-rpc contract conformance (consumer-side)", () => {
|
||||
it("round-trips a request through the canonical harness", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({
|
||||
echo: (params: ReadonlyArray<unknown>) => params[0],
|
||||
}));
|
||||
handles.push(h);
|
||||
const result = await h.client.call<number>("echo", [42]);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("surfaces UNKNOWN_METHOD from the server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
handles.push(h);
|
||||
await expect(h.client.call("missing", [])).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createDbProxyHarness } from "../../src/testing/testing.js";
|
||||
|
||||
/**
|
||||
* End-to-end conformance smoke. The full `runDbAdapterConformance`
|
||||
* harness lives in `@suaveplan/api-db` (Stage 4); this file pins the
|
||||
* scenarios that downstream adapters MUST observe through the proxy
|
||||
* surface so the proxy's protocol is locked before Stage-4 work
|
||||
* starts.
|
||||
*/
|
||||
describe("db-proxy conformance against in-memory adapter", () => {
|
||||
it("CRUD round-trip via the proxy", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
expect(h.client.engine).toBe("sqlite");
|
||||
const inserted = await h.client.execute("INSERT a", { v: 1 });
|
||||
expect(inserted.rowsAffected).toBe(1);
|
||||
const rows =
|
||||
await h.client.query<ReadonlyArray<unknown>>("SELECT *");
|
||||
expect(rows).toEqual([{ v: 1 }]);
|
||||
const deleted = await h.client.execute("DELETE a");
|
||||
expect(deleted.rowsAffected).toBe(1);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transaction commit persists; rollback path runs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT a", { v: 1 });
|
||||
});
|
||||
expect(h.db.rows.get("a")).toEqual({ v: 1 });
|
||||
await expect(
|
||||
h.client.transaction?.(async (tx) => {
|
||||
await tx.execute("INSERT b", { v: 2 });
|
||||
throw new Error("rollback me");
|
||||
})
|
||||
).rejects.toThrow("rollback me");
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cursor: 1000-row stream yields incrementally", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const h = createDbProxyHarness({
|
||||
seed: Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => [String(i), { i }] as const
|
||||
),
|
||||
});
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
let firstRowAt = -1;
|
||||
let count = 0;
|
||||
for await (const _row of h.client.cursor("SELECT-CURSOR *")) {
|
||||
if (count === 0) firstRowAt = Date.now() - t0;
|
||||
count += 1;
|
||||
}
|
||||
expect(count).toBe(1000);
|
||||
expect(firstRowAt).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prepared statement round-trips", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
const stmt = await h.client.prepare?.("SELECT *");
|
||||
expect(stmt).toBeDefined();
|
||||
if (stmt === undefined) throw new Error("prepare returned");
|
||||
expect(Array.isArray(await stmt.execute())).toBe(true);
|
||||
await stmt.finalize();
|
||||
} finally {
|
||||
await h.client.close();
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("close is observable on the main side", { timeout: 5000 }, async () => {
|
||||
const h = createDbProxyHarness();
|
||||
try {
|
||||
await h.client.close();
|
||||
expect(h.db.closed()).toBe(true);
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-db-worker-proxy/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-db-worker-proxy/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", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-db-worker-proxy/vite.config.ts
Normal file
30
packages/api-stack/api/api-db-worker-proxy/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-db-worker-proxy",
|
||||
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\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-db-worker-proxy/vitest.config.ts
Normal file
30
packages/api-stack/api/api-db-worker-proxy/vitest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user