From 6d0896cec107a520e6671bfe47fbf3394a451bea Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 24 Jul 2026 02:32:27 -0400 Subject: [PATCH 1/2] feat: support Bun as a Caplets process runtime --- .changeset/fuzzy-buns-travel.md | 9 + .github/workflows/ci.yml | 31 + CONTEXT.md | 16 +- README.md | 6 +- ...e-runtime-support-by-execution-boundary.md | 3 + mise.toml | 2 +- package.json | 2 + packages/cli/package.json | 1 + packages/core/package.json | 7 +- packages/core/rolldown.config.ts | 2 +- packages/core/src/admin-api/router.ts | 22 +- packages/core/src/cli.ts | 5 +- packages/core/src/serve/http.ts | 292 ++++++- .../core/src/storage/backend-auth-flows.ts | 84 +- packages/core/src/storage/backend-auth.ts | 104 +-- packages/core/src/storage/caplet-records.ts | 411 +++++----- packages/core/src/storage/coordination.ts | 67 +- .../core/src/storage/dashboard-sessions.ts | 76 +- packages/core/src/storage/database.ts | 142 +++- packages/core/src/storage/idempotency.ts | 163 ++-- packages/core/src/storage/installations.ts | 292 +++---- packages/core/src/storage/legacy-migration.ts | 88 +-- packages/core/src/storage/migrations.ts | 142 ++-- .../core/src/storage/operator-activity.ts | 67 +- packages/core/src/storage/project-bindings.ts | 22 +- packages/core/src/storage/remote-security.ts | 133 ++-- packages/core/src/storage/setup-state.ts | 100 +-- packages/core/src/storage/types.ts | 4 +- packages/core/src/storage/vault-grants.ts | 212 ++--- packages/core/src/storage/vault-state.ts | 10 +- packages/core/src/storage/vault-values.ts | 103 +-- packages/core/src/telemetry/events.ts | 3 +- packages/core/src/telemetry/index.ts | 5 + packages/core/src/telemetry/privacy.ts | 3 +- packages/core/src/telemetry/providers.ts | 23 + .../core/src/telemetry/runtime-environment.ts | 22 + packages/core/src/telemetry/runtime.ts | 5 +- .../test/backend-auth-flow-storage.test.ts | 70 +- .../test/backend-auth-state-table.test.ts | 56 +- .../core/test/backend-auth-storage.test.ts | 6 +- .../core/test/caplet-bundle-streaming.test.ts | 2 +- .../core/test/caplet-record-storage.test.ts | 4 +- .../test/current-host-administration.test.ts | 6 +- .../core/test/dashboard-session-store.test.ts | 6 +- packages/core/test/dashboard-session.test.ts | 27 +- .../core/test/host-storage-config.test.ts | 28 +- packages/core/test/host-storage.test.ts | 156 ++-- .../core/test/idempotency-storage.test.ts | 12 +- .../core/test/installation-storage.test.ts | 4 +- .../core/test/project-binding-storage.test.ts | 4 +- .../core/test/remote-security-storage.test.ts | 14 +- .../core/test/setup-state-storage.test.ts | 23 +- .../core/test/storage-records-cli.test.ts | 8 +- packages/core/test/telemetry-events.test.ts | 6 +- .../core/test/telemetry-providers.test.ts | 73 ++ packages/core/test/telemetry-runtime.test.ts | 14 + .../core/test/vault-grant-storage.test.ts | 8 +- .../core/test/vault-state-storage.test.ts | 20 +- .../core/test/vault-value-storage.test.ts | 29 +- packages/sdk/README.md | 6 +- packages/sdk/package.json | 3 - pnpm-lock.yaml | 726 +++++++++++------- scripts/check-package-runtime.mjs | 4 +- scripts/package-runtime-plan-000-smoke.mjs | 27 +- 64 files changed, 2469 insertions(+), 1552 deletions(-) create mode 100644 .changeset/fuzzy-buns-travel.md create mode 100644 docs/adr/0009-scope-runtime-support-by-execution-boundary.md create mode 100644 packages/core/src/telemetry/runtime-environment.ts diff --git a/.changeset/fuzzy-buns-travel.md b/.changeset/fuzzy-buns-travel.md new file mode 100644 index 00000000..b7eace70 --- /dev/null +++ b/.changeset/fuzzy-buns-travel.md @@ -0,0 +1,9 @@ +--- +"@caplets/core": minor +"caplets": minor +"@caplets/sdk": patch +"@caplets/opencode": patch +"@caplets/pi": patch +--- + +Support Bun 1.3.14 and newer as a Caplets process runtime while retaining Node.js 22 and newer as the default runtime. Use a cross-runtime asynchronous SQLite adapter, runtime-native HTTP and telemetry integrations, and release-blocking Node and Bun verification matrices. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b1347af..455dbf24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,37 @@ jobs: - name: Run quality gates run: pnpm verify + verify-bun: + name: Verify (Bun ${{ matrix.bun-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bun-version: [1.3.14, latest] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.bun-version }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Bun-native quality gates + run: pnpm verify:bun + postgres-contracts: name: PostgreSQL contracts runs-on: ubuntu-latest diff --git a/CONTEXT.md b/CONTEXT.md index a1c3d2d5..7518e71b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,18 @@ _Avoid_: All runtime files, shared cache A running server instance participating in one logical Caplets host. Host Nodes share Authoritative Host State but own their live Code Mode heap, bound workspace copy, and any node-local artifacts. _Avoid_: Independent host, tenant +**Caplets Process**: +A command-line or server process launched through the Caplets executable. Native agent integrations run inside their own extension hosts, and Caplets SDK consumers are outside this boundary. +_Avoid_: Caplets runtime, all Caplets packages + +**Native Agent Integration**: +A Caplets adapter loaded into a coding agent's extension host. Its runtime compatibility follows that host's supported environments rather than the Caplets Process contract. +_Avoid_: Caplets subprocess, standalone Caplets runtime + +**Target Project Package Manager**: +The package command selected for repository-specific actions authored into a Caplet. It is independent of the environment that runs the Caplets Process. +_Avoid_: Caplets package manager, Caplets runtime + **Caplets exposure projection**: A shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets. MCP, native, and attach host adapters render it differently; they do not own exposure policy or execution behavior. _Avoid_: Tool registration list, exposure wrapper map, transport-specific surface @@ -105,8 +117,8 @@ The canonical `/api/v2/admin/*` HTTP resource interface through which Operator b _Avoid_: Remote CLI RPC, dashboard backend, mixed-auth endpoint, dashboard Admin alias **Caplets SDK**: -The typed client Module for the canonical public Caplets HTTP API and the versioned Attach Project Binding WebSocket session protocol. It excludes MCP and dashboard-private authentication ceremonies. -_Avoid_: Admin client, core client, dashboard session client +The typed, web-platform client Module for the canonical public Caplets HTTP API and the versioned Attach Project Binding WebSocket session protocol. Platform-specific host capabilities are separate exports; MCP and dashboard-private authentication ceremonies are excluded. +_Avoid_: Node-only client, Admin client, core client, dashboard session client **Operator Activity Log**: A host-owned record of sensitive Operator Client actions performed through the dashboard or operator admin surfaces. diff --git a/README.md b/README.md index c1b5c552..2828293d 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ requires a fresh dashboard login. ## JavaScript and TypeScript SDK -Install `@caplets/sdk` for an isolated, typed public HTTP client in Node.js 22+ or a modern browser. +Install `@caplets/sdk` for an isolated, typed public HTTP client in a modern web-platform JavaScript runtime. Pass the Current Host Origin explicitly: ```ts @@ -356,8 +356,8 @@ See [GitHub Releases](https://github.com/spiritledsoftware/caplets/releases) for ## Repository -This monorepo uses pnpm. Published packages support Node.js `>=22`; CI verifies -that support floor and the current Node.js LTS, while owned runtime images use current LTS. +This monorepo uses pnpm. Caplets Processes support Node.js `>=22` and Bun `>=1.3.14`; Node remains +the default launcher and image runtime. CI verifies the minimum and current release lines for both. ```sh pnpm install --frozen-lockfile diff --git a/docs/adr/0009-scope-runtime-support-by-execution-boundary.md b/docs/adr/0009-scope-runtime-support-by-execution-boundary.md new file mode 100644 index 00000000..b6e81b5c --- /dev/null +++ b/docs/adr/0009-scope-runtime-support-by-execution-boundary.md @@ -0,0 +1,3 @@ +# Scope Runtime Support by Execution Boundary + +Caplets Processes support Node.js 22 or newer and Bun 1.3.14 or newer. Node remains the default for the `caplets` shebang and official runtime images; Bun execution is explicit through Bun's launcher. pnpm remains the sole dependency manager, while the complete verification toolchain and built-package runtime smoke are release-blocking under the minimum and latest supported Bun versions. The local backend remains SQLite, but its implementation uses the cross-runtime `@libsql/client` with Drizzle's asynchronous libSQL adapter; all storage operations are asynchronous and SQLite transactions acquire the write lock immediately. Existing SQLite files remain the storage contract, with no hosted Turso or synchronization feature. On Bun, Caplets uses Bun's native HTTP and WebSocket server; its runtime-owned in-flight body allocation is budgeted separately while smoke tests still reject an additional whole-body application buffer. The web-platform Caplets SDK stays capability-based, Native Agent Integrations inherit their peer hosts' supported runtimes, and Target Project Package Managers remain independent of the runtime executing Caplets. diff --git a/mise.toml b/mise.toml index 6038335c..565ea817 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -bun = "1" +bun = "1.3.14" node = "24" pnpm = "11.7.0" diff --git a/package.json b/package.json index afc33abe..908f175f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "telemetry:prepare-release-env": "tsx ./scripts/check-telemetry-release-env.ts --write-bundled-intake", "typecheck": "tsc --noEmit && turbo typecheck", "verify": "pnpm format:check && pnpm lint && pnpm code-mode:check-api && pnpm schema:check && pnpm storage:check && pnpm compose:check && pnpm docs:check && pnpm openapi:check && pnpm typecheck && pnpm test && pnpm benchmark:check && pnpm build", + "verify:bun": "pnpm verify && bun scripts/check-package-runtime.mjs", "version-packages": "changeset version && node scripts/sync-compose-image-version.mjs --write && oxlint --fix --quiet && oxfmt --write ." }, "devDependencies": { @@ -71,6 +72,7 @@ "vitest": "^4.1.10" }, "engines": { + "bun": ">=1.3.14", "node": ">=22" }, "packageManager": "pnpm@11.7.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index 5748f26d..61c3f7e6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,6 +52,7 @@ "vitest": "^4.1.10" }, "engines": { + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/core/package.json b/packages/core/package.json index 5f67a0ac..24a52787 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -107,12 +107,13 @@ "@hono/mcp": "^0.3.1", "@hono/node-server": "^2.0.10", "@hono/zod-openapi": "1.5.1", + "@libsql/client": "^0.17.4", "@modelcontextprotocol/sdk": "^1.29.0", - "@sentry/node": "^10.66.0", + "@sentry/bun": "^10.67.0", + "@sentry/node": "^10.67.0", "@ungap/structured-clone": "^1.3.3", "add-mcp": "1.13.0", "ajv": "^8.20.0", - "better-sqlite3": "^12.11.1", "commander": "^15.0.0", "drizzle-orm": "^0.45.2", "formdata-node": "^6.0.3", @@ -135,7 +136,6 @@ "zod": "^4.4.3" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", "@types/node": "^26.1.1", "@types/pg": "^8.20.0", "@types/semver": "^7.7.1", @@ -145,6 +145,7 @@ "vitest": "^4.1.10" }, "engines": { + "bun": ">=1.3.14", "node": ">=22" } } diff --git a/packages/core/rolldown.config.ts b/packages/core/rolldown.config.ts index 23519b61..9573a094 100644 --- a/packages/core/rolldown.config.ts +++ b/packages/core/rolldown.config.ts @@ -18,7 +18,7 @@ export default defineConfig([ }, plugins: runtimeSentryPlugins("core"), external: [ - "better-sqlite3", + "@libsql/client", "jsonc-parser", "quickjs-emscripten", "typescript", diff --git a/packages/core/src/admin-api/router.ts b/packages/core/src/admin-api/router.ts index 6a1f3dc7..0a01018b 100644 --- a/packages/core/src/admin-api/router.ts +++ b/packages/core/src/admin-api/router.ts @@ -654,7 +654,7 @@ async function validateBundleUploadRequest( throw new CapletsError("REQUEST_INVALID", "The Caplet Bundle upload body is required."); } const parsed = await parseAdminBundleUpload({ - input: Readable.fromWeb(context.req.raw.body as never), + input: requestBodyReadable(context.req.raw.body), contentType: context.req.header("Content-Type"), contentLength: context.req.header("Content-Length"), admission: options.bundleUploadAdmission, @@ -669,6 +669,26 @@ async function validateBundleUploadRequest( }; } +function requestBodyReadable(body: ReadableStream): Readable { + return Readable.from(requestBodyChunks(body), { + highWaterMark: 64 * 1024, + objectMode: false, + }); +} + +async function* requestBodyChunks(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) return; + yield Buffer.from(chunk.value.buffer, chunk.value.byteOffset, chunk.value.byteLength); + } + } finally { + reader.releaseLock(); + } +} + function validateRouteHeaders(context: Context, definition: AdminV2RouteDefinition): void { const schema = adminV2RequestHeadersForDefinition(definition); if (!schema) return; diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 4d52e3c3..5a8d7401 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -155,6 +155,7 @@ import { type TelemetrySurface, writeTelemetryAttribution, } from "./telemetry"; +import { runtimeDescriptor } from "./telemetry/runtime-environment"; import { maybePrintUpdateNotice } from "./update-check"; import { VAULT_MAX_VALUE_BYTES, @@ -615,6 +616,7 @@ async function captureCliTelemetry( if (options.outcome !== "failure") return; if (options.error === undefined) return; + const runtime = runtimeDescriptor(); const reliability = buildReliabilityTelemetryEvent({ name: "caplets_reliability_error", properties: { @@ -627,7 +629,8 @@ async function captureCliTelemetry( diagnostic_category: diagnosticCategoryForError(options.error), os_family: platform(), arch: architectureForTelemetry(), - node_major: Number(process.versions.node.split(".")[0] ?? 0), + runtime_name: runtime.name, + runtime_major: runtime.major, }, error: options.error, }); diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 303e3892..086322b3 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -4,13 +4,19 @@ import { dirname, isAbsolute, relative, resolve } from "node:path"; import { StreamableHTTPTransport } from "@hono/mcp"; import { serve, - upgradeWebSocket, + upgradeWebSocket as upgradeNodeWebSocket, type Http2Bindings, type HttpBindings, - type ServerType, type WebSocketServerLike, } from "@hono/node-server"; import { Hono, type MiddlewareHandler } from "hono"; +import { + createWSMessageEvent, + defineWebSocketHelper, + WSContext, + type UpgradeWebSocket, + type WSEvents, +} from "hono/ws"; import { logger } from "hono/logger"; import { WebSocketServer } from "ws"; import { defaultCapletsLockfilePath, resolveCapletsRoot, vaultStoreForAuthDir } from "../config"; @@ -104,6 +110,109 @@ import { CapletsMcpSession } from "./session"; import { readLimitedJsonObject } from "./request-body"; type RemoteCredentialStore = RemoteServerCredentialStore | RemoteSecurityStore; +type HttpServer = { + close(callback: (error?: Error) => void): void; + closeAllConnections?(): void; +}; +type BunRuntimeServer = { + stop(closeActiveConnections?: boolean): Promise; +}; + +type BunServeOptions = { + fetch: CapletsHttpApp["fetch"]; + hostname: string; + port: number; + websocket: BunWebSocketHandler; +}; + +type BunRuntime = { + serve(options: BunServeOptions): BunRuntimeServer; +}; +type BunWebSocketData = { + events: WSEvents; + protocol: string; + url: URL; +}; + +type BunServerWebSocket = { + close(code?: number, reason?: string): void; + data: BunWebSocketData; + readyState: 0 | 1 | 2 | 3; + send(data: string | ArrayBuffer | Uint8Array, compress?: boolean): void; +}; + +type BunWebSocketHandler = { + close(socket: BunServerWebSocket, code?: number, reason?: string): void; + message(socket: BunServerWebSocket, message: string | { buffer: ArrayBufferLike }): void; + open(socket: BunServerWebSocket): void; +}; + +type BunWebSocketUpgradeServer = { + upgrade(request: Request, options: { data: BunWebSocketData }): boolean; +}; + +const upgradeBunWebSocket = defineWebSocketHelper((context, events) => { + const server = bunWebSocketUpgradeServer(context.env); + if (!server) throw new Error("Bun WebSocket upgrade API is unavailable."); + const upgraded = server.upgrade(context.req.raw, { + data: { + events, + protocol: context.req.header("sec-websocket-protocol")?.split(",")[0]?.trim() ?? "", + url: new URL(context.req.url), + }, + }); + return upgraded ? new Response(null) : undefined; +}); + +const bunWebSocket: BunWebSocketHandler = { + open(socket) { + socket.data.events.onOpen?.(new Event("open"), bunWebSocketContext(socket)); + }, + close(socket, code, reason) { + socket.data.events.onClose?.( + new CloseEvent("close", { + ...(code === undefined ? {} : { code }), + ...(reason === undefined ? {} : { reason }), + }), + bunWebSocketContext(socket), + ); + }, + message(socket, message) { + socket.data.events.onMessage?.( + createWSMessageEvent(typeof message === "string" ? message : message.buffer), + bunWebSocketContext(socket), + ); + }, +}; +function bunWebSocketUpgradeServer(env: unknown): BunWebSocketUpgradeServer | undefined { + let candidate = env; + if (candidate !== null && typeof candidate === "object" && "server" in candidate) { + candidate = Reflect.get(candidate, "server"); + } + if ( + candidate === null || + typeof candidate !== "object" || + typeof Reflect.get(candidate, "upgrade") !== "function" + ) { + return undefined; + } + return candidate as BunWebSocketUpgradeServer; +} + +function bunWebSocketContext(socket: BunServerWebSocket): WSContext { + return new WSContext({ + close: (code, reason) => socket.close(code, reason), + protocol: socket.data.protocol, + raw: socket, + readyState: socket.readyState, + send: (source, options) => socket.send(source, options.compress), + url: socket.data.url, + }); +} + +const upgradeProjectBindingWebSocket = ( + process.versions.bun ? upgradeBunWebSocket : upgradeNodeWebSocket +) as UpgradeWebSocket; type HttpServeIo = { writeErr?: (value: string) => void; @@ -1119,7 +1228,10 @@ export function createHttpServeApp( 404, ); } - return existing.transport.handleRequest(c); + const response = await existing.transport.handleRequest(c); + return process.versions.bun && c.req.method === "GET" + ? prependEventStreamPreamble(response) + : response; } if (c.req.method !== "POST") { @@ -1427,7 +1539,7 @@ export function createHttpServeApp( if (!validation.ok) return validation.response; return next(); }, - upgradeWebSocket((c) => { + upgradeProjectBindingWebSocket((c) => { const validation = projectBindingSocketRecordForRequest(c); let socketAttached = false; return { @@ -2805,22 +2917,14 @@ export async function serveHttp( }); const origin = `http://${formatHost(options.host)}:${options.port}`; const baseUrl = origin; - const server = serve( - { - fetch: createNodeServerFetch(app), - hostname: options.host, - port: options.port, - websocket: { server: createProjectBindingWebSocketServer() }, - }, - () => { - writeErr(`Caplets HTTP service listening on ${baseUrl}\n`); - writeErr(`MCP endpoint: ${origin}${CURRENT_HOST_NAMESPACES.mcp}\n`); - writeErr(`Attach manifest: ${origin}${currentHostV1Path("attachManifest")}\n`); - writeErr(`Admin endpoint: ${origin}${CURRENT_HOST_PATHS.admin}\n`); - writeErr(`Health check: ${origin}${CURRENT_HOST_PATHS.health}\n`); - writeErr(`Auth: ${authDescription(options)}\n`); - }, - ); + const server = startHttpRuntimeServer(options, app, () => { + writeErr(`Caplets HTTP service listening on ${baseUrl}\n`); + writeErr(`MCP endpoint: ${origin}${CURRENT_HOST_NAMESPACES.mcp}\n`); + writeErr(`Attach manifest: ${origin}${currentHostV1Path("attachManifest")}\n`); + writeErr(`Admin endpoint: ${origin}${CURRENT_HOST_PATHS.admin}\n`); + writeErr(`Health check: ${origin}${CURRENT_HOST_PATHS.health}\n`); + writeErr(`Auth: ${authDescription(options)}\n`); + }); installHttpSignalHandlers(server, app, engine, writeErr); } @@ -2853,21 +2957,13 @@ export async function serveHttpWithSessionFactory( }); const origin = `http://${formatHost(options.host)}:${options.port}`; const baseUrl = origin; - const server = serve( - { - fetch: createNodeServerFetch(app), - hostname: options.host, - port: options.port, - websocket: { server: createProjectBindingWebSocketServer() }, - }, - () => { - writeErr(`Caplets HTTP service listening on ${baseUrl}\n`); - writeErr(`MCP endpoint: ${origin}${CURRENT_HOST_NAMESPACES.mcp}\n`); - writeErr(`Admin endpoint: ${origin}${CURRENT_HOST_PATHS.admin}\n`); - writeErr(`Health check: ${origin}${CURRENT_HOST_PATHS.health}\n`); - writeErr(`Auth: ${authDescription(options)}\n`); - }, - ); + const server = startHttpRuntimeServer(options, app, () => { + writeErr(`Caplets HTTP service listening on ${baseUrl}\n`); + writeErr(`MCP endpoint: ${origin}${CURRENT_HOST_NAMESPACES.mcp}\n`); + writeErr(`Admin endpoint: ${origin}${CURRENT_HOST_PATHS.admin}\n`); + writeErr(`Health check: ${origin}${CURRENT_HOST_PATHS.health}\n`); + writeErr(`Auth: ${authDescription(options)}\n`); + }); installHttpSignalHandlers(server, app, engine, writeErr); } @@ -2882,6 +2978,123 @@ export function sanitizeRemoteEngineOptions( }; } +function prependEventStreamPreamble(response: Response | undefined): Response | undefined { + const body = response?.body; + if (!body || !response.headers.get("content-type")?.includes("text/event-stream")) { + return response; + } + const reader = body.getReader(); + let preamblePending = true; + const stream = new ReadableStream({ + async pull(controller) { + if (preamblePending) { + preamblePending = false; + controller.enqueue(new TextEncoder().encode(": connected\n\n")); + return; + } + const chunk = await reader.read(); + if (chunk.done) { + controller.close(); + } else { + controller.enqueue(chunk.value); + } + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + return new Response(stream, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); +} + +function startHttpRuntimeServer( + options: HttpServeOptions, + app: CapletsHttpApp, + onListen: () => void, +): HttpServer { + if (process.versions.bun) { + const server = resolveBunRuntime().serve({ + fetch: app.fetch, + hostname: options.host, + port: options.port, + websocket: bunWebSocket, + }); + onListen(); + return bunHttpServer(server); + } + return serve( + { + fetch: createNodeServerFetch(app), + hostname: options.host, + port: options.port, + websocket: { server: createProjectBindingWebSocketServer() }, + }, + onListen, + ); +} + +function bunHttpServer(server: BunRuntimeServer): HttpServer { + let callback: ((error?: Error) => void) | undefined; + let stopped = false; + const finish = (error?: unknown) => { + if (stopped) return; + stopped = true; + if (error === undefined) { + callback?.(); + } else { + callback?.(error instanceof Error ? error : new Error(String(error))); + } + }; + const stop = (closeActiveConnections: boolean) => { + void server.stop(closeActiveConnections).then( + () => finish(), + (error: unknown) => finish(error), + ); + }; + return { + close(onStopped) { + callback = onStopped; + stop(false); + }, + closeAllConnections() { + stop(true); + }, + }; +} + +function resolveBunRuntime(): BunRuntime { + const runtime: unknown = Reflect.get(globalThis, "Bun"); + if (runtime === null || typeof runtime !== "object") { + throw new Error("Bun runtime API is unavailable."); + } + const serve: unknown = Reflect.get(runtime, "serve"); + if (typeof serve !== "function") { + throw new Error("Bun serve API is unavailable."); + } + return { + serve(options) { + const server: unknown = Reflect.apply(serve, runtime, [options]); + if (server === null || typeof server !== "object") { + throw new Error("Bun serve API returned an invalid server."); + } + const stop: unknown = Reflect.get(server, "stop"); + if (typeof stop !== "function") { + throw new Error("Bun server stop API is unavailable."); + } + return { + stop(closeActiveConnections) { + return Promise.resolve() + .then(() => Reflect.apply(stop, server, [closeActiveConnections])) + .then(() => undefined); + }, + }; + }, + }; +} + function createProjectBindingWebSocketServer(): WebSocketServerLike { return new WebSocketServer({ noServer: true }) as unknown as WebSocketServerLike; } @@ -3164,7 +3377,7 @@ function checkedActiveRequestGraceMs(value: number): number { } export async function shutdownHttpServer( - server: ServerType, + server: HttpServer, app: CapletsHttpApp, engine: CapletsEngine, options: CloseCapletsSessionsOptions = {}, @@ -3182,7 +3395,7 @@ export async function shutdownHttpServer( } function installHttpSignalHandlers( - server: ServerType, + server: HttpServer, app: CapletsHttpApp, engine: CapletsEngine, writeErr: (value: string) => void, @@ -3208,9 +3421,8 @@ function installHttpSignalHandlers( ); } -function closeAllServerConnections(server: ServerType): void { - const closeAllConnections = (server as { closeAllConnections?: () => void }).closeAllConnections; - closeAllConnections?.call(server); +function closeAllServerConnections(server: HttpServer): void { + server.closeAllConnections?.(); } function formatHost(host: string): string { diff --git a/packages/core/src/storage/backend-auth-flows.ts b/packages/core/src/storage/backend-auth-flows.ts index f9323430..9f882caa 100644 --- a/packages/core/src/storage/backend-auth-flows.ts +++ b/packages/core/src/storage/backend-auth-flows.ts @@ -182,7 +182,7 @@ export class BackendAuthFlowRepository { }; const row = this.database.dialect === "sqlite" - ? this.database.db.insert(sqlite.backendAuthFlows).values(values).returning().get() + ? await this.database.db.insert(sqlite.backendAuthFlows).values(values).returning().get() : (await this.database.db.insert(postgres.backendAuthFlows).values(values).returning())[0]; if (!row) throw new CapletsError("INTERNAL_ERROR", "Backend auth flow was not persisted."); return viewForRow(row); @@ -209,7 +209,7 @@ export class BackendAuthFlowRepository { await this.expireDue({ now, limit: MAX_BACKEND_AUTH_FLOW_PRUNE_BATCH }); const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.backendAuthFlows) .where( @@ -309,8 +309,8 @@ export class BackendAuthFlowRepository { const nowText = now.toISOString(); const claim = this.database.dialect === "sqlite" - ? this.database.db.transaction((transaction) => { - const row = transaction + ? await this.database.db.transaction(async (transaction) => { + const row = await transaction .update(sqlite.backendAuthFlows) .set({ status: "completing", claimToken, claimedAt: nowText, updatedAt: nowText }) .where( @@ -346,8 +346,8 @@ export class BackendAuthFlowRepository { const nowText = (input.now ?? new Date()).toISOString(); const completed = this.database.dialect === "sqlite" - ? this.database.db.transaction((transaction) => - completeClaimSqlite(transaction, input, nowText), + ? await this.database.db.transaction( + async (transaction) => await completeClaimSqlite(transaction, input, nowText), ) : await this.database.db.transaction( async (transaction) => await completeClaimPostgres(transaction, input, nowText), @@ -372,7 +372,7 @@ export class BackendAuthFlowRepository { const nowText = (input.now ?? new Date()).toISOString(); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set({ status: "pending", claimToken: null, claimedAt: null, updatedAt: nowText }) .where( @@ -410,7 +410,7 @@ export class BackendAuthFlowRepository { const nowText = (input.now ?? new Date()).toISOString(); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set({ claimedAt: nowText, updatedAt: nowText }) .where( @@ -465,7 +465,7 @@ export class BackendAuthFlowRepository { }; const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set(values) .where( @@ -527,7 +527,7 @@ export class BackendAuthFlowRepository { }; const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set(values) .where( @@ -580,7 +580,7 @@ export class BackendAuthFlowRepository { const values = terminalValues("expired", nowText); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set(values) .where( @@ -640,7 +640,7 @@ export class BackendAuthFlowRepository { const limit = validateBatchLimit(input.limit); const cutoff = new Date(now.getTime() - retentionMs).toISOString(); return this.database.dialect === "sqlite" - ? pruneSqlite(this.database.db, cutoff, limit) + ? await pruneSqlite(this.database.db, cutoff, limit) : await prunePostgres(this.database.db, cutoff, limit); } @@ -659,7 +659,7 @@ export class BackendAuthFlowRepository { private async readRow(flowId: string): Promise { return this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.backendAuthFlows) .where(eq(sqlite.backendAuthFlows.flowId, flowId)) @@ -678,7 +678,7 @@ export class BackendAuthFlowRepository { limit: number, ): Promise> { return this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ flowId: sqlite.backendAuthFlows.flowId }) .from(sqlite.backendAuthFlows) .where( @@ -707,7 +707,7 @@ export class BackendAuthFlowRepository { const values = terminalValues("expired", nowText); const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .update(sqlite.backendAuthFlows) .set(values) .where( @@ -758,19 +758,19 @@ function claimedFlow( }; } -function completeClaimSqlite( +async function completeClaimSqlite( transaction: SqliteHostTransaction, input: BackendAuthFlowCompletionInput, nowText: string, -): StoredOAuthTokenBundleView | undefined { - const row = transaction +): Promise { + const row = await transaction .select() .from(sqlite.backendAuthFlows) .where(eq(sqlite.backendAuthFlows.flowId, input.flowId)) .get(); if (!completionClaimMatches(row, input)) return undefined; if (row.expiresAt <= nowText) { - transaction + await transaction .update(sqlite.backendAuthFlows) .set(terminalValues("expired", nowText)) .where( @@ -784,7 +784,7 @@ function completeClaimSqlite( .run(); return undefined; } - const persisted = writeBackendAuthTokenBundleInTransaction( + const persisted = await writeBackendAuthTokenBundleInTransaction( input.bundle, { expectedGeneration: input.expectedGeneration, @@ -792,7 +792,7 @@ function completeClaimSqlite( }, { dialect: "sqlite", db: transaction }, ); - const completed = transaction + const completed = await transaction .update(sqlite.backendAuthFlows) .set({ ...terminalValues("completed", nowText), @@ -887,7 +887,7 @@ function completionClaimMatches( ); } -function reconcileAbandonedSqlite( +async function reconcileAbandonedSqlite( database: SqliteHostDatabase, input: { flowId: string; @@ -896,9 +896,9 @@ function reconcileAbandonedSqlite( observedBackendAuthGeneration?: number | undefined; now?: Date | undefined; }, -): BackendAuthFlowView | undefined { - return database.transaction((transaction) => { - const row = transaction +): Promise { + return await database.transaction(async (transaction) => { + const row = await transaction .select() .from(sqlite.backendAuthFlows) .where(eq(sqlite.backendAuthFlows.flowId, input.flowId)) @@ -906,7 +906,7 @@ function reconcileAbandonedSqlite( if (!isAbandonedRow(row, input.abandonedBefore)) return undefined; const nowText = (input.now ?? new Date()).toISOString(); const completed = correlationsMatch(row, input); - const updated = transaction + const updated = await transaction .update(sqlite.backendAuthFlows) .set({ ...terminalValues(completed ? "completed" : "unknown", nowText), @@ -965,9 +965,13 @@ async function reconcileAbandonedPostgres( }); } -function pruneSqlite(database: SqliteHostDatabase, cutoff: string, limit: number): number { - return database.transaction((transaction) => { - const rows = transaction +async function pruneSqlite( + database: SqliteHostDatabase, + cutoff: string, + limit: number, +): Promise { + return await database.transaction(async (transaction) => { + const rows = await transaction .select({ flowId: sqlite.backendAuthFlows.flowId }) .from(sqlite.backendAuthFlows) .where( @@ -980,16 +984,18 @@ function pruneSqlite(database: SqliteHostDatabase, cutoff: string, limit: number .limit(limit) .all(); if (rows.length === 0) return 0; - return transaction - .delete(sqlite.backendAuthFlows) - .where( - inArray( - sqlite.backendAuthFlows.flowId, - rows.map((row) => row.flowId), - ), - ) - .returning({ flowId: sqlite.backendAuthFlows.flowId }) - .all().length; + return ( + await transaction + .delete(sqlite.backendAuthFlows) + .where( + inArray( + sqlite.backendAuthFlows.flowId, + rows.map((row) => row.flowId), + ), + ) + .returning({ flowId: sqlite.backendAuthFlows.flowId }) + .all() + ).length; }); } diff --git a/packages/core/src/storage/backend-auth.ts b/packages/core/src/storage/backend-auth.ts index 88c25f59..efc3379d 100644 --- a/packages/core/src/storage/backend-auth.ts +++ b/packages/core/src/storage/backend-auth.ts @@ -57,7 +57,7 @@ export class BackendAuthStateStore { validateServer(server); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.backendAuthStates) .where(eq(sqlite.backendAuthStates.server, server)) @@ -85,7 +85,7 @@ export class BackendAuthStateStore { if (input.after !== undefined) validateConnectionPageKey(input.after); const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.backendAuthStates) .where( @@ -165,14 +165,14 @@ export class BackendAuthStateStore { } validateMutationOptions(options); return this.database.dialect === "sqlite" - ? writeSqlite(this.database.db, validatedBundle, options) + ? await writeSqlite(this.database.db, validatedBundle, options) : await writePostgres(this.database.db, validatedBundle, options); } async assertLegacyBundlesImportable(bundles: StoredOAuthTokenBundle[]): Promise { const validated = validateLegacyBundles(bundles); if (this.database.dialect === "sqlite") { - assertLegacyBundlesMatchSqlite(this.database.db, validated); + await assertLegacyBundlesMatchSqlite(this.database.db, validated); } else { await assertLegacyBundlesMatchPostgres(this.database.db, validated); } @@ -183,8 +183,8 @@ export class BackendAuthStateStore { if (validated.length === 0) return; const timestamp = new Date().toISOString(); if (this.database.dialect === "sqlite") { - this.database.db.transaction((transaction) => - importLegacyBundlesSqlite(transaction, validated, timestamp), + await this.database.db.transaction( + async (transaction) => await importLegacyBundlesSqlite(transaction, validated, timestamp), ); return; } @@ -193,16 +193,16 @@ export class BackendAuthStateStore { ); } - importLegacyBundlesInTransaction( + async importLegacyBundlesInTransaction( bundles: StoredOAuthTokenBundle[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const validated = validateLegacyBundles(bundles); if (validated.length === 0) return; const timestamp = new Date().toISOString(); return transaction.dialect === "sqlite" - ? importLegacyBundlesSqlite(transaction.db, validated, timestamp) - : importLegacyBundlesPostgres(transaction.db, validated, timestamp); + ? await importLegacyBundlesSqlite(transaction.db, validated, timestamp) + : await importLegacyBundlesPostgres(transaction.db, validated, timestamp); } async verifyLegacyBundles(bundles: StoredOAuthTokenBundle[]): Promise { @@ -216,14 +216,14 @@ export class BackendAuthStateStore { } } } - verifyLegacyBundlesInTransaction( + async verifyLegacyBundlesInTransaction( bundles: StoredOAuthTokenBundle[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const validated = validateLegacyBundles(bundles); return transaction.dialect === "sqlite" - ? verifyLegacyBundlesSqlite(transaction.db, validated) - : verifyLegacyBundlesPostgres(transaction.db, validated); + ? await verifyLegacyBundlesSqlite(transaction.db, validated) + : await verifyLegacyBundlesPostgres(transaction.db, validated); } async deleteTokenBundle( @@ -233,7 +233,7 @@ export class BackendAuthStateStore { validateServer(server); validateMutationOptions(options); return this.database.dialect === "sqlite" - ? deleteSqlite(this.database.db, server, options) + ? await deleteSqlite(this.database.db, server, options) : await deletePostgres(this.database.db, server, options); } } @@ -242,17 +242,17 @@ export function writeBackendAuthTokenBundleInTransaction( bundle: StoredOAuthTokenBundle, options: BackendAuthMutationOptions, transaction: Extract, -): StoredOAuthTokenBundleView; +): Promise; export function writeBackendAuthTokenBundleInTransaction( bundle: StoredOAuthTokenBundle, options: BackendAuthMutationOptions, transaction: Extract, ): Promise; -export function writeBackendAuthTokenBundleInTransaction( +export async function writeBackendAuthTokenBundleInTransaction( bundle: StoredOAuthTokenBundle, options: BackendAuthMutationOptions, transaction: HostDatabaseTransaction, -): StoredOAuthTokenBundleView | Promise { +): Promise { validateServer(bundle.server); const candidate: unknown = bundle; if (!isStoredOAuthTokenBundle(candidate)) { @@ -260,8 +260,8 @@ export function writeBackendAuthTokenBundleInTransaction( } validateMutationOptions(options); return transaction.dialect === "sqlite" - ? writeSqliteTransaction(transaction.db, candidate, options) - : writePostgresTransaction(transaction.db, candidate, options); + ? await writeSqliteTransaction(transaction.db, candidate, options) + : await writePostgresTransaction(transaction.db, candidate, options); } type SqliteBackendAuthDatabase = @@ -270,21 +270,19 @@ type SqliteBackendAuthDatabase = type PostgresBackendAuthDatabase = | PostgresHostDatabase | Parameters[0]>[0]; -function importLegacyBundlesSqlite( +async function importLegacyBundlesSqlite( database: SqliteBackendAuthDatabase, bundles: StoredOAuthTokenBundle[], timestamp: string, -): void { - assertLegacyBundlesMatchSqlite(database, bundles); +): Promise { + await assertLegacyBundlesMatchSqlite(database, bundles); + const existing = await database + .select({ server: sqlite.backendAuthStates.server }) + .from(sqlite.backendAuthStates) + .all(); + const existingServers = new Set(existing.map((row) => row.server)); const values = bundles - .filter( - (bundle) => - !database - .select({ server: sqlite.backendAuthStates.server }) - .from(sqlite.backendAuthStates) - .where(eq(sqlite.backendAuthStates.server, bundle.server)) - .get(), - ) + .filter((bundle) => !existingServers.has(bundle.server)) .map((bundle) => ({ server: bundle.server, generation: 1, @@ -292,7 +290,7 @@ function importLegacyBundlesSqlite( createdAt: timestamp, updatedAt: timestamp, })); - if (values.length > 0) database.insert(sqlite.backendAuthStates).values(values).run(); + if (values.length > 0) await database.insert(sqlite.backendAuthStates).values(values).run(); } async function importLegacyBundlesPostgres( @@ -318,12 +316,12 @@ async function importLegacyBundlesPostgres( if (values.length > 0) await database.insert(postgres.backendAuthStates).values(values); } -function verifyLegacyBundlesSqlite( +async function verifyLegacyBundlesSqlite( database: SqliteBackendAuthDatabase, bundles: StoredOAuthTokenBundle[], -): void { +): Promise { for (const bundle of bundles) { - const row = database + const row = await database .select() .from(sqlite.backendAuthStates) .where(eq(sqlite.backendAuthStates.server, bundle.server)) @@ -374,12 +372,12 @@ function validateLegacyBundles(bundles: StoredOAuthTokenBundle[]): StoredOAuthTo return validated.sort((left, right) => left.server.localeCompare(right.server)); } -function assertLegacyBundlesMatchSqlite( +async function assertLegacyBundlesMatchSqlite( database: SqliteBackendAuthDatabase, bundles: StoredOAuthTokenBundle[], -): void { +): Promise { for (const bundle of bundles) { - const row = database + const row = await database .select() .from(sqlite.backendAuthStates) .where(eq(sqlite.backendAuthStates.server, bundle.server)) @@ -412,20 +410,22 @@ async function assertLegacyBundlesMatchPostgres( } } -function writeSqlite( +async function writeSqlite( db: SqliteHostDatabase, bundle: StoredOAuthTokenBundle, options: BackendAuthMutationOptions, -): StoredOAuthTokenBundleView { - return db.transaction((transaction) => writeSqliteTransaction(transaction, bundle, options)); +): Promise { + return await db.transaction( + async (transaction) => await writeSqliteTransaction(transaction, bundle, options), + ); } -function writeSqliteTransaction( +async function writeSqliteTransaction( transaction: SqliteBackendAuthDatabase, bundle: StoredOAuthTokenBundle, options: BackendAuthMutationOptions, -): StoredOAuthTokenBundleView { - const current = transaction +): Promise { + const current = await transaction .select() .from(sqlite.backendAuthStates) .where(eq(sqlite.backendAuthStates.server, bundle.server)) @@ -434,7 +434,7 @@ function writeSqliteTransaction( assertExpectedGeneration(current, options.expectedGeneration); const generation = (current?.generation ?? 0) + 1; const now = new Date().toISOString(); - transaction + await transaction .insert(sqlite.backendAuthStates) .values({ server: bundle.server, @@ -449,7 +449,7 @@ function writeSqliteTransaction( }) .run(); if (options.operatorClientId) { - transaction + await transaction .insert(sqlite.operatorActivity) .values( activityValues( @@ -520,13 +520,13 @@ async function writePostgresTransaction( return { bundle, generation }; } -function deleteSqlite( +async function deleteSqlite( db: SqliteHostDatabase, server: string, options: BackendAuthMutationOptions, -): boolean { - return db.transaction((transaction) => { - const current = transaction +): Promise { + return await db.transaction(async (transaction) => { + const current = await transaction .select() .from(sqlite.backendAuthStates) .where(eq(sqlite.backendAuthStates.server, server)) @@ -536,13 +536,13 @@ function deleteSqlite( if (!current || !state?.bundle) return false; const generation = current.generation + 1; const now = new Date().toISOString(); - transaction + await transaction .update(sqlite.backendAuthStates) .set({ generation, tokenBundle: null, updatedAt: now }) .where(eq(sqlite.backendAuthStates.server, server)) .run(); if (options.operatorClientId) { - transaction + await transaction .insert(sqlite.operatorActivity) .values( activityValues(options.operatorClientId, "backend_auth_deleted", server, generation, now), diff --git a/packages/core/src/storage/caplet-records.ts b/packages/core/src/storage/caplet-records.ts index 4e2df338..5463a04b 100644 --- a/packages/core/src/storage/caplet-records.ts +++ b/packages/core/src/storage/caplet-records.ts @@ -269,7 +269,7 @@ export class CapletRecordStore { const [prepared] = await this.prepareBundlesFromSources([input]); try { if (this.database.dialect === "sqlite") { - importSqlite(this.database.db, prepared!); + await importSqlite(this.database.db, prepared!); } else { await importPostgres(this.database.db, prepared!); } @@ -299,7 +299,7 @@ export class CapletRecordStore { const bundles = await this.prepareBundlesFromSources(inputs); try { if (this.database.dialect === "sqlite") { - importManySqlite(this.database.db, bundles); + await importManySqlite(this.database.db, bundles); } else { await importManyPostgres(this.database.db, bundles); } @@ -328,30 +328,30 @@ export class CapletRecordStore { ); } - importBundlesInTransaction( + async importBundlesInTransaction( inputs: ImportCapletBundleInput[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise> { for (const input of inputs) requireOperator(input.operator); const bundles = inputs.map((input) => prepareBufferedBundle(input, this.limits)); if (bundles.length === 0) return; return transaction.dialect === "sqlite" - ? importManySqliteTransaction(transaction.db, bundles) + ? await importManySqliteTransaction(transaction.db, bundles) : importManyPostgresTransaction(transaction.db, bundles); } - getInTransaction( + async getInTransaction( id: string, transaction: HostDatabaseTransaction, - ): CapletRecordView | undefined | Promise { + ): Promise> { return transaction.dialect === "sqlite" - ? getSqlite(transaction.db, id) - : getPostgres(transaction.db, id); + ? await getSqlite(transaction.db, id) + : await getPostgres(transaction.db, id); } async get(id: string): Promise { return this.database.dialect === "sqlite" - ? getSqlite(this.database.db, id) + ? await getSqlite(this.database.db, id) : await getPostgres(this.database.db, id); } @@ -360,7 +360,7 @@ export class CapletRecordStore { ): Promise> { const normalized = normalizeRecordPageOptions(options); return this.database.dialect === "sqlite" - ? listRecordsPageSqlite(this.database.db, normalized) + ? await listRecordsPageSqlite(this.database.db, normalized) : await listRecordsPagePostgres(this.database.db, normalized); } @@ -387,7 +387,7 @@ export class CapletRecordStore { const cutoff = (input.now ?? new Date()).getTime() - input.graceMs; const blobs = this.database.dialect === "sqlite" - ? this.database.db.select().from(sqlite.capletAssetBlobs).all() + ? await this.database.db.select().from(sqlite.capletAssetBlobs).all() : await this.database.db.select().from(postgres.capletAssetBlobs); const candidates = blobs.filter((blob) => Date.parse(blob.createdAt) <= cutoff); const deletedKeys: string[] = []; @@ -395,7 +395,7 @@ export class CapletRecordStore { for (const candidate of candidates) { const objectKey = this.database.dialect === "sqlite" - ? deleteUnreferencedSqliteBlob(this.database.db, candidate.hash) + ? await deleteUnreferencedSqliteBlob(this.database.db, candidate.hash) : await deleteUnreferencedPostgresBlob(this.database.db, candidate.hash); if (objectKey === undefined) continue; blobRowsDeleted += 1; @@ -409,7 +409,7 @@ export class CapletRecordStore { } const remaining = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ objectKey: sqlite.capletAssetBlobs.objectKey }) .from(sqlite.capletAssetBlobs) .all() @@ -429,8 +429,11 @@ export class CapletRecordStore { } async assetStats(): Promise<{ blobs: number; entries: number }> { if (this.database.dialect === "sqlite") { - const blobs = this.database.db.select({ count: count() }).from(sqlite.capletAssetBlobs).get(); - const entries = this.database.db + const blobs = await this.database.db + .select({ count: count() }) + .from(sqlite.capletAssetBlobs) + .get(); + const entries = await this.database.db .select({ count: count() }) .from(sqlite.capletBundleEntries) .get(); @@ -447,7 +450,7 @@ export class CapletRecordStore { if (!this.objectStore) return { ready: true, affectedRecordIds: [] }; const references = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ recordId: sqlite.capletRecords.capletId, entryHash: sqlite.capletBundleEntries.blobHash, @@ -544,7 +547,7 @@ export class CapletRecordStore { const [prepared] = await this.prepareBundlesFromSources([input]); try { if (this.database.dialect === "sqlite") { - updateSqlite( + await updateSqlite( this.database.db, prepared!, input.expectedGeneration, @@ -593,7 +596,7 @@ export class CapletRecordStore { const [prepared] = await this.prepareBundlesFromSources([input]); try { if (this.database.dialect === "sqlite") { - updateFromSourceSqlite(this.database.db, prepared!, input); + await updateFromSourceSqlite(this.database.db, prepared!, input); } else { await updateFromSourcePostgres(this.database.db, prepared!, input); } @@ -640,7 +643,7 @@ export class CapletRecordStore { const normalized = normalizeRevisionPageOptions(options); const result = this.database.dialect === "sqlite" - ? listRevisionsPageSqlite(this.database.db, id, normalized) + ? await listRevisionsPageSqlite(this.database.db, id, normalized) : await listRevisionsPagePostgres(this.database.db, id, normalized); if (result.recordKey === undefined) throw missingCapletRecord(id); await this.appendOperatorActivity(actor, "caplet.revisions_list", result.recordKey, { @@ -663,7 +666,7 @@ export class CapletRecordStore { const normalized = normalizeRevisionPageOptions({ after }); const result = this.database.dialect === "sqlite" - ? listRevisionsPageSqlite(this.database.db, id, normalized) + ? await listRevisionsPageSqlite(this.database.db, id, normalized) : await listRevisionsPagePostgres(this.database.db, id, normalized); recordKey = result.recordKey; revisions.push(...result.page.items); @@ -680,7 +683,7 @@ export class CapletRecordStore { async deleteRevision(input: DeleteCapletRevisionInput): Promise { requireOperator(input.operator); if (this.database.dialect === "sqlite") { - deleteRevisionSqlite(this.database.db, input); + await deleteRevisionSqlite(this.database.db, input); } else { await deleteRevisionPostgres(this.database.db, input); } @@ -690,7 +693,7 @@ export class CapletRecordStore { async restoreRevision(input: RestoreCapletRevisionInput): Promise { requireOperator(input.operator); if (this.database.dialect === "sqlite") { - restoreRevisionSqlite(this.database.db, input); + await restoreRevisionSqlite(this.database.db, input); } else { await restoreRevisionPostgres(this.database.db, input); } @@ -703,7 +706,7 @@ export class CapletRecordStore { async rename(input: RenameCapletRecordInput): Promise { requireOperator(input.operator); validateCapletRecordId(input.newId); - if (this.database.dialect === "sqlite") renameSqlite(this.database.db, input); + if (this.database.dialect === "sqlite") await renameSqlite(this.database.db, input); else await renamePostgres(this.database.db, input); const record = await this.get(input.newId); if (!record) @@ -714,7 +717,7 @@ export class CapletRecordStore { async setRetention(input: SetCapletRetentionInput): Promise { requireOperator(input.operator); validateHistoryLimit(input.historyLimit); - if (this.database.dialect === "sqlite") setRetentionSqlite(this.database.db, input); + if (this.database.dialect === "sqlite") await setRetentionSqlite(this.database.db, input); else await setRetentionPostgres(this.database.db, input); const record = await this.get(input.id); if (!record) throw new CapletsError("INTERNAL_ERROR", `Caplet ${input.id} was not found.`); @@ -723,7 +726,7 @@ export class CapletRecordStore { async hardDelete(input: HardDeleteCapletRecordInput): Promise { requireOperator(input.operator); - if (this.database.dialect === "sqlite") hardDeleteSqlite(this.database.db, input); + if (this.database.dialect === "sqlite") await hardDeleteSqlite(this.database.db, input); else await hardDeletePostgres(this.database.db, input); } @@ -886,11 +889,13 @@ export class CapletRecordStore { }; if (this.database.dialect === "sqlite") { inserted = - this.database.db - .insert(sqlite.capletAssetBlobs) - .values(values) - .onConflictDoNothing() - .run().changes === 1; + ( + await this.database.db + .insert(sqlite.capletAssetBlobs) + .values(values) + .onConflictDoNothing() + .run() + ).rowsAffected === 1; } else { inserted = ( @@ -926,7 +931,7 @@ export class CapletRecordStore { private async assetMetadata(hash: string): Promise { if (this.database.dialect === "sqlite") { - return this.database.db + return await this.database.db .select({ hash: sqlite.capletAssetBlobs.hash, size: sqlite.capletAssetBlobs.size, @@ -958,7 +963,7 @@ export class CapletRecordStore { for (const hash of new Set(hashes)) { const objectKey = this.database.dialect === "sqlite" - ? deleteUnreferencedSqliteBlob(this.database.db, hash) + ? await deleteUnreferencedSqliteBlob(this.database.db, hash) : await deleteUnreferencedPostgresBlob(this.database.db, hash); if (objectKey && this.objectStore) { await this.objectStore.delete(objectKey).catch(() => undefined); @@ -1004,7 +1009,7 @@ export class CapletRecordStore { ): Promise> { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.capletAssetBlobs) .where(eq(sqlite.capletAssetBlobs.hash, expected.hash)) @@ -1149,12 +1154,19 @@ function createSqliteAssetBlobWriter( database: SqliteHostDatabase, bundles: ValidatedBundleSources[], ): SqliteAssetBlobWriter { - type InsertStatement = { - run(hash: string, size: number, payload: Buffer, createdAt: string): { changes: number }; - }; - type Client = { - prepare(source: string): InsertStatement; - }; + const insert = (hash: string, size: number, payload: Buffer, createdAt: string) => + database + .insert(sqlite.capletAssetBlobs) + .values({ + hash, + size, + payload, + objectKey: null, + verificationStatus: "verified", + createdAt, + }) + .onConflictDoNothing() + .run(); let maxAssetBytes = 0; for (const bundle of bundles) { @@ -1163,12 +1175,6 @@ function createSqliteAssetBlobWriter( } } const target = Buffer.allocUnsafe(maxAssetBytes); - const client = (database as SqliteHostDatabase & { $client: Client }).$client; - const insert = client.prepare( - "INSERT INTO caplet_asset_blobs " + - "(hash, size, payload, object_key, verification_status, created_at) " + - "VALUES (?, ?, ?, NULL, 'verified', ?) ON CONFLICT(hash) DO NOTHING", - ); return { async insert(source, createdAt) { @@ -1176,7 +1182,7 @@ function createSqliteAssetBlobWriter( maxBytes: target.byteLength, target, }); - return insert.run(source.sha256, source.size, payload, createdAt).changes === 1; + return (await insert(source.sha256, source.size, payload, createdAt)).rowsAffected === 1; }, }; } @@ -1537,45 +1543,47 @@ function projectFrontmatter(frontmatter: CapletFileFrontmatter): { return { content, backends }; } -function importSqlite(db: SqliteHostDatabase, bundle: PreparedBundle): void { - importManySqlite(db, [bundle]); +async function importSqlite(db: SqliteHostDatabase, bundle: PreparedBundle): Promise { + await importManySqlite(db, [bundle]); } -function importManySqlite(db: SqliteHostDatabase, bundles: PreparedBundle[]): void { - db.transaction((transaction) => importManySqliteTransaction(transaction, bundles)); +async function importManySqlite(db: SqliteHostDatabase, bundles: PreparedBundle[]): Promise { + await db.transaction( + async (transaction) => await importManySqliteTransaction(transaction, bundles), + ); } -function importManySqliteTransaction( +async function importManySqliteTransaction( transaction: SqliteHostTransaction, bundles: PreparedBundle[], -): void { +): Promise { for (const bundle of bundles) { - const inserted = transaction + const inserted = await transaction .insert(sqlite.capletRecords) .values(recordValues(bundle)) .onConflictDoNothing() .run(); - if (inserted.changes !== 1) { + if (inserted.rowsAffected !== 1) { throw new CapletsError("CONFIG_EXISTS", `Caplet Record ${bundle.id} already exists.`); } - transaction.insert(sqlite.capletRevisions).values(revisionValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisions).values(revisionValues(bundle)).run(); if (bundle.tags.length > 0) { - transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); } if (bundle.backends.length > 0) { - transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); } for (const entry of bundle.entries) { - ensureSqliteBlob(transaction, bundle, entry); - transaction.insert(sqlite.capletBundleEntries).values(entryValues(bundle, entry)).run(); + await ensureSqliteBlob(transaction, bundle, entry); + await transaction.insert(sqlite.capletBundleEntries).values(entryValues(bundle, entry)).run(); } - transaction + await transaction .update(sqlite.capletRecords) .set({ currentRevisionKey: bundle.revisionKey }) .where(eq(sqlite.capletRecords.recordKey, bundle.recordKey)) .run(); - insertSqliteInstallation(transaction, bundle); - transaction + await insertSqliteInstallation(transaction, bundle); + await transaction .insert(sqlite.operatorActivity) .values(recordActivityValues(bundle, "caplet.import")) .run(); @@ -1583,7 +1591,7 @@ function importManySqliteTransaction( const generationHash = createHash("sha256") .update(bundles.map((bundle) => bundle.contentHash).join("\0")) .digest("hex"); - advanceSqliteConfigGeneration(transaction, generationHash, bundles[0]!.actor); + await advanceSqliteConfigGeneration(transaction, generationHash, bundles[0]!.actor); } async function importPostgres(db: PostgresHostDatabase, bundle: PreparedBundle): Promise { @@ -1638,14 +1646,14 @@ async function importManyPostgresTransaction( await advancePostgresConfigGeneration(transaction, generationHash, bundles[0]!.actor); } -function updateSqlite( +async function updateSqlite( db: SqliteHostDatabase, bundle: PreparedBundle, expectedGeneration: number, detachInstallation: boolean, -): void { - db.transaction((transaction) => { - const record = transaction +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, bundle.id)) @@ -1655,7 +1663,7 @@ function updateSqlite( if (record.headGeneration !== expectedGeneration) { throw staleGeneration(bundle.id, expectedGeneration, record.headGeneration); } - const activeInstallation = transaction + const activeInstallation = await transaction .select() .from(sqlite.capletInstallations) .where( @@ -1667,7 +1675,7 @@ function updateSqlite( .get(); if (activeInstallation && !detachInstallation) throw trackedInstallation(bundle.id); if (activeInstallation) { - transaction + await transaction .update(sqlite.capletInstallations) .set({ status: "detached", @@ -1678,7 +1686,7 @@ function updateSqlite( }) .where(eq(sqlite.capletInstallations.installationKey, activeInstallation.installationKey)) .run(); - transaction + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -1694,18 +1702,18 @@ function updateSqlite( } const sequence = record.headGeneration + 1; bundle.recordKey = record.recordKey; - transaction.insert(sqlite.capletRevisions).values(revisionValues(bundle, sequence)).run(); + await transaction.insert(sqlite.capletRevisions).values(revisionValues(bundle, sequence)).run(); if (bundle.tags.length > 0) { - transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); } if (bundle.backends.length > 0) { - transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); } for (const entry of bundle.entries) { - ensureSqliteBlob(transaction, bundle, entry); - transaction.insert(sqlite.capletBundleEntries).values(entryValues(bundle, entry)).run(); + await ensureSqliteBlob(transaction, bundle, entry); + await transaction.insert(sqlite.capletBundleEntries).values(entryValues(bundle, entry)).run(); } - transaction + await transaction .update(sqlite.capletRecords) .set({ currentRevisionKey: bundle.revisionKey, @@ -1715,7 +1723,7 @@ function updateSqlite( .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); const retained = Math.max(1, record.historyLimit ?? 1); - const revisions = transaction + const revisions = await transaction .select({ revisionKey: sqlite.capletRevisions.revisionKey }) .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.recordKey, record.recordKey)) @@ -1723,17 +1731,17 @@ function updateSqlite( .all(); const expired = revisions.slice(retained).map((revision) => revision.revisionKey); if (expired.length > 0) { - transaction + await transaction .delete(sqlite.capletRevisions) .where(inArray(sqlite.capletRevisions.revisionKey, expired)) .run(); } - insertSqliteInstallation(transaction, bundle); - transaction + await insertSqliteInstallation(transaction, bundle); + await transaction .insert(sqlite.operatorActivity) .values(recordActivityValues(bundle, "caplet.update")) .run(); - advanceSqliteConfigGeneration(transaction, bundle.contentHash, bundle.actor); + await advanceSqliteConfigGeneration(transaction, bundle.contentHash, bundle.actor); }); } @@ -1832,13 +1840,13 @@ async function updatePostgres( }); } -function updateFromSourceSqlite( +async function updateFromSourceSqlite( db: SqliteHostDatabase, bundle: PreparedBundle, input: SourceUpdateMetadata, -): void { - db.transaction((transaction) => { - const record = transaction +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, bundle.id)) @@ -1848,7 +1856,7 @@ function updateFromSourceSqlite( if (record.headGeneration !== input.expectedGeneration) { throw staleGeneration(bundle.id, input.expectedGeneration, record.headGeneration); } - const installation = transaction + const installation = await transaction .select() .from(sqlite.capletInstallations) .where( @@ -1863,7 +1871,7 @@ function updateFromSourceSqlite( throw staleInstallationGeneration(bundle.id); } if (!record.currentRevisionKey) throw missingCurrentRevision(bundle.id); - const currentRevision = transaction + const currentRevision = await transaction .select({ contentHash: sqlite.capletRevisions.contentHash }) .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.revisionKey, record.currentRevisionKey)) @@ -1876,18 +1884,24 @@ function updateFromSourceSqlite( const sequence = record.headGeneration + (createRevision ? 1 : 0); bundle.recordKey = record.recordKey; if (createRevision) { - transaction.insert(sqlite.capletRevisions).values(revisionValues(bundle, sequence)).run(); + await transaction + .insert(sqlite.capletRevisions) + .values(revisionValues(bundle, sequence)) + .run(); if (bundle.tags.length > 0) { - transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionTags).values(tagValues(bundle)).run(); } if (bundle.backends.length > 0) { - transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); + await transaction.insert(sqlite.capletRevisionBackends).values(backendValues(bundle)).run(); } for (const entry of bundle.entries) { - ensureSqliteBlob(transaction, bundle, entry); - transaction.insert(sqlite.capletBundleEntries).values(entryValues(bundle, entry)).run(); + await ensureSqliteBlob(transaction, bundle, entry); + await transaction + .insert(sqlite.capletBundleEntries) + .values(entryValues(bundle, entry)) + .run(); } - const updatedRecord = transaction + const updatedRecord = await transaction .update(sqlite.capletRecords) .set({ currentRevisionKey: contentChanged ? bundle.revisionKey : record.currentRevisionKey, @@ -1901,10 +1915,10 @@ function updateFromSourceSqlite( ), ) .run(); - if (updatedRecord.changes !== 1) { + if (updatedRecord.rowsAffected !== 1) { throw staleGeneration(bundle.id, input.expectedGeneration, record.headGeneration); } - pruneSourceRevisionsSqlite( + await pruneSourceRevisionsSqlite( transaction, record.recordKey, contentChanged ? bundle.revisionKey : record.currentRevisionKey, @@ -1912,7 +1926,7 @@ function updateFromSourceSqlite( ); } - const latestObservation = transaction + const latestObservation = await transaction .select({ observedAt: sqlite.capletInstallationObservations.observedAt }) .from(sqlite.capletInstallationObservations) .where( @@ -1922,7 +1936,7 @@ function updateFromSourceSqlite( .limit(1) .get(); const observedAt = sourceObservationTime(bundle.now, latestObservation?.observedAt); - const updatedInstallation = transaction + const updatedInstallation = await transaction .update(sqlite.capletInstallations) .set({ generation: installation.generation + 1, updatedAt: observedAt }) .where( @@ -1933,12 +1947,12 @@ function updateFromSourceSqlite( ), ) .run(); - if (updatedInstallation.changes !== 1) throw staleInstallationGeneration(bundle.id); - transaction + if (updatedInstallation.rowsAffected !== 1) throw staleInstallationGeneration(bundle.id); + await transaction .insert(sqlite.capletInstallationObservations) .values(sourceObservationValues(installation.installationKey, input, observedAt)) .run(); - transaction + await transaction .insert(sqlite.operatorActivity) .values( sourceUpdateActivity( @@ -1952,7 +1966,7 @@ function updateFromSourceSqlite( ) .run(); if (contentChanged) - advanceSqliteConfigGeneration(transaction, bundle.contentHash, bundle.actor); + await advanceSqliteConfigGeneration(transaction, bundle.contentHash, bundle.actor); }); } @@ -2079,9 +2093,12 @@ async function updateFromSourcePostgres( } }); } -function deleteRevisionSqlite(db: SqliteHostDatabase, input: DeleteCapletRevisionInput): void { - db.transaction((transaction) => { - const record = transaction +async function deleteRevisionSqlite( + db: SqliteHostDatabase, + input: DeleteCapletRevisionInput, +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.id)) @@ -2090,7 +2107,7 @@ function deleteRevisionSqlite(db: SqliteHostDatabase, input: DeleteCapletRevisio if (record.headGeneration !== input.expectedGeneration) { throw staleGeneration(input.id, input.expectedGeneration, record.headGeneration); } - const revision = transaction + const revision = await transaction .select() .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.revisionKey, input.revisionKey)) @@ -2098,12 +2115,12 @@ function deleteRevisionSqlite(db: SqliteHostDatabase, input: DeleteCapletRevisio if (!revision || revision.recordKey !== record.recordKey) { throw missingCapletRevision(input.revisionKey); } - transaction + await transaction .delete(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.revisionKey, input.revisionKey)) .run(); const activityAt = new Date().toISOString(); - transaction + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -2116,21 +2133,25 @@ function deleteRevisionSqlite(db: SqliteHostDatabase, input: DeleteCapletRevisio createdAt: activityAt, }) .run(); - advanceSqliteConfigGeneration(transaction, input.revisionKey, requireOperator(input.operator)); - const remaining = transaction + await advanceSqliteConfigGeneration( + transaction, + input.revisionKey, + requireOperator(input.operator), + ); + const remaining = await transaction .select({ revisionKey: sqlite.capletRevisions.revisionKey }) .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.recordKey, record.recordKey)) .orderBy(desc(sqlite.capletRevisions.sequence)) .all(); if (remaining.length === 0) { - transaction + await transaction .delete(sqlite.capletRecords) .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); return; } - transaction + await transaction .update(sqlite.capletRecords) .set({ currentRevisionKey: @@ -2212,9 +2233,12 @@ async function deleteRevisionPostgres( }); } -function restoreRevisionSqlite(db: SqliteHostDatabase, input: RestoreCapletRevisionInput): void { - db.transaction((transaction) => { - const record = transaction +async function restoreRevisionSqlite( + db: SqliteHostDatabase, + input: RestoreCapletRevisionInput, +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.id)) @@ -2223,7 +2247,7 @@ function restoreRevisionSqlite(db: SqliteHostDatabase, input: RestoreCapletRevis if (record.headGeneration !== input.expectedGeneration) { throw staleGeneration(input.id, input.expectedGeneration, record.headGeneration); } - const source = transaction + const source = await transaction .select() .from(sqlite.capletRevisions) .where( @@ -2239,50 +2263,54 @@ function restoreRevisionSqlite(db: SqliteHostDatabase, input: RestoreCapletRevis const revisionKey = randomUUID(); const sequence = record.headGeneration + 1; const now = new Date().toISOString(); - transaction + await transaction .insert(sqlite.capletRevisions) .values({ ...source, revisionKey, sequence, createdAt: now, actor: input.operator.clientId }) .run(); - const tags = transaction + const tags = await transaction .select() .from(sqlite.capletRevisionTags) .where(eq(sqlite.capletRevisionTags.revisionKey, source.revisionKey)) .all(); if (tags.length > 0) { - transaction + await transaction .insert(sqlite.capletRevisionTags) .values(tags.map((tag) => ({ ...tag, revisionKey }))) .run(); } - const backends = transaction + const backends = await transaction .select() .from(sqlite.capletRevisionBackends) .where(eq(sqlite.capletRevisionBackends.revisionKey, source.revisionKey)) .all(); if (backends.length > 0) { - transaction + await transaction .insert(sqlite.capletRevisionBackends) .values(backends.map((backend) => ({ ...backend, revisionKey }))) .run(); } - const entries = transaction + const entries = await transaction .select() .from(sqlite.capletBundleEntries) .where(eq(sqlite.capletBundleEntries.revisionKey, source.revisionKey)) .all(); if (entries.length > 0) { - transaction + await transaction .insert(sqlite.capletBundleEntries) .values(entries.map((entry) => ({ ...entry, revisionKey }))) .run(); } - transaction + await transaction .update(sqlite.capletRecords) .set({ currentRevisionKey: revisionKey, headGeneration: sequence, updatedAt: now }) .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); - pruneSqliteRevisions(transaction, record.recordKey, Math.max(1, record.historyLimit ?? 1)); - transaction + await pruneSqliteRevisions( + transaction, + record.recordKey, + Math.max(1, record.historyLimit ?? 1), + ); + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -2295,7 +2323,7 @@ function restoreRevisionSqlite(db: SqliteHostDatabase, input: RestoreCapletRevis createdAt: now, }) .run(); - advanceSqliteConfigGeneration(transaction, source.contentHash, input.operator.clientId); + await advanceSqliteConfigGeneration(transaction, source.contentHash, input.operator.clientId); }); } @@ -2385,9 +2413,9 @@ async function restoreRevisionPostgres( }); } -function renameSqlite(db: SqliteHostDatabase, input: RenameCapletRecordInput): void { - db.transaction((transaction) => { - const record = transaction +async function renameSqlite(db: SqliteHostDatabase, input: RenameCapletRecordInput): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.id)) @@ -2397,7 +2425,7 @@ function renameSqlite(db: SqliteHostDatabase, input: RenameCapletRecordInput): v if (record.headGeneration !== input.expectedGeneration) { throw staleGeneration(input.id, input.expectedGeneration, record.headGeneration); } - const collision = transaction + const collision = await transaction .select({ recordKey: sqlite.capletRecords.recordKey }) .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.newId)) @@ -2405,12 +2433,12 @@ function renameSqlite(db: SqliteHostDatabase, input: RenameCapletRecordInput): v if (collision) throw new CapletsError("CONFIG_EXISTS", `Caplet Record ${input.newId} already exists.`); const now = new Date().toISOString(); - transaction + await transaction .update(sqlite.capletRecords) .set({ capletId: input.newId, headGeneration: record.headGeneration + 1, updatedAt: now }) .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); - transaction + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -2423,7 +2451,7 @@ function renameSqlite(db: SqliteHostDatabase, input: RenameCapletRecordInput): v createdAt: now, }) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, `rename:${record.recordKey}:${input.newId}`, input.operator.clientId, @@ -2477,9 +2505,12 @@ async function renamePostgres( }); } -function setRetentionSqlite(db: SqliteHostDatabase, input: SetCapletRetentionInput): void { - db.transaction((transaction) => { - const record = transaction +async function setRetentionSqlite( + db: SqliteHostDatabase, + input: SetCapletRetentionInput, +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.id)) @@ -2490,7 +2521,7 @@ function setRetentionSqlite(db: SqliteHostDatabase, input: SetCapletRetentionInp throw staleGeneration(input.id, input.expectedGeneration, record.headGeneration); } const now = new Date().toISOString(); - transaction + await transaction .update(sqlite.capletRecords) .set({ historyLimit: input.historyLimit, @@ -2499,8 +2530,8 @@ function setRetentionSqlite(db: SqliteHostDatabase, input: SetCapletRetentionInp }) .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); - pruneSqliteRevisions(transaction, record.recordKey, Math.max(1, input.historyLimit ?? 1)); - transaction + await pruneSqliteRevisions(transaction, record.recordKey, Math.max(1, input.historyLimit ?? 1)); + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -2559,9 +2590,12 @@ async function setRetentionPostgres( }); } -function hardDeleteSqlite(db: SqliteHostDatabase, input: HardDeleteCapletRecordInput): void { - db.transaction((transaction) => { - const record = transaction +async function hardDeleteSqlite( + db: SqliteHostDatabase, + input: HardDeleteCapletRecordInput, +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.id)) @@ -2571,12 +2605,12 @@ function hardDeleteSqlite(db: SqliteHostDatabase, input: HardDeleteCapletRecordI if (record.headGeneration !== input.expectedGeneration) { throw staleGeneration(input.id, input.expectedGeneration, record.headGeneration); } - transaction + await transaction .delete(sqlite.capletRecords) .where(eq(sqlite.capletRecords.recordKey, record.recordKey)) .run(); const now = new Date().toISOString(); - transaction + await transaction .insert(sqlite.operatorActivity) .values({ activityKey: randomUUID(), @@ -2589,7 +2623,7 @@ function hardDeleteSqlite(db: SqliteHostDatabase, input: HardDeleteCapletRecordI createdAt: now, }) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, `delete:${record.recordKey}`, input.operator.clientId, @@ -2635,12 +2669,12 @@ async function hardDeletePostgres( }); } -function pruneSqliteRevisions( +async function pruneSqliteRevisions( transaction: Parameters[0]>[0], recordKey: string, retained: number, -): void { - const revisions = transaction +): Promise { + const revisions = await transaction .select({ revisionKey: sqlite.capletRevisions.revisionKey }) .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.recordKey, recordKey)) @@ -2648,7 +2682,7 @@ function pruneSqliteRevisions( .all(); const expired = revisions.slice(retained).map((revision) => revision.revisionKey); if (expired.length > 0) { - transaction + await transaction .delete(sqlite.capletRevisions) .where(inArray(sqlite.capletRevisions.revisionKey, expired)) .run(); @@ -2673,13 +2707,13 @@ async function prunePostgresRevisions( } } -function pruneSourceRevisionsSqlite( +async function pruneSourceRevisionsSqlite( transaction: Parameters[0]>[0], recordKey: string, currentRevisionKey: string, retained: number, -): void { - const revisions = transaction +): Promise { + const revisions = await transaction .select({ revisionKey: sqlite.capletRevisions.revisionKey }) .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.recordKey, recordKey)) @@ -2694,7 +2728,7 @@ function pruneSourceRevisionsSqlite( .filter((revision) => !retainedKeys.has(revision.revisionKey)) .map((revision) => revision.revisionKey); if (expired.length > 0) { - transaction + await transaction .delete(sqlite.capletRevisions) .where(inArray(sqlite.capletRevisions.revisionKey, expired)) .run(); @@ -2748,12 +2782,12 @@ async function getRevisionByKey( revisionKey: string, ): Promise { if (database.dialect === "sqlite") { - const record = database.db + const record = await database.db .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.recordKey, recordKey)) .get(); - const revision = database.db + const revision = await database.db .select() .from(sqlite.capletRevisions) .where( @@ -2764,19 +2798,19 @@ async function getRevisionByKey( ) .get(); if (!record || !revision) return undefined; - const tags = database.db + const tags = await database.db .select() .from(sqlite.capletRevisionTags) .where(eq(sqlite.capletRevisionTags.revisionKey, revisionKey)) .orderBy(asc(sqlite.capletRevisionTags.position)) .all(); - const backends = database.db + const backends = await database.db .select() .from(sqlite.capletRevisionBackends) .where(eq(sqlite.capletRevisionBackends.revisionKey, revisionKey)) .orderBy(asc(sqlite.capletRevisionBackends.position)) .all(); - const entries = database.db + const entries = await database.db .select() .from(sqlite.capletBundleEntries) .where(eq(sqlite.capletBundleEntries.revisionKey, revisionKey)) @@ -2837,7 +2871,7 @@ async function appendOperatorActivity( createdAt: new Date().toISOString(), }; if (database.dialect === "sqlite") { - database.db.insert(sqlite.operatorActivity).values(values).run(); + await database.db.insert(sqlite.operatorActivity).values(values).run(); } else { await database.db.insert(postgres.operatorActivity).values(values); } @@ -2932,18 +2966,18 @@ function normalizeRevisionPageOptions( }; } -function listRevisionsPageSqlite( +async function listRevisionsPageSqlite( db: SqliteHostDatabase, id: string, options: NormalizedCapletRevisionPageOptions, -): CapletRevisionPageQueryResult { - const record = db +): Promise { + const record = await db .select({ recordKey: sqlite.capletRecords.recordKey }) .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, id)) .get(); if (!record) return { page: { items: [] } }; - const rows = db + const rows = await db .select({ revisionKey: sqlite.capletRevisions.revisionKey, sequence: sqlite.capletRevisions.sequence, @@ -3104,10 +3138,10 @@ function optionalPageFilter(value: string | undefined): string | undefined { return normalized ? normalized : undefined; } -function listRecordsPageSqlite( +async function listRecordsPageSqlite( db: SqliteHostDatabase, options: NormalizedCapletRecordPageOptions, -): StorageKeysetPage { +): Promise> { const latestInstallation = db .select({ installationKey: sqlite.capletInstallations.installationKey }) .from(sqlite.capletInstallations) @@ -3160,7 +3194,7 @@ function listRecordsPageSqlite( ); const compare = options.sort === "asc" ? gt : lt; const order = options.sort === "asc" ? asc : desc; - const rows = db + const rows = await db .select({ record: { recordKey: sqlite.capletRecords.recordKey, @@ -3343,35 +3377,35 @@ function recordKeysetPage( }; } -function getSqlite( +async function getSqlite( db: SqliteHostDatabase | SqliteHostTransaction, id: string, -): CapletRecordView | undefined { - const row = db +): Promise { + const row = await db .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, id)) .get(); if (!row?.currentRevisionKey) return undefined; - const revision = db + const revision = await db .select() .from(sqlite.capletRevisions) .where(eq(sqlite.capletRevisions.revisionKey, row.currentRevisionKey)) .get(); if (!revision) throw missingCurrentRevision(id); - const tags = db + const tags = await db .select() .from(sqlite.capletRevisionTags) .where(eq(sqlite.capletRevisionTags.revisionKey, revision.revisionKey)) .orderBy(asc(sqlite.capletRevisionTags.position)) .all(); - const backends = db + const backends = await db .select() .from(sqlite.capletRevisionBackends) .where(eq(sqlite.capletRevisionBackends.revisionKey, revision.revisionKey)) .orderBy(asc(sqlite.capletRevisionBackends.position)) .all(); - const entries = db + const entries = await db .select() .from(sqlite.capletBundleEntries) .where(eq(sqlite.capletBundleEntries.revisionKey, revision.revisionKey)) @@ -3520,25 +3554,28 @@ function staleGeneration( ); } -function deleteUnreferencedSqliteBlob( +async function deleteUnreferencedSqliteBlob( db: SqliteHostDatabase, hash: string, -): string | null | undefined { - return db.transaction((transaction) => { - const blob = transaction +): Promise { + return await db.transaction(async (transaction) => { + const blob = await transaction .select({ objectKey: sqlite.capletAssetBlobs.objectKey }) .from(sqlite.capletAssetBlobs) .where(eq(sqlite.capletAssetBlobs.hash, hash)) .get(); if (!blob) return undefined; - const reference = transaction + const reference = await transaction .select({ path: sqlite.capletBundleEntries.path }) .from(sqlite.capletBundleEntries) .where(eq(sqlite.capletBundleEntries.blobHash, hash)) .limit(1) .get(); if (reference) return undefined; - transaction.delete(sqlite.capletAssetBlobs).where(eq(sqlite.capletAssetBlobs.hash, hash)).run(); + await transaction + .delete(sqlite.capletAssetBlobs) + .where(eq(sqlite.capletAssetBlobs.hash, hash)) + .run(); return blob.objectKey; }); } @@ -3568,12 +3605,12 @@ async function deleteUnreferencedPostgresBlob( }); } -function ensureSqliteBlob( +async function ensureSqliteBlob( transaction: Parameters[0]>[0], _bundle: PreparedBundle, entry: PreparedBundle["entries"][number], -): void { - const row = transaction +): Promise { + const row = await transaction .select({ hash: sqlite.capletAssetBlobs.hash, size: sqlite.capletAssetBlobs.size, @@ -3707,12 +3744,12 @@ function recordActivityValues(bundle: PreparedBundle, action: string) { }; } -function insertSqliteInstallation( +async function insertSqliteInstallation( transaction: Parameters[0]>[0], bundle: PreparedBundle, -): void { +): Promise { if (!bundle.installation) return; - transaction + await transaction .insert(sqlite.capletInstallations) .values({ installationKey: bundle.installation.installationKey, @@ -3726,7 +3763,7 @@ function insertSqliteInstallation( updatedAt: bundle.now, }) .run(); - transaction + await transaction .insert(sqlite.capletInstallationObservations) .values({ observationKey: randomUUID(), diff --git a/packages/core/src/storage/coordination.ts b/packages/core/src/storage/coordination.ts index 3ef474d5..03c88561 100644 --- a/packages/core/src/storage/coordination.ts +++ b/packages/core/src/storage/coordination.ts @@ -72,7 +72,7 @@ export class HostCoordinationStore { async registerNode(input: RegisterNodeInput): Promise { validateNodeInput(input); return this.database.dialect === "sqlite" - ? registerSqlite(this.database.db, input) + ? await registerSqlite(this.database.db, input) : await registerPostgres(this.database.db, input); } @@ -82,7 +82,10 @@ export class HostCoordinationStore { async unregisterNode(nodeId: string): Promise { if (this.database.dialect === "sqlite") { - this.database.db.delete(sqlite.hostNodes).where(eq(sqlite.hostNodes.nodeId, nodeId)).run(); + await this.database.db + .delete(sqlite.hostNodes) + .where(eq(sqlite.hostNodes.nodeId, nodeId)) + .run(); } else { await this.database.db .delete(postgres.hostNodes) @@ -93,7 +96,7 @@ export class HostCoordinationStore { async nodeReady(nodeId: string): Promise { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ ready: sqlite.hostNodes.ready }) .from(sqlite.hostNodes) .where(eq(sqlite.hostNodes.nodeId, nodeId)) @@ -110,7 +113,7 @@ export class HostCoordinationStore { async activeNodeCount(maxHeartbeatAgeMs = 5_000): Promise { if (this.database.dialect === "sqlite") { - const row = this.database.db + const row = await this.database.db .select({ count: count() }) .from(sqlite.hostNodes) .where( @@ -133,8 +136,9 @@ export class HostCoordinationStore { async publishConfigGeneration(contentHash: string, createdBy: string): Promise { return this.database.dialect === "sqlite" - ? this.database.db.transaction((transaction) => - advanceSqliteConfigGeneration(transaction, contentHash, createdBy, true), + ? this.database.db.transaction( + async (transaction) => + await advanceSqliteConfigGeneration(transaction, contentHash, createdBy, true), ) : await this.database.db.transaction( async (transaction) => @@ -145,7 +149,7 @@ export class HostCoordinationStore { async currentConfigGeneration(): Promise { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ generation: sqlite.hostConfigGenerations.generation }) .from(sqlite.hostConfigGenerations) .orderBy(desc(sqlite.hostConfigGenerations.generation)) @@ -252,7 +256,7 @@ export class HostCoordinationStore { if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0) throw new CapletsError("REQUEST_INVALID", "Lease TTL must be positive."); return this.database.dialect === "sqlite" - ? acquireLeaseSqlite(this.database.db, input) + ? await acquireLeaseSqlite(this.database.db, input) : await acquireLeasePostgres(this.database.db, input); } @@ -263,7 +267,7 @@ export class HostCoordinationStore { cursor: string | null; now?: Date | undefined; }): Promise { - if (this.database.dialect === "sqlite") checkpointSqlite(this.database.db, input); + if (this.database.dialect === "sqlite") await checkpointSqlite(this.database.db, input); else await checkpointPostgres(this.database.db, input); } } @@ -300,13 +304,13 @@ async function connectPostgresListener( } } -export function advanceSqliteConfigGeneration( +export async function advanceSqliteConfigGeneration( transaction: Parameters[0]>[0], contentHash: string, createdBy: string, deduplicate = false, -): number { - const latest = transaction +): Promise { + const latest = await transaction .select({ generation: sqlite.hostConfigGenerations.generation, contentHash: sqlite.hostConfigGenerations.contentHash, @@ -316,7 +320,7 @@ export function advanceSqliteConfigGeneration( .get(); if (deduplicate && latest?.contentHash === contentHash) return latest.generation; const generation = (latest?.generation ?? 0) + 1; - transaction + await transaction .insert(sqlite.hostConfigGenerations) .values({ generation, @@ -406,21 +410,24 @@ function configGenerationWaitAborted(): Error { return error; } -function registerSqlite(db: SqliteHostDatabase, input: RegisterNodeInput): HostNodeRegistration { - return db.transaction((transaction) => { +async function registerSqlite( + db: SqliteHostDatabase, + input: RegisterNodeInput, +): Promise { + return await db.transaction(async (transaction) => { const now = input.now ?? new Date(); const nowText = now.toISOString(); const cutoff = new Date(now.getTime() - (input.heartbeatTtlMs ?? 15_000)).toISOString(); - let identity = transaction + let identity = await transaction .select() .from(sqlite.hostIdentity) .where(eq(sqlite.hostIdentity.singleton, 1)) .get(); if (!identity) { identity = { singleton: 1, hostId: randomUUID(), createdAt: nowText }; - transaction.insert(sqlite.hostIdentity).values(identity).run(); + await transaction.insert(sqlite.hostIdentity).values(identity).run(); } - const peers = transaction + const peers = await transaction .select() .from(sqlite.hostNodes) .where( @@ -428,7 +435,7 @@ function registerSqlite(db: SqliteHostDatabase, input: RegisterNodeInput): HostN ) .all(); const conflict = parityConflict(peers, input); - transaction + await transaction .insert(sqlite.hostNodes) .values({ nodeId: input.nodeId, @@ -520,14 +527,14 @@ function parityConflict( return null; } -function acquireLeaseSqlite( +async function acquireLeaseSqlite( db: SqliteHostDatabase, input: AcquireLeaseInput, -): MaintenanceLease | undefined { - return db.transaction( - (transaction) => { +): Promise { + return await db.transaction( + async (transaction) => { const now = input.now ?? new Date(); - const existing = transaction + const existing = await transaction .select() .from(sqlite.maintenanceLeases) .where(eq(sqlite.maintenanceLeases.leaseName, input.leaseName)) @@ -545,7 +552,7 @@ function acquireLeaseSqlite( expiresAt: new Date(now.getTime() + input.ttlMs).toISOString(), updatedAt: now.toISOString(), }; - transaction + await transaction .insert(sqlite.maintenanceLeases) .values(lease) .onConflictDoUpdate({ target: sqlite.maintenanceLeases.leaseName, set: lease }) @@ -601,7 +608,7 @@ async function acquireLeasePostgres( }); } -function checkpointSqlite( +async function checkpointSqlite( db: SqliteHostDatabase, input: { leaseName: string; @@ -610,15 +617,15 @@ function checkpointSqlite( cursor: string | null; now?: Date | undefined; }, -): void { - db.transaction((transaction) => { - const lease = transaction +): Promise { + await db.transaction(async (transaction) => { + const lease = await transaction .select() .from(sqlite.maintenanceLeases) .where(eq(sqlite.maintenanceLeases.leaseName, input.leaseName)) .get(); assertLease(lease, input); - transaction + await transaction .insert(sqlite.maintenanceCursors) .values({ jobName: input.leaseName, diff --git a/packages/core/src/storage/dashboard-sessions.ts b/packages/core/src/storage/dashboard-sessions.ts index 87356718..f4544470 100644 --- a/packages/core/src/storage/dashboard-sessions.ts +++ b/packages/core/src/storage/dashboard-sessions.ts @@ -20,14 +20,14 @@ export class DashboardSessionRepository { throw new CapletsError("REQUEST_INVALID", "Dashboard session record is invalid."); } return this.database.dialect === "sqlite" - ? createSqlite(this.database.db, persisted) + ? await createSqlite(this.database.db, persisted) : await createPostgres(this.database.db, persisted); } async get(sessionId: string): Promise { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) @@ -48,13 +48,13 @@ export class DashboardSessionRepository { now: Date, ): Promise { return this.database.dialect === "sqlite" - ? touchSqlite(this.database.db, sessionId, expectedSecretHash, now) + ? await touchSqlite(this.database.db, sessionId, expectedSecretHash, now) : await touchPostgres(this.database.db, sessionId, expectedSecretHash, now); } async delete(sessionId: string, options: DeleteOptions = {}): Promise { return this.database.dialect === "sqlite" - ? deleteSqlite(this.database.db, sessionId, options) + ? await deleteSqlite(this.database.db, sessionId, options) : await deletePostgres(this.database.db, sessionId, options); } @@ -62,16 +62,18 @@ export class DashboardSessionRepository { const nowText = now.toISOString(); const idleCutoff = new Date(now.getTime() - DASHBOARD_SESSION_IDLE_TIMEOUT_MS).toISOString(); return this.database.dialect === "sqlite" - ? this.database.db - .delete(sqlite.dashboardSessions) - .where( - or( - lte(sqlite.dashboardSessions.expiresAt, nowText), - lt(sqlite.dashboardSessions.lastUsedAt, idleCutoff), - ne(sqlite.dashboardSessions.role, "operator"), - ), - ) - .run().changes + ? ( + await this.database.db + .delete(sqlite.dashboardSessions) + .where( + or( + lte(sqlite.dashboardSessions.expiresAt, nowText), + lt(sqlite.dashboardSessions.lastUsedAt, idleCutoff), + ne(sqlite.dashboardSessions.role, "operator"), + ), + ) + .run() + ).rowsAffected : ( await this.database.db .delete(postgres.dashboardSessions) @@ -87,13 +89,21 @@ export class DashboardSessionRepository { } } -function createSqlite(db: SqliteHostDatabase, session: DashboardSessionRecord): boolean { - return db.transaction((transaction) => { +async function createSqlite( + db: SqliteHostDatabase, + session: DashboardSessionRecord, +): Promise { + return await db.transaction(async (transaction) => { const created = - transaction.insert(sqlite.dashboardSessions).values(session).onConflictDoNothing().run() - .changes > 0; + ( + await transaction + .insert(sqlite.dashboardSessions) + .values(session) + .onConflictDoNothing() + .run() + ).rowsAffected > 0; if (created) { - transaction + await transaction .insert(sqlite.operatorActivity) .values( activityValues(session.operatorClientId, "dashboard.session.create", session.sessionId), @@ -125,14 +135,14 @@ async function createPostgres( }); } -function touchSqlite( +async function touchSqlite( db: SqliteHostDatabase, sessionId: string, expectedSecretHash: string, now: Date, -): DashboardSessionRecord | undefined { - return db.transaction((transaction) => { - const row = transaction +): Promise { + return await db.transaction(async (transaction) => { + const row = await transaction .select() .from(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) @@ -140,7 +150,7 @@ function touchSqlite( const session = parseDashboardSessionRecord(row); if (!session) { if (row) { - transaction + await transaction .delete(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) .run(); @@ -149,14 +159,14 @@ function touchSqlite( } if (session.secretHash !== expectedSecretHash) return undefined; if (sessionExpired(session, now)) { - transaction + await transaction .delete(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) .run(); return undefined; } const lastUsedAt = now.toISOString(); - transaction + await transaction .update(sqlite.dashboardSessions) .set({ lastUsedAt }) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) @@ -203,9 +213,13 @@ async function touchPostgres( }); } -function deleteSqlite(db: SqliteHostDatabase, sessionId: string, options: DeleteOptions): boolean { - return db.transaction((transaction) => { - const row = transaction +async function deleteSqlite( + db: SqliteHostDatabase, + sessionId: string, + options: DeleteOptions, +): Promise { + return await db.transaction(async (transaction) => { + const row = await transaction .select() .from(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) @@ -218,12 +232,12 @@ function deleteSqlite(db: SqliteHostDatabase, sessionId: string, options: Delete ) { return false; } - transaction + await transaction .delete(sqlite.dashboardSessions) .where(eq(sqlite.dashboardSessions.sessionId, sessionId)) .run(); if (options.operatorInitiated && session) { - transaction + await transaction .insert(sqlite.operatorActivity) .values(activityValues(session.operatorClientId, "dashboard.session.delete", sessionId)) .run(); diff --git a/packages/core/src/storage/database.ts b/packages/core/src/storage/database.ts index 49d66c38..feba5446 100644 --- a/packages/core/src/storage/database.ts +++ b/packages/core/src/storage/database.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; -import { chmodSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import BetterSqlite3 from "better-sqlite3"; -import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient, type Client, type Transaction } from "@libsql/client"; +import { drizzle as drizzleSqlite } from "drizzle-orm/libsql"; import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { setTimeout as delay } from "node:timers/promises"; import { Pool } from "pg"; @@ -37,6 +39,8 @@ import type { const POSTGRES_SCHEMA_PATTERN = /^[a-z_][a-z0-9_]{0,62}$/u; const SQLITE_BUSY_RETRY_DELAYS_MS = [50, 100] as const; +const SQLITE_BUSY_TIMEOUT_MS = 5_000; +const SQLITE_OPERATION_QUEUES = new Map(); export type HostStorageOptions = { vaultRoot?: string | undefined; @@ -269,7 +273,7 @@ async function openMigratedSqliteStorage( options: HostStorageOptions = {}, ): Promise { return await retrySqliteBusy(async () => { - const storage = openSqliteStorage(config, options); + const storage = await openSqliteStorage(config, options); try { await migrateHostDatabase(storage.database); return storage; @@ -299,19 +303,25 @@ function isSqliteBusy(error: unknown): boolean { return error.code === "SQLITE_BUSY"; } -function openSqliteStorage( +async function openSqliteStorage( config: SqliteHostStorageConfig, options: HostStorageOptions, -): HostStorage { - const path = config.path ?? defaultSqliteStoragePath(); +): Promise { + const configuredPath = config.path ?? defaultSqliteStoragePath(); + const ephemeralRoot = + configuredPath === ":memory:" ? mkdtempSync(join(tmpdir(), "caplets-sqlite-")) : undefined; + const path = ephemeralRoot ? join(ephemeralRoot, "host.sqlite3") : resolve(configuredPath); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const client = new BetterSqlite3(path); + const operationQueue = sqliteOperationQueue(path); + const rawClient = createClient({ + url: pathToFileURL(path).href, + timeout: SQLITE_BUSY_TIMEOUT_MS, + }); + const client = serializeSqliteClient(rawClient, operationQueue); try { - client.pragma("busy_timeout = 5000"); - client.pragma("foreign_keys = ON"); - if (client.pragma("journal_mode", { simple: true }) !== "wal") - client.pragma("journal_mode = WAL"); - client.pragma("synchronous = FULL"); + await client.execute("PRAGMA foreign_keys = ON"); + await client.execute("PRAGMA journal_mode = WAL"); + await client.execute("PRAGMA synchronous = FULL"); try { chmodSync(path, 0o600); } catch { @@ -321,21 +331,121 @@ function openSqliteStorage( return new HostStorage( { dialect: "sqlite", db }, async () => { - client.close(); + await operationQueue.drain(); + rawClient.close(); + if (ephemeralRoot) rmSync(ephemeralRoot, { recursive: true, force: true }); }, config, options, ); } catch (error) { try { - client.close(); + await operationQueue.drain(); + rawClient.close(); } catch { // Preserve the startup error. } + if (ephemeralRoot) rmSync(ephemeralRoot, { recursive: true, force: true }); throw error; } } +class SqliteOperationQueue { + private tail = Promise.resolve(); + + async run(operation: () => Promise): Promise { + const release = await this.acquire(); + try { + return await operation(); + } finally { + release(); + } + } + + async acquire(): Promise<() => void> { + const previous = this.tail; + let releaseCurrent = (): void => {}; + this.tail = new Promise((resolveCurrent) => { + releaseCurrent = resolveCurrent; + }); + await previous; + let released = false; + return () => { + if (released) return; + released = true; + releaseCurrent(); + }; + } + + async drain(): Promise { + await this.tail; + } +} + +function sqliteOperationQueue(path: string): SqliteOperationQueue { + const existing = SQLITE_OPERATION_QUEUES.get(path); + if (existing) return existing; + const queue = new SqliteOperationQueue(); + SQLITE_OPERATION_QUEUES.set(path, queue); + return queue; +} + +function serializeSqliteClient(client: Client, queue: SqliteOperationQueue): Client { + const serializedMethods = new Set(["batch", "execute", "executeMultiple", "migrate", "sync"]); + return new Proxy(client, { + get(target, property, receiver) { + if (property === "transaction") { + return async (...args: Parameters) => { + const release = await queue.acquire(); + try { + return serializeSqliteTransaction(await target.transaction(...args), release); + } catch (error) { + release(); + throw error; + } + }; + } + const value = Reflect.get(target, property, receiver) as unknown; + if ( + typeof property === "string" && + serializedMethods.has(property) && + typeof value === "function" + ) { + return (...args: unknown[]) => + queue.run(async () => await Reflect.apply(value, target, args)); + } + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +function serializeSqliteTransaction(transaction: Transaction, release: () => void): Transaction { + return new Proxy(transaction, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) as unknown; + if ((property === "commit" || property === "rollback") && typeof value === "function") { + return async (...args: unknown[]) => { + try { + return await Reflect.apply(value, target, args); + } finally { + release(); + } + }; + } + if (property === "close" && typeof value === "function") { + return (...args: unknown[]) => { + try { + return Reflect.apply(value, target, args); + } finally { + release(); + } + }; + } + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + function openPostgresStorage( config: PostgresHostStorageConfig, options: HostStorageOptions, diff --git a/packages/core/src/storage/idempotency.ts b/packages/core/src/storage/idempotency.ts index a5147166..fbe33f20 100644 --- a/packages/core/src/storage/idempotency.ts +++ b/packages/core/src/storage/idempotency.ts @@ -162,21 +162,23 @@ export class IdempotencyStore { if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => { - const updated = transaction - .update(sqlite.idempotencyRecords) - .set({ heartbeatAt: nowText, updatedAt: nowText }) - .where( - and( - sqliteKeyWhere(input), - eq(sqlite.idempotencyRecords.state, "pending"), - eq(sqlite.idempotencyRecords.ownerToken, input.ownerToken), - gt(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), - ), - ) - .run().changes; + async (transaction) => { + const updated = ( + await transaction + .update(sqlite.idempotencyRecords) + .set({ heartbeatAt: nowText, updatedAt: nowText }) + .where( + and( + sqliteKeyWhere(input), + eq(sqlite.idempotencyRecords.state, "pending"), + eq(sqlite.idempotencyRecords.ownerToken, input.ownerToken), + gt(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), + ), + ) + .run() + ).rowsAffected; if (updated > 0) return true; - transitionPendingToUnknownSqlite( + await transitionPendingToUnknownSqlite( transaction, sqliteKeyWhere(input), staleBeforeText, @@ -236,21 +238,23 @@ export class IdempotencyStore { if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => { - const updated = transaction - .update(sqlite.idempotencyRecords) - .set(values) - .where( - and( - sqliteKeyWhere(input), - eq(sqlite.idempotencyRecords.state, "pending"), - eq(sqlite.idempotencyRecords.ownerToken, input.ownerToken), - gt(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), - ), - ) - .run().changes; + async (transaction) => { + const updated = ( + await transaction + .update(sqlite.idempotencyRecords) + .set(values) + .where( + and( + sqliteKeyWhere(input), + eq(sqlite.idempotencyRecords.state, "pending"), + eq(sqlite.idempotencyRecords.ownerToken, input.ownerToken), + gt(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), + ), + ) + .run() + ).rowsAffected; if (updated > 0) return true; - transitionPendingToUnknownSqlite( + await transitionPendingToUnknownSqlite( transaction, sqliteKeyWhere(input), staleBeforeText, @@ -296,7 +300,8 @@ export class IdempotencyStore { if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => pruneSqlite(transaction, staleBeforeText, nowText, unknownExpiryText), + async (transaction) => + await pruneSqlite(transaction, staleBeforeText, nowText, unknownExpiryText), { behavior: "immediate" }, ); } @@ -329,11 +334,11 @@ export class IdempotencyStore { }; } - private claimSqlite( + private async claimSqlite( transaction: SqliteHostTransaction, input: PreparedClaim, - ): IdempotencyClaimResult { - prunePrincipalSqlite( + ): Promise { + await prunePrincipalSqlite( transaction, input.key.principalClientId, input.staleBeforeText, @@ -341,19 +346,23 @@ export class IdempotencyStore { input.expiresAtText, ); const inserted = - transaction - .insert(sqlite.idempotencyRecords) - .values(newRecordValues(input)) - .onConflictDoNothing() - .run().changes > 0; + ( + await transaction + .insert(sqlite.idempotencyRecords) + .values(newRecordValues(input)) + .onConflictDoNothing() + .run() + ).rowsAffected > 0; if (inserted) { - const rowCount = transaction - .select({ count: count() }) - .from(sqlite.idempotencyRecords) - .where(eq(sqlite.idempotencyRecords.principalClientId, input.key.principalClientId)) - .get()?.count; + const rowCount = ( + await transaction + .select({ count: count() }) + .from(sqlite.idempotencyRecords) + .where(eq(sqlite.idempotencyRecords.principalClientId, input.key.principalClientId)) + .get() + )?.count; if ((rowCount ?? 0) <= this.maxRowsPerPrincipal) return acquiredResult(input); - transaction + await transaction .delete(sqlite.idempotencyRecords) .where( and( @@ -364,7 +373,7 @@ export class IdempotencyStore { .run(); return { outcome: "capacity_exceeded" }; } - const row = transaction + const row = await transaction .select() .from(sqlite.idempotencyRecords) .where(sqliteKeyWhere(input.key)) @@ -490,21 +499,21 @@ function existingClaimResult( }; } -function prunePrincipalSqlite( +async function prunePrincipalSqlite( transaction: SqliteHostTransaction, principalClientId: string, staleBeforeText: string, nowText: string, unknownExpiryText: string, -): void { - transitionPendingToUnknownSqlite( +): Promise { + await transitionPendingToUnknownSqlite( transaction, eq(sqlite.idempotencyRecords.principalClientId, principalClientId), staleBeforeText, nowText, unknownExpiryText, ); - transaction + await transaction .delete(sqlite.idempotencyRecords) .where( and( @@ -541,23 +550,25 @@ async function prunePrincipalPostgres( ); } -function pruneSqlite( +async function pruneSqlite( transaction: SqliteHostTransaction, staleBeforeText: string, nowText: string, unknownExpiryText: string, -): IdempotencyPruneResult { - const transitionedToUnknown = transitionPendingToUnknownSqlite( +): Promise { + const transitionedToUnknown = await transitionPendingToUnknownSqlite( transaction, undefined, staleBeforeText, nowText, unknownExpiryText, ); - const deleted = transaction - .delete(sqlite.idempotencyRecords) - .where(and(terminalStateWhereSqlite(), lte(sqlite.idempotencyRecords.expiresAt, nowText))) - .run().changes; + const deleted = ( + await transaction + .delete(sqlite.idempotencyRecords) + .where(and(terminalStateWhereSqlite(), lte(sqlite.idempotencyRecords.expiresAt, nowText))) + .run() + ).rowsAffected; return { transitionedToUnknown, deleted }; } @@ -581,31 +592,33 @@ async function prunePostgres( return { transitionedToUnknown, deleted: deleted.length }; } -function transitionPendingToUnknownSqlite( +async function transitionPendingToUnknownSqlite( transaction: SqliteHostTransaction, scope: SQL | undefined, staleBeforeText: string, nowText: string, unknownExpiryText: string, -): number { - return transaction - .update(sqlite.idempotencyRecords) - .set({ - state: "unknown", - ownerToken: null, - heartbeatAt: null, - terminalAt: nowText, - updatedAt: nowText, - expiresAt: unknownExpiryText, - }) - .where( - and( - scope, - eq(sqlite.idempotencyRecords.state, "pending"), - lte(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), - ), - ) - .run().changes; +): Promise { + return ( + await transaction + .update(sqlite.idempotencyRecords) + .set({ + state: "unknown", + ownerToken: null, + heartbeatAt: null, + terminalAt: nowText, + updatedAt: nowText, + expiresAt: unknownExpiryText, + }) + .where( + and( + scope, + eq(sqlite.idempotencyRecords.state, "pending"), + lte(sqlite.idempotencyRecords.heartbeatAt, staleBeforeText), + ), + ) + .run() + ).rowsAffected; } async function transitionPendingToUnknownPostgres( diff --git a/packages/core/src/storage/installations.ts b/packages/core/src/storage/installations.ts index b0d025f1..3a475212 100644 --- a/packages/core/src/storage/installations.ts +++ b/packages/core/src/storage/installations.ts @@ -123,7 +123,7 @@ export class CapletInstallationStore { const operatorId = requireOperator(input.operator); const installationKey = this.database.dialect === "sqlite" - ? installSqlite(this.database.db, input, operatorId) + ? await installSqlite(this.database.db, input, operatorId) : await installPostgres(this.database.db, input, operatorId); const installed = await this.getByKey(installationKey); if (!installed) @@ -134,7 +134,7 @@ export class CapletInstallationStore { async replaceDetached(input: ReplaceDetachedInstallationInput): Promise { const operatorId = requireOperator(input.operator); if (this.database.dialect === "sqlite") - replaceDetachedSqlite(this.database.db, input, operatorId); + await replaceDetachedSqlite(this.database.db, input, operatorId); else await replaceDetachedPostgres(this.database.db, input, operatorId); const installed = await this.getActive(input.capletId); if (!installed) @@ -150,39 +150,39 @@ export class CapletInstallationStore { ): Promise { const operatorId = requireOperator(input.operator); return this.database.dialect === "sqlite" - ? appendObservationSqlite(this.database.db, input, operatorId) + ? await appendObservationSqlite(this.database.db, input, operatorId) : await appendObservationPostgres(this.database.db, input, operatorId); } async detach(input: DetachInput): Promise { const operatorId = requireOperator(input.operator); - if (this.database.dialect === "sqlite") detachSqlite(this.database.db, input, operatorId); + if (this.database.dialect === "sqlite") await detachSqlite(this.database.db, input, operatorId); else await detachPostgres(this.database.db, input, operatorId); return await this.getByKey(input.installationKey); } async getActive(capletId: string): Promise { return this.database.dialect === "sqlite" - ? getSqlite(this.database.db, capletId, true) + ? await getSqlite(this.database.db, capletId, true) : await getPostgres(this.database.db, capletId, true); } - getActiveInTransaction( + async getActiveInTransaction( capletId: string, transaction: HostDatabaseTransaction, - ): CapletInstallationView | undefined | Promise { + ): Promise> { return transaction.dialect === "sqlite" - ? getSqlite(transaction.db, capletId, true) - : getPostgres(transaction.db, capletId, true); + ? await getSqlite(transaction.db, capletId, true) + : await getPostgres(transaction.db, capletId, true); } async getLatest(capletId: string): Promise { return this.database.dialect === "sqlite" - ? getSqlite(this.database.db, capletId, false) + ? await getSqlite(this.database.db, capletId, false) : await getPostgres(this.database.db, capletId, false); } async getByKey(installationKey: string): Promise { return this.database.dialect === "sqlite" - ? getByKeySqlite(this.database.db, installationKey) + ? await getByKeySqlite(this.database.db, installationKey) : await getByKeyPostgres(this.database.db, installationKey); } @@ -194,7 +194,7 @@ export class CapletInstallationStore { const limit = storagePageLimit(options.limit); const sort = options.sort ?? "desc"; return this.database.dialect === "sqlite" - ? listPageSqlite(this.database.db, capletId, limit, options.after, sort) + ? await listPageSqlite(this.database.db, capletId, limit, options.after, sort) : await listPagePostgres(this.database.db, capletId, limit, options.after, sort); } @@ -216,7 +216,7 @@ export class CapletInstallationStore { const installation = await this.getLatest(capletId); if (!installation) return undefined; return this.database.dialect === "sqlite" - ? latestObservationSqlite(this.database.db, installation.installationKey) + ? await latestObservationSqlite(this.database.db, installation.installationKey) : await latestObservationPostgres(this.database.db, installation.installationKey); } @@ -230,7 +230,7 @@ export class CapletInstallationStore { const limit = storagePageLimit(options.limit); const sort = options.sort ?? "asc"; return this.database.dialect === "sqlite" - ? listObservationsPageSqlite(this.database.db, capletId, limit, options.after, sort) + ? await listObservationsPageSqlite(this.database.db, capletId, limit, options.after, sort) : await listObservationsPagePostgres(this.database.db, capletId, limit, options.after, sort); } @@ -250,7 +250,7 @@ export class CapletInstallationStore { const bounded = Math.max(1, Math.min(limit, 500)); const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.operatorActivity) .orderBy(desc(sqlite.operatorActivity.createdAt)) @@ -272,15 +272,19 @@ export function requireOperator(principal: OperatorPrincipal): string { return principal.clientId; } -function installSqlite(db: SqliteHostDatabase, input: InstallInput, operatorId: string): string { - return db.transaction((transaction) => { - const record = transaction +async function installSqlite( + db: SqliteHostDatabase, + input: InstallInput, + operatorId: string, +): Promise { + return await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.capletId)) .get(); if (!record) throw missingCapletRecord(input.capletId); - const latest = transaction + const latest = await transaction .select() .from(sqlite.capletInstallations) .where(eq(sqlite.capletInstallations.recordKey, record.recordKey)) @@ -293,7 +297,7 @@ function installSqlite(db: SqliteHostDatabase, input: InstallInput, operatorId: assertFreshInstall(input.capletId, latest?.status); const now = new Date().toISOString(); const installationKey = input.installationKey ?? randomUUID(); - const inserted = transaction + const inserted = await transaction .insert(sqlite.capletInstallations) .values({ installationKey, @@ -308,8 +312,8 @@ function installSqlite(db: SqliteHostDatabase, input: InstallInput, operatorId: }) .onConflictDoNothing() .run(); - if (inserted.changes !== 1) throw installationKeyExists(installationKey); - transaction + if (inserted.rowsAffected !== 1) throw installationKeyExists(installationKey); + await transaction .insert(sqlite.operatorActivity) .values( activity(operatorId, "caplet.install", "installation", installationKey, now, { @@ -317,7 +321,7 @@ function installSqlite(db: SqliteHostDatabase, input: InstallInput, operatorId: }), ) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, `install:${record.recordKey}:${installationKey}`, operatorId, @@ -381,20 +385,20 @@ async function installPostgres( }); } -function replaceDetachedSqlite( +async function replaceDetachedSqlite( db: SqliteHostDatabase, input: ReplaceDetachedInstallationInput, operatorId: string, -): void { - db.transaction((transaction) => { - const record = transaction +): Promise { + await db.transaction(async (transaction) => { + const record = await transaction .select() .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, input.capletId)) .get(); if (!record) throw new CapletsError("REQUEST_INVALID", `Caplet Record ${input.capletId} was not found.`); - const latest = transaction + const latest = await transaction .select() .from(sqlite.capletInstallations) .where(eq(sqlite.capletInstallations.recordKey, record.recordKey)) @@ -407,7 +411,7 @@ function replaceDetachedSqlite( assertDetachedReplacement(input, latest); const now = nextTimestamp(latest!.updatedAt); const installationKey = randomUUID(); - transaction + await transaction .insert(sqlite.capletInstallations) .values({ installationKey, @@ -421,7 +425,7 @@ function replaceDetachedSqlite( updatedAt: now, }) .run(); - transaction + await transaction .insert(sqlite.operatorActivity) .values( activity(operatorId, "caplet.replace_installation", "installation", installationKey, now, { @@ -430,7 +434,7 @@ function replaceDetachedSqlite( }), ) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, `install:${record.recordKey}:${installationKey}`, operatorId, @@ -490,16 +494,16 @@ async function replaceDetachedPostgres( }); } -function appendObservationSqlite( +async function appendObservationSqlite( db: SqliteHostDatabase, input: AppendInstallationObservationInput, operatorId: string, -): CapletInstallationObservationView { - return db.transaction((transaction) => { - const current = activeSqlite(transaction, input.capletId); +): Promise { + return await db.transaction(async (transaction) => { + const current = await activeSqlite(transaction, input.capletId); if (!current) throw missingActiveInstallation(input.capletId); if (current.generation !== input.expectedGeneration) throw staleInstallation(input.capletId); - const latest = transaction + const latest = await transaction .select() .from(sqlite.capletInstallationObservations) .where(eq(sqlite.capletInstallationObservations.installationKey, current.installationKey)) @@ -511,7 +515,7 @@ function appendObservationSqlite( .get(); const now = nextTimestamp(latest?.observedAt); const observation = observationValues(current.installationKey, input, now); - const updated = transaction + const updated = await transaction .update(sqlite.capletInstallations) .set({ generation: current.generation + 1, updatedAt: now }) .where( @@ -522,9 +526,9 @@ function appendObservationSqlite( ), ) .run(); - if (updated.changes !== 1) throw staleInstallation(input.capletId); - transaction.insert(sqlite.capletInstallationObservations).values(observation).run(); - transaction + if (updated.rowsAffected !== 1) throw staleInstallation(input.capletId); + await transaction.insert(sqlite.capletInstallationObservations).values(observation).run(); + await transaction .insert(sqlite.operatorActivity) .values( activity( @@ -590,9 +594,13 @@ async function appendObservationPostgres( }); } -function detachSqlite(db: SqliteHostDatabase, input: DetachInput, operatorId: string): void { - db.transaction((transaction) => { - const current = transaction +async function detachSqlite( + db: SqliteHostDatabase, + input: DetachInput, + operatorId: string, +): Promise { + await db.transaction(async (transaction) => { + const current = await transaction .select({ installationKey: sqlite.capletInstallations.installationKey, generation: sqlite.capletInstallations.generation, @@ -620,7 +628,7 @@ function detachSqlite(db: SqliteHostDatabase, input: DetachInput, operatorId: st ); } const now = new Date().toISOString(); - const updated = transaction + const updated = await transaction .update(sqlite.capletInstallations) .set({ status: "detached", @@ -637,8 +645,8 @@ function detachSqlite(db: SqliteHostDatabase, input: DetachInput, operatorId: st ), ) .run(); - if (updated.changes !== 1) throw staleInstallation(input.capletId); - transaction + if (updated.rowsAffected !== 1) throw staleInstallation(input.capletId); + await transaction .insert(sqlite.operatorActivity) .values( activity(operatorId, "caplet.detach", "installation", current.installationKey, now, { @@ -646,7 +654,7 @@ function detachSqlite(db: SqliteHostDatabase, input: DetachInput, operatorId: st }), ) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, `detach:${current.installationKey}:${current.generation + 1}`, operatorId, @@ -720,12 +728,12 @@ async function detachPostgres( }); } -function getSqlite( +async function getSqlite( db: SqliteHostDatabase | SqliteHostTransaction, capletId: string, activeOnly: boolean, -): CapletInstallationView | undefined { - const row = db +): Promise { + const row = await db .select({ installation: sqlite.capletInstallations, capletId: sqlite.capletRecords.capletId }) .from(sqlite.capletInstallations) .innerJoin( @@ -780,11 +788,11 @@ async function getPostgres( return row ? installationView(row.capletId, row.installation) : undefined; } -function getByKeySqlite( +async function getByKeySqlite( db: SqliteHostDatabase, installationKey: string, -): CapletInstallationView | undefined { - const row = db +): Promise { + const row = await db .select({ installation: sqlite.capletInstallations, capletId: sqlite.capletRecords.capletId }) .from(sqlite.capletInstallations) .innerJoin( @@ -815,53 +823,54 @@ async function getByKeyPostgres( return row ? installationView(row.capletId, row.installation) : undefined; } -function listPageSqlite( +async function listPageSqlite( db: SqliteHostDatabase, capletId: string, limit: number, after: CapletInstallationPageKey | undefined, sort: KeysetSortDirection, -): StorageKeysetPage { - const rows = db - .select({ installation: sqlite.capletInstallations, capletId: sqlite.capletRecords.capletId }) - .from(sqlite.capletInstallations) - .innerJoin( - sqlite.capletRecords, - eq(sqlite.capletRecords.recordKey, sqlite.capletInstallations.recordKey), - ) - .where( - and( - eq(sqlite.capletRecords.capletId, capletId), - after - ? sort === "asc" - ? or( - gt(sqlite.capletInstallations.updatedAt, after.updatedAt), - and( - eq(sqlite.capletInstallations.updatedAt, after.updatedAt), - gt(sqlite.capletInstallations.installationKey, after.installationKey), - ), - ) - : or( - lt(sqlite.capletInstallations.updatedAt, after.updatedAt), - and( - eq(sqlite.capletInstallations.updatedAt, after.updatedAt), - lt(sqlite.capletInstallations.installationKey, after.installationKey), - ), - ) - : undefined, - ), - ) - .orderBy( - sort === "asc" - ? asc(sqlite.capletInstallations.updatedAt) - : desc(sqlite.capletInstallations.updatedAt), - sort === "asc" - ? asc(sqlite.capletInstallations.installationKey) - : desc(sqlite.capletInstallations.installationKey), - ) - .limit(limit + 1) - .all() - .map((row) => installationView(row.capletId, row.installation)); +): Promise> { + const rows = ( + await db + .select({ installation: sqlite.capletInstallations, capletId: sqlite.capletRecords.capletId }) + .from(sqlite.capletInstallations) + .innerJoin( + sqlite.capletRecords, + eq(sqlite.capletRecords.recordKey, sqlite.capletInstallations.recordKey), + ) + .where( + and( + eq(sqlite.capletRecords.capletId, capletId), + after + ? sort === "asc" + ? or( + gt(sqlite.capletInstallations.updatedAt, after.updatedAt), + and( + eq(sqlite.capletInstallations.updatedAt, after.updatedAt), + gt(sqlite.capletInstallations.installationKey, after.installationKey), + ), + ) + : or( + lt(sqlite.capletInstallations.updatedAt, after.updatedAt), + and( + eq(sqlite.capletInstallations.updatedAt, after.updatedAt), + lt(sqlite.capletInstallations.installationKey, after.installationKey), + ), + ) + : undefined, + ), + ) + .orderBy( + sort === "asc" + ? asc(sqlite.capletInstallations.updatedAt) + : desc(sqlite.capletInstallations.updatedAt), + sort === "asc" + ? asc(sqlite.capletInstallations.installationKey) + : desc(sqlite.capletInstallations.installationKey), + ) + .limit(limit + 1) + .all() + ).map((row) => installationView(row.capletId, row.installation)); return keysetPage(rows, limit, (item) => ({ updatedAt: item.updatedAt, installationKey: item.installationKey, @@ -924,13 +933,15 @@ async function listPagePostgres( })); } -function listObservationsPageSqlite( +async function listObservationsPageSqlite( db: SqliteHostDatabase, capletId: string, limit: number, after: CapletInstallationObservationPageKey | undefined, sort: KeysetSortDirection, -): StorageKeysetPage { +): Promise< + StorageKeysetPage +> { const latestInstallation = db .select({ installationKey: sqlite.capletInstallations.installationKey }) .from(sqlite.capletInstallations) @@ -944,42 +955,43 @@ function listObservationsPageSqlite( desc(sqlite.capletInstallations.installationKey), ) .limit(1); - const rows = db - .select() - .from(sqlite.capletInstallationObservations) - .where( - and( - inArray(sqlite.capletInstallationObservations.installationKey, latestInstallation), - after - ? sort === "asc" - ? or( - gt(sqlite.capletInstallationObservations.observedAt, after.observedAt), - and( - eq(sqlite.capletInstallationObservations.observedAt, after.observedAt), - gt(sqlite.capletInstallationObservations.observationKey, after.observationKey), - ), - ) - : or( - lt(sqlite.capletInstallationObservations.observedAt, after.observedAt), - and( - eq(sqlite.capletInstallationObservations.observedAt, after.observedAt), - lt(sqlite.capletInstallationObservations.observationKey, after.observationKey), - ), - ) - : undefined, - ), - ) - .orderBy( - sort === "asc" - ? asc(sqlite.capletInstallationObservations.observedAt) - : desc(sqlite.capletInstallationObservations.observedAt), - sort === "asc" - ? asc(sqlite.capletInstallationObservations.observationKey) - : desc(sqlite.capletInstallationObservations.observationKey), - ) - .limit(limit + 1) - .all() - .map(observationView); + const rows = ( + await db + .select() + .from(sqlite.capletInstallationObservations) + .where( + and( + inArray(sqlite.capletInstallationObservations.installationKey, latestInstallation), + after + ? sort === "asc" + ? or( + gt(sqlite.capletInstallationObservations.observedAt, after.observedAt), + and( + eq(sqlite.capletInstallationObservations.observedAt, after.observedAt), + gt(sqlite.capletInstallationObservations.observationKey, after.observationKey), + ), + ) + : or( + lt(sqlite.capletInstallationObservations.observedAt, after.observedAt), + and( + eq(sqlite.capletInstallationObservations.observedAt, after.observedAt), + lt(sqlite.capletInstallationObservations.observationKey, after.observationKey), + ), + ) + : undefined, + ), + ) + .orderBy( + sort === "asc" + ? asc(sqlite.capletInstallationObservations.observedAt) + : desc(sqlite.capletInstallationObservations.observedAt), + sort === "asc" + ? asc(sqlite.capletInstallationObservations.observationKey) + : desc(sqlite.capletInstallationObservations.observationKey), + ) + .limit(limit + 1) + .all() + ).map(observationView); return keysetPage(rows, limit, (item) => ({ observedAt: item.observedAt, observationKey: item.observationKey, @@ -1050,11 +1062,11 @@ async function listObservationsPagePostgres( })); } -function activeSqlite( +async function activeSqlite( db: Parameters[0]>[0], capletId: string, ) { - return db + return await db .select({ installationKey: sqlite.capletInstallations.installationKey, generation: sqlite.capletInstallations.generation, @@ -1099,11 +1111,11 @@ async function activePostgres( return row; } -function latestObservationSqlite( +async function latestObservationSqlite( db: SqliteHostDatabase, installationKey: string, -): CapletInstallationObservationView | undefined { - const row = db +): Promise { + const row = await db .select() .from(sqlite.capletInstallationObservations) .where(eq(sqlite.capletInstallationObservations.installationKey, installationKey)) @@ -1300,11 +1312,11 @@ function isRecord(value: unknown): value is Record { async function capletRecordExists(database: HostDatabase, capletId: string): Promise { if (database.dialect === "sqlite") { return ( - database.db + (await database.db .select({ recordKey: sqlite.capletRecords.recordKey }) .from(sqlite.capletRecords) .where(eq(sqlite.capletRecords.capletId, capletId)) - .get() !== undefined + .get()) !== undefined ); } const [record] = await database.db diff --git a/packages/core/src/storage/legacy-migration.ts b/packages/core/src/storage/legacy-migration.ts index 0ca612f3..24f4a58f 100644 --- a/packages/core/src/storage/legacy-migration.ts +++ b/packages/core/src/storage/legacy-migration.ts @@ -212,15 +212,16 @@ export async function migrateLegacyHostState( const transactionStorage = storage; if (storage.database.dialect === "sqlite") { - storage.database.db.transaction((transaction) => { - importAndVerifyLegacySqlite( - transactionStorage, - transaction, - pendingBundles, - plan, - operator, - ); - }); + await storage.database.db.transaction( + async (transaction) => + await importAndVerifyLegacySqlite( + transactionStorage, + transaction, + pendingBundles, + plan, + operator, + ), + ); } else { await storage.database.db.transaction( async (transaction) => @@ -243,45 +244,29 @@ export async function migrateLegacyHostState( } } -function importAndVerifyLegacySqlite( +async function importAndVerifyLegacySqlite( storage: HostStorage, transaction: SqliteHostTransaction, pendingBundles: ImportCapletBundleInput[], plan: PlannedLegacyState, operator: OperatorPrincipal, -): void { +): Promise { const adapter = { dialect: "sqlite", db: transaction } as const; - runSynchronous(storage.caplets.importBundlesInTransaction(pendingBundles, adapter)); - runSynchronous( - storage.backendAuth.importLegacyBundlesInTransaction(plan.backendAuth.bundles, adapter), - ); - runSynchronous(storage.vaultValues.importLegacyValuesInTransaction(plan.vault.values, adapter)); - runSynchronous( - storage.vaultGrants.importLegacyGrantsInTransaction(plan.vaultGrants, operator, adapter), - ); - runSynchronous( - storage.remoteSecurity.importLegacySnapshotInTransaction(plan.remoteSecurity, adapter), - ); - runSynchronous(storage.setupState.importLegacySnapshotInTransaction(plan.setup, adapter)); - runSynchronous( - storage.operatorActivity.importLegacyEntriesInTransaction(plan.operatorActivity, adapter), - ); + await storage.caplets.importBundlesInTransaction(pendingBundles, adapter); + await storage.backendAuth.importLegacyBundlesInTransaction(plan.backendAuth.bundles, adapter); + await storage.vaultValues.importLegacyValuesInTransaction(plan.vault.values, adapter); + await storage.vaultGrants.importLegacyGrantsInTransaction(plan.vaultGrants, operator, adapter); + await storage.remoteSecurity.importLegacySnapshotInTransaction(plan.remoteSecurity, adapter); + await storage.setupState.importLegacySnapshotInTransaction(plan.setup, adapter); + await storage.operatorActivity.importLegacyEntriesInTransaction(plan.operatorActivity, adapter); - verifyImportedArtifactsSqlite(storage, plan.artifacts, adapter); - runSynchronous( - storage.backendAuth.verifyLegacyBundlesInTransaction(plan.backendAuth.bundles, adapter), - ); - runSynchronous(storage.vaultValues.verifyLegacyValuesInTransaction(plan.vault.values, adapter)); - runSynchronous( - storage.vaultGrants.verifyLegacyGrantsInTransaction(plan.vaultGrants, operator, adapter), - ); - runSynchronous( - storage.remoteSecurity.verifyLegacySnapshotInTransaction(plan.remoteSecurity, adapter), - ); - runSynchronous(storage.setupState.verifyLegacySnapshotInTransaction(plan.setup, adapter)); - runSynchronous( - storage.operatorActivity.verifyLegacyEntriesInTransaction(plan.operatorActivity, adapter), - ); + await verifyImportedArtifactsSqlite(storage, plan.artifacts, adapter); + await storage.backendAuth.verifyLegacyBundlesInTransaction(plan.backendAuth.bundles, adapter); + await storage.vaultValues.verifyLegacyValuesInTransaction(plan.vault.values, adapter); + await storage.vaultGrants.verifyLegacyGrantsInTransaction(plan.vaultGrants, operator, adapter); + await storage.remoteSecurity.verifyLegacySnapshotInTransaction(plan.remoteSecurity, adapter); + await storage.setupState.verifyLegacySnapshotInTransaction(plan.setup, adapter); + await storage.operatorActivity.verifyLegacyEntriesInTransaction(plan.operatorActivity, adapter); } async function importAndVerifyLegacyPostgres( @@ -309,16 +294,6 @@ async function importAndVerifyLegacyPostgres( await storage.operatorActivity.verifyLegacyEntriesInTransaction(plan.operatorActivity, adapter); } -function runSynchronous(result: T | Promise): T { - if (result instanceof Promise) { - throw new CapletsError( - "INTERNAL_ERROR", - "SQLite legacy migration attempted an asynchronous transaction operation.", - ); - } - return result; -} - function planLegacyState(options: LegacyMigrationOptions, backupPath: string): PlannedLegacyState { const backupMoves: BackupMove[] = []; const capletsBackupRoot = join(backupPath, "caplets"); @@ -677,21 +652,22 @@ function hashPath(path: string, relativePath: string, hash: Hash): void { hash.update("\0"); } -function verifyImportedArtifactsSqlite( +async function verifyImportedArtifactsSqlite( storage: HostStorage, artifacts: PlannedArtifact[], transaction: Extract, -): void { +): Promise { for (const artifact of artifacts) { - const record = runSynchronous(storage.caplets.getInTransaction(artifact.id, transaction)); + const record = await storage.caplets.getInTransaction(artifact.id, transaction); if (record?.currentRevision.sourceContentHash !== artifact.installedHash) { throw new CapletsError( "INTERNAL_ERROR", `Caplet Record ${artifact.id} failed post-migration verification.`, ); } - const installation = runSynchronous( - storage.installations.getActiveInTransaction(artifact.id, transaction), + const installation = await storage.installations.getActiveInTransaction( + artifact.id, + transaction, ); if (!installation || installation.sourceIdentity !== artifact.sourceIdentity) { throw new CapletsError( diff --git a/packages/core/src/storage/migrations.ts b/packages/core/src/storage/migrations.ts index f8369a7f..07b2a0bc 100644 --- a/packages/core/src/storage/migrations.ts +++ b/packages/core/src/storage/migrations.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from "node:url"; +import type { Client } from "@libsql/client"; import { eq } from "drizzle-orm"; import { readMigrationFiles } from "drizzle-orm/migrator"; import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator"; @@ -13,7 +14,7 @@ const POSTGRES_MIGRATIONS_FOLDER = fileURLToPath(new URL("./drizzle/postgres", i export async function migrateHostDatabase(database: HostDatabase): Promise { const appliedAt = new Date().toISOString(); if (database.dialect === "sqlite") { - migrateSqliteExclusively(database, appliedAt); + await migrateSqliteExclusively(database, appliedAt); return; } @@ -32,97 +33,94 @@ export async function migrateHostDatabase(database: HostDatabase): Promise }); } -function migrateSqliteExclusively( +async function migrateSqliteExclusively( database: Extract, appliedAt: string, -): void { +): Promise { type AppliedMigration = { hash: string; created_at: number; }; - type Statement = { - all(...params: unknown[]): unknown[]; - get(...params: unknown[]): unknown; - run(...params: unknown[]): unknown; - }; - type Client = { - exec(source: string): void; - prepare(source: string): Statement; - transaction(run: () => T): { exclusive(): T }; - }; const client = (database.db as typeof database.db & { $client: Client }).$client; const migrations = readMigrationFiles({ migrationsFolder: SQLITE_MIGRATIONS_FOLDER }); - client - .transaction(() => { - const schemaTable = client - .prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'caplets_schema'", - ) - .get() as { present?: number } | undefined; - if (schemaTable?.present === 1) { - const row = client - .prepare("SELECT version FROM caplets_schema WHERE singleton = 1") - .get() as { version?: number } | undefined; - if (row?.version !== undefined && row.version > HOST_STORAGE_SCHEMA_VERSION) { - throw schemaNewerError(row.version); - } + const transaction = await client.transaction("write"); + try { + const schemaTable = ( + await transaction.execute( + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'caplets_schema'", + ) + ).rows[0] as { present?: number } | undefined; + if (schemaTable?.present === 1) { + const row = ( + await transaction.execute("SELECT version FROM caplets_schema WHERE singleton = 1") + ).rows[0] as { version?: number } | undefined; + if (row?.version !== undefined && row.version > HOST_STORAGE_SCHEMA_VERSION) { + throw schemaNewerError(row.version); } - client.exec( - "CREATE TABLE IF NOT EXISTS caplets_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric NOT NULL)", - ); - const appliedMigrations = client - .prepare("SELECT hash, created_at FROM caplets_migrations ORDER BY created_at") - .all() as AppliedMigration[]; - const appliedByCreatedAt = new Map(); - for (const migration of appliedMigrations) { - if ( - typeof migration.hash !== "string" || - typeof migration.created_at !== "number" || - appliedByCreatedAt.has(migration.created_at) - ) { - throw invalidSqliteMigrationHistory("contains malformed or duplicate entries"); - } - appliedByCreatedAt.set(migration.created_at, migration.hash); + } + await transaction.execute( + "CREATE TABLE IF NOT EXISTS caplets_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric NOT NULL)", + ); + const appliedMigrations = ( + await transaction.execute( + "SELECT hash, created_at FROM caplets_migrations ORDER BY created_at", + ) + ).rows as unknown as AppliedMigration[]; + const appliedByCreatedAt = new Map(); + for (const migration of appliedMigrations) { + if ( + typeof migration.hash !== "string" || + typeof migration.created_at !== "number" || + appliedByCreatedAt.has(migration.created_at) + ) { + throw invalidSqliteMigrationHistory("contains malformed or duplicate entries"); } - const latestApplied = appliedMigrations.at(-1)?.created_at; - for (const migration of migrations) { - const appliedHash = appliedByCreatedAt.get(migration.folderMillis); - if (appliedHash !== undefined) { - if (appliedHash !== migration.hash) { - throw invalidSqliteMigrationHistory( - `hash mismatch for migration ${migration.folderMillis}`, - ); - } - continue; - } - if (latestApplied !== undefined && migration.folderMillis < latestApplied) { + appliedByCreatedAt.set(migration.created_at, migration.hash); + } + const latestApplied = appliedMigrations.at(-1)?.created_at; + for (const migration of migrations) { + const appliedHash = appliedByCreatedAt.get(migration.folderMillis); + if (appliedHash !== undefined) { + if (appliedHash !== migration.hash) { throw invalidSqliteMigrationHistory( - `migration ${migration.folderMillis} is missing before ${latestApplied}`, + `hash mismatch for migration ${migration.folderMillis}`, ); } - for (const statement of migration.sql) client.exec(statement); - client - .prepare("INSERT INTO caplets_migrations (hash, created_at) VALUES (?, ?)") - .run(migration.hash, migration.folderMillis); + continue; + } + if (latestApplied !== undefined && migration.folderMillis < latestApplied) { + throw invalidSqliteMigrationHistory( + `migration ${migration.folderMillis} is missing before ${latestApplied}`, + ); } - client - .prepare( - "INSERT INTO caplets_schema (singleton, version, applied_at) VALUES (1, ?, ?) ON CONFLICT(singleton) DO UPDATE SET version = excluded.version, applied_at = excluded.applied_at", - ) - .run(HOST_STORAGE_SCHEMA_VERSION, appliedAt); - }) - .exclusive(); + for (const statement of migration.sql) await transaction.executeMultiple(statement); + await transaction.execute({ + sql: "INSERT INTO caplets_migrations (hash, created_at) VALUES (?, ?)", + args: [migration.hash, migration.folderMillis], + }); + } + await transaction.execute({ + sql: "INSERT INTO caplets_schema (singleton, version, applied_at) VALUES (1, ?, ?) ON CONFLICT(singleton) DO UPDATE SET version = excluded.version, applied_at = excluded.applied_at", + args: [HOST_STORAGE_SCHEMA_VERSION, appliedAt], + }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } } export async function inspectHostDatabase(database: HostDatabase): Promise { try { const version = database.dialect === "sqlite" - ? database.db - .select({ version: sqliteCapletsSchema.version }) - .from(sqliteCapletsSchema) - .where(eq(sqliteCapletsSchema.singleton, 1)) - .get()?.version + ? ( + await database.db + .select({ version: sqliteCapletsSchema.version }) + .from(sqliteCapletsSchema) + .where(eq(sqliteCapletsSchema.singleton, 1)) + .get() + )?.version : ( await database.db .select({ version: postgresCapletsSchema.version }) diff --git a/packages/core/src/storage/operator-activity.ts b/packages/core/src/storage/operator-activity.ts index b25bbbc9..e3c1a018 100644 --- a/packages/core/src/storage/operator-activity.ts +++ b/packages/core/src/storage/operator-activity.ts @@ -107,7 +107,7 @@ export class OperatorActivityStore { }; if (this.database.dialect === "sqlite") { - this.database.db.insert(sqlite.operatorActivity).values(row).run(); + await this.database.db.insert(sqlite.operatorActivity).values(row).run(); } else { await this.database.db.insert(postgres.operatorActivity).values(row); } @@ -117,7 +117,7 @@ export class OperatorActivityStore { async assertLegacyEntriesImportable(entries: OperatorActivityEntry[]): Promise { const rows = validateLegacyActivityEntries(entries); if (this.database.dialect === "sqlite") { - inspectLegacyActivitySqlite(this.database.db, rows); + await inspectLegacyActivitySqlite(this.database.db, rows); } else { await inspectLegacyActivityPostgres(this.database.db, rows); } @@ -127,10 +127,10 @@ export class OperatorActivityStore { const rows = validateLegacyActivityEntries(entries); if (rows.length === 0) return; if (this.database.dialect === "sqlite") { - this.database.db.transaction((transaction) => { - const pending = inspectLegacyActivitySqlite(transaction, rows); + await this.database.db.transaction(async (transaction) => { + const pending = await inspectLegacyActivitySqlite(transaction, rows); if (pending.length > 0) { - transaction.insert(sqlite.operatorActivity).values(pending).run(); + await transaction.insert(sqlite.operatorActivity).values(pending).run(); } }); return; @@ -142,22 +142,22 @@ export class OperatorActivityStore { } }); } - importLegacyEntriesInTransaction( + async importLegacyEntriesInTransaction( entries: OperatorActivityEntry[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const rows = validateLegacyActivityEntries(entries); if (rows.length === 0) return; return transaction.dialect === "sqlite" - ? importLegacyActivitySqlite(transaction.db, rows) - : importLegacyActivityPostgres(transaction.db, rows); + ? await importLegacyActivitySqlite(transaction.db, rows) + : await importLegacyActivityPostgres(transaction.db, rows); } async verifyLegacyEntries(entries: OperatorActivityEntry[]): Promise { const rows = validateLegacyActivityEntries(entries); const pending = this.database.dialect === "sqlite" - ? inspectLegacyActivitySqlite(this.database.db, rows) + ? await inspectLegacyActivitySqlite(this.database.db, rows) : await inspectLegacyActivityPostgres(this.database.db, rows); if (pending.length > 0) { throw new CapletsError( @@ -166,16 +166,16 @@ export class OperatorActivityStore { ); } } - verifyLegacyEntriesInTransaction( + async verifyLegacyEntriesInTransaction( entries: OperatorActivityEntry[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const rows = validateLegacyActivityEntries(entries); - if (transaction.dialect === "sqlite") { - verifyLegacyActivityPending(inspectLegacyActivitySqlite(transaction.db, rows)); - return; - } - return inspectLegacyActivityPostgres(transaction.db, rows).then(verifyLegacyActivityPending); + const pending = + transaction.dialect === "sqlite" + ? await inspectLegacyActivitySqlite(transaction.db, rows) + : await inspectLegacyActivityPostgres(transaction.db, rows); + verifyLegacyActivityPending(pending); } async listPage( @@ -186,7 +186,7 @@ export class OperatorActivityStore { if (input.after !== undefined) validateActivityPageKey(input.after); const rows = this.database.dialect === "sqlite" - ? this.listSqlite(limit + 1, input.after, input.action, sort) + ? await this.listSqlite(limit + 1, input.after, input.action, sort) : await this.listPostgres(limit + 1, input.after, input.action, sort); const hasMore = rows.length > limit; const pageRows = hasMore ? rows.slice(0, limit) : rows; @@ -214,7 +214,7 @@ export class OperatorActivityStore { private async findCursor(activityKey: string): Promise { if (this.database.dialect === "sqlite") { - return this.database.db + return await this.database.db .select({ activityKey: sqlite.operatorActivity.activityKey, createdAt: sqlite.operatorActivity.createdAt, @@ -234,12 +234,12 @@ export class OperatorActivityStore { return cursor; } - private listSqlite( + private async listSqlite( limit: number, cursor: ActivityCursor | undefined, action: string | undefined, sort: KeysetSortDirection, - ): ActivityRow[] { + ): Promise { const conditions: SQL[] = []; if (action !== undefined) conditions.push(eq(sqlite.operatorActivity.action, action)); if (cursor) { @@ -262,7 +262,7 @@ export class OperatorActivityStore { ); } return this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.operatorActivity) .where(and(...conditions)) @@ -330,9 +330,12 @@ type SqliteActivityDatabase = type PostgresActivityDatabase = | PostgresHostDatabase | Parameters[0]>[0]; -function importLegacyActivitySqlite(database: SqliteActivityDatabase, rows: ActivityRow[]): void { - const pending = inspectLegacyActivitySqlite(database, rows); - if (pending.length > 0) database.insert(sqlite.operatorActivity).values(pending).run(); +async function importLegacyActivitySqlite( + database: SqliteActivityDatabase, + rows: ActivityRow[], +): Promise { + const pending = await inspectLegacyActivitySqlite(database, rows); + if (pending.length > 0) await database.insert(sqlite.operatorActivity).values(pending).run(); } async function importLegacyActivityPostgres( @@ -388,12 +391,13 @@ function legacyActivityRow(entry: OperatorActivityEntry): ActivityRow { }; } -function inspectLegacyActivitySqlite( +async function inspectLegacyActivitySqlite( database: SqliteActivityDatabase, rows: ActivityRow[], -): ActivityRow[] { - return rows.filter((row) => { - const existing = database +): Promise { + const pending: ActivityRow[] = []; + for (const row of rows) { + const existing = await database .select() .from(sqlite.operatorActivity) .where(eq(sqlite.operatorActivity.activityKey, row.activityKey)) @@ -407,8 +411,9 @@ function inspectLegacyActivitySqlite( "Operator Activity conflicts with the legacy snapshot.", ); } - return existing === undefined; - }); + if (!existing) pending.push(row); + } + return pending; } async function inspectLegacyActivityPostgres( diff --git a/packages/core/src/storage/project-bindings.ts b/packages/core/src/storage/project-bindings.ts index 522524f3..4b5dfb9b 100644 --- a/packages/core/src/storage/project-bindings.ts +++ b/packages/core/src/storage/project-bindings.ts @@ -140,7 +140,7 @@ export class ProjectBindingStore { async get(bindingId: string): Promise { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.projectBindings) .where(eq(sqlite.projectBindings.bindingId, bindingId)) @@ -158,7 +158,7 @@ export class ProjectBindingStore { async list(): Promise { const rows = this.database.dialect === "sqlite" - ? this.database.db.select().from(sqlite.projectBindings).all() + ? await this.database.db.select().from(sqlite.projectBindings).all() : await this.database.db.select().from(postgres.projectBindings); return rows.map(bindingView); } @@ -166,7 +166,7 @@ export class ProjectBindingStore { const expiresAfter = now.toISOString(); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ bindingId: sqlite.projectBindings.bindingId }) .from(sqlite.projectBindings) .where( @@ -323,18 +323,18 @@ export class ProjectBindingStore { transition: (current: ProjectBindingAuthoritativeView | undefined) => BindingMutation, ): Promise { return this.database.dialect === "sqlite" - ? mutateBindingSqlite(this.database.db, bindingId, transition) + ? await mutateBindingSqlite(this.database.db, bindingId, transition) : await mutateBindingPostgres(this.database.db, bindingId, transition); } } -function mutateBindingSqlite( +async function mutateBindingSqlite( db: SqliteHostDatabase, bindingId: string, transition: (current: ProjectBindingAuthoritativeView | undefined) => BindingMutation, -): R { - return db.transaction((transaction) => { - const row = transaction +): Promise { + return await db.transaction(async (transaction) => { + const row = await transaction .select() .from(sqlite.projectBindings) .where(eq(sqlite.projectBindings.bindingId, bindingId)) @@ -344,7 +344,7 @@ function mutateBindingSqlite( assertExpectedGeneration(current?.generation, mutation.expectedGeneration); if (mutation.next) { if (current) { - transaction + await transaction .update(sqlite.projectBindings) .set(bindingRow(mutation.next)) .where( @@ -355,11 +355,11 @@ function mutateBindingSqlite( ) .run(); } else { - transaction.insert(sqlite.projectBindings).values(bindingRow(mutation.next)).run(); + await transaction.insert(sqlite.projectBindings).values(bindingRow(mutation.next)).run(); } } if (mutation.activity) { - transaction + await transaction .insert(sqlite.operatorActivity) .values( activityValues(mutation.activity, mutation.next?.updatedAt ?? new Date().toISOString()), diff --git a/packages/core/src/storage/remote-security.ts b/packages/core/src/storage/remote-security.ts index 485d273b..cc48a16f 100644 --- a/packages/core/src/storage/remote-security.ts +++ b/packages/core/src/storage/remote-security.ts @@ -618,7 +618,7 @@ export class RemoteSecurityStore { async getClient(clientId: string): Promise { const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ clientId: sqlite.remoteClients.clientId, clientLabel: sqlite.remoteClients.clientLabel, @@ -658,7 +658,7 @@ export class RemoteSecurityStore { const sort = input.sort ?? "asc"; const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ clientId: sqlite.remoteClients.clientId, clientLabel: sqlite.remoteClients.clientLabel, @@ -726,7 +726,8 @@ export class RemoteSecurityStore { async countClients(): Promise { if (this.database.dialect === "sqlite") { return ( - this.database.db.select({ value: count() }).from(sqlite.remoteClients).get()?.value ?? 0 + (await this.database.db.select({ value: count() }).from(sqlite.remoteClients).get()) + ?.value ?? 0 ); } const [row] = await this.database.db.select({ value: count() }).from(postgres.remoteClients); @@ -749,9 +750,9 @@ export class RemoteSecurityStore { now = new Date(), ): Promise { if (this.database.dialect === "sqlite") { - return this.database.db.transaction((transaction) => { - cleanupSqlitePendingLogins(transaction, now); - const row = transaction + return this.database.db.transaction(async (transaction) => { + await cleanupSqlitePendingLogins(transaction, now); + const row = await transaction .select({ flowId: sqlite.remotePendingLogins.flowId, hostUrl: sqlite.remotePendingLogins.hostUrl, @@ -814,18 +815,20 @@ export class RemoteSecurityStore { ): Promise { const normalizedStatuses = normalizePendingLoginStatuses(statuses); if (this.database.dialect === "sqlite") { - return this.database.db.transaction((transaction) => { - cleanupSqlitePendingLogins(transaction, now); + return this.database.db.transaction(async (transaction) => { + await cleanupSqlitePendingLogins(transaction, now); return ( - transaction - .select({ value: count() }) - .from(sqlite.remotePendingLogins) - .where( - normalizedStatuses - ? inArray(sqlite.remotePendingLogins.status, normalizedStatuses) - : undefined, - ) - .get()?.value ?? 0 + ( + await transaction + .select({ value: count() }) + .from(sqlite.remotePendingLogins) + .where( + normalizedStatuses + ? inArray(sqlite.remotePendingLogins.status, normalizedStatuses) + : undefined, + ) + .get() + )?.value ?? 0 ); }); } @@ -868,9 +871,9 @@ export class RemoteSecurityStore { const sort = input.sort ?? "asc"; const statuses = normalizePendingLoginStatuses(input.statuses); if (this.database.dialect === "sqlite") { - return this.database.db.transaction((transaction) => { - cleanupSqlitePendingLogins(transaction, now); - const rows = transaction + return this.database.db.transaction(async (transaction) => { + await cleanupSqlitePendingLogins(transaction, now); + const rows = await transaction .select({ flowId: sqlite.remotePendingLogins.flowId, hostUrl: sqlite.remotePendingLogins.hostUrl, @@ -1118,14 +1121,14 @@ export class RemoteSecurityStore { ); } } - verifyLegacySnapshotInTransaction( + async verifyLegacySnapshotInTransaction( snapshot: RemoteServerCredentialState, transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const validated = parseRemoteServerCredentialState(snapshot); return transaction.dialect === "sqlite" - ? verifyLegacyRemoteSecurityState(loadSqliteState(transaction.db), validated) - : verifyLegacyRemoteSecurityPostgres(transaction.db, validated); + ? await verifyLegacyRemoteSecurityState(await loadSqliteState(transaction.db), validated) + : await verifyLegacyRemoteSecurityPostgres(transaction.db, validated); } async dumpForTest(): Promise { @@ -1134,7 +1137,7 @@ export class RemoteSecurityStore { private async readState(): Promise { return this.database.dialect === "sqlite" - ? loadSqliteState(this.database.db) + ? await loadSqliteState(this.database.db) : await loadPostgresState(this.database.db); } @@ -1143,15 +1146,15 @@ export class RemoteSecurityStore { ): Promise { const result = this.database.dialect === "sqlite" - ? this.database.db.transaction( - (transaction) => { - const state = loadSqliteState(transaction); + ? await this.database.db.transaction( + async (transaction) => { + const state = await loadSqliteState(transaction); const transitionResult = transition(state); if ("error" in transitionResult) { - saveSqliteState(transaction, state); + await saveSqliteState(transaction, state); return transitionResult.error; } - if (transitionResult.save !== false) saveSqliteState(transaction, state); + if (transitionResult.save !== false) await saveSqliteState(transaction, state); return transitionResult.value; }, { behavior: "immediate" }, @@ -1179,12 +1182,12 @@ export class RemoteSecurityStore { ): Promise { if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => { - const state = loadSqliteState(transaction); + async (transaction) => { + const state = await loadSqliteState(transaction); const result = transition(state); if (result.save === false) return result.value; - saveSqliteState(transaction, state); - transaction + await saveSqliteState(transaction, state); + await transaction .insert(sqlite.operatorActivity) .values( activityValues( @@ -1292,17 +1295,17 @@ type OperatorTransitionResult = { type SqliteTransaction = Parameters[0]>[0]; type PostgresTransaction = Parameters[0]>[0]; -function importLegacyRemoteSecuritySqlite( +async function importLegacyRemoteSecuritySqlite( database: SqliteTransaction, snapshot: RemoteServerCredentialState, -): void { - const current = loadSqliteState(database); +): Promise { + const current = await loadSqliteState(database); const merged = mergeLegacyRemoteSecurityState(current, snapshot); if (!merged.changed) return; current.pairingCodes = merged.state.pairingCodes; current.pendingLogins = merged.state.pendingLogins; current.clients = merged.state.clients; - saveSqliteState(database, current); + await saveSqliteState(database, current); } async function importLegacyRemoteSecurityPostgres( @@ -1338,14 +1341,19 @@ async function verifyLegacyRemoteSecurityPostgres( verifyLegacyRemoteSecurityState(await loadPostgresState(database), snapshot); } -function loadSqliteState(database: SqliteHostDatabase | SqliteTransaction): RemoteSecurityState { +async function loadSqliteState( + database: SqliteHostDatabase | SqliteTransaction, +): Promise { return assembleState({ - pairingCodes: database.select().from(sqlite.remotePairingCodes).all(), - clients: database.select().from(sqlite.remoteClients).all(), - tokenFamilies: database.select().from(sqlite.remoteClientTokenFamilies).all(), - supersededRefreshes: database.select().from(sqlite.remoteClientSupersededRefreshTokens).all(), - pendingLogins: database.select().from(sqlite.remotePendingLogins).all(), - pendingSupersededRefreshes: database + pairingCodes: await database.select().from(sqlite.remotePairingCodes).all(), + clients: await database.select().from(sqlite.remoteClients).all(), + tokenFamilies: await database.select().from(sqlite.remoteClientTokenFamilies).all(), + supersededRefreshes: await database + .select() + .from(sqlite.remoteClientSupersededRefreshTokens) + .all(), + pendingLogins: await database.select().from(sqlite.remotePendingLogins).all(), + pendingSupersededRefreshes: await database .select() .from(sqlite.remotePendingSupersededRefreshTokens) .all(), @@ -1492,32 +1500,35 @@ function groupBy(values: T[], keyFor: (value: T) => string): Map return grouped; } -function saveSqliteState(database: SqliteTransaction, state: RemoteSecurityState): void { - database.delete(sqlite.remotePendingSupersededRefreshTokens).run(); - database.delete(sqlite.remoteClientSupersededRefreshTokens).run(); - database.delete(sqlite.remoteClientTokenFamilies).run(); - database.delete(sqlite.remotePendingLogins).run(); - database.delete(sqlite.remotePairingCodes).run(); - database.delete(sqlite.remoteClients).run(); +async function saveSqliteState( + database: SqliteTransaction, + state: RemoteSecurityState, +): Promise { + await database.delete(sqlite.remotePendingSupersededRefreshTokens).run(); + await database.delete(sqlite.remoteClientSupersededRefreshTokens).run(); + await database.delete(sqlite.remoteClientTokenFamilies).run(); + await database.delete(sqlite.remotePendingLogins).run(); + await database.delete(sqlite.remotePairingCodes).run(); + await database.delete(sqlite.remoteClients).run(); const values = relationalValues(state); if (values.pairingCodes.length > 0) { - database.insert(sqlite.remotePairingCodes).values(values.pairingCodes).run(); + await database.insert(sqlite.remotePairingCodes).values(values.pairingCodes).run(); } if (values.clients.length > 0) { - database.insert(sqlite.remoteClients).values(values.clients).run(); - database.insert(sqlite.remoteClientTokenFamilies).values(values.tokenFamilies).run(); + await database.insert(sqlite.remoteClients).values(values.clients).run(); + await database.insert(sqlite.remoteClientTokenFamilies).values(values.tokenFamilies).run(); } if (values.supersededRefreshes.length > 0) { - database + await database .insert(sqlite.remoteClientSupersededRefreshTokens) .values(values.supersededRefreshes) .run(); } if (values.pendingLogins.length > 0) { - database.insert(sqlite.remotePendingLogins).values(values.pendingLogins).run(); + await database.insert(sqlite.remotePendingLogins).values(values.pendingLogins).run(); } if (values.pendingSupersededRefreshes.length > 0) { - database + await database .insert(sqlite.remotePendingSupersededRefreshTokens) .values(values.pendingSupersededRefreshes) .run(); @@ -1833,9 +1844,9 @@ function stalePendingLoginWhere( ); } -function cleanupSqlitePendingLogins(database: SqliteTransaction, now: Date): void { +async function cleanupSqlitePendingLogins(database: SqliteTransaction, now: Date): Promise { const nowIso = now.toISOString(); - database + await database .update(sqlite.remotePendingLogins) .set({ status: "expired", @@ -1848,7 +1859,7 @@ function cleanupSqlitePendingLogins(database: SqliteTransaction, now: Date): voi ), ) .run(); - database + await database .delete(sqlite.remotePendingLogins) .where( stalePendingLoginWhere( diff --git a/packages/core/src/storage/setup-state.ts b/packages/core/src/storage/setup-state.ts index 1c03c602..bae43dd7 100644 --- a/packages/core/src/storage/setup-state.ts +++ b/packages/core/src/storage/setup-state.ts @@ -69,7 +69,7 @@ export class SetupStateStore { const identity = approvalIdentity(args); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ payload: sqlite.setupApprovals.payload }) .from(sqlite.setupApprovals) .where(sqliteApprovalWhere(identity)) @@ -91,7 +91,7 @@ export class SetupStateStore { const approval = parseSetupApproval(input); const timestamp = this.now().toISOString(); if (this.database.dialect === "sqlite") { - mutateApprovalSqlite(this.database.db, approval, options, timestamp); + await mutateApprovalSqlite(this.database.db, approval, options, timestamp); } else { await mutateApprovalPostgres(this.database.db, approval, options, timestamp); } @@ -113,7 +113,7 @@ export class SetupStateStore { return { attempts: this.prunedAttempts([...attempts, attempt]) }; }; if (this.database.dialect === "sqlite") { - mutateAttemptsSqlite(this.database.db, attempt, options, timestamp, transition); + await mutateAttemptsSqlite(this.database.db, attempt, options, timestamp, transition); } else { await mutateAttemptsPostgres(this.database.db, attempt, options, timestamp, transition); } @@ -139,7 +139,7 @@ export class SetupStateStore { args.length === 1 ? [DEFAULT_PROJECT_FINGERPRINT, args[0]] : args; const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select({ payload: sqlite.setupAttemptSets.payload }) .from(sqlite.setupAttemptSets) .where( @@ -179,7 +179,13 @@ export class SetupStateStore { : [DEFAULT_PROJECT_FINGERPRINT, args[0], args[1] ?? {}]; const timestamp = this.now().toISOString(); return this.database.dialect === "sqlite" - ? clearAttemptsSqlite(this.database.db, projectFingerprint, capletId, options, timestamp) + ? await clearAttemptsSqlite( + this.database.db, + projectFingerprint, + capletId, + options, + timestamp, + ) : await clearAttemptsPostgres( this.database.db, projectFingerprint, @@ -192,7 +198,7 @@ export class SetupStateStore { async assertLegacySnapshotImportable(snapshot: LegacySetupMigrationSnapshot): Promise { const validated = validateLegacySetupSnapshot(snapshot); if (this.database.dialect === "sqlite") { - inspectLegacySetupSqlite(this.database.db, validated); + await inspectLegacySetupSqlite(this.database.db, validated); } else { await inspectLegacySetupPostgres(this.database.db, validated); } @@ -202,13 +208,13 @@ export class SetupStateStore { const validated = validateLegacySetupSnapshot(snapshot); if (validated.approvals.length === 0 && validated.attemptSets.length === 0) return; if (this.database.dialect === "sqlite") { - this.database.db.transaction((transaction) => { - const pending = inspectLegacySetupSqlite(transaction, validated); + await this.database.db.transaction(async (transaction) => { + const pending = await inspectLegacySetupSqlite(transaction, validated); if (pending.approvals.length > 0) { - transaction.insert(sqlite.setupApprovals).values(pending.approvals).run(); + await transaction.insert(sqlite.setupApprovals).values(pending.approvals).run(); } if (pending.attemptSets.length > 0) { - transaction.insert(sqlite.setupAttemptSets).values(pending.attemptSets).run(); + await transaction.insert(sqlite.setupAttemptSets).values(pending.attemptSets).run(); } }); return; @@ -233,14 +239,14 @@ export class SetupStateStore { } }); } - importLegacySnapshotInTransaction( + async importLegacySnapshotInTransaction( snapshot: LegacySetupMigrationSnapshot, transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise> { const validated = validateLegacySetupSnapshot(snapshot); if (validated.approvals.length === 0 && validated.attemptSets.length === 0) return; return transaction.dialect === "sqlite" - ? importLegacySetupSqlite(transaction.db, validated) + ? await importLegacySetupSqlite(transaction.db, validated) : importLegacySetupPostgres(transaction.db, validated); } @@ -248,19 +254,19 @@ export class SetupStateStore { const validated = validateLegacySetupSnapshot(snapshot); const pending = this.database.dialect === "sqlite" - ? inspectLegacySetupSqlite(this.database.db, validated) + ? await inspectLegacySetupSqlite(this.database.db, validated) : await inspectLegacySetupPostgres(this.database.db, validated); if (pending.approvals.length > 0 || pending.attemptSets.length > 0) { throw new CapletsError("INTERNAL_ERROR", "Setup state failed post-migration verification."); } } - verifyLegacySnapshotInTransaction( + async verifyLegacySnapshotInTransaction( snapshot: LegacySetupMigrationSnapshot, transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise> { const validated = validateLegacySetupSnapshot(snapshot); if (transaction.dialect === "sqlite") { - verifyLegacySetupPending(inspectLegacySetupSqlite(transaction.db, validated)); + verifyLegacySetupPending(await inspectLegacySetupSqlite(transaction.db, validated)); return; } return inspectLegacySetupPostgres(transaction.db, validated).then(verifyLegacySetupPending); @@ -322,16 +328,16 @@ type SqliteSetupDatabase = type PostgresSetupDatabase = | PostgresHostDatabase | Parameters[0]>[0]; -function importLegacySetupSqlite( +async function importLegacySetupSqlite( database: SqliteSetupDatabase, snapshot: ValidatedLegacySetupSnapshot, -): void { - const pending = inspectLegacySetupSqlite(database, snapshot); +): Promise { + const pending = await inspectLegacySetupSqlite(database, snapshot); if (pending.approvals.length > 0) { - database.insert(sqlite.setupApprovals).values(pending.approvals).run(); + await database.insert(sqlite.setupApprovals).values(pending.approvals).run(); } if (pending.attemptSets.length > 0) { - database.insert(sqlite.setupAttemptSets).values(pending.attemptSets).run(); + await database.insert(sqlite.setupAttemptSets).values(pending.attemptSets).run(); } } @@ -422,13 +428,13 @@ function validateLegacySetupSnapshot( }; } -function inspectLegacySetupSqlite( +async function inspectLegacySetupSqlite( database: SqliteSetupDatabase, snapshot: ValidatedLegacySetupSnapshot, -): LegacySetupPendingRows { +): Promise { const pending: LegacySetupPendingRows = { approvals: [], attemptSets: [] }; for (const approval of snapshot.approvals) { - const existing = database + const existing = await database .select({ payload: sqlite.setupApprovals.payload }) .from(sqlite.setupApprovals) .where(sqliteApprovalWhere(approval)) @@ -445,7 +451,7 @@ function inspectLegacySetupSqlite( if (!existing) pending.approvals.push(legacySetupApprovalRow(approval)); } for (const set of snapshot.attemptSets) { - const existing = database + const existing = await database .select({ payload: sqlite.setupAttemptSets.payload }) .from(sqlite.setupAttemptSets) .where( @@ -583,20 +589,20 @@ function postgresApprovalWhere(identity: ApprovalIdentity) { ); } -function mutateApprovalSqlite( +async function mutateApprovalSqlite( db: SqliteHostDatabase, approval: SetupApproval, options: SetupStateMutationOptions, timestamp: string, -): void { - db.transaction((transaction) => { - const current = transaction +): Promise { + await db.transaction(async (transaction) => { + const current = await transaction .select({ generation: sqlite.setupApprovals.generation }) .from(sqlite.setupApprovals) .where(sqliteApprovalWhere(approval)) .get(); assertExpectedGeneration(current?.generation, options.expectedGeneration); - transaction + await transaction .insert(sqlite.setupApprovals) .values({ ...approval, @@ -621,7 +627,7 @@ function mutateApprovalSqlite( }, }) .run(); - insertSqliteActivity( + await insertSqliteActivity( transaction, options, "setup.approve", @@ -695,15 +701,15 @@ async function mutateApprovalPostgres( }); } -function mutateAttemptsSqlite( +async function mutateAttemptsSqlite( db: SqliteHostDatabase, attempt: SetupAttempt, options: SetupStateMutationOptions, timestamp: string, transition: (payload: unknown | undefined) => SetupAttemptsPayload, -): void { - db.transaction((transaction) => { - const current = transaction +): Promise { + await db.transaction(async (transaction) => { + const current = await transaction .select({ generation: sqlite.setupAttemptSets.generation, payload: sqlite.setupAttemptSets.payload, @@ -718,7 +724,7 @@ function mutateAttemptsSqlite( .get(); assertExpectedGeneration(current?.generation, options.expectedGeneration); const payload = transition(current?.payload); - transaction + await transaction .insert(sqlite.setupAttemptSets) .values({ projectFingerprint: attempt.projectFingerprint, @@ -738,7 +744,7 @@ function mutateAttemptsSqlite( }) .run(); const key = attemptsKey(attempt.projectFingerprint, attempt.capletId); - insertSqliteActivity( + await insertSqliteActivity( transaction, options, "setup.attempt.record", @@ -816,28 +822,28 @@ async function mutateAttemptsPostgres( }); } -function clearAttemptsSqlite( +async function clearAttemptsSqlite( db: SqliteHostDatabase, projectFingerprint: string, capletId: string, options: SetupStateMutationOptions, timestamp: string, -): boolean { - return db.transaction((transaction) => { +): Promise { + return await db.transaction(async (transaction) => { const where = and( eq(sqlite.setupAttemptSets.projectFingerprint, projectFingerprint), eq(sqlite.setupAttemptSets.capletId, capletId), ); - const current = transaction + const current = await transaction .select({ generation: sqlite.setupAttemptSets.generation }) .from(sqlite.setupAttemptSets) .where(where) .get(); assertExpectedGeneration(current?.generation, options.expectedGeneration); if (!current) return false; - transaction.delete(sqlite.setupAttemptSets).where(where).run(); + await transaction.delete(sqlite.setupAttemptSets).where(where).run(); const key = attemptsKey(projectFingerprint, capletId); - insertSqliteActivity( + await insertSqliteActivity( transaction, options, "setup.attempt.clear", @@ -948,7 +954,7 @@ function activityValues( }; } -function insertSqliteActivity( +async function insertSqliteActivity( transaction: Parameters[0]>[0], options: SetupStateMutationOptions, action: string, @@ -956,10 +962,10 @@ function insertSqliteActivity( targetKey: string, timestamp: string, metadata: Record, -): void { +): Promise { const operatorClientId = options.operatorClientId?.trim(); if (!operatorClientId) return; - transaction + await transaction .insert(sqlite.operatorActivity) .values(activityValues(operatorClientId, action, targetKind, targetKey, timestamp, metadata)) .run(); diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index 6a605879..7e6e4ad3 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -1,4 +1,4 @@ -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import type { LibSQLDatabase } from "drizzle-orm/libsql"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { HostStorageConfig } from "../config"; import type { postgresSchema } from "./schema/postgres"; @@ -31,7 +31,7 @@ export type HostStorageHealth = { | undefined; }; -export type SqliteHostDatabase = BetterSQLite3Database; +export type SqliteHostDatabase = LibSQLDatabase; export type PostgresHostDatabase = NodePgDatabase; export type SqliteHostTransaction = Parameters[0]>[0]; export type PostgresHostTransaction = Parameters< diff --git a/packages/core/src/storage/vault-grants.ts b/packages/core/src/storage/vault-grants.ts index 43697afd..7dea3ef8 100644 --- a/packages/core/src/storage/vault-grants.ts +++ b/packages/core/src/storage/vault-grants.ts @@ -149,8 +149,8 @@ export class VaultGrantStore { const prepared = prepareVaultGrant(input); const createdAt = new Date().toISOString(); if (this.database.dialect === "sqlite") { - return this.database.db.transaction((transaction) => - grantPreparedVaultSqlite(transaction, prepared, createdAt), + return await this.database.db.transaction( + async (transaction) => await grantPreparedVaultSqlite(transaction, prepared, createdAt), ); } return await this.database.db.transaction( @@ -162,7 +162,7 @@ export class VaultGrantStore { const operatorId = requireOperator(input.operator); const normalized = normalizeRevokeInput(input); return this.database.dialect === "sqlite" - ? revokeSqlite(this.database.db, normalized, operatorId) + ? await revokeSqlite(this.database.db, normalized, operatorId) : await revokePostgres(this.database.db, normalized, operatorId); } @@ -173,7 +173,7 @@ export class VaultGrantStore { const operatorId = requireOperator(operator); const validated = validateLegacyGrantImports(grants); if (this.database.dialect === "sqlite") { - assertLegacyGrantsMatchSqlite(this.database.db, validated, operatorId); + await assertLegacyGrantsMatchSqlite(this.database.db, validated, operatorId); } else { await assertLegacyGrantsMatchPostgres(this.database.db, validated, operatorId); } @@ -187,8 +187,8 @@ export class VaultGrantStore { const validated = validateLegacyGrantImports(grants); if (validated.length === 0) return; if (this.database.dialect === "sqlite") { - this.database.db.transaction((transaction) => - importLegacyGrantsSqlite(transaction, validated, operatorId), + await this.database.db.transaction( + async (transaction) => await importLegacyGrantsSqlite(transaction, validated, operatorId), ); return; } @@ -197,17 +197,17 @@ export class VaultGrantStore { ); } - importLegacyGrantsInTransaction( + async importLegacyGrantsInTransaction( grants: LegacyVaultGrantImport[], operator: OperatorPrincipal, transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const operatorId = requireOperator(operator); const validated = validateLegacyGrantImports(grants); if (validated.length === 0) return; return transaction.dialect === "sqlite" - ? importLegacyGrantsSqlite(transaction.db, validated, operatorId) - : importLegacyGrantsPostgres(transaction.db, validated, operatorId); + ? await importLegacyGrantsSqlite(transaction.db, validated, operatorId) + : await importLegacyGrantsPostgres(transaction.db, validated, operatorId); } async verifyLegacyGrants( @@ -235,24 +235,24 @@ export class VaultGrantStore { } } } - verifyLegacyGrantsInTransaction( + async verifyLegacyGrantsInTransaction( grants: LegacyVaultGrantImport[], operator: OperatorPrincipal, transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise { const operatorId = requireOperator(operator); const validated = validateLegacyGrantImports(grants); return transaction.dialect === "sqlite" - ? verifyLegacyGrantsSqlite(transaction.db, validated, operatorId) - : verifyLegacyGrantsPostgres(transaction.db, validated, operatorId); + ? await verifyLegacyGrantsSqlite(transaction.db, validated, operatorId) + : await verifyLegacyGrantsPostgres(transaction.db, validated, operatorId); } async get(input: VaultGrantLookupInput): Promise { const normalized = normalizeVaultGrantLookupInput(input); if (this.database.dialect === "sqlite") { - const subject = lookupSubjectSqlite(this.database.db, normalized); + const subject = await lookupSubjectSqlite(this.database.db, normalized); if (!subject) return undefined; - const row = this.database.db + const row = await this.database.db .select({ grant: sqlite.vaultAccessGrants, recordCapletId: sqlite.capletRecords.capletId, @@ -299,11 +299,13 @@ export class VaultGrantStore { const normalizedVaultKey = validateGrantName(vaultKey); if (this.database.dialect === "sqlite") { return ( - this.database.db - .select({ value: count() }) - .from(sqlite.vaultAccessGrants) - .where(eq(sqlite.vaultAccessGrants.vaultKey, normalizedVaultKey)) - .get()?.value ?? 0 + ( + await this.database.db + .select({ value: count() }) + .from(sqlite.vaultAccessGrants) + .where(eq(sqlite.vaultAccessGrants.vaultKey, normalizedVaultKey)) + .get() + )?.value ?? 0 ); } const [row] = await this.database.db @@ -335,7 +337,7 @@ export class VaultGrantStore { const normalized = normalizeGrantPageOptions(options); if (this.database.dialect === "sqlite") { const after = normalized.after; - const rows = this.database.db + const rows = await this.database.db .select({ grant: sqlite.vaultAccessGrants, recordCapletId: sqlite.capletRecords.capletId, @@ -687,15 +689,15 @@ type SqliteVaultGrantDatabase = type PostgresVaultGrantDatabase = | PostgresHostDatabase | Parameters[0]>[0]; -function importLegacyGrantsSqlite( +async function importLegacyGrantsSqlite( database: SqliteVaultGrantDatabase, grants: LegacyVaultGrantImport[], operatorId: string, -): void { - const rows = assertLegacyGrantsMatchSqlite(database, grants, operatorId); +): Promise { + const rows = await assertLegacyGrantsMatchSqlite(database, grants, operatorId); const pending = rows.filter((row) => row.existing === undefined); if (pending.length > 0) { - database + await database .insert(sqlite.vaultAccessGrants) .values(pending.map((row) => row.values)) .run(); @@ -725,12 +727,12 @@ async function importLegacyGrantsPostgres( } } -function verifyLegacyGrantsSqlite( +async function verifyLegacyGrantsSqlite( database: SqliteVaultGrantDatabase, grants: LegacyVaultGrantImport[], operatorId: string, -): void { - const rows = assertLegacyGrantsMatchSqlite(database, grants, operatorId); +): Promise { + const rows = await assertLegacyGrantsMatchSqlite(database, grants, operatorId); if (rows.some((row) => row.existing === undefined)) { throw new CapletsError("INTERNAL_ERROR", "A Vault grant failed post-migration verification."); } @@ -784,33 +786,35 @@ function validateLegacyGrantImports(grants: LegacyVaultGrantImport[]): LegacyVau ); } -function assertLegacyGrantsMatchSqlite( +async function assertLegacyGrantsMatchSqlite( database: SqliteVaultGrantDatabase, grants: LegacyVaultGrantImport[], operatorId: string, ) { - return grants.map((grant) => { - const subject = legacyGrantSubjectSqlite(database, grant); - const values = legacyGrantValues(subject, grant, operatorId); - const existing = database - .select() - .from(sqlite.vaultAccessGrants) - .where( - and( - eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), - eq(sqlite.vaultAccessGrants.subjectKey, subject.key), - eq(sqlite.vaultAccessGrants.referenceName, grant.referenceName), - ), - ) - .get(); - if (existing && !legacyGrantMatches(existing, values)) { - throw new CapletsError( - "CONFIG_EXISTS", - `Vault grant for ${grant.capletId} conflicts with the legacy snapshot.`, - ); - } - return { existing, values }; - }); + return await Promise.all( + grants.map(async (grant) => { + const subject = await legacyGrantSubjectSqlite(database, grant); + const values = legacyGrantValues(subject, grant, operatorId); + const existing = await database + .select() + .from(sqlite.vaultAccessGrants) + .where( + and( + eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), + eq(sqlite.vaultAccessGrants.subjectKey, subject.key), + eq(sqlite.vaultAccessGrants.referenceName, grant.referenceName), + ), + ) + .get(); + if (existing && !legacyGrantMatches(existing, values)) { + throw new CapletsError( + "CONFIG_EXISTS", + `Vault grant for ${grant.capletId} conflicts with the legacy snapshot.`, + ); + } + return { existing, values }; + }), + ); } async function assertLegacyGrantsMatchPostgres( @@ -844,18 +848,20 @@ async function assertLegacyGrantsMatchPostgres( return rows; } -function legacyGrantSubjectSqlite( +async function legacyGrantSubjectSqlite( database: SqliteVaultGrantDatabase, grant: LegacyVaultGrantImport, -): VaultGrantSubject { +): Promise { if (grant.originKind !== "stored-record") { return fileSubject(grant.capletId, grant.originKind, grant.originPath as string); } - const recordKey = database - .select({ recordKey: sqlite.capletRecords.recordKey }) - .from(sqlite.capletRecords) - .where(eq(sqlite.capletRecords.capletId, grant.capletId)) - .get()?.recordKey; + const recordKey = ( + await database + .select({ recordKey: sqlite.capletRecords.recordKey }) + .from(sqlite.capletRecords) + .where(eq(sqlite.capletRecords.capletId, grant.capletId)) + .get() + )?.recordKey; if (!recordKey) { throw new CapletsError( "CONFIG_INVALID", @@ -938,13 +944,13 @@ function legacyGrantMatches( ); } -export function grantPreparedVaultSqlite( +export async function grantPreparedVaultSqlite( db: SqliteVaultGrantDatabase, prepared: PreparedVaultGrant, createdAt: string, recordActivity = true, -): string { - const subject = sqliteSubject(db, prepared.input); +): Promise { + const subject = await sqliteSubject(db, prepared.input); const values = grantValues( subject, prepared.input, @@ -954,16 +960,17 @@ export function grantPreparedVaultSqlite( ); if (prepared.input.expectedResourceVersion === undefined) { if (prepared.input.createOnly === true) { - const inserted = db + const inserted = await db .insert(sqlite.vaultAccessGrants) .values(values) .onConflictDoNothing() .run(); - if (inserted.changes !== 1) { + if (inserted.rowsAffected !== 1) { throw new CapletsError("CONFIG_EXISTS", "Vault grant already exists."); } } else { - db.insert(sqlite.vaultAccessGrants) + await db + .insert(sqlite.vaultAccessGrants) .values(values) .onConflictDoUpdate({ target: [ @@ -976,7 +983,7 @@ export function grantPreparedVaultSqlite( .run(); } } else { - const updated = db + const updated = await db .update(sqlite.vaultAccessGrants) .set(grantReplacementValues(values)) .where( @@ -986,12 +993,13 @@ export function grantPreparedVaultSqlite( ), ) .run(); - if (updated.changes !== 1) { + if (updated.rowsAffected !== 1) { throw staleVaultGrant(prepared.input.expectedResourceVersion); } } if (recordActivity) { - db.insert(sqlite.operatorActivity) + await db + .insert(sqlite.operatorActivity) .values( activity( prepared.operatorId, @@ -1076,18 +1084,18 @@ export async function grantPreparedVaultPostgres( return prepared.resourceVersion; } -function revokeSqlite( +async function revokeSqlite( db: SqliteHostDatabase, input: NormalizedRevokeInput, operatorId: string, -): boolean { - return db.transaction((transaction) => { +): Promise { + return await db.transaction(async (transaction) => { const subject = input.originKind - ? sqliteSubject(transaction, input as NormalizedGrantInput) + ? await sqliteSubject(transaction, input as NormalizedGrantInput) : undefined; const recordKey = input.originKind ? undefined - : sqliteRecordKey(transaction, input.capletId, false); + : await sqliteRecordKey(transaction, input.capletId, false); const subjectMatch = subject ? and( eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), @@ -1106,25 +1114,27 @@ function revokeSqlite( ), ); const removed = - transaction - .delete(sqlite.vaultAccessGrants) - .where( - and( - subjectMatch, - eq(sqlite.vaultAccessGrants.referenceName, input.referenceName), - eq(sqlite.vaultAccessGrants.vaultKey, input.vaultKey), - input.expectedResourceVersion === undefined - ? undefined - : eq(sqlite.vaultAccessGrants.resourceVersion, input.expectedResourceVersion), - ), - ) - .run().changes > 0; + ( + await transaction + .delete(sqlite.vaultAccessGrants) + .where( + and( + subjectMatch, + eq(sqlite.vaultAccessGrants.referenceName, input.referenceName), + eq(sqlite.vaultAccessGrants.vaultKey, input.vaultKey), + input.expectedResourceVersion === undefined + ? undefined + : eq(sqlite.vaultAccessGrants.resourceVersion, input.expectedResourceVersion), + ), + ) + .run() + ).rowsAffected > 0; if (!removed && input.expectedResourceVersion !== undefined) { throw staleVaultGrant(input.expectedResourceVersion); } if (removed) { const now = new Date().toISOString(); - transaction + await transaction .insert(sqlite.operatorActivity) .values( activity( @@ -1269,14 +1279,14 @@ function grantReplacementValues(values: VaultGrantValues) { createdBy: values.createdBy, }; } -function sqliteSubject( +async function sqliteSubject( db: SqliteVaultGrantDatabase, input: NormalizedGrantInput, -): VaultGrantSubject { +): Promise { if (input.originKind !== "stored-record") { return fileSubject(input.capletId, input.originKind, input.originPath as string); } - const recordKey = sqliteRecordKey(db, input.capletId, true) as string; + const recordKey = (await sqliteRecordKey(db, input.capletId, true)) as string; return { kind: "record", key: recordKey, recordKey, capletId: null }; } @@ -1290,14 +1300,14 @@ async function postgresSubject( const recordKey = (await postgresRecordKey(db, input.capletId, true)) as string; return { kind: "record", key: recordKey, recordKey, capletId: null }; } -function lookupSubjectSqlite( +async function lookupSubjectSqlite( db: SqliteVaultGrantDatabase, input: NormalizedVaultGrantLookupInput, -): VaultGrantSubject | undefined { +): Promise { if (input.originKind !== "stored-record") { return fileSubject(input.capletId, input.originKind, input.originPath as string); } - const recordKey = sqliteRecordKey(db, input.capletId, false); + const recordKey = await sqliteRecordKey(db, input.capletId, false); return recordKey ? { kind: "record", key: recordKey, recordKey, capletId: null } : undefined; } @@ -1325,16 +1335,18 @@ function fileSubject( }; } -function sqliteRecordKey( +async function sqliteRecordKey( db: SqliteVaultGrantDatabase, capletId: string, required: boolean, -): string | undefined { - const recordKey = db - .select({ recordKey: sqlite.capletRecords.recordKey }) - .from(sqlite.capletRecords) - .where(eq(sqlite.capletRecords.capletId, capletId)) - .get()?.recordKey; +): Promise { + const recordKey = ( + await db + .select({ recordKey: sqlite.capletRecords.recordKey }) + .from(sqlite.capletRecords) + .where(eq(sqlite.capletRecords.capletId, capletId)) + .get() + )?.recordKey; if (!recordKey && required) { throw new CapletsError("REQUEST_INVALID", `Caplet Record ${capletId} was not found.`); } diff --git a/packages/core/src/storage/vault-state.ts b/packages/core/src/storage/vault-state.ts index cfae305e..3c8a68b6 100644 --- a/packages/core/src/storage/vault-state.ts +++ b/packages/core/src/storage/vault-state.ts @@ -75,12 +75,12 @@ export class VaultStateStore { if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => { - const status = setPreparedVaultValueSqlite(transaction, preparedValue, false); + async (transaction) => { + const status = await setPreparedVaultValueSqlite(transaction, preparedValue, false); const grantResourceVersion = preparedGrant - ? grantPreparedVaultSqlite(transaction, preparedGrant, status.updatedAt, false) + ? await grantPreparedVaultSqlite(transaction, preparedGrant, status.updatedAt, false) : undefined; - transaction + await transaction .insert(sqlite.operatorActivity) .values( vaultSetActivity( @@ -92,7 +92,7 @@ export class VaultStateStore { ), ) .run(); - advanceSqliteConfigGeneration( + await advanceSqliteConfigGeneration( transaction, vaultConfigHash(preparedValue.key, status.generation, grantResourceVersion), input.operatorClientId, diff --git a/packages/core/src/storage/vault-values.ts b/packages/core/src/storage/vault-values.ts index fab358ed..5509b128 100644 --- a/packages/core/src/storage/vault-values.ts +++ b/packages/core/src/storage/vault-values.ts @@ -213,7 +213,7 @@ export class VaultValueStore implements VaultValueRepository { }); if (this.database.dialect === "sqlite") { return this.database.db.transaction( - (transaction) => setPreparedVaultValueSqlite(transaction, prepared), + async (transaction) => await setPreparedVaultValueSqlite(transaction, prepared), { behavior: "immediate" }, ); } @@ -226,7 +226,7 @@ export class VaultValueStore implements VaultValueRepository { const normalizedKey = validateVaultKeyName(key); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, normalizedKey)) @@ -250,7 +250,7 @@ export class VaultValueStore implements VaultValueRepository { if (after !== undefined) validateVaultKeyName(after); const rows = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.vaultValues) .where( @@ -290,7 +290,10 @@ export class VaultValueStore implements VaultValueRepository { async countValues(): Promise { if (this.database.dialect === "sqlite") { - return this.database.db.select({ value: count() }).from(sqlite.vaultValues).get()?.value ?? 0; + return ( + (await this.database.db.select({ value: count() }).from(sqlite.vaultValues).get())?.value ?? + 0 + ); } const [row] = await this.database.db.select({ value: count() }).from(postgres.vaultValues); return row?.value ?? 0; @@ -311,7 +314,7 @@ export class VaultValueStore implements VaultValueRepository { const normalizedKey = validateVaultKeyName(key); const row = this.database.dialect === "sqlite" - ? this.database.db + ? await this.database.db .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, normalizedKey)) @@ -335,7 +338,7 @@ export class VaultValueStore implements VaultValueRepository { async assertLegacyValuesImportable(values: LegacyVaultValueMigrationRecord[]): Promise { const validated = validateLegacyValueImports(values); if (this.database.dialect === "sqlite") { - assertLegacyValuesMatchSqlite(this.database.db, validated, () => + await assertLegacyValuesMatchSqlite(this.database.db, validated, () => loadVaultKey({ keyFile: this.keyFile, env: this.env }), ); } else { @@ -349,8 +352,9 @@ export class VaultValueStore implements VaultValueRepository { const validated = validateLegacyValueImports(values); if (validated.length === 0) return; if (this.database.dialect === "sqlite") { - this.database.db.transaction((transaction) => - importLegacyValuesSqlite(transaction, validated, this.keyFile, this.env), + await this.database.db.transaction( + async (transaction) => + await importLegacyValuesSqlite(transaction, validated, this.keyFile, this.env), ); return; } @@ -360,14 +364,14 @@ export class VaultValueStore implements VaultValueRepository { ); } - importLegacyValuesInTransaction( + async importLegacyValuesInTransaction( values: LegacyVaultValueMigrationRecord[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise> { const validated = validateLegacyValueImports(values); if (validated.length === 0) return; return transaction.dialect === "sqlite" - ? importLegacyValuesSqlite(transaction.db, validated, this.keyFile, this.env) + ? await importLegacyValuesSqlite(transaction.db, validated, this.keyFile, this.env) : importLegacyValuesPostgres(transaction.db, validated, this.keyFile, this.env); } @@ -391,14 +395,14 @@ export class VaultValueStore implements VaultValueRepository { } } } - verifyLegacyValuesInTransaction( + async verifyLegacyValuesInTransaction( values: LegacyVaultValueMigrationRecord[], transaction: HostDatabaseTransaction, - ): void | Promise { + ): Promise> { const validated = validateLegacyValueImports(values); const encryptionKey = () => loadVaultKey({ keyFile: this.keyFile, env: this.env }); return transaction.dialect === "sqlite" - ? verifyLegacyValuesSqlite(transaction.db, validated, encryptionKey) + ? await verifyLegacyValuesSqlite(transaction.db, validated, encryptionKey) : verifyLegacyValuesPostgres(transaction.db, validated, encryptionKey); } @@ -409,7 +413,7 @@ export class VaultValueStore implements VaultValueRepository { const normalizedKey = validateVaultKeyName(key); validateDeleteOptions(options); return this.database.dialect === "sqlite" - ? deleteSqlite(this.database.db, normalizedKey, options) + ? await deleteSqlite(this.database.db, normalizedKey, options) : await deletePostgres(this.database.db, normalizedKey, options); } @@ -424,24 +428,25 @@ type PostgresVaultValueTransaction = Parameters< Parameters[0] >[0]; type PostgresVaultValueDatabase = PostgresHostDatabase | PostgresVaultValueTransaction; -function importLegacyValuesSqlite( +async function importLegacyValuesSqlite( database: SqliteVaultValueDatabase, values: LegacyVaultValueMigrationRecord[], keyFile: string, env: Record, -): void { - assertLegacyValuesMatchSqlite(database, values, () => loadVaultKey({ keyFile, env })); - const pending = values.filter( - (value) => - !database - .select({ key: sqlite.vaultValues.vaultKey }) - .from(sqlite.vaultValues) - .where(eq(sqlite.vaultValues.vaultKey, value.key)) - .get(), - ); +): Promise { + await assertLegacyValuesMatchSqlite(database, values, () => loadVaultKey({ keyFile, env })); + const pending: LegacyVaultValueMigrationRecord[] = []; + for (const value of values) { + const existing = await database + .select({ key: sqlite.vaultValues.vaultKey }) + .from(sqlite.vaultValues) + .where(eq(sqlite.vaultValues.vaultKey, value.key)) + .get(); + if (!existing) pending.push(value); + } if (pending.length === 0) return; const key = ensureVaultKey({ keyFile, env }); - database + await database .insert(sqlite.vaultValues) .values(pending.map((value) => legacyValueRow(value, key))) .run(); @@ -474,13 +479,13 @@ async function importLegacyValuesPostgres( .values(pending.map((value) => legacyValueRow(value, key))); } -function verifyLegacyValuesSqlite( +async function verifyLegacyValuesSqlite( database: SqliteVaultValueDatabase, values: LegacyVaultValueMigrationRecord[], encryptionKey: () => Buffer, -): void { +): Promise { for (const value of values) { - const row = database + const row = await database .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, value.key)) @@ -535,13 +540,13 @@ function validateLegacyValueImports( return validated.sort((left, right) => left.key.localeCompare(right.key)); } -function assertLegacyValuesMatchSqlite( +async function assertLegacyValuesMatchSqlite( database: SqliteVaultValueDatabase, values: LegacyVaultValueMigrationRecord[], encryptionKey: () => Buffer, -): void { +): Promise { for (const value of values) { - const row = database + const row = await database .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, value.key)) @@ -601,11 +606,11 @@ function legacyValueRow(value: LegacyVaultValueMigrationRecord, key: Buffer) { return rowValues(value.key, 1, encrypted); } -export function setPreparedVaultValueSqlite( +export async function setPreparedVaultValueSqlite( db: SqliteVaultValueTransaction, prepared: PreparedVaultValueSet, recordActivity = true, -): PresentVaultValueStatus { +): Promise { let generation: number; let mutationCreatedAt: string; let encrypted: VaultEncryptedRecord; @@ -613,14 +618,14 @@ export function setPreparedVaultValueSqlite( generation = 1; mutationCreatedAt = new Date().toISOString(); encrypted = encryptPreparedVaultValue(prepared, mutationCreatedAt, undefined); - const inserted = db + const inserted = await db .insert(sqlite.vaultValues) .values(rowValues(prepared.key, generation, encrypted)) .onConflictDoNothing() .run(); - if (inserted.changes !== 1) throw vaultValueExists(prepared.key); + if (inserted.rowsAffected !== 1) throw vaultValueExists(prepared.key); } else { - const current = db + const current = await db .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, prepared.key)) @@ -631,7 +636,7 @@ export function setPreparedVaultValueSqlite( encrypted = encryptPreparedVaultValue(prepared, mutationCreatedAt, existing); generation = (current?.generation ?? 0) + 1; if (prepared.expectedGeneration !== undefined) { - const updated = db + const updated = await db .update(sqlite.vaultValues) .set(rowValues(prepared.key, generation, encrypted)) .where( @@ -641,11 +646,12 @@ export function setPreparedVaultValueSqlite( ), ) .run(); - if (updated.changes !== 1) { + if (updated.rowsAffected !== 1) { throw staleVaultValue(prepared.expectedGeneration, current?.generation); } } else { - db.insert(sqlite.vaultValues) + await db + .insert(sqlite.vaultValues) .values(rowValues(prepared.key, generation, encrypted)) .onConflictDoUpdate({ target: sqlite.vaultValues.vaultKey, @@ -655,7 +661,8 @@ export function setPreparedVaultValueSqlite( } } if (recordActivity && prepared.operatorClientId) { - db.insert(sqlite.operatorActivity) + await db + .insert(sqlite.operatorActivity) .values( activity( prepared.operatorClientId, @@ -743,13 +750,13 @@ export async function setPreparedVaultValuePostgres( return statusForEncryptedRecord(prepared.key, generation, encrypted); } -function deleteSqlite( +async function deleteSqlite( db: SqliteHostDatabase, key: string, options: VaultValueDeleteOptions, -): VaultValueDeleteResult { - return db.transaction((transaction) => { - const current = transaction +): Promise { + return await db.transaction(async (transaction) => { + const current = await transaction .select() .from(sqlite.vaultValues) .where(eq(sqlite.vaultValues.vaultKey, key)) @@ -760,9 +767,9 @@ function deleteSqlite( } encryptedRecordForRow(current, key); assertExpectedGeneration(current.generation, options.expectedGeneration); - transaction.delete(sqlite.vaultValues).where(eq(sqlite.vaultValues.vaultKey, key)).run(); + await transaction.delete(sqlite.vaultValues).where(eq(sqlite.vaultValues.vaultKey, key)).run(); if (options.operatorClientId) { - transaction + await transaction .insert(sqlite.operatorActivity) .values(activity(options.operatorClientId, "vault_value_deleted", key, current.generation)) .run(); diff --git a/packages/core/src/telemetry/events.ts b/packages/core/src/telemetry/events.ts index 0f7af7de..c19b671f 100644 --- a/packages/core/src/telemetry/events.ts +++ b/packages/core/src/telemetry/events.ts @@ -86,7 +86,8 @@ export type TelemetryProperties = Partial<{ first_activation: boolean; os_family: NodeJS.Platform | "unknown"; arch: NodeJS.Architecture | "unknown"; - node_major: number; + runtime_name: "bun" | "node"; + runtime_major: number; }>; export type ProductTelemetryEvent = { diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index f32c832e..7556797f 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -63,6 +63,11 @@ export { type TelemetryDispatcherOptions, type TelemetryProviderFactories, } from "./providers"; +export { + runtimeDescriptor, + type JavaScriptRuntime, + type RuntimeDescriptor, +} from "./runtime-environment"; export { backendFamilyCounts, captureRuntimeReliabilityEvent, diff --git a/packages/core/src/telemetry/privacy.ts b/packages/core/src/telemetry/privacy.ts index 6a4d4d05..27f24a2d 100644 --- a/packages/core/src/telemetry/privacy.ts +++ b/packages/core/src/telemetry/privacy.ts @@ -37,7 +37,8 @@ const ALLOWED_PROPERTY_KEYS = new Set([ "first_activation", "os_family", "arch", - "node_major", + "runtime_name", + "runtime_major", ]); const SAFE_STRING = /^[a-zA-Z0-9@._:-]{1,80}$/u; diff --git a/packages/core/src/telemetry/providers.ts b/packages/core/src/telemetry/providers.ts index 49b87da4..6091fa74 100644 --- a/packages/core/src/telemetry/providers.ts +++ b/packages/core/src/telemetry/providers.ts @@ -4,6 +4,7 @@ import type { ProductTelemetryEvent, ReliabilityTelemetryEvent, TelemetryEvent } import { BUNDLED_POSTHOG_TOKEN, BUNDLED_SENTRY_DSN } from "./intake.generated"; import { stripSentryEvent } from "./privacy"; import { recordTelemetryDrop, type TelemetryState } from "./state"; +import { runtimeDescriptor } from "./runtime-environment"; export type PostHogClient = Pick; export type SentryClient = Pick; @@ -127,6 +128,28 @@ async function defaultPostHogFactory(token: string): Promise { } async function defaultSentryFactory(dsn: string): Promise { + const runtime = runtimeDescriptor(); + // Runtime-selected provider SDKs keep each host on its native Sentry transport. + if (runtime.name === "bun") { + const sentry = await import("@sentry/bun"); + const options = { + dsn, + release: process.env.CAPLETS_SENTRY_RELEASE, + environment: process.env.CAPLETS_SENTRY_ENVIRONMENT, + sendDefaultPii: false, + integrations: [], + tracesSampleRate: 0, + transport: sentry.makeFetchTransport, + stackParser: sentry.defaultStackParser, + beforeSend(event) { + return stripSentryEvent( + event as unknown as Record, + ) as unknown as typeof event; + }, + } satisfies ConstructorParameters[0]; + return new sentry.NodeClient(options); + } + const sentry = await import("@sentry/node"); const options = { dsn, diff --git a/packages/core/src/telemetry/runtime-environment.ts b/packages/core/src/telemetry/runtime-environment.ts new file mode 100644 index 00000000..c506cfc5 --- /dev/null +++ b/packages/core/src/telemetry/runtime-environment.ts @@ -0,0 +1,22 @@ +export type JavaScriptRuntime = "bun" | "node"; + +export type RuntimeDescriptor = { + name: JavaScriptRuntime; + version: string; + major: number; +}; + +type RuntimeVersions = { + node: string; + bun?: string | undefined; +}; + +export function runtimeDescriptor(versions: RuntimeVersions = process.versions): RuntimeDescriptor { + const name = versions.bun === undefined ? "node" : "bun"; + const version = versions.bun ?? versions.node; + return { + name, + version, + major: Number(version.split(".")[0] ?? 0), + }; +} diff --git a/packages/core/src/telemetry/runtime.ts b/packages/core/src/telemetry/runtime.ts index 251326b3..5fba2cbe 100644 --- a/packages/core/src/telemetry/runtime.ts +++ b/packages/core/src/telemetry/runtime.ts @@ -17,6 +17,7 @@ import { import { TelemetryDebugSink } from "./debug"; import { createTelemetryDispatcher, type TelemetryDispatcher } from "./providers"; import { resolveTelemetryState } from "./context"; +import { runtimeDescriptor } from "./runtime-environment"; import { acknowledgeTelemetryAttributionClaim, claimTelemetryAttribution, @@ -123,6 +124,7 @@ export async function captureRuntimeReliabilityEvent( if (state.status !== "enabled" && state.status !== "debug") { return; } + const runtime = runtimeDescriptor(); const event = buildReliabilityTelemetryEvent({ name: "caplets_reliability_error", properties: { @@ -134,7 +136,8 @@ export async function captureRuntimeReliabilityEvent( ...(context.integration ? { integration: context.integration } : {}), os_family: platform(), arch: architectureForTelemetry(), - node_major: Number(process.versions.node.split(".")[0] ?? 0), + runtime_name: runtime.name, + runtime_major: runtime.major, ...properties, }, error, diff --git a/packages/core/test/backend-auth-flow-storage.test.ts b/packages/core/test/backend-auth-flow-storage.test.ts index 5772d06f..bc05d8b9 100644 --- a/packages/core/test/backend-auth-flow-storage.test.ts +++ b/packages/core/test/backend-auth-flow-storage.test.ts @@ -62,7 +62,7 @@ describe("durable backend auth flow storage", () => { expect(JSON.stringify(await secondRepository.list({ server: SERVER, now: NOW }))).not.toContain( "pkce-round-trip-secret", ); - const raw = rawRow(flowId); + const raw = await rawRow(flowId); expect(JSON.stringify(raw)).not.toContain("pkce-round-trip-secret"); expect(raw.encryptedPayload).toMatchObject({ version: 1, @@ -118,18 +118,20 @@ describe("durable backend auth flow storage", () => { await createTerminalFlow(`flow_migration_${status}`, status); } - const pendingPayload = rawRow("flow_migration_pending").encryptedPayload; - const completingPayload = rawRow("flow_migration_completing").encryptedPayload; + const pendingPayload = (await rawRow("flow_migration_pending")).encryptedPayload; + const completingPayload = (await rawRow("flow_migration_completing")).encryptedPayload; const terminalRows = Object.fromEntries( - ["completed", "expired", "failed", "unknown"].map((status) => { - const flowId = `flow_migration_${status}`; - return [flowId, rawRow(flowId)]; - }), + await Promise.all( + ["completed", "expired", "failed", "unknown"].map(async (status) => { + const flowId = `flow_migration_${status}`; + return [flowId, await rawRow(flowId)] as const; + }), + ), ); await migrateHostStorage({ type: "sqlite", path: storagePath }); - const pending = rawRow("flow_migration_pending"); + const pending = await rawRow("flow_migration_pending"); expect(pending).toMatchObject({ status: "failed", encryptedPayload: pendingPayload, @@ -143,7 +145,7 @@ describe("durable backend auth flow storage", () => { expect(pending.updatedAt).toBe(pending.terminalAt); expect(Number.isNaN(Date.parse(pending.terminalAt!))).toBe(false); - const completing = rawRow("flow_migration_completing"); + const completing = await rawRow("flow_migration_completing"); expect(completing).toMatchObject({ status: "unknown", encryptedPayload: completingPayload, @@ -156,7 +158,7 @@ describe("durable backend auth flow storage", () => { }); expect(completing.updatedAt).toBe(completing.terminalAt); for (const [flowId, row] of Object.entries(terminalRows)) { - expect(rawRow(flowId)).toEqual(row); + expect(await rawRow(flowId)).toEqual(row); } await firstRepository.create({ @@ -166,9 +168,9 @@ describe("durable backend auth flow storage", () => { expiresAt: after(100_000), now: after(2_000), }); - const postMigrationRow = rawRow("flow_created_after_migration"); + const postMigrationRow = await rawRow("flow_created_after_migration"); await migrateHostStorage({ type: "sqlite", path: storagePath }); - expect(rawRow("flow_created_after_migration")).toEqual(postMigrationRow); + expect(await rawRow("flow_created_after_migration")).toEqual(postMigrationRow); const postMigrationClaim = await secondRepository.claim({ flowId: "flow_created_after_migration", claimToken: "claim_created_after_migration", @@ -204,7 +206,7 @@ describe("durable backend auth flow storage", () => { migrateHostStorage({ type: "sqlite", path: storagePath }), ]); - const row = rawRow("flow_migration_claim_race"); + const row = await rawRow("flow_migration_claim_race"); expect(row.status === "failed" || row.status === "unknown").toBe(true); expect(row).toMatchObject({ claimToken: null, @@ -268,7 +270,7 @@ describe("durable backend auth flow storage", () => { now: after(1_000), }), ).rejects.toMatchObject({ code: "CONFIG_INVALID" }); - expect(rawRow("flow_wrong_key")).toMatchObject({ + expect(await rawRow("flow_wrong_key")).toMatchObject({ status: "pending", claimToken: null, claimedAt: null, @@ -302,8 +304,8 @@ describe("durable backend auth flow storage", () => { now: NOW, }); if (firstStorage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - const source = rawRow("flow_aad_source"); - firstStorage.database.db + const source = await rawRow("flow_aad_source"); + await firstStorage.database.db .update(sqlite.backendAuthFlows) .set({ encryptedPayload: source.encryptedPayload }) .where(eq(sqlite.backendAuthFlows.flowId, "flow_aad_target")) @@ -324,7 +326,7 @@ describe("durable backend auth flow storage", () => { expiresAt: after(10_000), now: NOW, }); - firstStorage.database.db + await firstStorage.database.db .update(sqlite.backendAuthFlows) .set({ envelopeVersion: 2 }) .where(eq(sqlite.backendAuthFlows.flowId, "flow_wrong_version")) @@ -344,7 +346,7 @@ describe("durable backend auth flow storage", () => { expiresAt: after(10_000), now: NOW, }); - firstStorage.database.db + await firstStorage.database.db .update(sqlite.backendAuthFlows) .set({ server: "other-server" }) .where(eq(sqlite.backendAuthFlows.flowId, "flow_wrong_server")) @@ -371,7 +373,7 @@ describe("durable backend auth flow storage", () => { await expect( secondRepository.claim({ flowId, claimToken: "claim_expired", now: after(1_001) }), ).resolves.toMatchObject({ acquired: false, reason: "expired" }); - expect(rawRow(flowId)).toMatchObject({ + expect(await rawRow(flowId)).toMatchObject({ status: "expired", encryptedPayload: null, completionCorrelation: null, @@ -468,7 +470,7 @@ describe("durable backend auth flow storage", () => { }), ).resolves.toBe(true); - const raw = rawRow(flowId); + const raw = await rawRow(flowId); expect(raw).toMatchObject({ status: "completed", encryptedPayload: null, @@ -526,7 +528,7 @@ describe("durable backend auth flow storage", () => { generation: 1, bundle: { accessToken: "atomic-access-token" }, }); - expect(rawRow(flowId)).toMatchObject({ + expect(await rawRow(flowId)).toMatchObject({ status: "completed", encryptedPayload: null, startingBackendAuthGeneration: null, @@ -596,7 +598,7 @@ describe("durable backend auth flow storage", () => { generation: 1, bundle: { accessToken: "unchanged-access-token" }, }); - expect(rawRow("flow_expired_before_persist")).toMatchObject({ + expect(await rawRow("flow_expired_before_persist")).toMatchObject({ status: "expired", encryptedPayload: null, claimToken: null, @@ -621,7 +623,7 @@ describe("durable backend auth flow storage", () => { }); if (!claim.acquired) throw new Error("Expected active completion claim."); if (firstStorage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - firstStorage.database.db.run( + await firstStorage.database.db.run( sql.raw(` create temp trigger fail_backend_auth_flow_completion before update on backend_auth_flows @@ -651,9 +653,9 @@ describe("durable backend auth flow storage", () => { }, now: after(2_000), }), - ).rejects.toThrow("forced completion failure"); + ).rejects.toThrow(); await expect(firstStorage.backendAuth.readTokenBundle(SERVER)).resolves.toBeUndefined(); - expect(rawRow(flowId)).toMatchObject({ + expect(await rawRow(flowId)).toMatchObject({ status: "completing", claimToken: claim.claimToken, completedBackendAuthGeneration: null, @@ -724,7 +726,7 @@ describe("durable backend auth flow storage", () => { now: after(2_000), }), ).resolves.toBe(false); - expect(rawRow(failedFlowId)).toMatchObject({ + expect(await rawRow(failedFlowId)).toMatchObject({ status: "completing", claimToken: "claim_terminal_failed", encryptedPayload: expect.any(Object), @@ -739,7 +741,7 @@ describe("durable backend auth flow storage", () => { now: after(2_000), }), ).resolves.toBe(true); - expect(rawRow(failedFlowId)).toMatchObject({ + expect(await rawRow(failedFlowId)).toMatchObject({ status: "failed", expiresAt: after(10_000).toISOString(), encryptedPayload: null, @@ -768,7 +770,7 @@ describe("durable backend auth flow storage", () => { now: after(2_000), }), ).resolves.toBe(true); - expect(rawRow(unknownFlowId)).toMatchObject({ + expect(await rawRow(unknownFlowId)).toMatchObject({ status: "unknown", encryptedPayload: null, completionCorrelation: null, @@ -806,7 +808,7 @@ describe("durable backend auth flow storage", () => { now: after(2_000), }), ).resolves.toMatchObject({ status: "unknown" }); - expect(rawRow("flow_unknown")).toMatchObject({ + expect(await rawRow("flow_unknown")).toMatchObject({ status: "unknown", encryptedPayload: null, completionCorrelation: null, @@ -969,17 +971,17 @@ describe("durable backend auth flow storage", () => { now: NOW, }), ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); - expect(rawRows()).toEqual([]); + expect(await rawRows()).toEqual([]); }); }); async function rewindBackendAuthFlowInvalidationMigration(): Promise { if (firstStorage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - firstStorage.database.db.run( + await firstStorage.database.db.run( sql`DELETE FROM caplets_migrations WHERE created_at >= ${BACKEND_AUTH_FLOW_INVALIDATION_MIGRATION_CREATED_AT}`, ); - firstStorage.database.db + await firstStorage.database.db .update(sqlite.capletsSchema) .set({ version: 17 }) .where(eq(sqlite.capletsSchema.singleton, 1)) @@ -1057,9 +1059,9 @@ function after(milliseconds: number): Date { return new Date(NOW.getTime() + milliseconds); } -function rawRow(flowId: string) { +async function rawRow(flowId: string) { if (firstStorage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - const row = firstStorage.database.db + const row = await firstStorage.database.db .select() .from(sqlite.backendAuthFlows) .where(eq(sqlite.backendAuthFlows.flowId, flowId)) diff --git a/packages/core/test/backend-auth-state-table.test.ts b/packages/core/test/backend-auth-state-table.test.ts index ae2d80b8..05dc6876 100644 --- a/packages/core/test/backend-auth-state-table.test.ts +++ b/packages/core/test/backend-auth-state-table.test.ts @@ -1,18 +1,24 @@ -import { readFileSync } from "node:fs"; -import BetterSqlite3 from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; import { describe, expect, it } from "vitest"; import { BackendAuthStateStore } from "../src/storage/backend-auth"; import { backendAuthStates, operatorActivity, sqliteSchema } from "../src/storage/schema/sqlite"; describe("BackendAuthStateStore dedicated SQL table", () => { it("keeps CAS state and sanitized activity atomic", async () => { - const client = new BetterSqlite3(":memory:"); - client.exec(` + const directory = mkdtempSync(join(tmpdir(), "caplets-backend-auth-table-")); + const client = createClient({ + url: pathToFileURL(join(directory, "storage.sqlite3")).href, + }); + await client.execute(` create table backend_auth_states ( server text primary key not null, generation integer not null, - token_bundle text, + token_bundle text, created_at text not null, updated_at text not null ) @@ -31,7 +37,7 @@ describe("BackendAuthStateStore dedicated SQL table", () => { ).rejects.toThrow(); await expect(store.readTokenBundle("github")).resolves.toBeUndefined(); - client.exec(` + await client.execute(` create table operator_activity ( activity_key text primary key not null, operator_client_id text not null, @@ -56,8 +62,8 @@ describe("BackendAuthStateStore dedicated SQL table", () => { details: { kind: "stale_generation", currentGeneration: 1 }, }); await expect(store.listTokenBundles()).resolves.toEqual([{ bundle, generation: 1 }]); - expect(database.select().from(backendAuthStates).all()).toHaveLength(1); - expect(database.select().from(operatorActivity).all()).toMatchObject([ + expect(await database.select().from(backendAuthStates).all()).toHaveLength(1); + expect(await database.select().from(operatorActivity).all()).toMatchObject([ { operatorClientId: "operator-1", action: "backend_auth_written", @@ -65,7 +71,7 @@ describe("BackendAuthStateStore dedicated SQL table", () => { metadata: { generation: 1 }, }, ]); - expect(JSON.stringify(database.select().from(operatorActivity).all())).not.toContain( + expect(JSON.stringify(await database.select().from(operatorActivity).all())).not.toContain( "secret", ); await expect( @@ -77,13 +83,14 @@ describe("BackendAuthStateStore dedicated SQL table", () => { await expect(store.readTokenBundle("github")).resolves.toBeUndefined(); } finally { client.close(); + rmSync(directory, { recursive: true, force: true }); } }); it("migrates legacy generic backend auth rows into the dedicated table", async () => { - const client = new BetterSqlite3(":memory:"); + const client = createClient({ url: "file::memory:" }); const bundle = { server: "github", accessToken: "existing-secret" }; - client.exec(` + await client.execute(` create table host_state_records ( namespace text not null, state_key text not null, @@ -94,20 +101,21 @@ describe("BackendAuthStateStore dedicated SQL table", () => { primary key (namespace, state_key) ) `); - client - .prepare(` - insert into host_state_records - (namespace, state_key, generation, payload, created_at, updated_at) - values (?, ?, ?, ?, ?, ?) - `) - .run( + await client.execute({ + sql: ` + insert into host_state_records + (namespace, state_key, generation, payload, created_at, updated_at) + values (?, ?, ?, ?, ?, ?) + `, + args: [ "backend-auth", "github", 3, JSON.stringify(bundle), "2026-07-17T00:00:00.000Z", "2026-07-18T00:00:00.000Z", - ); + ], + }); try { const migration = readFileSync( @@ -115,15 +123,17 @@ describe("BackendAuthStateStore dedicated SQL table", () => { "utf8", ); for (const statement of migration.split("--> statement-breakpoint")) { - if (statement.trim()) client.exec(statement); + if (statement.trim()) await client.execute(statement); } const database = drizzle(client, { schema: sqliteSchema }); const store = new BackendAuthStateStore({ dialect: "sqlite", db: database }); await expect(store.readTokenBundle("github")).resolves.toEqual({ bundle, generation: 3 }); - expect(client.prepare("select count(*) as count from host_state_records").get()).toEqual({ + expect( + (await client.execute("select count(*) as count from host_state_records")).rows[0], + ).toEqual({ count: 0, }); - expect(database.select().from(backendAuthStates).all()).toMatchObject([ + expect(await database.select().from(backendAuthStates).all()).toMatchObject([ { server: "github", generation: 3, tokenBundle: bundle }, ]); } finally { diff --git a/packages/core/test/backend-auth-storage.test.ts b/packages/core/test/backend-auth-storage.test.ts index 29904f90..e7af53fa 100644 --- a/packages/core/test/backend-auth-storage.test.ts +++ b/packages/core/test/backend-auth-storage.test.ts @@ -124,13 +124,13 @@ describe("BackendAuthStateStore", () => { }); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage"); - expect(storage.database.db.select().from(backendAuthStates).all()).toEqual( + expect(await storage.database.db.select().from(backendAuthStates).all()).toEqual( expect.arrayContaining([ expect.objectContaining({ server: "alpha", generation: 3 }), expect.objectContaining({ server: "beta", generation: 2, tokenBundle: updatedBundle }), ]), ); - const activity = storage.database.db.select().from(operatorActivity).all(); + const activity = await storage.database.db.select().from(operatorActivity).all(); expect(activity.map(({ action, metadata }) => ({ action, metadata }))).toEqual([ { action: "backend_auth_written", metadata: { generation: 1 } }, { action: "backend_auth_written", metadata: { generation: 2 } }, @@ -319,7 +319,7 @@ describe("BackendAuthStateStore", () => { const store = new BackendAuthStateStore(storage.database); try { if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage"); - storage.database.db + await storage.database.db .insert(backendAuthStates) .values({ server: "invalid", diff --git a/packages/core/test/caplet-bundle-streaming.test.ts b/packages/core/test/caplet-bundle-streaming.test.ts index cd100562..954fc2ac 100644 --- a/packages/core/test/caplet-bundle-streaming.test.ts +++ b/packages/core/test/caplet-bundle-streaming.test.ts @@ -334,7 +334,7 @@ describe("Caplet Bundle streaming storage", () => { if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); const firstEntry = created.currentRevision.bundle[0]!; - storage.database.db + await storage.database.db .update(sqlite.capletAssetBlobs) .set({ payload: Buffer.from("corrupt") }) .where(eq(sqlite.capletAssetBlobs.hash, firstEntry.hash)) diff --git a/packages/core/test/caplet-record-storage.test.ts b/packages/core/test/caplet-record-storage.test.ts index 348cdd77..e4b4a05f 100644 --- a/packages/core/test/caplet-record-storage.test.ts +++ b/packages/core/test/caplet-record-storage.test.ts @@ -46,13 +46,13 @@ describe("Caplet Record storage", () => { }); try { if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - expect(storage.database.db.all(sql.raw(`PRAGMA index_info("${indexName}")`))).toEqual([ + expect(await storage.database.db.all(sql.raw(`PRAGMA index_info("${indexName}")`))).toEqual([ expect.objectContaining({ seqno: 0, name: "updated_at" }), expect.objectContaining({ seqno: 1, name: "record_key" }), ]); for (const direction of ["ASC", "DESC"]) { expect( - storage.database.db.all( + await storage.database.db.all( sql.raw( `EXPLAIN QUERY PLAN SELECT record_key FROM caplet_records ORDER BY updated_at ${direction}, record_key ${direction} LIMIT 50`, ), diff --git a/packages/core/test/current-host-administration.test.ts b/packages/core/test/current-host-administration.test.ts index ef5af411..472809ab 100644 --- a/packages/core/test/current-host-administration.test.ts +++ b/packages/core/test/current-host-administration.test.ts @@ -627,7 +627,7 @@ describe("Current Host administration operations", () => { }); if (setup.storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage"); try { - setup.storage.database.db.run( + await setup.storage.database.db.run( sql.raw(` create temp trigger fail_current_host_vault_grant before insert on vault_access_grants @@ -646,7 +646,7 @@ describe("Current Host administration operations", () => { referenceName: "API_TOKEN", createOnly: true, }), - ).rejects.toThrow("injected current-host grant failure"); + ).rejects.toThrow(); await expect(setup.storage.vaultValues.getStatus("SQL_TOKEN")).resolves.toEqual({ key: "SQL_TOKEN", @@ -657,7 +657,7 @@ describe("Current Host administration operations", () => { await expect(setup.storage.coordination.currentConfigGeneration()).resolves.toBe(0); expect(activationCalls).toBe(0); } finally { - setup.storage.database.db.run( + await setup.storage.database.db.run( sql.raw("drop trigger if exists fail_current_host_vault_grant"), ); await setup.engine.close(); diff --git a/packages/core/test/dashboard-session-store.test.ts b/packages/core/test/dashboard-session-store.test.ts index 2d787e8b..7bd7b951 100644 --- a/packages/core/test/dashboard-session-store.test.ts +++ b/packages/core/test/dashboard-session-store.test.ts @@ -132,7 +132,7 @@ describe("SQL-backed dashboard sessions", () => { await expect(firstStore.delete(cookieHeader(created.cookieValue))).resolves.toBe(false); if (firstStorage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - firstStorage.database.db + await firstStorage.database.db .insert(sqlite.dashboardSessions) .values({ sessionId: "dash_invalid", @@ -152,7 +152,9 @@ describe("SQL-backed dashboard sessions", () => { now: NOW, }), ).rejects.toMatchObject({ code: "AUTH_FAILED" }); - expect(firstStorage.database.db.select().from(sqlite.dashboardSessions).all()).toEqual([]); + expect(await firstStorage.database.db.select().from(sqlite.dashboardSessions).all()).toEqual( + [], + ); }); }); diff --git a/packages/core/test/dashboard-session.test.ts b/packages/core/test/dashboard-session.test.ts index 316c5dc8..a901d2d1 100644 --- a/packages/core/test/dashboard-session.test.ts +++ b/packages/core/test/dashboard-session.test.ts @@ -1,7 +1,8 @@ -import BetterSqlite3 from "better-sqlite3"; +import { createClient } from "@libsql/client"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { dashboardSessionCookie, expiredDashboardSessionCookie } from "../src/dashboard/auth"; import { CapletsEngine } from "../src/engine"; @@ -665,21 +666,29 @@ mcpServer: if (setup.storage.database.dialect !== "sqlite") { throw new Error("Expected SQLite HostStorage."); } - const sqlite = setup.storage.database.db as unknown as { - $client: { pragma(source: string): unknown }; - }; - sqlite.$client.pragma("busy_timeout = 1"); - const lock = new BetterSqlite3(setup.databasePath); + const sqliteDatabase = setup.storage.database.db; + if (!("$client" in sqliteDatabase)) throw new Error("Expected a libSQL client."); + const sqliteClient = sqliteDatabase.$client; + if ( + !sqliteClient || + typeof sqliteClient !== "object" || + !("execute" in sqliteClient) || + typeof sqliteClient.execute !== "function" + ) { + throw new Error("Expected an executable libSQL client."); + } + await sqliteClient.execute("PRAGMA busy_timeout = 1"); + const lockClient = createClient({ url: pathToFileURL(setup.databasePath).href }); + const lock = await lockClient.transaction("write"); try { - lock.exec("BEGIN IMMEDIATE"); const response = await setup.app.request("http://127.0.0.1:5387/dashboard/api/session", { headers: { cookie, ...sameOriginHeaders }, }); expect(response.status).toBe(503); } finally { - lock.exec("ROLLBACK"); - lock.close(); + await lock.rollback(); + lockClient.close(); } await setup.engine.close(); }); diff --git a/packages/core/test/host-storage-config.test.ts b/packages/core/test/host-storage-config.test.ts index 624c7aa6..6abde616 100644 --- a/packages/core/test/host-storage-config.test.ts +++ b/packages/core/test/host-storage-config.test.ts @@ -304,7 +304,7 @@ describe("stored Caplet source", () => { try { const first = await CapletsEngine.create(firstNode); engines.push(first); - const [firstPersisted] = persistedNodes(); + const [firstPersisted] = await persistedNodes(); expect(firstPersisted?.runtimeFingerprint).toMatch(/^hmac-sha256:[a-f0-9]{64}$/u); expect(firstPersisted?.runtimeFingerprint).not.toBe( loadConfigWithSources(firstNode.configPath, firstNode.projectConfigPath).runtimeFingerprint @@ -313,9 +313,9 @@ describe("stored Caplet source", () => { const sameKeyAndConfig = await CapletsEngine.create(firstNode); engines.push(sameKeyAndConfig); - expect(persistedNodes()).toHaveLength(2); + expect(await persistedNodes()).toHaveLength(2); expect( - persistedNodes().every( + (await persistedNodes()).every( (node) => node.globalFileManifest === firstPersisted?.globalFileManifest && node.runtimeFingerprint === firstPersisted?.runtimeFingerprint, @@ -324,7 +324,7 @@ describe("stored Caplet source", () => { const nodeWithLocalConfigChange = await CapletsEngine.create(localConfigChanged); engines.push(nodeWithLocalConfigChange); - const afterLocalConfigChange = persistedNodes(); + const afterLocalConfigChange = await persistedNodes(); expect(afterLocalConfigChange).toHaveLength(3); expect( afterLocalConfigChange.every( @@ -337,13 +337,15 @@ describe("stored Caplet source", () => { await expect(CapletsEngine.create(runtimeConfigChanged)).rejects.toMatchObject({ details: { conflict: "runtime_fingerprint" }, }); - expect(new Set(persistedNodes().map((node) => node.runtimeFingerprint))).toHaveLength(2); + expect(new Set((await persistedNodes()).map((node) => node.runtimeFingerprint))).toHaveLength( + 2, + ); process.env.CAPLETS_ENCRYPTION_KEY = Buffer.alloc(32, 2).toString("base64url"); await expect(CapletsEngine.create(firstNode)).rejects.toMatchObject({ details: { conflict: "runtime_fingerprint" }, }); - const sameManifest = persistedNodes().filter( + const sameManifest = (await persistedNodes()).filter( (node) => node.globalFileManifest === firstPersisted?.globalFileManifest, ); expect(sameManifest).toHaveLength(5); @@ -353,7 +355,9 @@ describe("stored Caplet source", () => { await expect(CapletsEngine.create(capletFileChanged)).rejects.toMatchObject({ details: { conflict: "global_file_manifest" }, }); - expect(new Set(persistedNodes().map((node) => node.globalFileManifest))).toHaveLength(2); + expect(new Set((await persistedNodes()).map((node) => node.globalFileManifest))).toHaveLength( + 2, + ); } finally { await inspector.close(); await Promise.all(engines.map(async (engine) => await engine.close())); @@ -386,22 +390,22 @@ describe("stored Caplet source", () => { const sqliteDatabase = inspector.database; try { - const initial = sqliteDatabase.db.select().from(hostNodes).get(); + const initial = await sqliteDatabase.db.select().from(hostNodes).get(); expect(initial?.runtimeFingerprint).toMatch(/^hmac-sha256:[a-f0-9]{64}$/u); await expect - .poll(() => sqliteDatabase.db.select().from(hostNodes).get()?.heartbeatAt, { + .poll(async () => (await sqliteDatabase.db.select().from(hostNodes).get())?.heartbeatAt, { timeout: 2_500, interval: 50, }) .not.toBe(initial?.heartbeatAt); - expect(sqliteDatabase.db.select().from(hostNodes).get()?.runtimeFingerprint).toBe( + expect((await sqliteDatabase.db.select().from(hostNodes).get())?.runtimeFingerprint).toBe( initial?.runtimeFingerprint, ); writeFileSync(projectFilePath, caplet("project-v2")); await expect(engine.reload()).resolves.toBe(true); expect(engine.currentConfig().mcpServers.github?.command).toBe("project-v2"); - expect(sqliteDatabase.db.select().from(hostNodes).get()).toMatchObject({ + expect(await sqliteDatabase.db.select().from(hostNodes).get()).toMatchObject({ globalFileManifest: initial?.globalFileManifest, runtimeFingerprint: initial?.runtimeFingerprint, }); @@ -415,7 +419,7 @@ describe("stored Caplet source", () => { ); await expect(engine.reload()).resolves.toBe(true); expect(engine.currentConfig().mcpServers.github?.command).toBe("project-v2"); - const afterGlobalReload = sqliteDatabase.db.select().from(hostNodes).get(); + const afterGlobalReload = await sqliteDatabase.db.select().from(hostNodes).get(); expect(afterGlobalReload?.globalFileManifest).toBe(initial?.globalFileManifest); expect(afterGlobalReload?.runtimeFingerprint).not.toBe(initial?.runtimeFingerprint); } finally { diff --git a/packages/core/test/host-storage.test.ts b/packages/core/test/host-storage.test.ts index 2d60b2b3..c886ae0f 100644 --- a/packages/core/test/host-storage.test.ts +++ b/packages/core/test/host-storage.test.ts @@ -1,10 +1,11 @@ import { Buffer } from "node:buffer"; import { once } from "node:events"; -import BetterSqlite3 from "better-sqlite3"; +import { createClient } from "@libsql/client"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { Pool } from "pg"; import { Worker } from "node:worker_threads"; import { afterEach, describe, expect, it } from "vitest"; @@ -81,15 +82,20 @@ describe("host storage", () => { // This integration test must outlive SQLite's native busy timeout; fake timers cannot drive it. const worker = new Worker( ` - const BetterSqlite3 = require("better-sqlite3"); + const { createClient } = require("@libsql/client"); + const { pathToFileURL } = require("node:url"); const { parentPort, workerData } = require("node:worker_threads"); - const database = new BetterSqlite3(workerData.path); - database.exec("BEGIN IMMEDIATE"); - parentPort.postMessage("locked"); - setTimeout(() => { - database.exec("COMMIT"); - database.close(); - }, workerData.holdMs); + void (async () => { + const database = createClient({ url: pathToFileURL(workerData.path).href }); + const transaction = await database.transaction("write"); + parentPort.postMessage("locked"); + setTimeout(async () => { + await transaction.commit(); + database.close(); + }, workerData.holdMs); + })().catch((error) => { + parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) }); + }); `, { eval: true, workerData: { path, holdMs: 5_250 } }, ); @@ -115,26 +121,21 @@ describe("host storage", () => { const config = { type: "sqlite" as const, path }; await migrateHostStorage(config); - const database = new BetterSqlite3(path); + const database = createClient({ url: pathToFileURL(path).href }); try { - const idColumn = ( - database.pragma("table_info(caplets_migrations)") as Array<{ - name: string; - type: string; - pk: number; - }> - ).find((column) => column.name === "id"); + const idColumn = (await database.execute("PRAGMA table_info(caplets_migrations)")).rows.find( + (column) => column.name === "id", + ); expect(idColumn).toMatchObject({ type: "INTEGER", pk: 1 }); - const rows = database - .prepare("SELECT id, hash FROM caplets_migrations ORDER BY created_at") - .all() as Array<{ id: number; hash: string }>; + const rows = ( + await database.execute("SELECT id, hash FROM caplets_migrations ORDER BY created_at") + ).rows; expect(rows.length).toBeGreaterThan(0); expect(rows.every((row) => Number.isInteger(row.id))).toBe(true); - database - .prepare( - "UPDATE caplets_migrations SET hash = 'tampered' WHERE created_at = (SELECT MIN(created_at) FROM caplets_migrations)", - ) - .run(); + await database.execute( + "UPDATE caplets_migrations SET hash = 'tampered' " + + "WHERE created_at = (SELECT MIN(created_at) FROM caplets_migrations)", + ); } finally { database.close(); } @@ -151,13 +152,12 @@ describe("host storage", () => { const config = { type: "sqlite" as const, path }; await migrateHostStorage(config); - const database = new BetterSqlite3(path); + const database = createClient({ url: pathToFileURL(path).href }); try { - database - .prepare( - "DELETE FROM caplets_migrations WHERE created_at = (SELECT MIN(created_at) FROM caplets_migrations)", - ) - .run(); + await database.execute( + "DELETE FROM caplets_migrations " + + "WHERE created_at = (SELECT MIN(created_at) FROM caplets_migrations)", + ); } finally { database.close(); } @@ -175,16 +175,14 @@ describe("host storage", () => { await migrateHostStorage(config); const subjectKey = JSON.stringify(["shared", "global-file", "/shared/CAPLET.md"]); - const database = new BetterSqlite3(path); + const database = createClient({ url: pathToFileURL(path).href }); try { - database - .prepare( - `INSERT INTO remote_clients ( - client_id, client_label, role, host_url, access_token_hash, - access_expires_at, generation, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( + await database.execute({ + sql: `INSERT INTO remote_clients ( + client_id, client_label, role, host_url, access_token_hash, + access_expires_at, generation, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ "rcli_existing", "Existing", "access", @@ -193,16 +191,15 @@ describe("host storage", () => { "2027-01-01T00:00:00.000Z", 9, "2026-01-01T00:00:00.000Z", - ); - database - .prepare( - `INSERT INTO remote_pending_logins ( - flow_id, host_url, operator_code_hash, pending_refresh_hash, - pending_completion_hash, client_label, requested_role, created_at, - code_expires_at, flow_expires_at, generation, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( + ], + }); + await database.execute({ + sql: `INSERT INTO remote_pending_logins ( + flow_id, host_url, operator_code_hash, pending_refresh_hash, + pending_completion_hash, client_label, requested_role, created_at, + code_expires_at, flow_expires_at, generation, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ "rlogin_existing", "https://remote.example.test", "secret-code-hash", @@ -215,15 +212,14 @@ describe("host storage", () => { "2026-01-02T00:00:00.000Z", 7, "pending", - ); - database - .prepare( - `INSERT INTO vault_access_grants ( - subject_kind, subject_key, caplet_id, vault_key, reference_name, - origin_kind, origin_path, resource_version, created_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( + ], + }); + await database.execute({ + sql: `INSERT INTO vault_access_grants ( + subject_kind, subject_key, caplet_id, vault_key, reference_name, + origin_kind, origin_path, resource_version, created_at, created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ "file", subjectKey, "shared", @@ -234,8 +230,9 @@ describe("host storage", () => { "old-version", "2026-01-01T00:00:00.000Z", "secret-operator", - ); - database.exec(` + ], + }); + await database.executeMultiple(` DROP INDEX caplet_records_updated_key_idx; DROP INDEX remote_pending_logins_status_created_idx; ALTER TABLE remote_clients DROP COLUMN generation; @@ -250,26 +247,33 @@ describe("host storage", () => { } await migrateHostStorage(config); - const migrated = new BetterSqlite3(path); + const migrated = createClient({ url: pathToFileURL(path).href }); try { expect( - migrated - .prepare("SELECT generation FROM remote_clients WHERE client_id = ?") - .get("rcli_existing"), + ( + await migrated.execute({ + sql: "SELECT generation FROM remote_clients WHERE client_id = ?", + args: ["rcli_existing"], + }) + ).rows[0], ).toEqual({ generation: 1 }); expect( - migrated - .prepare("SELECT generation FROM remote_pending_logins WHERE flow_id = ?") - .get("rlogin_existing"), + ( + await migrated.execute({ + sql: "SELECT generation FROM remote_pending_logins WHERE flow_id = ?", + args: ["rlogin_existing"], + }) + ).rows[0], ).toEqual({ generation: 1 }); - const grant = migrated - .prepare( - "SELECT resource_version AS resourceVersion FROM vault_access_grants WHERE subject_key = ?", - ) - .get(subjectKey) as { resourceVersion: string }; - expect(grant.resourceVersion).toBe(legacyGrantVersion("file", subjectKey, "TOKEN")); - expect(grant.resourceVersion).not.toContain("SECRET_TOKEN"); - expect(grant.resourceVersion).not.toContain("secret-operator"); + const grant = ( + await migrated.execute({ + sql: "SELECT resource_version AS resourceVersion FROM vault_access_grants WHERE subject_key = ?", + args: [subjectKey], + }) + ).rows[0]; + expect(grant?.resourceVersion).toBe(legacyGrantVersion("file", subjectKey, "TOKEN")); + expect(grant?.resourceVersion).not.toContain("SECRET_TOKEN"); + expect(grant?.resourceVersion).not.toContain("secret-operator"); } finally { migrated.close(); } diff --git a/packages/core/test/idempotency-storage.test.ts b/packages/core/test/idempotency-storage.test.ts index e1b18580..cbe85bf2 100644 --- a/packages/core/test/idempotency-storage.test.ts +++ b/packages/core/test/idempotency-storage.test.ts @@ -69,7 +69,7 @@ it("does not persist an offline verifier for Vault plaintext requests", async () }); if (storage.database.dialect !== "sqlite") throw new Error("expected SQLite storage"); - const row = storage.database.db + const row = await storage.database.db .select({ requestHash: sqlite.idempotencyRecords.requestHash }) .from(sqlite.idempotencyRecords) .limit(1) @@ -382,7 +382,7 @@ it("encrypts OAuth authorization responses at rest and replays them after reopen }); if (first.database.dialect !== "sqlite") throw new Error("expected SQLite storage"); - const stored = first.database.db + const stored = await first.database.db .select({ responseBody: sqlite.idempotencyRecords.responseBody }) .from(sqlite.idempotencyRecords) .limit(1) @@ -411,7 +411,7 @@ it("rejects tampered, malformed, or identity-relocated finalized response envelo now: after(1_000), }); if (storage.database.dialect !== "sqlite") throw new Error("expected SQLite storage"); - const stored = storage.database.db + const stored = await storage.database.db .select({ responseBody: sqlite.idempotencyRecords.responseBody }) .from(sqlite.idempotencyRecords) .where(eq(sqlite.idempotencyRecords.idempotencyKey, BASE_CLAIM.idempotencyKey)) @@ -419,7 +419,7 @@ it("rejects tampered, malformed, or identity-relocated finalized response envelo if (!stored?.responseBody) throw new Error("expected an encrypted finalized body"); const envelope = JSON.parse(stored.responseBody) as { ciphertext: string }; const tamperedCiphertext = `${envelope.ciphertext.startsWith("A") ? "B" : "A"}${envelope.ciphertext.slice(1)}`; - storage.database.db + await storage.database.db .update(sqlite.idempotencyRecords) .set({ responseBody: JSON.stringify({ ...envelope, ciphertext: tamperedCiphertext }) }) .where(eq(sqlite.idempotencyRecords.idempotencyKey, BASE_CLAIM.idempotencyKey)) @@ -429,7 +429,7 @@ it("rejects tampered, malformed, or identity-relocated finalized response envelo message: expect.not.stringContaining("oauth-state-secret"), }); - storage.database.db + await storage.database.db .update(sqlite.idempotencyRecords) .set({ responseBody: stored.responseBody, idempotencyKey: "relocated-request" }) .where(eq(sqlite.idempotencyRecords.idempotencyKey, BASE_CLAIM.idempotencyKey)) @@ -439,7 +439,7 @@ it("rejects tampered, malformed, or identity-relocated finalized response envelo code: "CONFIG_INVALID", }); - storage.database.db + await storage.database.db .update(sqlite.idempotencyRecords) .set({ responseBody: "oauth-state-secret" }) .where(eq(sqlite.idempotencyRecords.idempotencyKey, "relocated-request")) diff --git a/packages/core/test/installation-storage.test.ts b/packages/core/test/installation-storage.test.ts index c77348b1..c18fa772 100644 --- a/packages/core/test/installation-storage.test.ts +++ b/packages/core/test/installation-storage.test.ts @@ -480,7 +480,7 @@ describe("Caplet installation storage", () => { const githubKeys = await createInstallationHistory(storage, "github", 5); const gitlabKeys = await createInstallationHistory(storage, "gitlab", 2); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - storage.database.db.run(sql` + await storage.database.db.run(sql` update caplet_installations set updated_at = '2026-07-20T12:00:00.000Z' where record_key = ( @@ -650,7 +650,7 @@ describe("Caplet installation storage", () => { }); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage."); - storage.database.db.run(sql` + await storage.database.db.run(sql` update caplet_installation_observations set observed_at = '2026-07-20T12:00:00.000Z' where installation_key = ${current.installationKey} diff --git a/packages/core/test/project-binding-storage.test.ts b/packages/core/test/project-binding-storage.test.ts index 3c84e338..e01e6c2c 100644 --- a/packages/core/test/project-binding-storage.test.ts +++ b/packages/core/test/project-binding-storage.test.ts @@ -177,7 +177,9 @@ describe("SQL Project Binding metadata", () => { if (firstStorage.database.dialect !== "sqlite") { throw new Error("Expected SQLite storage."); } - expect(firstStorage.database.db.select().from(sqlite.projectBindings).all()).toHaveLength(2); + expect( + await firstStorage.database.db.select().from(sqlite.projectBindings).all(), + ).toHaveLength(2); } finally { await firstStorage.close(); } diff --git a/packages/core/test/remote-security-storage.test.ts b/packages/core/test/remote-security-storage.test.ts index 3672cfd1..73a769f7 100644 --- a/packages/core/test/remote-security-storage.test.ts +++ b/packages/core/test/remote-security-storage.test.ts @@ -48,7 +48,7 @@ describe("RemoteSecurityStore", () => { if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite test storage."); const createdAt = "2026-07-20T00:00:00.000Z"; const clientIds = ["rcli_Z", "rcli_a", "rcli_zz"]; - storage.database.db + await storage.database.db .insert(sqlite.remoteClients) .values( clientIds.map((clientId, index) => ({ @@ -64,7 +64,7 @@ describe("RemoteSecurityStore", () => { ) .run(); const flowIds = ["rlogin_Z", "rlogin_a", "rlogin_zz"]; - storage.database.db + await storage.database.db .insert(sqlite.remotePendingLogins) .values( flowIds.map((flowId, index) => ({ @@ -335,10 +335,10 @@ describe("RemoteSecurityStore", () => { expect(attempts.filter((result) => result.status === "rejected")).toHaveLength(1); expect(await security.listClients()).toHaveLength(1); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite test storage."); - expect(storage.database.db.select().from(sqlite.remoteClients).all()).toHaveLength(1); - expect(storage.database.db.select().from(sqlite.remotePairingCodes).all()[0]?.usedAt).toEqual( - expect.any(String), - ); + expect(await storage.database.db.select().from(sqlite.remoteClients).all()).toHaveLength(1); + expect( + (await storage.database.db.select().from(sqlite.remotePairingCodes).all())[0]?.usedAt, + ).toEqual(expect.any(String)); }); it("rotates refresh material and revokes the family after replay outside the grace window", async () => { @@ -598,7 +598,7 @@ describe("RemoteSecurityStore", () => { }); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite test storage."); - const activities = storage.database.db + const activities = await storage.database.db .select() .from(sqlite.operatorActivity) .where(eq(sqlite.operatorActivity.operatorClientId, "rcli_admin")) diff --git a/packages/core/test/setup-state-storage.test.ts b/packages/core/test/setup-state-storage.test.ts index 4961f9d3..9fe25c88 100644 --- a/packages/core/test/setup-state-storage.test.ts +++ b/packages/core/test/setup-state-storage.test.ts @@ -97,7 +97,9 @@ describe("SQL setup state", () => { if (firstStorage.database.dialect !== "sqlite") { throw new Error("Expected SQLite storage."); } - expect(firstStorage.database.db.select().from(sqlite.setupApprovals).all()).toHaveLength(1); + expect( + await firstStorage.database.db.select().from(sqlite.setupApprovals).all(), + ).toHaveLength(1); } finally { await firstStorage.close(); } @@ -124,12 +126,17 @@ describe("SQL setup state", () => { approvedAt: now, actor: "automation", }; - storage.database.db + await storage.database.db .insert(sqlite.setupApprovals) .values({ - ...hostedPayload, - generation: 0, + projectFingerprint: "project-a", + capletId: "legacy-hosted", + contentHash: "sha256:legacy", + targetKind: "hosted_sandbox" as never, + generation: 1, payload: hostedPayload, + approvedAt: now, + actor: "rcli_operator", createdAt: now, updatedAt: now, }) @@ -143,11 +150,9 @@ describe("SQL setup state", () => { store.getApproval("project-a", "legacy-hosted", "sha256:legacy", "remote_host"), ).resolves.toBeUndefined(); - const hostedRow = storage.database.db - .select() - .from(sqlite.setupApprovals) - .all() - .find((row) => row.targetKind === "hosted_sandbox"); + const hostedRow = (await storage.database.db.select().from(sqlite.setupApprovals).all()).find( + (row) => row.targetKind === "hosted_sandbox", + ); expect(hostedRow?.payload).toEqual(hostedPayload); } finally { await storage.close(); diff --git a/packages/core/test/storage-records-cli.test.ts b/packages/core/test/storage-records-cli.test.ts index 463184a6..becdcbd0 100644 --- a/packages/core/test/storage-records-cli.test.ts +++ b/packages/core/test/storage-records-cli.test.ts @@ -243,11 +243,9 @@ describe("stored Caplet Record CLI", () => { const activity = await storage.installations.listActivity(); await expect(storage.coordination.currentConfigGeneration()).resolves.toBe(8); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite test storage."); - const invalidations = storage.database.db - .select() - .from(hostConfigGenerations) - .all() - .filter(({ contentHash }) => contentHash.startsWith("mutation:")); + const invalidations = ( + await storage.database.db.select().from(hostConfigGenerations).all() + ).filter(({ contentHash }) => contentHash.startsWith("mutation:")); expect(invalidations).toEqual([]); expect(activity.length).toBeGreaterThan(10); expect(activity.every((entry) => entry.operatorClientId === "local_cli")).toBe(true); diff --git a/packages/core/test/telemetry-events.test.ts b/packages/core/test/telemetry-events.test.ts index 03b552c3..9fc6ee2f 100644 --- a/packages/core/test/telemetry-events.test.ts +++ b/packages/core/test/telemetry-events.test.ts @@ -164,7 +164,8 @@ describe("telemetry event builders", () => { diagnostic_category: "config", os_family: "linux", arch: "x64", - node_major: 22, + runtime_name: "node", + runtime_major: 22, }, error, }), @@ -182,7 +183,8 @@ describe("telemetry event builders", () => { diagnostic_category: "config", os_family: "linux", arch: "x64", - node_major: "22", + runtime_name: "node", + runtime_major: "22", }, exception: { values: [ diff --git a/packages/core/test/telemetry-providers.test.ts b/packages/core/test/telemetry-providers.test.ts index 582942c9..33d615fb 100644 --- a/packages/core/test/telemetry-providers.test.ts +++ b/packages/core/test/telemetry-providers.test.ts @@ -8,8 +8,42 @@ import { createTelemetryDispatcher, readTelemetryDeliveryHealth, resolveTelemetryState, + runtimeDescriptor, } from "../src/telemetry"; +const sentrySdkMocks = vi.hoisted(() => ({ + bunClient: vi.fn(), + captureEvent: vi.fn(), + flush: vi.fn(), + nodeClient: vi.fn(), +})); + +vi.mock("@sentry/bun", () => ({ + BunClient: class { + constructor(options: unknown) { + sentrySdkMocks.bunClient(options); + } + + captureEvent = sentrySdkMocks.captureEvent; + flush = sentrySdkMocks.flush; + }, + defaultStackParser: vi.fn(), + makeFetchTransport: vi.fn(), +})); + +vi.mock("@sentry/node", () => ({ + NodeClient: class { + constructor(options: unknown) { + sentrySdkMocks.nodeClient(options); + } + + captureEvent = sentrySdkMocks.captureEvent; + flush = sentrySdkMocks.flush; + }, + defaultStackParser: vi.fn(), + makeNodeTransport: vi.fn(), +})); + const roots: string[] = []; function tempRoot(): string { @@ -20,6 +54,7 @@ function tempRoot(): string { describe("telemetry providers", () => { afterEach(() => { + vi.clearAllMocks(); for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }); } @@ -108,6 +143,44 @@ describe("telemetry providers", () => { }); }); + it("constructs the runtime-native default Sentry client", async () => { + const stateDir = tempRoot(); + const dispatcher = createTelemetryDispatcher({ + posthogToken: "", + sentryDsn: "https://public@sentry.example/1", + }); + const state = resolveTelemetryState({ + stateDir, + env: { CI: "true" }, + surface: "cli", + visibility: "hidden", + }); + + await dispatcher.capture( + state, + buildReliabilityTelemetryEvent({ + name: "caplets_reliability_error", + properties: { + package: "@caplets/core", + surface: "cli", + command_family: "serve", + runtime_mode: "local", + error_code: "CONFIG_INVALID", + diagnostic_category: "config", + }, + error: new Error("invalid config"), + }), + ); + + const selected = + runtimeDescriptor().name === "bun" ? sentrySdkMocks.bunClient : sentrySdkMocks.nodeClient; + const unselected = + runtimeDescriptor().name === "bun" ? sentrySdkMocks.nodeClient : sentrySdkMocks.bunClient; + expect(selected).toHaveBeenCalledOnce(); + expect(unselected).not.toHaveBeenCalled(); + expect(sentrySdkMocks.captureEvent).toHaveBeenCalledOnce(); + }); + it("captures Sentry reliability events with categorical tags and fingerprint", async () => { const captureEvent = vi.fn(); const error = new Error("Bad /home/alex/.caplets/config.json"); diff --git a/packages/core/test/telemetry-runtime.test.ts b/packages/core/test/telemetry-runtime.test.ts index 55739e59..117022a9 100644 --- a/packages/core/test/telemetry-runtime.test.ts +++ b/packages/core/test/telemetry-runtime.test.ts @@ -10,6 +10,7 @@ import { outcomeFromResult, readTelemetryAttribution, runtimeFailureTelemetryProperties, + runtimeDescriptor, TelemetryDebugSink, writeTelemetryAttribution, } from "../src/telemetry"; @@ -29,6 +30,19 @@ describe("telemetry runtime helpers", () => { } }); + it("identifies Node and Bun runtime versions without conflating them", () => { + expect(runtimeDescriptor({ node: "22.19.0" })).toEqual({ + name: "node", + version: "22.19.0", + major: 22, + }); + expect(runtimeDescriptor({ node: "24.3.0", bun: "1.3.14" })).toEqual({ + name: "bun", + version: "1.3.14", + major: 1, + }); + }); + it("maps current runtime operation names into stable families", () => { expect(operationFamilyFromOperation("describe_tool")).toBe("tools"); expect(operationFamilyFromOperation("search_resources")).toBe("resources"); diff --git a/packages/core/test/vault-grant-storage.test.ts b/packages/core/test/vault-grant-storage.test.ts index 104fdff0..86a84b9a 100644 --- a/packages/core/test/vault-grant-storage.test.ts +++ b/packages/core/test/vault-grant-storage.test.ts @@ -207,11 +207,9 @@ describe("VaultGrantStore", () => { ]); if (storage.database.dialect !== "sqlite") throw new Error("Expected SQLite storage"); - const activities = storage.database.db - .select() - .from(operatorActivity) - .all() - .filter((entry) => entry.targetKind === "vault_grant"); + const activities = (await storage.database.db.select().from(operatorActivity).all()).filter( + (entry) => entry.targetKind === "vault_grant", + ); expect(activities).toHaveLength(5); const activityPayload = JSON.stringify(activities); expect(activityPayload).not.toContain(firstPath); diff --git a/packages/core/test/vault-state-storage.test.ts b/packages/core/test/vault-state-storage.test.ts index e85f4938..e80866a3 100644 --- a/packages/core/test/vault-state-storage.test.ts +++ b/packages/core/test/vault-state-storage.test.ts @@ -91,7 +91,7 @@ describe("VaultStateStore", () => { it("rolls back a new value and its activity when grant insertion fails", async () => { const fixture = await openFixture(); try { - fixture.database.db.run( + await fixture.database.db.run( sql.raw(` create temp trigger fail_vault_grant_insert before insert on vault_access_grants @@ -109,7 +109,7 @@ describe("VaultStateStore", () => { grant: fileGrant("NEW_SECRET", "new-secret-caplet"), operatorClientId: operator.clientId, }), - ).rejects.toThrow("injected grant insertion failure"); + ).rejects.toThrow(); await expect(fixture.values.getStatus("NEW_SECRET")).resolves.toEqual({ key: "NEW_SECRET", @@ -121,7 +121,7 @@ describe("VaultStateStore", () => { }); await expect(fixture.storage.coordination.currentConfigGeneration()).resolves.toBe(0); } finally { - fixture.database.db.run(sql.raw("drop trigger if exists fail_vault_grant_insert")); + await fixture.database.db.run(sql.raw("drop trigger if exists fail_vault_grant_insert")); await closeFixture(fixture); } }); @@ -132,7 +132,7 @@ describe("VaultStateStore", () => { await fixture.values.set("EXISTING_SECRET", "original-secret", { createOnly: true, }); - fixture.database.db.run( + await fixture.database.db.run( sql.raw(` create temp trigger fail_vault_set_activity before insert on operator_activity @@ -155,7 +155,7 @@ describe("VaultStateStore", () => { }, operatorClientId: operator.clientId, }), - ).rejects.toThrow("injected vault set activity failure"); + ).rejects.toThrow(); await expect(fixture.values.resolveValue("EXISTING_SECRET")).resolves.toBe("original-secret"); await expect(fixture.values.getStatus("EXISTING_SECRET")).resolves.toMatchObject({ @@ -168,7 +168,7 @@ describe("VaultStateStore", () => { }); await expect(fixture.storage.coordination.currentConfigGeneration()).resolves.toBe(0); } finally { - fixture.database.db.run(sql.raw("drop trigger if exists fail_vault_set_activity")); + await fixture.database.db.run(sql.raw("drop trigger if exists fail_vault_set_activity")); await closeFixture(fixture); } }); @@ -179,7 +179,7 @@ describe("VaultStateStore", () => { await fixture.values.set("EXISTING_SECRET", "original-secret", { createOnly: true, }); - fixture.database.db.run( + await fixture.database.db.run( sql.raw(` create temp trigger fail_vault_config_publication before insert on host_config_generations @@ -198,7 +198,7 @@ describe("VaultStateStore", () => { grant: fileGrant("EXISTING_SECRET", "config-failure-caplet"), operatorClientId: operator.clientId, }), - ).rejects.toThrow("injected config publication failure"); + ).rejects.toThrow(); await expect(fixture.values.resolveValue("EXISTING_SECRET")).resolves.toBe("original-secret"); await expect(fixture.values.getStatus("EXISTING_SECRET")).resolves.toMatchObject({ @@ -209,7 +209,9 @@ describe("VaultStateStore", () => { await expect(fixture.storage.operatorActivity.list()).resolves.toEqual({ entries: [] }); await expect(fixture.storage.coordination.currentConfigGeneration()).resolves.toBe(0); } finally { - fixture.database.db.run(sql.raw("drop trigger if exists fail_vault_config_publication")); + await fixture.database.db.run( + sql.raw("drop trigger if exists fail_vault_config_publication"), + ); await closeFixture(fixture); } }); diff --git a/packages/core/test/vault-value-storage.test.ts b/packages/core/test/vault-value-storage.test.ts index 0a77f763..b443f649 100644 --- a/packages/core/test/vault-value-storage.test.ts +++ b/packages/core/test/vault-value-storage.test.ts @@ -60,7 +60,7 @@ describe("VaultValueStore", () => { }); expect(firstStatus.createdAt).toBe(firstStatus.updatedAt); - const persisted = firstStorage.database.db.select().from(vaultValues).get(); + const persisted = await firstStorage.database.db.select().from(vaultValues).get(); expect(persisted).toMatchObject({ vaultKey: "API_TOKEN", generation: 1 }); expect(JSON.stringify(persisted)).not.toContain(plaintext); expect(persisted).not.toHaveProperty("value"); @@ -144,7 +144,7 @@ describe("VaultValueStore", () => { code: "CONFIG_INVALID", }); - firstStorage.database.db + await firstStorage.database.db .insert(vaultValues) .values({ vaultKey: "BROKEN_TOKEN", @@ -167,7 +167,7 @@ describe("VaultValueStore", () => { }); await expect(second.listValues()).rejects.toMatchObject({ code: "CONFIG_INVALID" }); - const activity = firstStorage.database.db.select().from(operatorActivity).all(); + const activity = await firstStorage.database.db.select().from(operatorActivity).all(); expect(activity.map(({ action, metadata }) => ({ action, metadata }))).toEqual([ { action: "vault_value_written", metadata: { generation: 1 } }, { action: "vault_value_written", metadata: { generation: 2 } }, @@ -276,30 +276,43 @@ describe("VaultValueStore", () => { ); vi.setSystemTime(new Date("2026-07-18T10:02:00.000Z")); + const firstWrite = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); const serializedFirst = storage.database.db.transaction( - (transaction) => setPreparedVaultValueSqlite(transaction, preparedSecond), + async (transaction) => { + const result = await setPreparedVaultValueSqlite(transaction, preparedSecond); + firstWrite.resolve(); + await releaseFirst.promise; + return result; + }, { behavior: "immediate" }, ); + await firstWrite.promise; vi.setSystemTime(new Date("2026-07-18T10:03:00.000Z")); const serializedSecond = storage.database.db.transaction( (transaction) => setPreparedVaultValueSqlite(transaction, preparedFirst), { behavior: "immediate" }, ); + releaseFirst.resolve(); + const [serializedFirstResult, serializedSecondResult] = await Promise.all([ + serializedFirst, + serializedSecond, + ]); - expect(serializedFirst).toMatchObject({ + expect(serializedFirstResult).toMatchObject({ generation: 1, createdAt: "2026-07-18T10:02:00.000Z", updatedAt: "2026-07-18T10:02:00.000Z", }); - expect(serializedSecond).toMatchObject({ + expect(serializedSecondResult).toMatchObject({ generation: 2, createdAt: "2026-07-18T10:02:00.000Z", updatedAt: "2026-07-18T10:03:00.000Z", }); - await expect(values.getStatus("ORDERED_SECRET")).resolves.toEqual(serializedSecond); + await expect(values.getStatus("ORDERED_SECRET")).resolves.toEqual(serializedSecondResult); await expect(values.resolveValue("ORDERED_SECRET")).resolves.toBe("prepared-first"); - const activity = storage.database.db.select().from(operatorActivity).all(); + const activity = await storage.database.db.select().from(operatorActivity).all(); expect( activity .map((entry) => ({ diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4de634a4..4c0bc909 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,7 +1,7 @@ # `@caplets/sdk` -Typed ESM client for the public Caplets HTTP API and Project Binding v1 protocol. It uses standard -Fetch and WebSocket APIs and supports Node.js 22+ and modern browsers. +Typed ESM client for the public Caplets HTTP API and Project Binding v1 protocol. Its root and +Project Binding exports use standard Fetch and WebSocket APIs in modern web-platform runtimes. ## Install @@ -224,7 +224,7 @@ WebSocket URL, events, or error messages. ## Runtime scope -- Root HTTP client and `@caplets/sdk/project-binding`: modern browsers and Node.js 22+. +- Root HTTP client and `@caplets/sdk/project-binding`: modern web-platform runtimes. - `@caplets/sdk/project-binding/node`: Node.js 22+ only; it uses filesystem and crypto APIs. - The root exposes public HTTP discovery, health, Remote Login, Attach, Project Binding controls, and Admin operations at their canonical `/api/*` paths. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f107c22c..cdf9514e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -54,8 +54,5 @@ "rolldown": "^1.2.0", "typescript": "^7.0.2", "vitest": "^4.1.10" - }, - "engines": { - "node": ">=22" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e38af73..9ffbdae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,16 +19,16 @@ importers: version: 5.20260721.1 '@sentry/rollup-plugin': specifier: ^5.4.0 - version: 5.4.0(supports-color@10.2.2) + version: 5.4.0 '@sentry/vite-plugin': specifier: ^5.4.0 - version: 5.4.0(supports-color@10.2.2) + version: 5.4.0 '@types/node': specifier: ^26.1.1 version: 26.1.1 alchemy: specifier: 0.93.12 - version: 0.93.12(@aws-sdk/client-s3@3.1091.0)(@cloudflare/vite-plugin@1.45.1(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)))(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(workerd@1.20260714.1) + version: 0.93.12(@aws-sdk/client-s3@3.1091.0)(@cloudflare/vite-plugin@1.45.1(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)))(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(workerd@1.20260714.1) husky: specifier: ^9.1.7 version: 9.1.7 @@ -64,7 +64,7 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^14.1.3 - version: 14.1.4(@types/node@26.1.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1))(yaml@2.9.0) + version: 14.1.4(@types/node@26.1.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1))(yaml@2.9.0) '@caplets/core': specifier: workspace:* version: link:../../packages/core @@ -91,7 +91,7 @@ importers: version: 3.17.5 astro: specifier: ^7.1.1 - version: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) posthog-js: specifier: ^1.404.1 version: 1.405.3 @@ -103,7 +103,7 @@ importers: version: 10.0.1 remark-parse: specifier: ^11.0.0 - version: 11.0.0(supports-color@10.2.2) + version: 11.0.0 remark-rehype: specifier: ^11.1.2 version: 11.1.2 @@ -140,7 +140,7 @@ importers: dependencies: '@astrojs/react': specifier: ^6.0.1 - version: 6.0.1(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(tsx@4.23.1)(yaml@2.9.0) + version: 6.0.1(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.23.1)(yaml@2.9.0) '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -155,7 +155,7 @@ importers: version: 3.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) astro: specifier: ^7.1.1 - version: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -176,7 +176,7 @@ importers: version: 19.2.7(react@19.2.7) shadcn: specifier: ^4.13.1 - version: 4.13.1(supports-color@10.2.2)(typescript@7.0.2) + version: 4.13.1(typescript@7.0.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -207,7 +207,7 @@ importers: dependencies: '@astrojs/starlight': specifier: ^0.41.3 - version: 0.41.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(supports-color@10.2.2)(typescript@7.0.2) + version: 0.41.3(@astrojs/markdown-remark@7.2.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@7.0.2) '@caplets/web-observability': specifier: workspace:* version: link:../../packages/web-observability @@ -219,7 +219,7 @@ importers: version: 4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) astro: specifier: ^7.1.1 - version: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) posthog-js: specifier: ^1.404.1 version: 1.405.3 @@ -262,7 +262,7 @@ importers: version: 4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) astro: specifier: ^7.1.1 - version: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + version: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) posthog-js: specifier: ^1.404.1 version: 1.405.3 @@ -330,7 +330,7 @@ importers: version: link:../core '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) pg: specifier: ^8.22.0 version: 8.22.0 @@ -370,19 +370,25 @@ importers: version: 3.2.0 '@hono/mcp': specifier: ^0.3.1 - version: 0.3.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.31)(unstorage@1.17.5(aws4fetch@1.0.20)))(hono@4.12.31)(zod@4.4.3) + version: 0.3.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.31)(unstorage@1.17.5(aws4fetch@1.0.20)))(hono@4.12.31)(zod@4.4.3) '@hono/node-server': specifier: ^2.0.10 version: 2.0.11(hono@4.12.31) '@hono/zod-openapi': specifier: 1.5.1 version: 1.5.1(hono@4.12.31)(zod@4.4.3) + '@libsql/client': + specifier: ^0.17.4 + version: 0.17.4 '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) + '@sentry/bun': + specifier: ^10.67.0 + version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) '@sentry/node': - specifier: ^10.66.0 - version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2) + specifier: ^10.67.0 + version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) '@ungap/structured-clone': specifier: ^1.3.3 version: 1.3.3 @@ -392,15 +398,12 @@ importers: ajv: specifier: ^8.20.0 version: 8.20.0 - better-sqlite3: - specifier: ^12.11.1 - version: 12.11.1 commander: specifier: ^15.0.0 version: 15.0.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0) + version: 0.45.2(@cloudflare/workers-types@5.20260721.1)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0) formdata-node: specifier: ^6.0.3 version: 6.0.3 @@ -456,9 +459,6 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -519,7 +519,7 @@ importers: version: link:../core '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3) + version: 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-tui': specifier: '*' version: 0.80.10 @@ -2295,6 +2295,63 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@libsql/client@0.17.4': + resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==} + + '@libsql/core@0.17.4': + resolution: {integrity: sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -2430,6 +2487,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -3125,6 +3185,10 @@ packages: resolution: {integrity: sha512-/ZhsAvte4rYhg0A0RtSFFgAgXhyMOfQIeOAfMfptN+X6IVSYOfkA9jtrP+Ej4+6vlaUFWRir1HweF56y63dEEA==} engines: {node: '>=18'} + '@sentry/bun@10.67.0': + resolution: {integrity: sha512-SnFJnEpICjfYgQaG5mTERcQ/ZjoPBZ5VBPkyp1Nr3sXbk2H5/DEByaGt+6sW/AD6pMn9CTkncjmkEqK0PtuWZA==} + engines: {node: '>=18'} + '@sentry/bundler-plugins@10.67.0': resolution: {integrity: sha512-HKLhbMZJsabZlXTog8CTa1ReeDW/mf1cwN7O8K+DKhe/kGHB3whHRseqsyjxyJwPdzC/0lM+8rgfqgxpc7jR9A==} engines: {node: '>= 18'} @@ -4086,6 +4150,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4390,6 +4457,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -5324,6 +5395,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-base64@3.9.1: + resolution: {integrity: sha512-U73qptcvf/HIOauFOmqT3a0mDUp0MYlfd15oqoe9kqZt5XhiXVb+HG09sLvI9PQ9tZIBFS4nlErai8zbWazP0g==} + js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -5416,6 +5490,11 @@ packages: libsodium@0.8.4: resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==} + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -6403,6 +6482,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -7692,10 +7774,10 @@ snapshots: semifies: 1.0.0 source-map: 0.6.1 - '@apm-js-collab/tracing-hooks@0.13.0(supports-color@10.2.2)': + '@apm-js-collab/tracing-hooks@0.13.0': dependencies: '@apm-js-collab/code-transformer': 0.18.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -7705,12 +7787,12 @@ snapshots: openapi3-ts: 4.6.0 zod: 4.4.3 - '@astrojs/cloudflare@14.1.4(@types/node@26.1.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1))(yaml@2.9.0)': + '@astrojs/cloudflare@14.1.4(@types/node@26.1.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1))(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.10.1 '@astrojs/underscore-redirects': 1.0.3 '@cloudflare/vite-plugin': 1.45.1(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)) - astro: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) piccolore: 0.1.3 vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) wrangler: 4.112.0(@cloudflare/workers-types@5.20260721.1) @@ -7823,7 +7905,7 @@ snapshots: transitivePeerDependencies: - typescript - '@astrojs/markdown-remark@7.2.1(supports-color@10.2.2)': + '@astrojs/markdown-remark@7.2.1': dependencies: '@astrojs/internal-helpers': 0.10.1 '@astrojs/prism': 4.0.2 @@ -7833,8 +7915,8 @@ snapshots: mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 - remark-gfm: 4.0.1(supports-color@10.2.2) - remark-parse: 11.0.0(supports-color@10.2.2) + remark-gfm: 4.0.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 remark-smartypants: 3.0.2 unified: 11.0.5 @@ -7853,19 +7935,19 @@ snapshots: hast-util-from-html: 2.0.3 satteri: 0.9.5 - '@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(supports-color@10.2.2)': + '@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.1 - '@astrojs/markdown-remark': 7.2.1(supports-color@10.2.2) - '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) + '@astrojs/markdown-remark': 7.2.1 + '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 piccolore: 0.1.3 rehype-raw: 7.0.0 - remark-gfm: 4.0.1(supports-color@10.2.2) + remark-gfm: 4.0.1 remark-smartypants: 3.0.2 source-map: 0.7.6 unist-util-visit: 5.1.0 @@ -7879,12 +7961,12 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@6.0.1(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(tsx@4.23.1)(yaml@2.9.0)': + '@astrojs/react@6.0.1(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tsx@4.23.1)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.10.1 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@vitejs/plugin-react': 5.2.0(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitejs/plugin-react': 5.2.0(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) devalue: 5.8.2 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -7911,17 +7993,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.41.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(supports-color@10.2.2)(typescript@7.0.2)': + '@astrojs/starlight@0.41.3(@astrojs/markdown-remark@7.2.1)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@7.0.2)': dependencies: '@astrojs/markdown-satteri': 0.3.4 - '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(supports-color@10.2.2) + '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - astro-expressive-code: 0.44.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + astro: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro-expressive-code: 0.44.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) bcp-47: 2.1.1 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -7931,20 +8013,20 @@ snapshots: js-yaml: 4.3.0 klona: 2.0.6 magic-string: 0.30.21 - mdast-util-directive: 3.1.0(supports-color@10.2.2) + mdast-util-directive: 3.1.0 mdast-util-to-markdown: 2.1.2 mdast-util-to-string: 4.0.0 pagefind: 1.5.2 rehype: 13.0.2 rehype-format: 5.0.1 - remark-directive: 4.0.0(supports-color@10.2.2) + remark-directive: 4.0.0 satteri: 0.9.5 ultrahtml: 1.7.0 unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 optionalDependencies: - '@astrojs/markdown-remark': 7.2.1(supports-color@10.2.2) + '@astrojs/markdown-remark': 7.2.1 transitivePeerDependencies: - supports-color - typescript @@ -8242,20 +8324,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@10.2.2)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -8282,41 +8364,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -8326,18 +8408,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -8365,53 +8447,53 @@ snapshots: dependencies: '@babel/types': 8.0.4 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -8423,7 +8505,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7(supports-color@10.2.2)': + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -8431,7 +8513,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8761,9 +8843,9 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} - '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -8775,16 +8857,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2(supports-color@10.2.2) - https-proxy-agent: 7.0.6(supports-color@10.2.2) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 openai: 6.26.0(ws@8.21.1)(zod@4.4.3) partial-json: 0.1.7 typebox: 1.1.38 @@ -8796,10 +8878,10 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-tui': 0.80.10 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -9159,14 +9241,14 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: - google-auth-library: 10.9.0(supports-color@10.2.2) + google-auth-library: 10.9.0 p-retry: 4.6.2 protobufjs: 7.6.5 ws: 8.21.1 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9222,9 +9304,9 @@ snapshots: '@hey-api/types@0.1.4': {} - '@hono/mcp@0.3.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.31)(unstorage@1.17.5(aws4fetch@1.0.20)))(hono@4.12.31)(zod@4.4.3)': + '@hono/mcp@0.3.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.31)(unstorage@1.17.5(aws4fetch@1.0.20)))(hono@4.12.31)(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) hono: 4.12.31 hono-rate-limiter: 0.5.3(hono@4.12.31)(unstorage@1.17.5(aws4fetch@1.0.20)) pkce-challenge: 5.0.1 @@ -9515,6 +9597,64 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@libsql/client@0.17.4': + dependencies: + '@libsql/core': 0.17.4 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.9.1 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.17.4': + dependencies: + js-base64: 3.9.1 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@lukeed/ms@2.0.2': {} '@manypkg/find-root@1.1.0': @@ -9577,7 +9717,7 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true - '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -9589,14 +9729,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.17.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@10.2.2) - remark-mdx: 3.1.1(supports-color@10.2.2) - remark-parse: 11.0.0(supports-color@10.2.2) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -9619,7 +9759,7 @@ snapshots: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) ajv: 8.20.0 @@ -9629,8 +9769,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.6.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) hono: 4.12.31 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -9641,7 +9781,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) ajv: 8.20.0 @@ -9651,8 +9791,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.6.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) hono: 4.12.31 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -9695,6 +9835,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neon-rs/load@0.0.4': {} + '@nodable/entities@3.0.0': {} '@nodelib/fs.scandir@2.1.5': @@ -9801,12 +9943,12 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.220.0 import-in-the-middle: 3.3.2 - require-in-the-middle: 8.0.1(supports-color@10.2.2) + require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color @@ -10144,10 +10286,21 @@ snapshots: '@sentry/replay': 10.67.0 '@sentry/replay-canvas': 10.67.0 - '@sentry/bundler-plugins@10.67.0(supports-color@10.2.2)': + '@sentry/bun@10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@sentry/cli': 2.58.6(supports-color@10.2.2) + '@apm-js-collab/code-transformer-bundler-plugins': 0.7.1 + '@sentry/core': 10.67.0 + '@sentry/node': 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.67.0 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/bundler-plugins@10.67.0': + dependencies: + '@babel/core': 7.29.7 + '@sentry/cli': 2.58.6 '@sentry/core': 10.67.0 dotenv: 17.4.2 find-up: 5.0.0 @@ -10181,9 +10334,9 @@ snapshots: '@sentry/cli-win32-x64@2.58.6': optional: true - '@sentry/cli@2.58.6(supports-color@10.2.2)': + '@sentry/cli@2.58.6': dependencies: - https-proxy-agent: 5.0.1(supports-color@10.2.2) + https-proxy-agent: 5.0.1 node-fetch: 2.7.0 progress: 2.0.3 proxy-from-env: 1.1.0 @@ -10211,7 +10364,7 @@ snapshots: dependencies: '@sentry/core': 10.67.0 - '@sentry/node-core@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + '@sentry/node-core@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.16.0 '@sentry/core': 10.67.0 @@ -10220,19 +10373,19 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) - '@sentry/node@10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2)': + '@sentry/node@10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) '@sentry/conventions': 0.16.0 '@sentry/core': 10.67.0 - '@sentry/node-core': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/node-core': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) '@sentry/opentelemetry': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) - '@sentry/server-utils': 10.67.0(supports-color@10.2.2) + '@sentry/server-utils': 10.67.0 import-in-the-middle: 3.3.2 transitivePeerDependencies: - '@opentelemetry/core' @@ -10257,26 +10410,26 @@ snapshots: '@sentry/browser-utils': 10.67.0 '@sentry/core': 10.67.0 - '@sentry/rollup-plugin@5.4.0(supports-color@10.2.2)': + '@sentry/rollup-plugin@5.4.0': dependencies: - '@sentry/bundler-plugins': 10.67.0(supports-color@10.2.2) + '@sentry/bundler-plugins': 10.67.0 transitivePeerDependencies: - encoding - supports-color - webpack - '@sentry/server-utils@10.67.0(supports-color@10.2.2)': + '@sentry/server-utils@10.67.0': dependencies: '@apm-js-collab/code-transformer-bundler-plugins': 0.7.1 - '@apm-js-collab/tracing-hooks': 0.13.0(supports-color@10.2.2) + '@apm-js-collab/tracing-hooks': 0.13.0 '@sentry/conventions': 0.16.0 '@sentry/core': 10.67.0 transitivePeerDependencies: - supports-color - '@sentry/vite-plugin@5.4.0(supports-color@10.2.2)': + '@sentry/vite-plugin@5.4.0': dependencies: - '@sentry/bundler-plugins': 10.67.0(supports-color@10.2.2) + '@sentry/bundler-plugins': 10.67.0 transitivePeerDependencies: - encoding - rollup @@ -10529,6 +10682,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 26.1.1 + optional: true '@types/chai@5.2.3': dependencies: @@ -10693,11 +10847,11 @@ snapshots: '@ungap/structured-clone@1.3.3': {} - '@vitejs/plugin-react@5.2.0(supports-color@10.2.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -10816,9 +10970,9 @@ snapshots: js-yaml: 4.3.0 jsonc-parser: 3.3.1 - agent-base@6.0.2(supports-color@10.2.2): + agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10847,7 +11001,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.93.12(@aws-sdk/client-s3@3.1091.0)(@cloudflare/vite-plugin@1.45.1(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)))(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(workerd@1.20260714.1): + alchemy@0.93.12(@aws-sdk/client-s3@3.1091.0)(@cloudflare/vite-plugin@1.45.1(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(wrangler@4.112.0(@cloudflare/workers-types@5.20260721.1)))(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(workerd@1.20260714.1): dependencies: '@aws-sdk/credential-providers': 3.1091.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260714.1) @@ -10857,7 +11011,7 @@ snapshots: '@smithy/node-config-provider': 4.5.11 '@smithy/types': 4.16.1 aws4fetch: 1.0.20 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -10960,13 +11114,13 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + astro-expressive-code@0.44.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - astro: 7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro@7.1.3(@astrojs/markdown-remark@7.2.1(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): + astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) '@astrojs/internal-helpers': 0.10.1 @@ -11022,7 +11176,7 @@ snapshots: yargs-parser: 22.0.0 zod: 4.4.3 optionalDependencies: - '@astrojs/markdown-remark': 7.2.1(supports-color@10.2.2) + '@astrojs/markdown-remark': 7.2.1 sharp: 0.35.3(@types/node@26.1.1) transitivePeerDependencies: - '@azure/app-configuration' @@ -11093,26 +11247,29 @@ snapshots: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 + optional: true bignumber.js@9.3.1: {} bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 + optional: true bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true blake3-wasm@2.1.5: {} - body-parser@2.3.0(supports-color@10.2.2): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -11158,6 +11315,12 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true + + bun-types@1.3.14: + dependencies: + '@types/node': 26.1.1 + optional: true bundle-name@4.1.0: dependencies: @@ -11223,7 +11386,8 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: {} + chownr@1.1.4: + optional: true ci-info@4.4.0: {} @@ -11362,11 +11526,9 @@ snapshots: dependencies: mimic-fn: 3.1.0 - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 decode-named-character-reference@1.3.0: dependencies: @@ -11375,10 +11537,12 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 + optional: true dedent@1.7.2: {} - deep-extend@0.6.0: {} + deep-extend@0.6.0: + optional: true deepmerge@4.3.1: {} @@ -11403,6 +11567,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} devalue@5.8.2: {} @@ -11454,22 +11620,25 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.1 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0): optionalDependencies: '@cloudflare/workers-types': 4.20260702.1 '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.20.0 better-sqlite3: 12.11.1 + bun-types: 1.3.14 pg: 8.22.0 - drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(pg@8.22.0): + drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260721.1)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(bun-types@1.3.14)(pg@8.22.0): optionalDependencies: '@cloudflare/workers-types': 5.20260721.1 + '@libsql/client': 0.17.4 '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.20.0 better-sqlite3: 12.11.1 + bun-types: 1.3.14 pg: 8.22.0 dset@3.1.4: {} @@ -11519,6 +11688,7 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.24.3: dependencies: @@ -11739,32 +11909,33 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expand-template@2.0.3: {} + expand-template@2.0.3: + optional: true expect-type@1.4.0: {} - express-rate-limit@8.6.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): + express-rate-limit@8.6.0(express@5.2.1): dependencies: - debug: 4.4.3(supports-color@10.2.2) - express: 5.2.1(supports-color@10.2.2) + debug: 4.4.3 + express: 5.2.1 ip-address: 10.2.0 transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@10.2.2): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@10.2.2) + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@10.2.2) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -11775,9 +11946,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0(supports-color@10.2.2) - send: 1.2.1(supports-color@10.2.2) - serve-static: 2.2.1(supports-color@10.2.2) + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -11862,15 +12033,16 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-uri-to-path@1.0.0: {} + file-uri-to-path@1.0.0: + optional: true fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1(supports-color@10.2.2): + finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -11926,7 +12098,8 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} + fs-constants@1.0.0: + optional: true fs-extra@11.3.6: dependencies: @@ -11953,17 +12126,17 @@ snapshots: fuzzysort@3.1.0: {} - gaxios@7.2.0(supports-color@10.2.2): + gaxios@7.2.0: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6(supports-color@10.2.2) + https-proxy-agent: 7.0.6 node-fetch: 3.3.2 transitivePeerDependencies: - supports-color - gcp-metadata@8.1.2(supports-color@10.2.2): + gcp-metadata@8.1.2: dependencies: - gaxios: 7.2.0(supports-color@10.2.2) + gaxios: 7.2.0 google-logging-utils: 1.1.3 json-bigint: 1.0.0 transitivePeerDependencies: @@ -12010,7 +12183,8 @@ snapshots: giget@3.3.0: {} - github-from-package@0.0.0: {} + github-from-package@0.0.0: + optional: true github-slugger@2.0.0: {} @@ -12042,12 +12216,12 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - google-auth-library@10.9.0(supports-color@10.2.2): + google-auth-library@10.9.0: dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 7.2.0(supports-color@10.2.2) - gcp-metadata: 8.1.2(supports-color@10.2.2) + gaxios: 7.2.0 + gcp-metadata: 8.1.2 google-logging-utils: 1.1.3 jws: 4.0.1 transitivePeerDependencies: @@ -12201,7 +12375,7 @@ snapshots: unist-util-visit: 5.1.0 zwitch: 2.0.4 - hast-util-to-estree@3.1.3(supports-color@10.2.2): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -12211,9 +12385,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -12236,7 +12410,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@10.2.2): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 @@ -12245,9 +12419,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -12324,24 +12498,24 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@10.2.2): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1(supports-color@10.2.2): + https-proxy-agent@5.0.1: dependencies: - agent-base: 6.0.2(supports-color@10.2.2) - debug: 4.4.3(supports-color@10.2.2) + agent-base: 6.0.2 + debug: 4.4.3 transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@10.2.2): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12361,7 +12535,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} + ieee754@1.2.1: + optional: true ignore@5.3.2: {} @@ -12382,7 +12557,8 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} + ini@1.3.8: + optional: true ini@7.0.0: {} @@ -12481,6 +12657,8 @@ snapshots: jose@6.2.3: {} + js-base64@3.9.1: {} + js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -12569,6 +12747,21 @@ snapshots: libsodium@0.8.4: {} + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -12743,13 +12936,13 @@ snapshots: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - mdast-util-directive@3.1.0(supports-color@10.2.2): + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -12764,14 +12957,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@10.2.2): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@10.2.2) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -12789,67 +12982,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@10.2.2): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@10.2.2): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@10.2.2): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@10.2.2) - mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@10.2.2): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -12857,7 +13050,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -12866,23 +13059,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@10.2.2): + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) - mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) - mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) - mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -13188,10 +13381,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@10.2.2): + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -13227,7 +13420,8 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@3.1.0: {} + mimic-response@3.1.0: + optional: true mini-svg-data-uri@1.4.4: {} @@ -13267,7 +13461,8 @@ snapshots: minipass@7.1.3: {} - mkdirp-classic@0.5.3: {} + mkdirp-classic@0.5.3: + optional: true module-details-from-path@1.0.4: {} @@ -13299,7 +13494,8 @@ snapshots: nanoid@3.3.16: {} - napi-build-utils@2.0.0: {} + napi-build-utils@2.0.0: + optional: true negotiator@1.0.0: {} @@ -13321,6 +13517,7 @@ snapshots: node-abi@3.94.0: dependencies: semver: 7.8.5 + optional: true node-domexception@1.0.0: {} @@ -13752,6 +13949,7 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.5 tunnel-agent: 0.6.0 + optional: true prettier-plugin-astro@0.14.1: dependencies: @@ -13775,6 +13973,8 @@ snapshots: progress@2.0.3: {} + promise-limit@2.7.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -13813,6 +14013,7 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true punycode@2.3.1: {} @@ -13863,6 +14064,7 @@ snapshots: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 + optional: true react-dom@19.2.7(react@19.2.7): dependencies: @@ -13895,6 +14097,7 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + optional: true readdirp@5.0.0: {} @@ -13966,11 +14169,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@10.2.2): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3(supports-color@10.2.2) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color @@ -13992,37 +14195,37 @@ snapshots: rehype-stringify: 10.0.1 unified: 11.0.5 - remark-directive@4.0.0(supports-color@10.2.2): + remark-directive@4.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-directive: 3.1.0(supports-color@10.2.2) + mdast-util-directive: 3.1.0 micromark-extension-directive: 4.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-gfm@4.0.1(supports-color@10.2.2): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@10.2.2) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@10.2.2) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@10.2.2): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@10.2.2) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@10.2.2): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -14055,9 +14258,9 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@8.0.1(supports-color@10.2.2): + require-in-the-middle@8.0.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -14148,9 +14351,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.0 '@rolldown/binding-win32-x64-msvc': 1.2.0 - router@2.2.0(supports-color@10.2.2): + router@2.2.0: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -14207,9 +14410,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1(supports-color@10.2.2): + send@1.2.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -14223,12 +14426,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1(supports-color@10.2.2): + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@10.2.2) + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -14238,14 +14441,14 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.13.1(supports-color@10.2.2)(typescript@7.0.2): + shadcn@4.13.1(typescript@7.0.2): dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/parser': 7.29.7 - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.6 commander: 14.0.3 @@ -14393,13 +14596,15 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 + optional: true sisteransi@1.0.5: {} @@ -14478,6 +14683,7 @@ snapshots: string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + optional: true stringify-entities@4.0.4: dependencies: @@ -14504,7 +14710,8 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@2.0.1: {} + strip-json-comments@2.0.1: + optional: true strnum@2.4.1: dependencies: @@ -14558,6 +14765,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.4 tar-stream: 2.2.0 + optional: true tar-stream@2.2.0: dependencies: @@ -14566,6 +14774,7 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true term-size@2.2.1: {} @@ -14632,6 +14841,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + optional: true turbo@2.10.5: optionalDependencies: diff --git a/scripts/check-package-runtime.mjs b/scripts/check-package-runtime.mjs index 68cd18bf..88ec0933 100644 --- a/scripts/check-package-runtime.mjs +++ b/scripts/check-package-runtime.mjs @@ -156,7 +156,7 @@ async function main() { "PASS strict route cutover: representative old, trailing-slash, and prefix paths return exact no-store JSON 404 without redirect or migration headers.", ...planReports, "Cleanup: all Host Nodes/provider stopped; isolated SQLite/config/staging root scheduled for recursive removal.", - "Command: node scripts/check-package-runtime.mjs", + `Command: ${process.versions.bun ? "bun" : "node"} scripts/check-package-runtime.mjs`, ].join("\n") + "\n", ); } catch (error) { @@ -456,7 +456,6 @@ async function verifyMcpLifecycle(currentHostOrigin) { ) { throw new Error("Built MCP session did not open its canonical GET stream."); } - await stream.body?.cancel(); const deleted = await fetch(url, { method: "DELETE", @@ -469,6 +468,7 @@ async function verifyMcpLifecycle(currentHostOrigin) { if (deleted.status !== 200) { throw new Error("Built MCP session DELETE did not complete cleanup."); } + await stream.body?.cancel(); const afterDelete = await fetch(url, { method: "POST", headers: sessionHeaders, diff --git a/scripts/package-runtime-plan-000-smoke.mjs b/scripts/package-runtime-plan-000-smoke.mjs index 734f1d43..d6436aa9 100644 --- a/scripts/package-runtime-plan-000-smoke.mjs +++ b/scripts/package-runtime-plan-000-smoke.mjs @@ -701,12 +701,20 @@ async function verifyLargeBundle({ clearInterval(downloadSampler); } + // Bun's native HTTP server retains one request/response-sized runtime allocation while a body + // is in flight. Keep the application parser bounded and reject any additional whole-body copy. + const runtimeBodyAllowance = process.versions.bun ? totalPayloadBytes : 0; + const runtimeBodyCopies = process.versions.bun ? 1 : 0; const streamingBoundedDelta = - RSS_PARSER_ALLOWANCE_BYTES + RSS_ASSET_BYTES + RSS_FIXED_RUNTIME_ALLOWANCE_BYTES; + RSS_PARSER_ALLOWANCE_BYTES + + RSS_ASSET_BYTES + + RSS_FIXED_RUNTIME_ALLOWANCE_BYTES + + runtimeBodyAllowance; const rejectedUploadThresholdRss = rejectedUploadBaselineRss + streamingBoundedDelta; assert( - streamingBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= totalPayloadBytes, - "Rejected-upload RSS ceiling does not distinguish streaming from whole-bundle buffering.", + streamingBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= + totalPayloadBytes * (runtimeBodyCopies + 1), + "Rejected-upload RSS ceiling cannot detect an additional whole-bundle buffer.", ); assert( rejectedUploadPeakRss <= rejectedUploadThresholdRss, @@ -714,12 +722,12 @@ async function verifyLargeBundle({ ); // SQLite may retain one payload-sized native allocation while importing blobs. The ceiling - // still rejects an additional whole-request buffer on top of that resident storage copy. - const uploadBoundedDelta = totalPayloadBytes + RSS_PARSER_ALLOWANCE_BYTES; + // still rejects an additional whole-request buffer on top of storage and runtime-owned copies. + const uploadBoundedDelta = totalPayloadBytes + RSS_PARSER_ALLOWANCE_BYTES + runtimeBodyAllowance; const uploadThresholdRss = uploadBaselineRss + uploadBoundedDelta; assert( - uploadBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= totalPayloadBytes * 2, - "Successful-upload RSS ceiling does not distinguish SQLite residency from whole-request buffering.", + uploadBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= totalPayloadBytes * (runtimeBodyCopies + 2), + "Successful-upload RSS ceiling cannot detect an additional whole-bundle buffer.", ); assert( uploadPeakRss <= uploadThresholdRss, @@ -728,8 +736,9 @@ async function verifyLargeBundle({ const downloadThresholdRss = downloadBaselineRss + streamingBoundedDelta; assert( - streamingBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= totalPayloadBytes, - "Download RSS ceiling does not distinguish streaming from whole-bundle buffering.", + streamingBoundedDelta + RSS_WHOLE_BUNDLE_GAP_BYTES <= + totalPayloadBytes * (runtimeBodyCopies + 1), + "Download RSS ceiling cannot detect an additional whole-bundle buffer.", ); assert( downloadPeakRss <= downloadThresholdRss, From 47d77cc9d3ba4748045aee528dcb367a7fcff80a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 24 Jul 2026 05:59:22 -0400 Subject: [PATCH 2/2] fix: address Bun runtime review findings --- packages/core/src/admin-api/router.ts | 18 +- packages/core/src/serve/http.ts | 13 +- packages/core/src/storage/database.ts | 10 +- packages/core/src/storage/installations.ts | 2 +- packages/core/src/storage/migrations.ts | 2 +- packages/core/src/storage/project-bindings.ts | 65 +++---- packages/core/src/storage/vault-grants.ts | 168 +++++++++--------- packages/core/test/admin-api-router.test.ts | 5 + 8 files changed, 161 insertions(+), 122 deletions(-) diff --git a/packages/core/src/admin-api/router.ts b/packages/core/src/admin-api/router.ts index 0a01018b..8ddce927 100644 --- a/packages/core/src/admin-api/router.ts +++ b/packages/core/src/admin-api/router.ts @@ -670,14 +670,26 @@ async function validateBundleUploadRequest( } function requestBodyReadable(body: ReadableStream): Readable { - return Readable.from(requestBodyChunks(body), { + const reader = body.getReader(); + const input = Readable.from(requestBodyChunks(reader), { highWaterMark: 64 * 1024, objectMode: false, }); + const destroy = input.destroy.bind(input); + let cancelStarted = false; + input.destroy = (error?: Error) => { + if (!input.destroyed && !input.readableEnded && !cancelStarted) { + cancelStarted = true; + void reader.cancel(error).catch(() => undefined); + } + return destroy(error); + }; + return input; } -async function* requestBodyChunks(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); +async function* requestBodyChunks( + reader: ReadableStreamDefaultReader, +): AsyncGenerator { try { while (true) { const chunk = await reader.read(); diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 086322b3..73b093af 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -143,7 +143,10 @@ type BunServerWebSocket = { type BunWebSocketHandler = { close(socket: BunServerWebSocket, code?: number, reason?: string): void; - message(socket: BunServerWebSocket, message: string | { buffer: ArrayBufferLike }): void; + message( + socket: BunServerWebSocket, + message: string | { buffer: ArrayBufferLike; byteLength: number; byteOffset: number }, + ): void; open(socket: BunServerWebSocket): void; }; @@ -179,7 +182,11 @@ const bunWebSocket: BunWebSocketHandler = { }, message(socket, message) { socket.data.events.onMessage?.( - createWSMessageEvent(typeof message === "string" ? message : message.buffer), + createWSMessageEvent( + typeof message === "string" + ? message + : message.buffer.slice(message.byteOffset, message.byteOffset + message.byteLength), + ), bunWebSocketContext(socket), ); }, @@ -3049,6 +3056,7 @@ function bunHttpServer(server: BunRuntimeServer): HttpServer { } }; const stop = (closeActiveConnections: boolean) => { + if (stopped) return; void server.stop(closeActiveConnections).then( () => finish(), (error: unknown) => finish(error), @@ -3059,6 +3067,7 @@ function bunHttpServer(server: BunRuntimeServer): HttpServer { callback = onStopped; stop(false); }, + // Escalate the pending graceful stop only after application sessions have closed. closeAllConnections() { stop(true); }, diff --git a/packages/core/src/storage/database.ts b/packages/core/src/storage/database.ts index feba5446..78279992 100644 --- a/packages/core/src/storage/database.ts +++ b/packages/core/src/storage/database.ts @@ -333,7 +333,10 @@ async function openSqliteStorage( async () => { await operationQueue.drain(); rawClient.close(); - if (ephemeralRoot) rmSync(ephemeralRoot, { recursive: true, force: true }); + if (ephemeralRoot) { + SQLITE_OPERATION_QUEUES.delete(path); + rmSync(ephemeralRoot, { recursive: true, force: true }); + } }, config, options, @@ -345,7 +348,10 @@ async function openSqliteStorage( } catch { // Preserve the startup error. } - if (ephemeralRoot) rmSync(ephemeralRoot, { recursive: true, force: true }); + if (ephemeralRoot) { + SQLITE_OPERATION_QUEUES.delete(path); + rmSync(ephemeralRoot, { recursive: true, force: true }); + } throw error; } } diff --git a/packages/core/src/storage/installations.ts b/packages/core/src/storage/installations.ts index 3a475212..01a359ab 100644 --- a/packages/core/src/storage/installations.ts +++ b/packages/core/src/storage/installations.ts @@ -169,7 +169,7 @@ export class CapletInstallationStore { async getActiveInTransaction( capletId: string, transaction: HostDatabaseTransaction, - ): Promise> { + ): Promise { return transaction.dialect === "sqlite" ? await getSqlite(transaction.db, capletId, true) : await getPostgres(transaction.db, capletId, true); diff --git a/packages/core/src/storage/migrations.ts b/packages/core/src/storage/migrations.ts index 07b2a0bc..a00f2101 100644 --- a/packages/core/src/storage/migrations.ts +++ b/packages/core/src/storage/migrations.ts @@ -105,7 +105,7 @@ async function migrateSqliteExclusively( }); await transaction.commit(); } catch (error) { - await transaction.rollback(); + await transaction.rollback().catch(() => undefined); throw error; } } diff --git a/packages/core/src/storage/project-bindings.ts b/packages/core/src/storage/project-bindings.ts index 4b5dfb9b..a371121b 100644 --- a/packages/core/src/storage/project-bindings.ts +++ b/packages/core/src/storage/project-bindings.ts @@ -333,41 +333,44 @@ async function mutateBindingSqlite( bindingId: string, transition: (current: ProjectBindingAuthoritativeView | undefined) => BindingMutation, ): Promise { - return await db.transaction(async (transaction) => { - const row = await transaction - .select() - .from(sqlite.projectBindings) - .where(eq(sqlite.projectBindings.bindingId, bindingId)) - .get(); - const current = row ? bindingView(row) : undefined; - const mutation = transition(current); - assertExpectedGeneration(current?.generation, mutation.expectedGeneration); - if (mutation.next) { - if (current) { + return await db.transaction( + async (transaction) => { + const row = await transaction + .select() + .from(sqlite.projectBindings) + .where(eq(sqlite.projectBindings.bindingId, bindingId)) + .get(); + const current = row ? bindingView(row) : undefined; + const mutation = transition(current); + assertExpectedGeneration(current?.generation, mutation.expectedGeneration); + if (mutation.next) { + if (current) { + await transaction + .update(sqlite.projectBindings) + .set(bindingRow(mutation.next)) + .where( + and( + eq(sqlite.projectBindings.bindingId, bindingId), + eq(sqlite.projectBindings.generation, current.generation), + ), + ) + .run(); + } else { + await transaction.insert(sqlite.projectBindings).values(bindingRow(mutation.next)).run(); + } + } + if (mutation.activity) { await transaction - .update(sqlite.projectBindings) - .set(bindingRow(mutation.next)) - .where( - and( - eq(sqlite.projectBindings.bindingId, bindingId), - eq(sqlite.projectBindings.generation, current.generation), - ), + .insert(sqlite.operatorActivity) + .values( + activityValues(mutation.activity, mutation.next?.updatedAt ?? new Date().toISOString()), ) .run(); - } else { - await transaction.insert(sqlite.projectBindings).values(bindingRow(mutation.next)).run(); } - } - if (mutation.activity) { - await transaction - .insert(sqlite.operatorActivity) - .values( - activityValues(mutation.activity, mutation.next?.updatedAt ?? new Date().toISOString()), - ) - .run(); - } - return mutation.value; - }); + return mutation.value; + }, + { behavior: "immediate" }, + ); } async function mutateBindingPostgres( diff --git a/packages/core/src/storage/vault-grants.ts b/packages/core/src/storage/vault-grants.ts index 7dea3ef8..0602b815 100644 --- a/packages/core/src/storage/vault-grants.ts +++ b/packages/core/src/storage/vault-grants.ts @@ -151,6 +151,7 @@ export class VaultGrantStore { if (this.database.dialect === "sqlite") { return await this.database.db.transaction( async (transaction) => await grantPreparedVaultSqlite(transaction, prepared, createdAt), + { behavior: "immediate" }, ); } return await this.database.db.transaction( @@ -791,30 +792,30 @@ async function assertLegacyGrantsMatchSqlite( grants: LegacyVaultGrantImport[], operatorId: string, ) { - return await Promise.all( - grants.map(async (grant) => { - const subject = await legacyGrantSubjectSqlite(database, grant); - const values = legacyGrantValues(subject, grant, operatorId); - const existing = await database - .select() - .from(sqlite.vaultAccessGrants) - .where( - and( - eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), - eq(sqlite.vaultAccessGrants.subjectKey, subject.key), - eq(sqlite.vaultAccessGrants.referenceName, grant.referenceName), - ), - ) - .get(); - if (existing && !legacyGrantMatches(existing, values)) { - throw new CapletsError( - "CONFIG_EXISTS", - `Vault grant for ${grant.capletId} conflicts with the legacy snapshot.`, - ); - } - return { existing, values }; - }), - ); + const rows = []; + for (const grant of grants) { + const subject = await legacyGrantSubjectSqlite(database, grant); + const values = legacyGrantValues(subject, grant, operatorId); + const existing = await database + .select() + .from(sqlite.vaultAccessGrants) + .where( + and( + eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), + eq(sqlite.vaultAccessGrants.subjectKey, subject.key), + eq(sqlite.vaultAccessGrants.referenceName, grant.referenceName), + ), + ) + .get(); + if (existing && !legacyGrantMatches(existing, values)) { + throw new CapletsError( + "CONFIG_EXISTS", + `Vault grant for ${grant.capletId} conflicts with the legacy snapshot.`, + ); + } + rows.push({ existing, values }); + } + return rows; } async function assertLegacyGrantsMatchPostgres( @@ -1089,67 +1090,70 @@ async function revokeSqlite( input: NormalizedRevokeInput, operatorId: string, ): Promise { - return await db.transaction(async (transaction) => { - const subject = input.originKind - ? await sqliteSubject(transaction, input as NormalizedGrantInput) - : undefined; - const recordKey = input.originKind - ? undefined - : await sqliteRecordKey(transaction, input.capletId, false); - const subjectMatch = subject - ? and( - eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), - eq(sqlite.vaultAccessGrants.subjectKey, subject.key), - ) - : or( - recordKey - ? and( - eq(sqlite.vaultAccessGrants.subjectKind, "record"), - eq(sqlite.vaultAccessGrants.recordKey, recordKey), - ) - : undefined, - and( - eq(sqlite.vaultAccessGrants.subjectKind, "file"), - eq(sqlite.vaultAccessGrants.capletId, input.capletId), - ), - ); - const removed = - ( - await transaction - .delete(sqlite.vaultAccessGrants) - .where( + return await db.transaction( + async (transaction) => { + const subject = input.originKind + ? await sqliteSubject(transaction, input as NormalizedGrantInput) + : undefined; + const recordKey = input.originKind + ? undefined + : await sqliteRecordKey(transaction, input.capletId, false); + const subjectMatch = subject + ? and( + eq(sqlite.vaultAccessGrants.subjectKind, subject.kind), + eq(sqlite.vaultAccessGrants.subjectKey, subject.key), + ) + : or( + recordKey + ? and( + eq(sqlite.vaultAccessGrants.subjectKind, "record"), + eq(sqlite.vaultAccessGrants.recordKey, recordKey), + ) + : undefined, and( - subjectMatch, - eq(sqlite.vaultAccessGrants.referenceName, input.referenceName), - eq(sqlite.vaultAccessGrants.vaultKey, input.vaultKey), - input.expectedResourceVersion === undefined - ? undefined - : eq(sqlite.vaultAccessGrants.resourceVersion, input.expectedResourceVersion), + eq(sqlite.vaultAccessGrants.subjectKind, "file"), + eq(sqlite.vaultAccessGrants.capletId, input.capletId), + ), + ); + const removed = + ( + await transaction + .delete(sqlite.vaultAccessGrants) + .where( + and( + subjectMatch, + eq(sqlite.vaultAccessGrants.referenceName, input.referenceName), + eq(sqlite.vaultAccessGrants.vaultKey, input.vaultKey), + input.expectedResourceVersion === undefined + ? undefined + : eq(sqlite.vaultAccessGrants.resourceVersion, input.expectedResourceVersion), + ), + ) + .run() + ).rowsAffected > 0; + if (!removed && input.expectedResourceVersion !== undefined) { + throw staleVaultGrant(input.expectedResourceVersion); + } + if (removed) { + const now = new Date().toISOString(); + await transaction + .insert(sqlite.operatorActivity) + .values( + activity( + operatorId, + "vault.revoke", + subject ?? fileSubject(input.capletId, "global-file", ""), + input.vaultKey, + input.referenceName, + now, ), ) - .run() - ).rowsAffected > 0; - if (!removed && input.expectedResourceVersion !== undefined) { - throw staleVaultGrant(input.expectedResourceVersion); - } - if (removed) { - const now = new Date().toISOString(); - await transaction - .insert(sqlite.operatorActivity) - .values( - activity( - operatorId, - "vault.revoke", - subject ?? fileSubject(input.capletId, "global-file", ""), - input.vaultKey, - input.referenceName, - now, - ), - ) - .run(); - } - return removed; - }); + .run(); + } + return removed; + }, + { behavior: "immediate" }, + ); } async function revokePostgres( diff --git a/packages/core/test/admin-api-router.test.ts b/packages/core/test/admin-api-router.test.ts index a69b1045..b43114d6 100644 --- a/packages/core/test/admin-api-router.test.ts +++ b/packages/core/test/admin-api-router.test.ts @@ -2038,12 +2038,16 @@ describe("relative Admin v2 router", () => { }); const activeLease = await admission.acquire(); let bodyPulls = 0; + let bodyCancelled = false; const body = new ReadableStream( { pull() { bodyPulls += 1; throw new Error("Capacity-rejected request body must not be read."); }, + cancel() { + bodyCancelled = true; + }, }, { highWaterMark: 0 }, ); @@ -2100,6 +2104,7 @@ describe("relative Admin v2 router", () => { type: "urn:caplets:problem:too-many-requests", }); expect(bodyPulls).toBe(0); + expect(bodyCancelled).toBe(true); expect(execute).not.toHaveBeenCalled(); } finally { await activeLease.cleanup();