diff --git a/README.md b/README.md index 0e0d34b9..99dfe67f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2849 keys - 5807+ tests / 529 files + 5807+ tests / 531 files Codecov Coverage License MIT CI Status @@ -471,7 +471,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2849 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (5807+ tests / 529 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (5807+ tests / 531 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -508,7 +508,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (5807+ tests, 529 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (5807+ tests, 531 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -667,7 +667,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-07-28):** -- **5807+ unit tests** across **529 test files** — all passing +- **5807+ unit tests** across **531 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) - i18n: **2849 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) diff --git a/features/featureCatalog.ts b/features/featureCatalog.ts index 52933b29..d78b9485 100644 --- a/features/featureCatalog.ts +++ b/features/featureCatalog.ts @@ -430,7 +430,7 @@ const RAW_FEATURE_CATALOG: CatalogEntryInput[] = [ description: 'Conditionally shows DuckDB query tab', }, ], - implementedIn: ['services/duckdb/', 'workers/duckdbWorker.ts', 'hooks/useDuckDb.ts'], + implementedIn: ['services/duckdb/', 'workers/v2/duckdb.worker.ts', 'hooks/useDuckDb.ts'], drifts: [], }, diff --git a/packages/worker-bus/src/workerBus.ts b/packages/worker-bus/src/workerBus.ts index b99bb73e..f7e4b964 100644 --- a/packages/worker-bus/src/workerBus.ts +++ b/packages/worker-bus/src/workerBus.ts @@ -32,6 +32,8 @@ export class WorkerBus { private readonly dlq: DeadLetterQueue; private readonly progress = new ProgressEmitter(); private readonly tokens = new Map(); + // QNBS-v3: [Tracks which pool each in-flight task is running on, so terminatePool() can cancel exactly the affected tasks instead of leaving their result promises hanging forever.] + private readonly taskPools = new Map(); private readonly listeners = new Set(); private running = false; private pumpScheduled = false; @@ -49,6 +51,11 @@ export class WorkerBus { this.schedulePump(); } + // QNBS-v3: [Lets callers (e.g. ensureDuckDbPool()) detect a pool removed via terminatePool() and re-register it, instead of assuming a non-null bus always has every pool.] + hasPool(poolId: string): boolean { + return this.pools.has(poolId); + } + registerPool( poolId: string, capabilities: readonly WorkerCapability[], @@ -195,6 +202,25 @@ export class WorkerBus { this.progress.clear(); } + /** + * Terminate all workers in a single named pool without shutting down the rest of the bus. + * QNBS-v3: unlike shutdown() (tears down every pool), this only affects `poolId` — added so + * duckdbClient's terminate()/shutdown() can stop the DuckDB worker without also killing + * in-flight inference/webllm/plugin work on the shared bus (docs/adr/0014-worker-generation- + * duplication.md's migration follow-up). The pool is removed from the registry; a later task + * routed to its capabilities fails with NO_POOL until the bus re-registers it. + * QNBS-v3: [Cancels every in-flight task routed to this pool before removing it — otherwise their result promises would await a RESULT message from a worker that's already been terminated and never arrives.] + */ + async terminatePool(poolId: string): Promise { + const pool = this.pools.get(poolId); + if (!pool) return; + for (const [taskId, taskPoolId] of this.taskPools) { + if (taskPoolId === poolId) this.cancel(taskId, 'Pool terminated'); + } + await pool.terminateAll(); + this.pools.delete(poolId); + } + // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- @@ -220,6 +246,19 @@ export class WorkerBus { }; } + this.taskPools.set(task.taskId, pool.poolId); + try { + return await this.runOnPool(task, token, pool); + } finally { + this.taskPools.delete(task.taskId); + } + } + + private async runOnPool( + task: WorkerTask, + token: CancellationToken, + pool: WorkerPool, + ): Promise> { const worker = await pool.acquire(token.signal); const startedAt = performance.now(); const queueTimeMs = Math.round(startedAt - task.createdAt); diff --git a/packages/worker-bus/tests/workerBus.test.ts b/packages/worker-bus/tests/workerBus.test.ts index 45f494ba..c0c83332 100644 --- a/packages/worker-bus/tests/workerBus.test.ts +++ b/packages/worker-bus/tests/workerBus.test.ts @@ -209,6 +209,103 @@ describe('WorkerBus', () => { expect(pool).toBeUndefined(); }); + it('terminatePool terminates and removes only the named pool, leaving others intact', async () => { + bus.registerPool('other', ['db.duckdb'], { + maxWorkers: 1, + minWorkers: 1, + idleTimeoutMs: 120_000, + workerScript: '/other.worker.js', + capabilities: ['db.duckdb'], + labels: {}, + }); + const pools = (bus as unknown as { pools: Map }).pools; + const terminateAllSpy = vi.spyOn(pools.get('fake')!, 'terminateAll'); + vi.spyOn(pools.get('other')!, 'acquire').mockReturnValue(new Promise(() => {})); + + await bus.terminatePool('fake'); + + expect(terminateAllSpy).toHaveBeenCalled(); + expect(pools.get('fake')).toBeUndefined(); + expect(pools.get('other')).toBeDefined(); + }); + + it('terminatePool is a no-op for an unknown pool id', async () => { + await expect(bus.terminatePool('does-not-exist')).resolves.toBeUndefined(); + }); + + it('terminatePool cancels only the in-flight tasks routed to that pool', async () => { + // QNBS-v3: [beforeEach's mocked acquire() ignores the abort signal entirely (returns a + // promise that never settles); override it here with a signal-aware implementation + // mirroring WorkerPool's real waitForIdle() so cancellation can actually be observed + // unsticking the caller, not just verified as "cancel() was called".] + const fakePool = (bus as unknown as { pools: Map }).pools.get('fake')!; + vi.spyOn(fakePool, 'acquire').mockImplementation( + (signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('Aborted')), { once: true }); + }), + ); + bus.registerPool('other', ['db.duckdb'], { + maxWorkers: 1, + minWorkers: 1, + idleTimeoutMs: 120_000, + workerScript: '/other.worker.js', + capabilities: ['db.duckdb'], + labels: {}, + }); + const otherPool = (bus as unknown as { pools: Map }).pools.get('other')!; + vi.spyOn(otherPool, 'acquire').mockReturnValue(new Promise(() => {})); + + const fakeHandle = bus.enqueue('test.task', {}, { capabilities: ['inference.text'] }); + const otherHandle = bus.enqueue('other.task', {}, { capabilities: ['db.duckdb'] }); + otherHandle.result.catch(() => {}); + + const cancelSpy = vi.spyOn(bus, 'cancel'); + await bus.terminatePool('fake'); + + expect(cancelSpy).toHaveBeenCalledTimes(1); + expect(cancelSpy).toHaveBeenCalledWith(fakeHandle.taskId, 'Pool terminated'); + // Without the fix, this task would hang forever at pool.acquire() — terminatePool() + // cancelling it lets the result promise settle instead. + await expect(fakeHandle.result).rejects.toThrow('Aborted'); + }); + + it('hasPool reports true for a registered pool and false for an unknown one', () => { + expect(bus.hasPool('fake')).toBe(true); + expect(bus.hasPool('does-not-exist')).toBe(false); + }); + + it('hasPool returns false after terminatePool removes the pool', async () => { + await bus.terminatePool('fake'); + expect(bus.hasPool('fake')).toBe(false); + }); + + it('isolates a throwing subscriber so a later subscriber still receives the same event', async () => { + // QNBS-v3: [emit() is only reached by 'circuit-breaker-open'/'backpressure-rejected' events + // (grep-verified — regular enqueue/completion never calls this.emit()); reuse the + // same circuit-breaker trigger as 'rejects when circuit breaker open' above, adding a + // throwing listener ahead of a recording one to prove the try/catch isolates it.] + const events: Array<{ kind: string }> = []; + bus.subscribe(() => { + throw new Error('listener boom'); + }); + bus.subscribe((ev: unknown) => events.push(ev as { kind: string })); + + const cb = ( + bus as unknown as { getCircuitBreaker: (t: string) => { recordFailure: () => void } } + ).getCircuitBreaker('fragile.task'); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + + const handle = bus.enqueue('fragile.task', {}); + await expect(handle.result).rejects.toThrow('Circuit breaker is open'); + + expect(events).toContainEqual( + expect.objectContaining({ kind: 'circuit-breaker-open', taskType: 'fragile.task' }), + ); + }); + it('progress callback receives emitted progress', async () => { const progressUpdates: Array<{ stage: string; progress: number }> = []; let capturedTaskId = ''; diff --git a/services/duckdb/duckdbClient.ts b/services/duckdb/duckdbClient.ts index 5751f0d4..62757b8c 100644 --- a/services/duckdb/duckdbClient.ts +++ b/services/duckdb/duckdbClient.ts @@ -1,80 +1,124 @@ -// QNBS-v3: Singleton proxy for the DuckDB-WASM worker. -// Exposes typed query/exec/init/shutdown helpers with AbortSignal cancellation. -// Worker is instantiated lazily on first call to init(). - -import type { - DuckDbRequest, - DuckDbRequestType, - DuckDbResponse, - DuckDbWorkerEvent, -} from '../../workers/duckdbWorker'; - -let worker: Worker | null = null; -const pendingResolvers = new Map void>(); -let messageIdCounter = 0; -// QNBS-v3: Settable by useDuckDb to surface OPFS fallback state in Redux. -let opfsFallbackCb: ((reason: string) => void) | null = null; +// QNBS-v3: Singleton proxy for DuckDB analytics — routes through the shared WorkerBus v2 'duckdb' +// pool (docs/adr/0014-worker-generation-duplication.md migration, superseded by +// ADR-0015). Public API is unchanged from the v1-backed client (init/query/exec/ +// shutdown/terminate/setOpfsFallbackHandler) so all 5 consumers (useDuckDb.ts, +// duckdbAnalytics.ts, duckdbMigration.ts, ragVectorMigration.ts, telemetryService.ts) +// need no changes — only the transport underneath swapped. + +import { ensureDuckDbPool, getWorkerBus } from '../workerBusManager'; + +export type DuckDbRequestType = 'INIT' | 'QUERY' | 'EXEC' | 'SHUTDOWN'; + +export interface DuckDbResponse { + messageId: string; + ok: boolean; + rows?: Record[]; + error?: string; + latencyMs?: number; +} + +const TASK_TYPE: Record = { + INIT: 'db.duckdb.init', + QUERY: 'db.duckdb.query', + EXEC: 'db.duckdb.exec', + SHUTDOWN: 'db.duckdb.shutdown', +}; +let messageIdCounter = 0; function generateMessageId(): string { return `duckdb-${Date.now()}-${++messageIdCounter}`; } -function getWorker(): Worker { - if (!worker) { - // QNBS-v3: Vite import.meta.url pattern — same as inference.worker.ts. - worker = new Worker(new URL('../../workers/duckdbWorker.ts', import.meta.url), { - type: 'module', - }); - worker.addEventListener('message', (event: MessageEvent) => { - const data = event.data as DuckDbResponse | DuckDbWorkerEvent; - // QNBS-v3: OPFS_FALLBACK is out-of-band — no messageId resolver to call. - if ((data as DuckDbWorkerEvent).type === 'OPFS_FALLBACK') { - opfsFallbackCb?.((data as DuckDbWorkerEvent).reason); - return; - } - const { messageId } = data as DuckDbResponse; - const resolve = pendingResolvers.get(messageId); - if (resolve) { - pendingResolvers.delete(messageId); - resolve(data as DuckDbResponse); - } - }); - worker.addEventListener('error', (event) => { - // Reject all pending promises on fatal worker error - const error = event.message ?? 'DuckDB worker crashed'; - for (const [id, resolve] of pendingResolvers) { - resolve({ messageId: id, ok: false, error }); - } - pendingResolvers.clear(); - worker = null; - }); - } - return worker; +// QNBS-v3: Settable by useDuckDb to surface OPFS fallback state in Redux. +let opfsFallbackCb: ((reason: string) => void) | null = null; + +function errorMessage(err: unknown): string { + const code = (err as { code?: string } | undefined)?.code; + if (code === 'CIRCUIT_OPEN') return 'DuckDB temporarily unavailable (circuit open)'; + return err instanceof Error ? err.message : String(err); } -function send( +async function send( type: DuckDbRequestType, sql?: string, params?: readonly unknown[], signal?: AbortSignal, + isReinitRetry = false, ): Promise { const messageId = generateMessageId(); - const w = getWorker(); + const start = Date.now(); - return new Promise((resolve) => { - pendingResolvers.set(messageId, resolve); + // QNBS-v3: attach synchronously, before the `await` below — v1 set up its abort listener + // inside a synchronous Promise executor, so a caller aborting in the same tick as the + // call was always caught. This function is async and awaits ensureDuckDbPool() before + // a handle exists, which opens a gap; a same-tick abort must not be lost inside it. + let abortedEarly = signal?.aborted ?? false; + let handleRef: { cancel: (reason?: string) => void } | undefined; + const onAbort = (): void => { + abortedEarly = true; + handleRef?.cancel('Aborted'); + }; + signal?.addEventListener('abort', onAbort, { once: true }); - if (signal) { - signal.addEventListener('abort', () => { - w.postMessage({ type: 'WORKER_CANCEL', messageId }); - pendingResolvers.delete(messageId); - resolve({ messageId, ok: false, error: 'Aborted' }); - }); - } + try { + const bus = await ensureDuckDbPool(); + if (!bus) return { messageId, ok: false, error: 'WorkerBus v2 unavailable' }; - const req: DuckDbRequest = { messageId, type, sql, params }; - w.postMessage(req); - }); + // QNBS-v3: v1 never retried a failed QUERY/EXEC (caller decided) — disabling the bus's + // default 2-retry here keeps that behavior instead of silently stacking under + // duckdbAnalytics.ts's own withDuckDbRetry() app-level retry + // (app/listenerMiddleware.ts). + const handle = bus.enqueue< + { sql?: string | undefined; params?: readonly unknown[] | undefined }, + unknown + >( + TASK_TYPE[type], + { sql, params }, + { + capabilities: ['db.duckdb'], + retryPolicy: { maxRetries: 0 }, + onProgress: (p) => { + if (p.stage === 'opfs-fallback') opfsFallbackCb?.(p.message ?? 'OPFS unavailable'); + }, + }, + ); + handleRef = handle; + if (abortedEarly) handle.cancel('Aborted'); + + try { + const raw = await handle.result; + const latencyMs = Date.now() - start; + if (type === 'QUERY') { + return { messageId, ok: true, rows: raw as Record[], latencyMs }; + } + return { messageId, ok: true, latencyMs }; + } catch (err) { + const latencyMs = Date.now() - start; + if (signal?.aborted) return { messageId, ok: false, error: 'Aborted', latencyMs }; + const message = errorMessage(err); + // QNBS-v3: [The duckdb pool's worker can idle-terminate (minWorkers:0) or crash-restart; a + // freshly spawned worker's module state has no `connection` until INIT runs again. + // Transparently re-init once and retry, instead of surfacing a confusing "not + // initialized" error for what looks like a routine idle-timeout respawn.] + if ( + !isReinitRetry && + type !== 'INIT' && + type !== 'SHUTDOWN' && + message === 'DuckDB not initialized' + ) { + const reinit = await send('INIT', undefined, undefined, signal, true); + if (reinit.ok) { + return send(type, sql, params, signal, true); + } + } + return { messageId, ok: false, error: message, latencyMs }; + } + } finally { + // QNBS-v3: [{once:true} only self-removes once the listener actually fires — a caller that + // reuses one AbortSignal across many queries (a common hook/thunk pattern) would + // otherwise accumulate one listener per call for the signal's whole lifetime.] + signal?.removeEventListener('abort', onAbort); + } } export const duckdbClient = { @@ -98,14 +142,17 @@ export const duckdbClient = { return send('SHUTDOWN', undefined, undefined, signal); }, - /** Terminate the worker immediately (no flush). */ + /** + * Terminate the DuckDB pool immediately (no flush). + * QNBS-v3: scoped via WorkerBus.terminatePool('duckdb') so this doesn't tear down the shared + * bus's other pools (inference/webllm/plugin) — bus.shutdown() would kill all of them. + */ terminate(): void { - worker?.terminate(); - worker = null; - pendingResolvers.clear(); + const bus = getWorkerBus(); + void bus?.terminatePool('duckdb'); }, - /** Register a callback invoked when the worker falls back to in-memory (OPFS unavailable). */ + /** Register a callback invoked when DuckDB falls back to in-memory (OPFS unavailable). */ setOpfsFallbackHandler(cb: ((reason: string) => void) | null): void { opfsFallbackCb = cb; }, diff --git a/services/workerBusManager.ts b/services/workerBusManager.ts index eccf3f18..a8004663 100644 --- a/services/workerBusManager.ts +++ b/services/workerBusManager.ts @@ -46,6 +46,27 @@ export async function initWorkerBus(): Promise { } } +// QNBS-v3: [Shared with reRegisterDuckDbPool() below so a pool removed via terminatePool() gets re-registered with identical options, not a drifted copy.] +async function duckDbPoolOptions() { + const { WORKER_IDLE_TIMEOUT_MS } = await import('@domain/worker-bus'); + return { + // QNBS-v3: DuckDB is single-writer — minWorkers 0 keeps the thread alive only while in use. + maxWorkers: 1, + minWorkers: 0, + idleTimeoutMs: WORKER_IDLE_TIMEOUT_MS, + workerScript: new URL('../workers/v2/duckdb.worker.ts', import.meta.url).href, + capabilities: ['db.duckdb'] as const, + labels: { pool: 'duckdb', version: 'v2' }, + }; +} + +/** Re-register the 'duckdb' pool if it was removed via terminatePool() — a no-op if it's already present. */ +async function reRegisterDuckDbPool(bus: WorkerBus): Promise { + if (bus.hasPool('duckdb')) return; + const options = await duckDbPoolOptions(); + bus.registerPool('duckdb', options.capabilities, options); +} + async function doInitWorkerBus(): Promise { try { const { @@ -78,7 +99,6 @@ async function doInitWorkerBus(): Promise { // QNBS-v3: new URL(path, import.meta.url) lets Vite emit the worker script as a proper // asset URL. The .ts extension is allowed — Vite transforms it during build. const inferenceUrl = new URL('../workers/v2/inference.worker.ts', import.meta.url).href; - const duckdbUrl = new URL('../workers/v2/duckdb.worker.ts', import.meta.url).href; // QNBS-v3: P1-1 — dedicated WebLLM (WebGPU) worker. Separate pool keeps @mlc-ai/web-llm out // of the transformers.js worker bundle and isolates the GPU lifecycle. const webllmUrl = new URL('../workers/v2/webllm.worker.ts', import.meta.url).href; @@ -96,18 +116,11 @@ async function doInitWorkerBus(): Promise { }, }); + const duckdbOptions = await duckDbPoolOptions(); registry.register({ poolId: 'duckdb', - // QNBS-v3: DuckDB is single-writer — minWorkers 0 keeps the thread alive only while in use. - capabilities: ['db.duckdb'], - options: { - maxWorkers: 1, - minWorkers: 0, - idleTimeoutMs: WORKER_IDLE_TIMEOUT_MS, - workerScript: duckdbUrl, - capabilities: ['db.duckdb'], - labels: { pool: 'duckdb', version: 'v2' }, - }, + capabilities: duckdbOptions.capabilities, + options: duckdbOptions, }); // QNBS-v3: P1-1 — WebLLM pool. maxWorkers:1 (single heavy GPU consumer; tab-leader election @@ -164,6 +177,23 @@ export async function ensureWebLlmPool(): Promise { return _bus; } +/** + * Ensure the DuckDB worker pool is available, initializing the WorkerBus on demand. + * QNBS-v3: mirrors ensureWebLlmPool()'s decoupling from `enableWorkerBusV2` — DuckDB analytics + * defaults on and was never gated by that flag in the v1 worker it replaces + * (docs/adr/0014-worker-generation-duplication.md), so toggling an experimental infra flag off + * must not silently break analytics. Returns null only if init failed. + */ +export async function ensureDuckDbPool(): Promise { + if (_bus === null) { + await initWorkerBus(); + return _bus; + } + // QNBS-v3: [terminatePool('duckdb') can remove the pool while the bus itself stays alive — re-register it here instead of assuming a non-null bus always has every pool.] + await reRegisterDuckDbPool(_bus); + return _bus; +} + /** * Shut down the WorkerBus v2 and terminate all worker threads. * Called when the feature flag is disabled or the app unmounts. diff --git a/tests/unit/duckdbClient.test.ts b/tests/unit/duckdbClient.test.ts index ee1726b4..34392674 100644 --- a/tests/unit/duckdbClient.test.ts +++ b/tests/unit/duckdbClient.test.ts @@ -1,130 +1,370 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// QNBS-v3: duckdbClient instantiates a Worker via new URL(…, import.meta.url). -// We mock the Worker constructor so no real worker is spun up in jsdom. +// QNBS-v3: duckdbClient now routes through WorkerBus v2 (workers/v2/duckdb.worker.ts) instead of +// spawning a raw Worker directly — mock services/workerBusManager's ensureDuckDbPool()/ +// getWorkerBus() instead of the global Worker constructor. -const mockPostMessage = vi.fn(); -const mockTerminate = vi.fn(); -const mockAddEventListener = vi.fn(); +const { mockEnqueue, mockTerminatePool, mockEnsureDuckDbPool, mockGetWorkerBus } = vi.hoisted( + () => ({ + mockEnqueue: vi.fn(), + mockTerminatePool: vi.fn(), + mockEnsureDuckDbPool: vi.fn(), + mockGetWorkerBus: vi.fn(), + }), +); -class MockWorker { - postMessage = mockPostMessage; - terminate = mockTerminate; - addEventListener = mockAddEventListener; +vi.mock('../../services/workerBusManager', () => ({ + ensureDuckDbPool: mockEnsureDuckDbPool, + getWorkerBus: mockGetWorkerBus, +})); + +const { duckdbClient } = await import('../../services/duckdb/duckdbClient'); + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; } -vi.stubGlobal('Worker', MockWorker); +function makeHandle(result: Promise, cancel: ReturnType = vi.fn()) { + return { taskId: 't1', result, progress: (async function* () {})(), cancel }; +} -// Re-import after stubbing so the module captures MockWorker -const { duckdbClient } = await import('../../services/duckdb/duckdbClient'); +function makeBus() { + return { enqueue: mockEnqueue, terminatePool: mockTerminatePool }; +} beforeEach(() => { vi.clearAllMocks(); - // Reset internal worker state between tests - duckdbClient.terminate(); + mockEnsureDuckDbPool.mockResolvedValue(makeBus()); + mockGetWorkerBus.mockReturnValue(makeBus()); }); describe('duckdbClient.init', () => { - it('posts an INIT message with a messageId', async () => { - const promise = duckdbClient.init(); + it('enqueues db.duckdb.init with the db.duckdb capability and returns {ok:true}', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve({ ok: true }))); + + const res = await duckdbClient.init(); - // Simulate worker responding synchronously via the message event listener - const listenerCall = mockAddEventListener.mock.calls.find(([ev]) => ev === 'message'); - expect(listenerCall).toBeDefined(); - const onMessage = listenerCall![1] as (e: MessageEvent) => void; + expect(mockEnqueue).toHaveBeenCalledWith( + 'db.duckdb.init', + expect.anything(), + expect.objectContaining({ capabilities: ['db.duckdb'] }), + ); + expect(res.ok).toBe(true); + }); - // Retrieve the messageId from the postMessage call - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string; type: string }; - expect(req.type).toBe('INIT'); + it('returns {ok:false} without enqueuing when the pool is unavailable', async () => { + mockEnsureDuckDbPool.mockResolvedValue(null); - onMessage({ data: { messageId: req.messageId, ok: true } } as MessageEvent); - const res = await promise; + const res = await duckdbClient.init(); - expect(res.ok).toBe(true); - expect(res.messageId).toBe(req.messageId); + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'WorkerBus v2 unavailable' })); + expect(mockEnqueue).not.toHaveBeenCalled(); }); }); describe('duckdbClient.query', () => { - it('posts a QUERY message and returns rows', async () => { - const promise = duckdbClient.query('SELECT 1 AS n'); + it('enqueues db.duckdb.query and wraps the raw row array as {ok:true, rows}', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve([{ n: 1 }]))); - const listenerCall = mockAddEventListener.mock.calls.find(([ev]) => ev === 'message'); - const onMessage = listenerCall![1] as (e: MessageEvent) => void; - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string; type: string }; + const res = await duckdbClient.query('SELECT 1 AS n'); - expect(req.type).toBe('QUERY'); + const [taskType, payload] = mockEnqueue.mock.calls[0] as [string, unknown]; + expect(taskType).toBe('db.duckdb.query'); + expect(payload).toEqual({ sql: 'SELECT 1 AS n', params: undefined }); + expect(res).toEqual(expect.objectContaining({ ok: true, rows: [{ n: 1 }] })); + }); - onMessage({ - data: { messageId: req.messageId, ok: true, rows: [{ n: 1 }] }, - } as MessageEvent); + it('returns {ok:false, error} (not a thrown exception) when the task rejects', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(new Error('bad sql')))); - const res = await promise; - expect(res.ok).toBe(true); - expect(res.rows).toEqual([{ n: 1 }]); + const res = await duckdbClient.query('garbage'); + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'bad sql' })); + }); + + it('maps a CIRCUIT_OPEN error code to a friendly message', async () => { + const err = Object.assign(new Error('circuit open'), { code: 'CIRCUIT_OPEN' }); + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(err))); + + const res = await duckdbClient.query('SELECT 1'); + + expect(res).toEqual( + expect.objectContaining({ + ok: false, + error: 'DuckDB temporarily unavailable (circuit open)', + }), + ); + }); + + it('stringifies a rejection that is not an Error instance', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject('plain string rejection'))); + + const res = await duckdbClient.query('SELECT 1'); + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'plain string rejection' })); + }); + + it('disables WorkerBus-level retries (retryPolicy maxRetries:0) to avoid stacking under withDuckDbRetry', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve([]))); + + await duckdbClient.query('SELECT 1'); + + expect(mockEnqueue).toHaveBeenCalledWith( + 'db.duckdb.query', + expect.anything(), + expect.objectContaining({ retryPolicy: { maxRetries: 0 } }), + ); + }); + + describe('auto-reinit on "DuckDB not initialized"', () => { + // QNBS-v3: [Regression coverage for the idle-respawn/crash-restart gap — a fresh duckdb pool + // worker has no `connection` until INIT runs again on it.] + it('re-inits once and retries the original query, then succeeds', async () => { + let queryCallCount = 0; + mockEnqueue.mockImplementation((taskType: string) => { + if (taskType === 'db.duckdb.query') { + queryCallCount++; + if (queryCallCount === 1) { + return makeHandle(Promise.reject(new Error('DuckDB not initialized'))); + } + return makeHandle(Promise.resolve([{ n: 1 }])); + } + if (taskType === 'db.duckdb.init') { + return makeHandle(Promise.resolve({ ok: true })); + } + throw new Error(`unexpected taskType ${taskType}`); + }); + + const res = await duckdbClient.query('SELECT 1 AS n'); + + expect(res).toEqual(expect.objectContaining({ ok: true, rows: [{ n: 1 }] })); + expect(queryCallCount).toBe(2); + const initCalls = mockEnqueue.mock.calls.filter(([t]) => t === 'db.duckdb.init'); + expect(initCalls).toHaveLength(1); + }); + + it('attempts exactly one reinit — does not loop when the retry also fails', async () => { + mockEnqueue.mockImplementation((taskType: string) => { + if (taskType === 'db.duckdb.query') { + return makeHandle(Promise.reject(new Error('DuckDB not initialized'))); + } + if (taskType === 'db.duckdb.init') { + return makeHandle(Promise.resolve({ ok: true })); + } + throw new Error(`unexpected taskType ${taskType}`); + }); + + const res = await duckdbClient.query('SELECT 1'); + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'DuckDB not initialized' })); + const initCalls = mockEnqueue.mock.calls.filter(([t]) => t === 'db.duckdb.init'); + expect(initCalls).toHaveLength(1); + }); + + it('does not retry the original query when the reinit INIT task itself rejects', async () => { + // QNBS-v3: [reinit.ok is only false when send('INIT', ...) itself hits the catch branch — + // i.e. the INIT task's handle.result *rejects*. Distinct from the 'INIT enqueue + // resolves but with an app-level failure shape' case, which send() doesn't model: + // any resolved handle.result is always treated as {ok:true}.] + mockEnqueue.mockImplementation((taskType: string) => { + if (taskType === 'db.duckdb.query') { + return makeHandle(Promise.reject(new Error('DuckDB not initialized'))); + } + if (taskType === 'db.duckdb.init') { + return makeHandle(Promise.reject(new Error('worker spawn failed'))); + } + throw new Error(`unexpected taskType ${taskType}`); + }); + + const res = await duckdbClient.query('SELECT 1'); + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'DuckDB not initialized' })); + const queryCalls = mockEnqueue.mock.calls.filter(([t]) => t === 'db.duckdb.query'); + expect(queryCalls).toHaveLength(1); + }); + + it('does not reinit-retry unrelated error messages', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(new Error('syntax error')))); + + const res = await duckdbClient.query('garbage'); + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'syntax error' })); + const initCalls = mockEnqueue.mock.calls.filter(([t]) => t === 'db.duckdb.init'); + expect(initCalls).toHaveLength(0); + }); + + it('does not recursively reinit when INIT itself fails with "DuckDB not initialized"', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(new Error('DuckDB not initialized')))); + + await duckdbClient.init(); + + const initCalls = mockEnqueue.mock.calls.filter(([t]) => t === 'db.duckdb.init'); + expect(initCalls).toHaveLength(1); + }); }); }); describe('duckdbClient.exec', () => { - it('posts an EXEC message', async () => { - const promise = duckdbClient.exec('CREATE TABLE t (id INTEGER)'); + it('enqueues db.duckdb.exec and returns {ok:true}', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve({ ok: true }))); - const listenerCall = mockAddEventListener.mock.calls.find(([ev]) => ev === 'message'); - const onMessage = listenerCall![1] as (e: MessageEvent) => void; - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string; type: string }; + const res = await duckdbClient.exec('CREATE TABLE t (id INTEGER)'); - expect(req.type).toBe('EXEC'); - - onMessage({ data: { messageId: req.messageId, ok: true } } as MessageEvent); - const res = await promise; + expect((mockEnqueue.mock.calls[0] as [string])[0]).toBe('db.duckdb.exec'); expect(res.ok).toBe(true); }); }); describe('duckdbClient.shutdown', () => { - it('posts a SHUTDOWN message', async () => { - const promise = duckdbClient.shutdown(); - - const listenerCall = mockAddEventListener.mock.calls.find(([ev]) => ev === 'message'); - const onMessage = listenerCall![1] as (e: MessageEvent) => void; - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string; type: string }; + it('enqueues db.duckdb.shutdown', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve({ ok: true }))); - expect(req.type).toBe('SHUTDOWN'); + const res = await duckdbClient.shutdown(); - onMessage({ data: { messageId: req.messageId, ok: true } } as MessageEvent); - const res = await promise; + expect((mockEnqueue.mock.calls[0] as [string])[0]).toBe('db.duckdb.shutdown'); expect(res.ok).toBe(true); }); }); describe('duckdbClient.terminate', () => { - it('calls worker.terminate()', async () => { - // Ensure the worker is created by making a call first - const promise = duckdbClient.query('SELECT 1'); - const listenerCall = mockAddEventListener.mock.calls.find(([ev]) => ev === 'message'); - const onMessage = listenerCall![1] as (e: MessageEvent) => void; - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string }; - onMessage({ data: { messageId: req.messageId, ok: true, rows: [] } } as MessageEvent); - await promise; - + it('terminates only the "duckdb" pool, not the whole shared bus', () => { duckdbClient.terminate(); - expect(mockTerminate).toHaveBeenCalled(); + + expect(mockTerminatePool).toHaveBeenCalledWith('duckdb'); + }); + + it('is a no-op when the bus was never initialized', () => { + mockGetWorkerBus.mockReturnValue(null); + + expect(() => duckdbClient.terminate()).not.toThrow(); + expect(mockTerminatePool).not.toHaveBeenCalled(); }); }); describe('duckdbClient abort', () => { - it('posts WORKER_CANCEL when AbortSignal fires', async () => { + it('cancels the task handle when the AbortSignal fires mid-flight', async () => { + const cancel = vi.fn(); + const { promise: resultPromise, reject } = deferred(); + mockEnqueue.mockReturnValue(makeHandle(resultPromise, cancel)); + const controller = new AbortController(); - void duckdbClient.query('SELECT 1', undefined, controller.signal); + const resPromise = duckdbClient.query('SELECT 1', undefined, controller.signal); + controller.abort(); + + // QNBS-v3: cancel() fires after the async ensureDuckDbPool()/enqueue() chain settles, not + // synchronously — unlike v1's Promise-executor version. waitFor tolerates that gap + // without hardcoding a specific number of microtask ticks. + await vi.waitFor(() => expect(cancel).toHaveBeenCalledWith('Aborted')); - const req = mockPostMessage.mock.calls[0]?.[0] as { messageId: string }; + reject(Object.assign(new Error('Task was cancelled'), { code: 'CANCELLED' })); + const res = await resPromise; + + expect(res).toEqual(expect.objectContaining({ ok: false, error: 'Aborted' })); + }); + + it('registers the abort listener synchronously, so an already-fired-before-enqueue abort is not lost', async () => { + const cancel = vi.fn(); + const { promise: resultPromise } = deferred(); + mockEnqueue.mockReturnValue(makeHandle(resultPromise, cancel)); + + const controller = new AbortController(); controller.abort(); + void duckdbClient.query('SELECT 1', undefined, controller.signal); - // The cancel message should be posted after abort - const cancelMsg = mockPostMessage.mock.calls.find( - ([m]) => (m as { type: string }).type === 'WORKER_CANCEL', + await vi.waitFor(() => expect(cancel).toHaveBeenCalledWith('Aborted')); + }); + + it('does not miss an abort fired in the same tick as the call, before ensureDuckDbPool resolves', async () => { + const cancel = vi.fn(); + const { promise: resultPromise } = deferred(); + mockEnqueue.mockReturnValue(makeHandle(resultPromise, cancel)); + // QNBS-v3: regression guard for the real timing bug this PR fixed — the abort listener must + // be attached before the `await ensureDuckDbPool()` inside send() yields, so an + // abort fired synchronously right after the call (same tick, no microtask flush + // in between) still gets caught once the handle exists. + let resolveEnsurePool!: (bus: ReturnType) => void; + mockEnsureDuckDbPool.mockReturnValue( + new Promise((res) => { + resolveEnsurePool = res; + }), ); - expect(cancelMsg).toBeDefined(); - expect((cancelMsg![0] as { messageId: string }).messageId).toBe(req.messageId); + + const controller = new AbortController(); + void duckdbClient.query('SELECT 1', undefined, controller.signal); + controller.abort(); + resolveEnsurePool(makeBus()); + + await vi.waitFor(() => expect(cancel).toHaveBeenCalledWith('Aborted')); + }); + + it('detaches its abort listener once the call settles, so a reused signal does not accumulate listeners', async () => { + // QNBS-v3: [regression guard — {once:true} only self-removes when the signal actually + // fires; a caller reusing one AbortSignal across many non-aborted queries must not + // leak one listener per call for the signal's whole lifetime.] + mockEnqueue.mockReturnValue(makeHandle(Promise.resolve([{ n: 1 }]))); + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + await duckdbClient.query('SELECT 1', undefined, controller.signal); + + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); + +describe('duckdbClient OPFS fallback', () => { + // QNBS-v3: afterEach (not end-of-test) so the handler resets even if an assertion throws first + afterEach(() => { + duckdbClient.setOpfsFallbackHandler(null); + }); + + it('forwards the opfs-fallback progress stage to setOpfsFallbackHandler', async () => { + let capturedOnProgress: ((p: { stage: string; message?: string }) => void) | undefined; + mockEnqueue.mockImplementation((_type, _payload, opts) => { + capturedOnProgress = opts.onProgress; + return makeHandle(Promise.resolve({ ok: true })); + }); + const onFallback = vi.fn(); + duckdbClient.setOpfsFallbackHandler(onFallback); + + await duckdbClient.init(); + capturedOnProgress?.({ stage: 'opfs-fallback', message: 'OPFS unavailable in private mode' }); + + expect(onFallback).toHaveBeenCalledWith('OPFS unavailable in private mode'); + }); + + it('defaults to a generic message when the opfs-fallback progress event omits one', async () => { + let capturedOnProgress: ((p: { stage: string; message?: string }) => void) | undefined; + mockEnqueue.mockImplementation((_type, _payload, opts) => { + capturedOnProgress = opts.onProgress; + return makeHandle(Promise.resolve({ ok: true })); + }); + const onFallback = vi.fn(); + duckdbClient.setOpfsFallbackHandler(onFallback); + + await duckdbClient.init(); + capturedOnProgress?.({ stage: 'opfs-fallback' }); + + expect(onFallback).toHaveBeenCalledWith('OPFS unavailable'); + }); + + it('ignores non-opfs-fallback progress stages', async () => { + let capturedOnProgress: ((p: { stage: string; message?: string }) => void) | undefined; + mockEnqueue.mockImplementation((_type, _payload, opts) => { + capturedOnProgress = opts.onProgress; + return makeHandle(Promise.resolve({ ok: true })); + }); + const onFallback = vi.fn(); + duckdbClient.setOpfsFallbackHandler(onFallback); + + await duckdbClient.init(); + capturedOnProgress?.({ stage: 'loading', message: 'irrelevant' }); + + expect(onFallback).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/duckdbWorkerHandler.test.ts b/tests/unit/duckdbWorkerHandler.test.ts index 5ebe5fa3..18f8db18 100644 --- a/tests/unit/duckdbWorkerHandler.test.ts +++ b/tests/unit/duckdbWorkerHandler.test.ts @@ -145,6 +145,24 @@ describe('duckdb.worker handlers', () => { expect(mockConnect).toHaveBeenCalledTimes(2); }); + it('still falls back and emits the original OPFS error when close() itself rejects', async () => { + const opfsClose = vi.fn().mockRejectedValue(new Error('close also failed')); + const opfsQuery = vi.fn().mockRejectedValue(new Error('ATTACH failed')); + mockConnect + .mockResolvedValueOnce({ query: opfsQuery, close: opfsClose }) + .mockResolvedValueOnce(mockConnection()); + mockRegisterFileHandle.mockResolvedValue(undefined); + stubOpfsDirectory(vi.fn().mockResolvedValue({})); + const emitProgress = vi.fn(); + + await expect(initDuckDb(emitProgress)).resolves.toBeUndefined(); + + expect(opfsClose).toHaveBeenCalled(); + // The original ATTACH error reaches emitProgress, not the close() failure. + expect(emitProgress).toHaveBeenCalledWith('opfs-fallback', 1, 'ATTACH failed'); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + it('does not emit opfs-fallback when OPFS is simply unsupported (no navigator.storage)', async () => { mockConnect.mockResolvedValue(mockConnection()); stubNoOpfs(); @@ -198,6 +216,67 @@ describe('duckdb.worker handlers', () => { 'bad sql', ); }); + + it('binds params via a prepared statement instead of dropping them', async () => { + const row = { toJSON: () => ({ id: 1 }) }; + const stmtQuery = vi.fn().mockResolvedValue({ toArray: () => [row] }); + const stmtClose = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ query: stmtQuery, close: stmtClose }); + const rawQuery = vi.fn(); + mockConnect.mockResolvedValue({ + query: rawQuery, + close: vi.fn().mockResolvedValue(undefined), + prepare, + }); + stubNoOpfs(); + await initDuckDb(); + + const result = await handleQuery( + makeCtx({ payload: { sql: 'select * from t where id = ?', params: [1] } }), + ); + + expect(prepare).toHaveBeenCalledWith('select * from t where id = ?'); + expect(stmtQuery).toHaveBeenCalledWith(1); + expect(stmtClose).toHaveBeenCalled(); + expect(rawQuery).not.toHaveBeenCalled(); + expect(result).toEqual([{ id: 1 }]); + }); + + it('closes the prepared statement even when the bound query rejects', async () => { + const stmtQuery = vi.fn().mockRejectedValue(new Error('bind failed')); + const stmtClose = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ query: stmtQuery, close: stmtClose }); + mockConnect.mockResolvedValue({ + query: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + prepare, + }); + stubNoOpfs(); + await initDuckDb(); + + await expect( + handleQuery(makeCtx({ payload: { sql: 'select ?', params: ['x'] } })), + ).rejects.toThrow('bind failed'); + expect(stmtClose).toHaveBeenCalled(); + }); + + it('does not use a prepared statement when params is an empty array', async () => { + const row = { toJSON: () => ({ id: 1 }) }; + const rawQuery = vi.fn().mockResolvedValue({ toArray: () => [row] }); + const prepare = vi.fn(); + mockConnect.mockResolvedValue({ + query: rawQuery, + close: vi.fn().mockResolvedValue(undefined), + prepare, + }); + stubNoOpfs(); + await initDuckDb(); + + await handleQuery(makeCtx({ payload: { sql: 'select 1', params: [] } })); + + expect(prepare).not.toHaveBeenCalled(); + expect(rawQuery).toHaveBeenCalledWith('select 1'); + }); }); describe('handleExec', () => { @@ -218,6 +297,35 @@ describe('duckdb.worker handlers', () => { expect(result).toEqual({ ok: true }); expect(connectionQuery).toHaveBeenCalledWith('create table t (id int)'); }); + + it('binds params via a prepared statement instead of dropping them', async () => { + const stmtQuery = vi.fn().mockResolvedValue(undefined); + const stmtClose = vi.fn().mockResolvedValue(undefined); + const prepare = vi.fn().mockResolvedValue({ query: stmtQuery, close: stmtClose }); + const rawQuery = vi.fn(); + mockConnect.mockResolvedValue({ + query: rawQuery, + close: vi.fn().mockResolvedValue(undefined), + prepare, + }); + stubNoOpfs(); + await initDuckDb(); + + const result = await handleExec( + makeCtx({ + payload: { + sql: 'insert into ai_telemetry values (?, ?, ?, ?, ?)', + params: ['task', 'backend', 'model', 12, true], + }, + }), + ); + + expect(prepare).toHaveBeenCalledWith('insert into ai_telemetry values (?, ?, ?, ?, ?)'); + expect(stmtQuery).toHaveBeenCalledWith('task', 'backend', 'model', 12, true); + expect(stmtClose).toHaveBeenCalled(); + expect(rawQuery).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true }); + }); }); describe('handleShutdown', () => { diff --git a/tests/unit/inferenceWorkerHandlerV2.test.ts b/tests/unit/inferenceWorkerHandlerV2.test.ts index 07f16091..575c34ac 100644 --- a/tests/unit/inferenceWorkerHandlerV2.test.ts +++ b/tests/unit/inferenceWorkerHandlerV2.test.ts @@ -26,7 +26,8 @@ function makeCtx(overrides: Partial = {}): WorkerHandlerCo describe('inference.worker (v2) handleInference', () => { beforeEach(() => { - vi.clearAllMocks(); + // QNBS-v3: resetAllMocks (not clearAllMocks) clears mock implementations too, preventing state leakage between tests + vi.resetAllMocks(); }); afterEach(() => { diff --git a/tests/unit/workerBusManager.test.ts b/tests/unit/workerBusManager.test.ts index 20cf41a7..151f3717 100644 --- a/tests/unit/workerBusManager.test.ts +++ b/tests/unit/workerBusManager.test.ts @@ -4,10 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // QNBS-v3: vi.hoisted ensures these references are available inside the vi.mock factory // which is hoisted before all imports. -const { mockShutdown, mockRegisterPool, mockInstall, MockWorkBus, MockRegistry } = vi.hoisted( - () => { +const { mockShutdown, mockRegisterPool, mockHasPool, mockInstall, MockWorkBus, MockRegistry } = + vi.hoisted(() => { const mockShutdown = vi.fn().mockResolvedValue(undefined); const mockRegisterPool = vi.fn(); + // QNBS-v3: [Defaults to true (pool present) so existing tests that don't care about + // re-registration behavior are unaffected; the dedicated ensureDuckDbPool tests + // override this per-case.] + const mockHasPool = vi.fn(() => true); const mockInstall = vi.fn(); // QNBS-v3: Regular function (not arrow) so new MockWorkBus() works as a constructor. @@ -15,6 +19,7 @@ const { mockShutdown, mockRegisterPool, mockInstall, MockWorkBus, MockRegistry } const MockWorkBus = vi.fn(function (this: Record) { this['shutdown'] = mockShutdown; this['registerPool'] = mockRegisterPool; + this['hasPool'] = mockHasPool; this['enqueue'] = vi.fn(); this['cancel'] = vi.fn(() => true); this['getTelemetry'] = vi.fn(() => ({ @@ -39,9 +44,8 @@ const { mockShutdown, mockRegisterPool, mockInstall, MockWorkBus, MockRegistry } this['install'] = mockInstall; }); - return { mockShutdown, mockRegisterPool, mockInstall, MockWorkBus, MockRegistry }; - }, -); + return { mockShutdown, mockRegisterPool, mockHasPool, mockInstall, MockWorkBus, MockRegistry }; + }); vi.mock('@domain/worker-bus', () => ({ WorkerBus: MockWorkBus, @@ -69,6 +73,8 @@ describe('workerBusManager', () => { MockRegistry.mockClear(); mockShutdown.mockClear(); mockRegisterPool.mockClear(); + mockHasPool.mockClear(); + mockHasPool.mockReturnValue(true); mockInstall.mockClear(); }); @@ -143,4 +149,45 @@ describe('workerBusManager', () => { await initWorkerBus(); expect(getLegacyAdapter()).not.toBeNull(); }); + + describe('ensureDuckDbPool', () => { + it('initializes the bus when not yet running', async () => { + const { ensureDuckDbPool, isWorkerBusReady } = await import( + '../../services/workerBusManager' + ); + const bus = await ensureDuckDbPool(); + expect(bus).not.toBeNull(); + expect(isWorkerBusReady()).toBe(true); + }); + + it('does not re-register the pool when it is already present', async () => { + const { initWorkerBus, ensureDuckDbPool } = await import('../../services/workerBusManager'); + await initWorkerBus(); + mockRegisterPool.mockClear(); + mockHasPool.mockReturnValue(true); + + await ensureDuckDbPool(); + + expect(mockHasPool).toHaveBeenCalledWith('duckdb'); + expect(mockRegisterPool).not.toHaveBeenCalled(); + }); + + it('re-registers the duckdb pool when the bus is alive but the pool was removed', async () => { + // QNBS-v3: [Simulates the gap terminatePool('duckdb') can leave — the bus stays non-null + // but the pool is gone; ensureDuckDbPool must not just trust `_bus !== null`.] + const { initWorkerBus, ensureDuckDbPool } = await import('../../services/workerBusManager'); + await initWorkerBus(); + mockRegisterPool.mockClear(); + mockHasPool.mockReturnValue(false); + + const bus = await ensureDuckDbPool(); + + expect(bus).not.toBeNull(); + expect(mockRegisterPool).toHaveBeenCalledWith( + 'duckdb', + expect.arrayContaining(['db.duckdb']), + expect.objectContaining({ workerScript: expect.stringContaining('duckdb.worker') }), + ); + }); + }); }); diff --git a/workers/duckdbWorker.ts b/workers/duckdbWorker.ts deleted file mode 100644 index 30899b36..00000000 --- a/workers/duckdbWorker.ts +++ /dev/null @@ -1,219 +0,0 @@ -/// -// QNBS-v3: Off-main-thread DuckDB-WASM worker. OPFS persistence with in-memory fallback. -// Uses duckdb-eh bundle (no SharedArrayBuffer required — no COOP/COEP needed). -// Message protocol mirrors inference.worker.ts for consistency. - -export type DuckDbRequestType = 'INIT' | 'QUERY' | 'EXEC' | 'MIGRATE' | 'SHUTDOWN'; - -// QNBS-v3: Out-of-band event emitted when OPFS attach fails — no messageId, fires independently. -export interface DuckDbWorkerEvent { - type: 'OPFS_FALLBACK'; - reason: string; -} - -export interface DuckDbRequest { - messageId: string; - type: DuckDbRequestType; - sql?: string | undefined; - params?: readonly unknown[] | undefined; -} - -export interface DuckDbResponse { - messageId: string; - ok: boolean; - rows?: Record[]; - error?: string; - latencyMs?: number; -} - -// QNBS-v3: Lazy module reference — DuckDB bundle is ~2 MB; only load when INIT is received. -let duckdbModule: typeof import('@duckdb/duckdb-wasm') | null = null; -let db: import('@duckdb/duckdb-wasm').AsyncDuckDB | null = null; -let connection: import('@duckdb/duckdb-wasm').AsyncDuckDBConnection | null = null; - -async function getDuckDb() { - if (!duckdbModule) { - duckdbModule = await import('@duckdb/duckdb-wasm'); - } - return duckdbModule; -} - -// QNBS-v3: Security guard — only process messages from the same origin (mirrors inference.worker.ts). -function isTrustedWorkerMessage(event: MessageEvent): boolean { - return event.origin === '' || event.origin === globalThis.location?.origin; -} - -async function isOPFSSupported(): Promise { - try { - const root = await navigator.storage?.getDirectory?.(); - if (!root) return false; - await root.getFileHandle('__duckdb_opfs_test__', { create: true }); - return true; - } catch { - return false; - } -} - -// QNBS-v3 (F-09): self-hosted, same-origin DuckDB-WASM assets (scripts/copy-duckdb-assets.mjs) replacing an unversioned CDN that was already dead under worker-src 'self' blob:' CSP; absolute path so resolution is unambiguous from either the main thread or this nested worker context. -const DUCKDB_ASSET_BASE = `${import.meta.env.BASE_URL}duckdb/`; -const SELF_HOSTED_BUNDLES = { - mvp: { - mainModule: `${DUCKDB_ASSET_BASE}duckdb-mvp.wasm`, - mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-mvp.worker.js`, - }, - eh: { - mainModule: `${DUCKDB_ASSET_BASE}duckdb-eh.wasm`, - mainWorker: `${DUCKDB_ASSET_BASE}duckdb-browser-eh.worker.js`, - }, -}; - -async function initDuckDb(): Promise { - const { AsyncDuckDB, selectBundle, ConsoleLogger } = await getDuckDb(); - - const bundle = await selectBundle(SELF_HOSTED_BUNDLES); - const logger = new ConsoleLogger(); - // QNBS-v3: bundle.mainWorker can be null for non-browser bundles; guard before constructing Worker. - if (!bundle.mainWorker) throw new Error('DuckDB bundle has no worker URL'); - const worker = new Worker(bundle.mainWorker); - const newDb = new AsyncDuckDB(logger, worker); - await newDb.instantiate(bundle.mainModule); - - // QNBS-v3: OPFS for persistence when available; fall back to in-memory (private browsing, old Safari). - const useOpfs = await isOPFSSupported(); - if (useOpfs) { - try { - const { DuckDBDataProtocol } = await getDuckDb(); - const opfsRoot = await navigator.storage.getDirectory(); - const fileHandle = await opfsRoot.getFileHandle('worldscript_analytics.duckdb', { - create: true, - }); - await newDb.registerFileHandle( - 'worldscript_analytics.duckdb', - fileHandle, - DuckDBDataProtocol.BROWSER_FSACCESS, - true, - ); - connection = await newDb.connect(); - await connection.query("ATTACH 'worldscript_analytics.duckdb' AS analytics (TYPE duckdb)"); - } catch (opfsErr) { - // QNBS-v3: Notify main thread so the hook can set persistence-mode badge in Redux. - self.postMessage({ - type: 'OPFS_FALLBACK', - reason: opfsErr instanceof Error ? opfsErr.message : String(opfsErr), - } satisfies DuckDbWorkerEvent); - connection = await newDb.connect(); - } - } else { - connection = await newDb.connect(); - } - - db = newDb; -} - -async function handleQuery(req: DuckDbRequest): Promise { - const start = Date.now(); - if (!connection) { - return { messageId: req.messageId, ok: false, error: 'DuckDB not initialized', latencyMs: 0 }; - } - try { - const result = await connection.query(req.sql ?? ''); - const rows = result.toArray().map((row: { toJSON(): Record }) => row.toJSON()); - return { messageId: req.messageId, ok: true, rows, latencyMs: Date.now() - start }; - } catch (err) { - return { - messageId: req.messageId, - ok: false, - error: err instanceof Error ? err.message : String(err), - latencyMs: Date.now() - start, - }; - } -} - -async function handleExec(req: DuckDbRequest): Promise { - const start = Date.now(); - if (!connection) { - return { messageId: req.messageId, ok: false, error: 'DuckDB not initialized', latencyMs: 0 }; - } - try { - await connection.query(req.sql ?? ''); - return { messageId: req.messageId, ok: true, latencyMs: Date.now() - start }; - } catch (err) { - return { - messageId: req.messageId, - ok: false, - error: err instanceof Error ? err.message : String(err), - latencyMs: Date.now() - start, - }; - } -} - -async function handleShutdown(req: DuckDbRequest): Promise { - try { - await connection?.close(); - await db?.terminate(); - connection = null; - db = null; - return { messageId: req.messageId, ok: true }; - } catch (err) { - return { - messageId: req.messageId, - ok: false, - error: err instanceof Error ? err.message : String(err), - }; - } -} - -// QNBS-v3: In-flight AbortController map (mirrors inference.worker.ts; DuckDB cancellation via close/reopen). -const abortMap = new Map(); - -self.addEventListener('message', (event: MessageEvent) => { - if (!isTrustedWorkerMessage(event)) return; - - // QNBS-v3: Check raw type as string before narrowing to DuckDbRequest so WORKER_CANCEL is reachable. - const raw = event.data as { type?: string; messageId?: string }; - - if (raw.type === 'WORKER_CANCEL' && raw.messageId) { - abortMap.get(raw.messageId)?.abort(); - abortMap.delete(raw.messageId); - return; - } - - const req = event.data as DuckDbRequest; - - const controller = new AbortController(); - abortMap.set(req.messageId, controller); - - let task: Promise; - - switch (req.type) { - case 'INIT': - task = initDuckDb() - .then(() => ({ messageId: req.messageId, ok: true })) - .catch((err: unknown) => ({ - messageId: req.messageId, - ok: false, - error: err instanceof Error ? err.message : String(err), - })); - break; - case 'QUERY': - task = handleQuery(req); - break; - case 'EXEC': - task = handleExec(req); - break; - case 'SHUTDOWN': - task = handleShutdown(req); - break; - default: - task = Promise.resolve({ - messageId: req.messageId, - ok: false, - error: `Unknown request type: ${String(req.type)}`, - }); - } - - void task.then((response) => { - abortMap.delete(req.messageId); - self.postMessage(response); - }); -}); diff --git a/workers/v2/duckdb.worker.ts b/workers/v2/duckdb.worker.ts index 891886ea..28789984 100644 --- a/workers/v2/duckdb.worker.ts +++ b/workers/v2/duckdb.worker.ts @@ -1,15 +1,21 @@ /// -// QNBS-v3: WorkerBus v2 DuckDB worker. Wraps legacy duckdbWorker.ts logic -// in the typed bootstrap protocol. +// QNBS-v3: [Sole DuckDB worker generation post-consolidation (docs/adr/0014); formerly wrapped the now-deleted legacy workers/duckdbWorker.ts.] +import type { AsyncDuckDB, AsyncDuckDBConnection } from '@duckdb/duckdb-wasm'; import { registerTaskHandler, type WorkerHandlerContext, } from '../../packages/worker-bus/src/workerBootstrap'; +import { createLogger } from '../../services/logger'; + +// QNBS-v3: [services/logger.ts is worker-safe — its window.localStorage/Tauri touches are all +// guarded (typeof window !== 'undefined' / dynamic import) — so this matches +// workerBusManager.ts's log.warn/log.error convention instead of raw console.warn.] +const log = createLogger('duckdb.worker'); let duckdbModule: typeof import('@duckdb/duckdb-wasm') | null = null; -let db: import('@duckdb/duckdb-wasm').AsyncDuckDB | null = null; -let connection: import('@duckdb/duckdb-wasm').AsyncDuckDBConnection | null = null; +let db: AsyncDuckDB | null = null; +let connection: AsyncDuckDBConnection | null = null; async function getDuckDb() { if (!duckdbModule) { @@ -29,7 +35,7 @@ async function isOPFSSupported(): Promise { } } -// QNBS-v3 (F-09): self-hosted, same-origin DuckDB-WASM assets — see workers/duckdbWorker.ts for the full rationale (unpinned CDN URL, supply-chain trust, already CSP-dead code). +// QNBS-v3 (F-09): [Self-hosted, same-origin DuckDB-WASM assets — replaces an unpinned CDN URL that was already CSP-dead (supply-chain trust); see docs/adr/0013-csp-wasm-and-blob-frames.md.] const DUCKDB_ASSET_BASE = `${import.meta.env.BASE_URL}duckdb/`; const SELF_HOSTED_BUNDLES = { mvp: { @@ -58,7 +64,7 @@ export async function initDuckDb( const useOpfs = await isOPFSSupported(); if (useOpfs) { // QNBS-v3: [Tracked separately from `connection` so a failed ATTACH can close this partial connection instead of leaking it.] - let opfsConnection: import('@duckdb/duckdb-wasm').AsyncDuckDBConnection | null = null; + let opfsConnection: AsyncDuckDBConnection | null = null; try { const { DuckDBDataProtocol } = await getDuckDb(); const opfsRoot = await navigator.storage.getDirectory(); @@ -77,8 +83,13 @@ export async function initDuckDb( ); connection = opfsConnection; } catch (opfsErr) { + // QNBS-v3: [Cleanup is best-effort — a rejecting close() must not block the fallback below or replace the original OPFS error.] + try { + await opfsConnection?.close(); + } catch (closeErr) { + log.warn('Failed to close partial OPFS connection', closeErr); + } // QNBS-v3: [No bare postMessage from inside a task handler — OPFS-unavailable is surfaced via the progress channel duckdbClient's INIT adapter listens on instead.] - await opfsConnection?.close(); emitProgress?.( 'opfs-fallback', 1, @@ -93,19 +104,50 @@ export async function initDuckDb( db = newDb; } +interface DuckDbQueryResult { + toArray(): Array<{ toJSON(): Record }>; +} + +interface PreparableConnection { + prepare(sql: string): Promise<{ + query(...params: unknown[]): Promise; + close(): Promise; + }>; +} + +// QNBS-v3: [tsgo doesn't resolve AsyncDuckDBConnection.prepare() through this package's nested +// `export *` chain (present and correct in @duckdb/duckdb-wasm's own .d.ts, verified +// directly) — a narrow structural type sidesteps the gap, the same class of tsgo/external- +// package resolution issue CLAUDE.md documents for the transformers.js path alias.] +function asPreparable(conn: AsyncDuckDBConnection): PreparableConnection { + return conn as unknown as PreparableConnection; +} + +// QNBS-v3: [Bind via a prepared statement when params are present — a raw connection.query(sql) silently drops them, leaving an unbound query and a SQL-injection surface.] +async function runSql(sql: string, params?: readonly unknown[]): Promise { + if (!connection) throw new Error('DuckDB not initialized'); + if (params && params.length > 0) { + const stmt = await asPreparable(connection).prepare(sql); + try { + return await stmt.query(...params); + } finally { + await stmt.close(); + } + } + return connection.query(sql); +} + export async function handleQuery(ctx: WorkerHandlerContext): Promise { const { payload } = ctx; - const req = payload as { sql?: string }; - if (!connection) throw new Error('DuckDB not initialized'); - const result = await connection.query(req.sql ?? ''); + const req = payload as { sql?: string; params?: readonly unknown[] }; + const result = await runSql(req.sql ?? '', req.params); return result.toArray().map((row: { toJSON(): Record }) => row.toJSON()); } export async function handleExec(ctx: WorkerHandlerContext): Promise { const { payload } = ctx; - const req = payload as { sql?: string }; - if (!connection) throw new Error('DuckDB not initialized'); - await connection.query(req.sql ?? ''); + const req = payload as { sql?: string; params?: readonly unknown[] }; + await runSql(req.sql ?? '', req.params); return { ok: true }; }