diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index e501ef5..c9f0131 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -116,7 +116,9 @@ node-sql-parser/build/bigquery.js The worker verifies that `self === globalThis` and that no DOM window exists. It snapshots and restores the exact `NodeSQLParser` and `global` descriptors -around dialect loading. Cleanup failure poisons that worker generation. +around the complete backend operation: dialect loading, module decoding, +parser construction, parsing, and output normalization. Cleanup failure +poisons that worker generation. ### Private wire protocol @@ -139,7 +141,7 @@ The initial response contains only one closed outcome: - Parsed normalized statement kind - Syntax rejection - Bounded unsupported reason -- Bounded failure code plus retryability +- Bounded failure code; retryability is derived from that code Messages do not contain: @@ -150,6 +152,11 @@ Messages do not contain: - Absolute document ranges - Raw ASTs or generic payload bags +The wire does not transport an independently supplied retryability boolean. +The host treats only `module-load` as retryable; `backend` and +`malformed-output` are terminal. A module-load failure closes the current +worker generation so a retry cannot reuse a rejected dynamic-import realm. + The host requires the current protocol version and correlation ID, validates all keys and closed values, and copies accepted data into new frozen objects. It then constructs an authentic `SqlParserAnalysis` with the exact pending diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index 9178526..a65006f 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -59,8 +59,45 @@ exists, or `global` does not resolve exactly to `globalThis`, before loading a bundle. This blocks browser windows and Node DOM shims from exposing an unguarded secondary target. Pure Node loads restore the exact prior descriptors synchronously after module evaluation, including removing names that were -previously absent. Cleanup failure permanently poisons loading. A -dedicated-worker loader remains future work. +previously absent. Cleanup failure permanently poisons loading. The dedicated +worker applies the same exact restoration rule around the complete backend +operation, including module evaluation, module decoding, parser construction, +parsing, and output normalization. + +### Private browser worker endpoint + +The package contains a production-shaped but private module-worker endpoint. +It is not exported from the package and is not reachable through `/vnext`. +There is no public worker constructor, executor, queue, language-service +module, or session integration yet. + +The endpoint: + +- uses only the extension-qualified PostgreSQL and BigQuery builds above; +- loads each grammar lazily after a valid request; +- reuses the realm-neutral backend engine for module, AST, and parser-error + normalization; +- accepts and emits only a closed, versioned plain-data protocol; +- returns normalized statement kind, bounded unsupported or failure evidence, + and never returns source text, raw errors, or backend ASTs; +- derives retryability from the closed failure code instead of trusting a + separate wire flag; +- restores the exact prior `NodeSQLParser` and `global` descriptors around + each complete backend operation; and +- permanently poisons and closes its worker realm if cleanup cannot be proven + exact. + +The endpoint accepts only one request at a time. Overlap and malformed messages +fail closed instead of creating an implicit worker-side queue. The future +service-owned executor is responsible for serialization, correlation, +deadlines, cancellation, generation replacement, and disposal. + +Direct Chromium tests construct this source module worker and exercise both +real grammar builds. The separate worker-placement fixture remains +diagnostic packaging evidence: it records resource timing, emitted chunk +reachability, and bundle sizes with a fixture-owned protocol. It is not the +public integration boundary and must not be read as evidence that an executor +or session API already exists. Approximate local Node 24 arm64 measurements for the installed package were: diff --git a/scripts/changed-coverage.mjs b/scripts/changed-coverage.mjs index 340ef8a..2bf104d 100644 --- a/scripts/changed-coverage.mjs +++ b/scripts/changed-coverage.mjs @@ -34,7 +34,8 @@ const changedProductionFiles = execFileSync( !path.endsWith(".test.ts") && !path.includes("/__tests__/") && !path.includes("/browser_tests/") && - path !== "src/debug.ts", + path !== "src/debug.ts" && + path !== "src/vnext/node-sql-parser-browser-worker.ts", ); const changedRuntimeFiles = ( await Promise.all( diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 0f3ed1c..f794f27 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -169,6 +169,21 @@ try { if (typeof packedPackage.dependencies?.["node-sql-parser"] !== "string") { throw new Error("Packed manifest does not declare node-sql-parser"); } + const privateWorkerArtifacts = [ + "dist/vnext/node-sql-parser-browser-worker.d.ts", + "dist/vnext/node-sql-parser-browser-worker.js", + "dist/vnext/node-sql-parser-browser-worker-endpoint.d.ts", + "dist/vnext/node-sql-parser-browser-worker-endpoint.js", + "dist/vnext/node-sql-parser-wire.d.ts", + "dist/vnext/node-sql-parser-wire.js", + ]; + for (const artifact of privateWorkerArtifacts) { + if (!existsSync(join(packageDirectory, artifact))) { + throw new Error( + `Packed archive omitted private worker artifact ${artifact}`, + ); + } + } writeFileSync( join(temporaryDirectory, "vnext-consumer.mjs"), diff --git a/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts b/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts new file mode 100644 index 0000000..749c469 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts @@ -0,0 +1,916 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + installNodeSqlParserBrowserWorkerEndpoint, + type NodeSqlParserBrowserWorkerModuleLoaders, + type NodeSqlParserBrowserWorkerScope, +} from "../node-sql-parser-browser-worker-endpoint.js"; +import { + encodeNodeSqlParserWireRequest, + type NodeSqlParserWireGrammar, +} from "../node-sql-parser-wire.js"; + +type MessageListener = (event: { readonly data: unknown }) => void; + +interface TestWorkerScope + extends NodeSqlParserBrowserWorkerScope { + readonly messages: unknown[]; + readonly closeCalls: () => number; + readonly listenerCount: () => number; + readonly removeCalls: () => number; + readonly dispatch: (data: unknown) => void; + readonly setPostHook: ( + hook: ((message: unknown) => void) | undefined, + ) => void; +} + +interface TestWorkerScopeOptions { + readonly add?: () => void; + readonly close?: () => void; + readonly post?: (message: unknown) => void; +} + +function createTestWorkerScope( + options: TestWorkerScopeOptions = {}, +): TestWorkerScope { + const listeners = new Set(); + const messages: unknown[] = []; + let closes = 0; + let removals = 0; + let postHook = options.post; + + const scope = { + self: undefined as unknown, + addEventListener( + type: "message", + listener: MessageListener, + ): void { + expect(type).toBe("message"); + options.add?.(); + listeners.add(listener); + }, + close(): void { + closes += 1; + options.close?.(); + }, + closeCalls: () => closes, + dispatch(data: unknown): void { + for (const listener of listeners) { + listener({ data }); + } + }, + listenerCount: () => listeners.size, + messages, + postMessage(message: unknown): void { + messages.push(message); + postHook?.(message); + }, + removeCalls: () => removals, + removeEventListener( + type: "message", + listener: MessageListener, + ): void { + expect(type).toBe("message"); + removals += 1; + listeners.delete(listener); + }, + setPostHook( + hook: ((message: unknown) => void) | undefined, + ): void { + postHook = hook; + }, + }; + Object.defineProperty(scope, "self", { + configurable: true, + enumerable: true, + value: scope, + writable: false, + }); + return scope; +} + +function parserModule( + astify: (statementText: string) => unknown = () => ({ + type: "select", + }), +): unknown { + return { + Parser: class Parser { + astify(statementText: string): unknown { + return astify(statementText); + } + }, + }; +} + +function loaders( + overrides: Partial< + NodeSqlParserBrowserWorkerModuleLoaders + > = {}, +): NodeSqlParserBrowserWorkerModuleLoaders { + return { + bigquery: async () => parserModule(), + postgresql: async () => parserModule(), + ...overrides, + }; +} + +function request( + requestId: number, + grammar: NodeSqlParserWireGrammar = "postgresql", + text = "SELECT 1", +): unknown { + return encodeNodeSqlParserWireRequest( + grammar, + requestId, + text, + ); +} + +async function waitForMessageCount( + scope: TestWorkerScope, + count: number, +): Promise { + await vi.waitFor(() => { + expect(scope.messages).toHaveLength(count); + }); +} + +describe("node-sql-parser browser worker endpoint", () => { + it("installs one listener, posts ready, and lazily routes both grammars", async () => { + const scope = createTestWorkerScope(); + const evaluations = { + bigquery: 0, + postgresql: 0, + }; + + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + bigquery: async () => { + evaluations.bigquery += 1; + return parserModule(() => ({ type: "select" })); + }, + postgresql: async () => { + evaluations.postgresql += 1; + return parserModule(() => ({ type: "insert" })); + }, + }), + ); + + expect(scope.listenerCount()).toBe(1); + expect(scope.messages).toStrictEqual([ + { kind: "ready", protocolVersion: 1 }, + ]); + expect(evaluations).toStrictEqual({ + bigquery: 0, + postgresql: 0, + }); + + scope.dispatch(request(1, "bigquery")); + await waitForMessageCount(scope, 2); + expect(scope.messages[1]).toStrictEqual({ + kind: "parsed", + protocolVersion: 1, + requestId: 1, + statementKind: "query", + }); + expect(evaluations).toStrictEqual({ + bigquery: 1, + postgresql: 0, + }); + + scope.dispatch(request(2, "postgresql")); + await waitForMessageCount(scope, 3); + expect(scope.messages[2]).toStrictEqual({ + kind: "parsed", + protocolVersion: 1, + requestId: 2, + statementKind: "insert", + }); + + scope.dispatch(request(3, "bigquery")); + await waitForMessageCount(scope, 4); + expect(evaluations).toStrictEqual({ + bigquery: 1, + postgresql: 1, + }); + expect(scope.closeCalls()).toBe(0); + }); + + it("restores exact data and accessor descriptors after each async import", async () => { + const scope = createTestWorkerScope(); + const originalNodeSqlParser = Object.freeze({ + owner: "worker", + }); + const originalGetter = () => "original-global"; + const originalSetter = (_value: unknown) => undefined; + Object.defineProperties(scope, { + NodeSQLParser: { + configurable: true, + enumerable: true, + value: originalNodeSqlParser, + writable: false, + }, + global: { + configurable: true, + enumerable: false, + get: originalGetter, + set: originalSetter, + }, + }); + const nodeSqlParserDescriptor = + Object.getOwnPropertyDescriptor(scope, "NodeSQLParser"); + const globalDescriptor = Object.getOwnPropertyDescriptor( + scope, + "global", + ); + + const pollutingLoader = async () => { + Object.defineProperties(scope, { + NodeSQLParser: { + configurable: true, + enumerable: false, + value: "temporary-parser", + writable: true, + }, + global: { + configurable: true, + enumerable: true, + value: "temporary-global", + writable: true, + }, + }); + await Promise.resolve(); + return parserModule(); + }; + + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + bigquery: pollutingLoader, + postgresql: pollutingLoader, + }), + ); + + scope.dispatch(request(1, "postgresql")); + await waitForMessageCount(scope, 2); + expect( + Object.getOwnPropertyDescriptor(scope, "NodeSQLParser"), + ).toStrictEqual(nodeSqlParserDescriptor); + expect( + Object.getOwnPropertyDescriptor(scope, "global"), + ).toStrictEqual(globalDescriptor); + + scope.dispatch(request(2, "bigquery")); + await waitForMessageCount(scope, 3); + expect( + Object.getOwnPropertyDescriptor(scope, "NodeSQLParser"), + ).toStrictEqual(nodeSqlParserDescriptor); + expect( + Object.getOwnPropertyDescriptor(scope, "global"), + ).toStrictEqual(globalDescriptor); + expect(scope.closeCalls()).toBe(0); + }); + + it("restores globals mutated during backend construction and parsing", async () => { + const scope = createTestWorkerScope(); + const nodeSqlParserSentinel = Object.freeze({ + owner: "worker-parser", + }); + const globalSentinel = Object.freeze({ + owner: "worker-global", + }); + const nodeSqlParserDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: false, + value: nodeSqlParserSentinel, + writable: false, + }; + const globalDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: true, + value: globalSentinel, + writable: true, + }; + Object.defineProperty( + scope, + "NodeSQLParser", + nodeSqlParserDescriptor, + ); + Object.defineProperty(scope, "global", globalDescriptor); + + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => ({ + Parser: class Parser { + constructor() { + Object.defineProperty(scope, "NodeSQLParser", { + configurable: true, + value: "constructor-pollution", + }); + } + + astify(): unknown { + Object.defineProperty(scope, "global", { + configurable: true, + value: "parse-pollution", + }); + return { type: "select" }; + } + }, + }), + }), + ); + + scope.dispatch(request(19)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + kind: "parsed", + protocolVersion: 1, + requestId: 19, + statementKind: "query", + }); + expect( + Object.getOwnPropertyDescriptor(scope, "NodeSQLParser"), + ).toStrictEqual(nodeSqlParserDescriptor); + expect( + Object.getOwnPropertyDescriptor(scope, "global"), + ).toStrictEqual(globalDescriptor); + expect(scope.closeCalls()).toBe(0); + }); + + it("cleans up a rejected import and encodes only retry policy code", async () => { + const scope = createTestWorkerScope(); + const rawImportError = Object.freeze({ + message: "secret import path", + source: "/private/backend.js", + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => { + Object.defineProperties(scope, { + NodeSQLParser: { + configurable: true, + value: "temporary-parser", + }, + global: { + configurable: true, + get: () => "temporary-global", + }, + }); + throw rawImportError; + }, + }), + ); + + scope.dispatch(request(17)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + code: "module-load", + kind: "failed", + protocolVersion: 1, + requestId: 17, + }); + expect(JSON.stringify(scope.messages[1])).not.toContain( + "secret import path", + ); + expect(JSON.stringify(scope.messages[1])).not.toContain( + "/private/backend.js", + ); + expect( + Object.getOwnPropertyDescriptor(scope, "NodeSQLParser"), + ).toBeUndefined(); + expect( + Object.getOwnPropertyDescriptor(scope, "global"), + ).toBeUndefined(); + expect(scope.closeCalls()).toBe(1); + expect(scope.listenerCount()).toBe(0); + }); + + it("permanently poisons both grammar paths after restoration failure", async () => { + const scope = createTestWorkerScope(); + let postgresqlEvaluations = 0; + let bigqueryEvaluations = 0; + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + bigquery: async () => { + bigqueryEvaluations += 1; + return parserModule(); + }, + postgresql: async () => { + postgresqlEvaluations += 1; + Object.defineProperty(scope, "NodeSQLParser", { + configurable: false, + value: "permanent-pollution", + }); + return parserModule(); + }, + }), + ); + + scope.dispatch(request(1, "postgresql")); + await waitForMessageCount(scope, 2); + expect(scope.messages[1]).toStrictEqual({ + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 1, + }); + expect(scope.closeCalls()).toBe(1); + + scope.dispatch(request(2, "bigquery")); + await Promise.resolve(); + expect(postgresqlEvaluations).toBe(1); + expect(bigqueryEvaluations).toBe(0); + expect(scope.messages).toHaveLength(2); + }); + + it("poisons when an original descriptor cannot be restored", async () => { + const scope = createTestWorkerScope(); + Object.defineProperty(scope, "NodeSQLParser", { + configurable: true, + value: "original-parser", + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => { + Object.defineProperty(scope, "NodeSQLParser", { + configurable: false, + value: "permanent-parser", + }); + return parserModule(); + }, + }), + ); + + scope.dispatch(request(8)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 8, + }); + expect(scope.closeCalls()).toBe(1); + }); + + it("poisons when restoration reports success without descriptor equality", async () => { + const target = createTestWorkerScope(); + Object.defineProperty(target, "NodeSQLParser", { + configurable: true, + enumerable: true, + value: "original-parser", + writable: true, + }); + let ignoreRestoration = false; + const scope = new Proxy(target, { + defineProperty(value, key, descriptor) { + if (ignoreRestoration && key === "NodeSQLParser") { + return true; + } + return Reflect.defineProperty(value, key, descriptor); + }, + }); + Object.defineProperty(target, "self", { + configurable: true, + enumerable: true, + value: scope, + writable: false, + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => { + Object.defineProperty(target, "NodeSQLParser", { + configurable: true, + enumerable: false, + value: "temporary-parser", + writable: false, + }); + ignoreRestoration = true; + return parserModule(); + }, + }), + ); + + scope.dispatch(request(10)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 10, + }); + expect(scope.closeCalls()).toBe(1); + }); + + it("poisons when restored descriptors cannot be verified", async () => { + const target = createTestWorkerScope(); + let trapVerification = false; + const scope = new Proxy(target, { + getOwnPropertyDescriptor(value, key) { + if (trapVerification && key === "NodeSQLParser") { + throw new Error("private verification trap"); + } + return Reflect.getOwnPropertyDescriptor(value, key); + }, + }); + Object.defineProperty(target, "self", { + configurable: true, + enumerable: true, + value: scope, + writable: false, + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => { + trapVerification = true; + return parserModule(); + }, + }), + ); + + scope.dispatch(request(11)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 11, + }); + expect(JSON.stringify(scope.messages[1])).not.toContain( + "private verification trap", + ); + expect(scope.closeCalls()).toBe(1); + }); + + it("maps descriptor snapshot failures to a terminal backend failure", async () => { + const target = createTestWorkerScope(); + let evaluations = 0; + const scope = new Proxy(target, { + getOwnPropertyDescriptor(value, key) { + if (key === "NodeSQLParser") { + throw new Error("private descriptor trap"); + } + return Reflect.getOwnPropertyDescriptor(value, key); + }, + }); + Object.defineProperty(target, "self", { + configurable: true, + enumerable: true, + value: scope, + writable: false, + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => { + evaluations += 1; + return parserModule(); + }, + }), + ); + + scope.dispatch(request(9)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 9, + }); + expect(evaluations).toBe(0); + expect(scope.closeCalls()).toBe(1); + }); + + it("treats overlapping requests as a terminal protocol error", async () => { + const scope = createTestWorkerScope(); + let resolveImport: ((value: unknown) => void) | undefined; + let postgresqlEvaluations = 0; + let bigqueryEvaluations = 0; + const importPromise = new Promise((resolve) => { + resolveImport = resolve; + }); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + bigquery: async () => { + bigqueryEvaluations += 1; + return parserModule(); + }, + postgresql: async () => { + postgresqlEvaluations += 1; + return await importPromise; + }, + }), + ); + + scope.dispatch(request(1, "postgresql")); + scope.dispatch(request(2, "bigquery")); + expect(scope.messages[1]).toStrictEqual({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + }); + expect(scope.closeCalls()).toBe(1); + expect(scope.listenerCount()).toBe(0); + + resolveImport?.(parserModule()); + await vi.waitFor(() => { + expect(postgresqlEvaluations).toBe(1); + }); + await Promise.resolve(); + expect(bigqueryEvaluations).toBe(0); + expect(scope.messages).toHaveLength(2); + }); + + it("strictly rejects malformed requests and closes", () => { + const scope = createTestWorkerScope(); + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()); + const valid = request(1) as object; + + scope.dispatch({ ...valid, extra: true }); + + expect(scope.messages).toStrictEqual([ + { kind: "ready", protocolVersion: 1 }, + { + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + }, + ]); + expect(scope.closeCalls()).toBe(1); + expect(scope.listenerCount()).toBe(0); + }); + + it("elides parser roots and source text from parsed messages", async () => { + const scope = createTestWorkerScope(); + const source = "SELECT super_secret_column FROM private_table"; + const root = { + source, + type: "select", + nested: { + rawError: new Error("secret backend error"), + }, + }; + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => parserModule(() => root), + }), + ); + + scope.dispatch(request(23, "postgresql", source)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual({ + kind: "parsed", + protocolVersion: 1, + requestId: 23, + statementKind: "query", + }); + expect(scope.messages[1]).not.toHaveProperty("root"); + expect(JSON.stringify(scope.messages[1])).not.toContain(source); + expect(JSON.stringify(scope.messages[1])).not.toContain( + "secret backend error", + ); + }); + + it.each([ + { + code: undefined, + expected: { + kind: "syntax-rejected", + protocolVersion: 1, + requestId: 4, + }, + moduleValue: parserModule(() => { + throw { + location: { + end: { offset: 8 }, + start: { offset: 7 }, + }, + message: "syntax rejected", + name: "SyntaxError", + }; + }), + }, + { + code: undefined, + expected: { + kind: "unsupported", + protocolVersion: 1, + reason: "multiple-statements", + requestId: 4, + }, + moduleValue: parserModule(() => [ + { type: "select" }, + { type: "select" }, + ]), + }, + { + code: "backend", + expected: { + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 4, + }, + moduleValue: parserModule(() => { + throw new Error("ordinary backend failure"); + }), + }, + { + code: "malformed-output", + expected: { + code: "malformed-output", + kind: "failed", + protocolVersion: 1, + requestId: 4, + }, + moduleValue: {}, + }, + ])( + "keeps the worker open after ordinary $code outcomes", + async ({ expected, moduleValue }) => { + const scope = createTestWorkerScope(); + installNodeSqlParserBrowserWorkerEndpoint( + scope, + loaders({ + postgresql: async () => moduleValue, + }), + ); + + scope.dispatch(request(4)); + await waitForMessageCount(scope, 2); + + expect(scope.messages[1]).toStrictEqual(expected); + expect(scope.closeCalls()).toBe(0); + expect(scope.listenerCount()).toBe(1); + }, + ); + + it("mutates state before posting a successful settlement", async () => { + const scope = createTestWorkerScope(); + let dispatchedReentrantly = false; + scope.setPostHook((message) => { + if ( + !dispatchedReentrantly && + (message as { readonly kind?: unknown }).kind === "parsed" + ) { + dispatchedReentrantly = true; + scope.dispatch(request(2)); + } + }); + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()); + + scope.dispatch(request(1)); + await waitForMessageCount(scope, 3); + + expect(scope.messages.map((message) => + (message as { readonly kind: string }).kind, + )).toStrictEqual(["ready", "parsed", "parsed"]); + expect(scope.closeCalls()).toBe(0); + }); + + it("closes without leaking ready-post, result-post, or close failures", async () => { + const readyFailure = createTestWorkerScope({ + post(message) { + if ( + (message as { readonly kind?: unknown }).kind === "ready" + ) { + throw new Error("ready post failed"); + } + }, + }); + expect(() => + installNodeSqlParserBrowserWorkerEndpoint( + readyFailure, + loaders(), + ), + ).not.toThrow(); + expect(readyFailure.closeCalls()).toBe(1); + + const resultFailure = createTestWorkerScope(); + installNodeSqlParserBrowserWorkerEndpoint( + resultFailure, + loaders(), + ); + resultFailure.setPostHook((message) => { + if ( + (message as { readonly kind?: unknown }).kind === "parsed" + ) { + throw new Error("result post failed"); + } + }); + resultFailure.dispatch(request(1)); + await vi.waitFor(() => { + expect(resultFailure.closeCalls()).toBe(1); + }); + expect(resultFailure.listenerCount()).toBe(0); + + const closeFailure = createTestWorkerScope({ + close() { + throw new Error("close failed"); + }, + }); + installNodeSqlParserBrowserWorkerEndpoint( + closeFailure, + loaders(), + ); + expect(() => closeFailure.dispatch({ invalid: true })).not.toThrow(); + expect(closeFailure.closeCalls()).toBe(1); + expect(closeFailure.listenerCount()).toBe(0); + }); + + it("closes when listener installation fails", () => { + const scope = createTestWorkerScope({ + add() { + throw new Error("listener installation failed"); + }, + }); + + expect(() => + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()), + ).not.toThrow(); + expect(scope.messages).toHaveLength(0); + expect(scope.listenerCount()).toBe(0); + expect(scope.closeCalls()).toBe(1); + }); + + it("ignores events after closure when listener removal is unavailable", () => { + const scope = createTestWorkerScope(); + Object.defineProperty(scope, "removeEventListener", { + configurable: true, + value: undefined, + }); + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()); + + scope.dispatch({ invalid: true }); + scope.dispatch(request(1)); + + expect(scope.messages).toStrictEqual([ + { kind: "ready", protocolVersion: 1 }, + { + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + }, + ]); + expect(scope.closeCalls()).toBe(1); + expect(scope.listenerCount()).toBe(1); + }); + + it("rejects a window-like realm without closing, installing, or posting", () => { + const scope = createTestWorkerScope() as TestWorkerScope & { + window?: unknown; + }; + scope.window = scope; + + expect(() => + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()), + ).toThrowError( + "node-sql-parser endpoint requires a dedicated worker realm", + ); + + expect(scope.messages).toHaveLength(0); + expect(scope.listenerCount()).toBe(0); + expect(scope.closeCalls()).toBe(0); + }); + + it("rejects a realm whose shape cannot be inspected", () => { + const target = createTestWorkerScope(); + const scope = new Proxy(target, { + has() { + throw new Error("private realm trap"); + }, + }); + Object.defineProperty(target, "self", { + configurable: true, + enumerable: true, + value: scope, + writable: false, + }); + + expect(() => + installNodeSqlParserBrowserWorkerEndpoint(scope, loaders()), + ).toThrowError( + "node-sql-parser endpoint requires a dedicated worker realm", + ); + expect(scope.messages).toHaveLength(0); + expect(scope.closeCalls()).toBe(0); + }); +}); diff --git a/src/vnext/__tests__/node-sql-parser-wire.test.ts b/src/vnext/__tests__/node-sql-parser-wire.test.ts new file mode 100644 index 0000000..7647ca5 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-wire.test.ts @@ -0,0 +1,738 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, + type NodeSqlParserBackendOutcome, +} from "../node-sql-parser-backend.js"; +import { + decodeNodeSqlParserWireMessage, + decodeNodeSqlParserWireRequest, + encodeNodeSqlParserWireBackendOutcome, + encodeNodeSqlParserWireProtocolError, + encodeNodeSqlParserWireReady, + encodeNodeSqlParserWireRequest, + isNodeSqlParserWireFailureRetryable, + NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + type NodeSqlParserWireMessage, +} from "../node-sql-parser-wire.js"; +import type { SqlStatementKind } from "../syntax.js"; + +const statementKinds = [ + "alter", + "create", + "delete", + "drop", + "insert", + "merge", + "other", + "query", + "transaction", + "update", +] as const satisfies readonly SqlStatementKind[]; + +const validRequest = { + grammar: "postgresql", + kind: "parse", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + text: "SELECT 1", +} as const; + +const validMessages: readonly NodeSqlParserWireMessage[] = [ + { + kind: "ready", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }, + ...statementKinds.map( + (statementKind): NodeSqlParserWireMessage => ({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + statementKind, + }), + ), + { + kind: "syntax-rejected", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + }, + { + kind: "unsupported", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + reason: "multiple-statements", + requestId: 1, + }, + { + kind: "unsupported", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + reason: "resource-limit", + requestId: 1, + }, + { + code: "backend", + kind: "failed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + }, + { + code: "malformed-output", + kind: "failed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + }, + { + code: "module-load", + kind: "failed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + }, + { + code: "invalid-request", + kind: "protocol-error", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }, +]; + +function expectFreshFrozenPlain( + source: object, + decoded: T | null, +): asserts decoded is T { + expect(decoded).not.toBeNull(); + expect(decoded).not.toBe(source); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype); +} + +function omitKey( + value: Readonly>, + omitted: string, +): Record { + return Object.fromEntries( + Object.entries(value).filter(([key]) => key !== omitted), + ); +} + +function hostileRecords(): { + readonly assertAccessorsUntouched: () => void; + readonly values: readonly unknown[]; +} { + const accessor = { ...validRequest }; + let invoked = false; + Object.defineProperty(accessor, "text", { + enumerable: true, + get() { + invoked = true; + throw new Error("private accessor detail"); + }, + }); + const nonEnumerable = { ...validRequest }; + Object.defineProperty(nonEnumerable, "text", { + enumerable: false, + value: validRequest.text, + }); + const ownKeysTrap = new Proxy( + { ...validRequest }, + { + ownKeys() { + throw new Error("private ownKeys detail"); + }, + }, + ); + const descriptorTrap = new Proxy( + { ...validRequest }, + { + getOwnPropertyDescriptor() { + throw new Error("private descriptor detail"); + }, + }, + ); + const prototypeTrap = new Proxy( + { ...validRequest }, + { + getPrototypeOf() { + throw new Error("private prototype detail"); + }, + }, + ); + const revoked = Proxy.revocable({ ...validRequest }, {}); + revoked.revoke(); + const withSymbol = { ...validRequest }; + Object.defineProperty(withSymbol, Symbol("private"), { + enumerable: false, + value: "secret", + }); + + const values: unknown[] = [ + null, + undefined, + true, + 1, + "record", + () => validRequest, + [], + new Date(), + Object.assign(Object.create(null), validRequest), + Object.assign( + Object.create({ inherited: true }), + validRequest, + ), + accessor, + nonEnumerable, + ownKeysTrap, + descriptorTrap, + prototypeTrap, + revoked.proxy, + withSymbol, + ]; + return { + assertAccessorsUntouched() { + expect(invoked).toBe(false); + }, + values, + }; +} + +describe("node-sql-parser wire request codec", () => { + it.each(["postgresql", "bigquery"] as const)( + "round trips the %s grammar through a fresh frozen record", + (grammar) => { + const encoded = encodeNodeSqlParserWireRequest( + grammar, + Number.MAX_SAFE_INTEGER, + " SELECT 1\n", + ); + const decoded = decodeNodeSqlParserWireRequest(encoded); + + expect(encoded).toStrictEqual({ + grammar, + kind: "parse", + protocolVersion: 1, + requestId: Number.MAX_SAFE_INTEGER, + text: " SELECT 1\n", + }); + expectFreshFrozenPlain(encoded, decoded); + expect(decoded).toStrictEqual(encoded); + }, + ); + + it.each([ + "", + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH), + ])("accepts bounded exact text %#", (text) => { + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + text, + }), + ).toStrictEqual({ ...validRequest, text }); + }); + + it("rejects every missing request key and any extra key", () => { + for (const key of Object.keys(validRequest)) { + expect( + decodeNodeSqlParserWireRequest(omitKey(validRequest, key)), + ).toBeNull(); + } + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + extra: true, + }), + ).toBeNull(); + }); + + it.each([ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + "1", + 1n, + ])("rejects invalid request ID %#", (requestId) => { + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + requestId, + }), + ).toBeNull(); + }); + + it.each([ + "duckdb", + "PostgreSQL", + "", + null, + 1, + ])("rejects invalid grammar %#", (grammar) => { + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + grammar, + }), + ).toBeNull(); + }); + + it.each([ + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + 1), + null, + 1, + {}, + ])("rejects invalid text %#", (text) => { + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + text, + }), + ).toBeNull(); + }); + + it.each([0, 2, "1", null])( + "rejects protocol version %#", + (protocolVersion) => { + expect( + decodeNodeSqlParserWireRequest({ + ...validRequest, + protocolVersion, + }), + ).toBeNull(); + }, + ); + + it("rejects hostile and non-plain records without invoking accessors", () => { + const hostile = hostileRecords(); + for (const value of hostile.values) { + expect(decodeNodeSqlParserWireRequest(value)).toBeNull(); + } + hostile.assertAccessorsUntouched(); + }); + + it("rejects oversized records before inspecting property descriptors", () => { + const oversizedPlainRecord = { + ...validRequest, + extra: true, + }; + let descriptorInspections = 0; + const oversizedProxyRecord = new Proxy(oversizedPlainRecord, { + getOwnPropertyDescriptor(target, key) { + descriptorInspections += 1; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + + expect( + decodeNodeSqlParserWireRequest(oversizedPlainRecord), + ).toBeNull(); + expect( + decodeNodeSqlParserWireRequest(oversizedProxyRecord), + ).toBeNull(); + expect(descriptorInspections).toBe(0); + }); + + it("rejects invalid values at the encoder boundary", () => { + expect(() => + encodeNodeSqlParserWireRequest( + "duckdb" as "postgresql", + 1, + "SELECT 1", + ), + ).toThrow(TypeError); + expect(() => + encodeNodeSqlParserWireRequest("postgresql", 0, "SELECT 1"), + ).toThrow(TypeError); + expect(() => + encodeNodeSqlParserWireRequest( + "postgresql", + 1, + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + 1), + ), + ).toThrow(TypeError); + }); +}); + +describe("node-sql-parser wire message codec", () => { + it.each(validMessages)( + "decodes good variant $kind as a fresh frozen record", + (message) => { + const decoded = decodeNodeSqlParserWireMessage(message); + + expectFreshFrozenPlain(message, decoded); + expect(decoded).toStrictEqual(message); + }, + ); + + it.each(statementKinds)( + "encodes parsed statement kind %s", + (statementKind) => { + const outcome: NodeSqlParserBackendOutcome = { + kind: "parsed", + root: { private: true }, + statementKind, + }; + + expect( + encodeNodeSqlParserWireBackendOutcome(7, outcome), + ).toStrictEqual({ + kind: "parsed", + protocolVersion: 1, + requestId: 7, + statementKind, + }); + }, + ); + + it("encodes every backend outcome and ready state", () => { + const cases: readonly [ + NodeSqlParserBackendOutcome, + NodeSqlParserWireMessage, + ][] = [ + [ + { kind: "syntax-rejected" }, + { + kind: "syntax-rejected", + protocolVersion: 1, + requestId: 3, + }, + ], + [ + { kind: "unsupported", reason: "multiple-statements" }, + { + kind: "unsupported", + protocolVersion: 1, + reason: "multiple-statements", + requestId: 3, + }, + ], + [ + { kind: "unsupported", reason: "resource-limit" }, + { + kind: "unsupported", + protocolVersion: 1, + reason: "resource-limit", + requestId: 3, + }, + ], + [ + { code: "backend", kind: "failed", retryable: false }, + { + code: "backend", + kind: "failed", + protocolVersion: 1, + requestId: 3, + }, + ], + [ + { + code: "malformed-output", + kind: "failed", + retryable: false, + }, + { + code: "malformed-output", + kind: "failed", + protocolVersion: 1, + requestId: 3, + }, + ], + [ + { code: "module-load", kind: "failed", retryable: true }, + { + code: "module-load", + kind: "failed", + protocolVersion: 1, + requestId: 3, + }, + ], + ]; + + const ready = encodeNodeSqlParserWireReady(); + expect(ready).toStrictEqual({ + kind: "ready", + protocolVersion: 1, + }); + expect(Object.isFrozen(ready)).toBe(true); + for (const [outcome, expected] of cases) { + const encoded = encodeNodeSqlParserWireBackendOutcome( + 3, + outcome, + ); + expect(encoded).toStrictEqual(expected); + expect(Object.isFrozen(encoded)).toBe(true); + expect(Object.getPrototypeOf(encoded)).toBe(Object.prototype); + } + }); + + it("never transports a parsed root or failure retryable flag", () => { + const root = new Proxy( + { type: "select" }, + { + get() { + throw new Error("wire inspected private AST root"); + }, + getOwnPropertyDescriptor() { + throw new Error("wire inspected private AST root"); + }, + ownKeys() { + throw new Error("wire inspected private AST root"); + }, + }, + ); + const parsed = encodeNodeSqlParserWireBackendOutcome(1, { + kind: "parsed", + root, + statementKind: "query", + }); + const failed = encodeNodeSqlParserWireBackendOutcome(1, { + code: "module-load", + kind: "failed", + retryable: true, + }); + + expect(Reflect.ownKeys(parsed)).toStrictEqual([ + "kind", + "protocolVersion", + "requestId", + "statementKind", + ]); + expect(Reflect.ownKeys(failed)).toStrictEqual([ + "code", + "kind", + "protocolVersion", + "requestId", + ]); + expect(JSON.stringify(parsed)).not.toContain("root"); + expect(JSON.stringify(failed)).not.toContain("retryable"); + }); + + it("derives retryability only from the closed failure code", () => { + expect(isNodeSqlParserWireFailureRetryable("module-load")).toBe( + true, + ); + expect(isNodeSqlParserWireFailureRetryable("backend")).toBe(false); + expect( + isNodeSqlParserWireFailureRetryable("malformed-output"), + ).toBe(false); + expect(() => + isNodeSqlParserWireFailureRetryable( + "timeout" as "module-load", + ), + ).toThrow(TypeError); + }); + + it("emits one closed protocol error without echoing an invalid ID", () => { + const invalidRequest = { + ...validRequest, + requestId: "private invalid identifier", + }; + expect(decodeNodeSqlParserWireRequest(invalidRequest)).toBeNull(); + + const protocolError = encodeNodeSqlParserWireProtocolError(); + expect(protocolError).toStrictEqual({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + }); + expect(Reflect.ownKeys(protocolError)).not.toContain("requestId"); + expect(Object.isFrozen(protocolError)).toBe(true); + }); + + it("rejects missing and extra keys for every message variant", () => { + for (const message of validMessages) { + for (const key of Object.keys(message)) { + expect( + decodeNodeSqlParserWireMessage( + omitKey(message, key), + ), + ).toBeNull(); + } + expect( + decodeNodeSqlParserWireMessage({ + ...message, + extra: "private", + }), + ).toBeNull(); + } + }); + + it.each([ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + "1", + ])("rejects invalid response request ID %#", (requestId) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "syntax-rejected", + protocolVersion: 1, + requestId, + }), + ).toBeNull(); + }); + + it.each([ + "select", + "QUERY", + "", + null, + 1, + ])("rejects statement kind %#", (statementKind) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "parsed", + protocolVersion: 1, + requestId: 1, + statementKind, + }), + ).toBeNull(); + }); + + it.each([ + "timeout", + "multiple-statement", + "", + null, + ])("rejects unsupported reason %#", (reason) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "unsupported", + protocolVersion: 1, + reason, + requestId: 1, + }), + ).toBeNull(); + }); + + it.each([ + "timeout", + "syntax", + "", + null, + ])("rejects failure code %#", (code) => { + expect( + decodeNodeSqlParserWireMessage({ + code, + kind: "failed", + protocolVersion: 1, + requestId: 1, + }), + ).toBeNull(); + }); + + it.each([ + "parse", + "unknown", + "", + null, + ])("rejects response kind %#", (kind) => { + expect( + decodeNodeSqlParserWireMessage({ + kind, + protocolVersion: 1, + }), + ).toBeNull(); + }); + + it.each([0, 2, "1", null])( + "rejects response protocol version %#", + (protocolVersion) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "ready", + protocolVersion, + }), + ).toBeNull(); + }, + ); + + it("rejects retryable on failed messages and IDs on protocol errors", () => { + expect( + decodeNodeSqlParserWireMessage({ + code: "module-load", + kind: "failed", + protocolVersion: 1, + requestId: 1, + retryable: true, + }), + ).toBeNull(); + expect( + decodeNodeSqlParserWireMessage({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + requestId: 1, + }), + ).toBeNull(); + }); + + it("rejects hostile and non-plain message records", () => { + const hostile = hostileRecords(); + for (const value of hostile.values) { + expect(decodeNodeSqlParserWireMessage(value)).toBeNull(); + } + hostile.assertAccessorsUntouched(); + }); + + it("rejects invalid values at backend outcome encoder boundary", () => { + expect(() => + encodeNodeSqlParserWireBackendOutcome(0, { + kind: "syntax-rejected", + }), + ).toThrow(TypeError); + expect(() => + encodeNodeSqlParserWireBackendOutcome(1, { + kind: "parsed", + root: {}, + statementKind: "select" as "query", + }), + ).toThrow(TypeError); + expect(() => + encodeNodeSqlParserWireBackendOutcome(1, { + kind: "unsupported", + reason: "timeout" as "resource-limit", + }), + ).toThrow(TypeError); + expect(() => + encodeNodeSqlParserWireBackendOutcome(1, { + code: "timeout" as "backend", + kind: "failed", + retryable: false, + }), + ).toThrow(TypeError); + }); + + it("fails closed without reflecting an unknown runtime outcome kind", () => { + const privateKind = "__private_backend_outcome_kind__"; + const outcome: NodeSqlParserBackendOutcome = { + kind: "syntax-rejected", + }; + Object.defineProperty(outcome, "kind", { + value: privateKind, + }); + + expect(() => + Reflect.apply( + encodeNodeSqlParserWireBackendOutcome, + undefined, + [1, outcome], + ), + ).toThrowError( + new TypeError( + "node-sql-parser wire backend outcome kind must be closed", + ), + ); + try { + Reflect.apply( + encodeNodeSqlParserWireBackendOutcome, + undefined, + [1, outcome], + ); + } catch (error) { + expect(String(error)).not.toContain(privateKind); + } + }); +}); diff --git a/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts b/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts new file mode 100644 index 0000000..714a853 --- /dev/null +++ b/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts @@ -0,0 +1,269 @@ +import { expect, test } from "vitest"; +import { + decodeNodeSqlParserWireMessage, + encodeNodeSqlParserWireRequest, + NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + type NodeSqlParserWireGrammar, + type NodeSqlParserWireMessage, +} from "../node-sql-parser-wire.js"; + +const MESSAGE_TIMEOUT_MS = 4_000; +let activeMessageWaits = 0; + +function createParserWorker(): Worker { + return new Worker( + new URL( + "../node-sql-parser-browser-worker.ts", + import.meta.url, + ), + { + name: "codemirror-sql-parser-endpoint-test", + type: "module", + }, + ); +} + +function waitForWireMessage( + worker: Worker, +): Promise { + activeMessageWaits += 1; + return new Promise((resolve, reject) => { + let settled = false; + const finish = ( + operation: () => void, + ): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + activeMessageWaits -= 1; + operation(); + }; + const onError = (): void => { + finish(() => { + reject(new Error("Parser module worker failed")); + }); + }; + const onMessage = (event: MessageEvent): void => { + const message = decodeNodeSqlParserWireMessage(event.data); + finish(() => { + if (message === null) { + reject( + new Error("Parser module worker returned malformed wire data"), + ); + } else { + resolve(message); + } + }); + }; + const timeout = setTimeout(() => { + finish(() => { + reject(new Error("Parser module worker message timed out")); + }); + }, MESSAGE_TIMEOUT_MS); + worker.addEventListener("error", onError); + worker.addEventListener("message", onMessage); + }); +} + +async function request( + worker: Worker, + grammar: NodeSqlParserWireGrammar, + requestId: number, + text: string, +): Promise< + Exclude< + NodeSqlParserWireMessage, + { readonly kind: "ready" | "protocol-error" } + > +> { + const responsePromise = waitForWireMessage(worker); + worker.postMessage( + encodeNodeSqlParserWireRequest( + grammar, + requestId, + text, + ), + ); + const response = await responsePromise; + if ( + response.kind === "ready" || + response.kind === "protocol-error" || + response.requestId !== requestId + ) { + throw new Error("Parser module worker response did not correlate"); + } + return response; +} + +test( + "runs both lazy grammar backends through the closed worker protocol", + { timeout: 10_000 }, + async () => { + const keys = ["NodeSQLParser", "global"] as const; + const originalDescriptors = keys.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(globalThis, key), + key, + })); + const nodeSqlParserSentinel = Object.freeze({ + owner: "browser-main-node-sql-parser", + }); + const globalSentinel = Object.freeze({ + owner: "browser-main-global", + }); + const nodeSqlParserDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: true, + value: nodeSqlParserSentinel, + writable: false, + }; + const globalDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: false, + value: globalSentinel, + writable: true, + }; + Object.defineProperty( + globalThis, + "NodeSQLParser", + nodeSqlParserDescriptor, + ); + Object.defineProperty(globalThis, "global", globalDescriptor); + + let worker: Worker | undefined; + try { + worker = createParserWorker(); + await expect(waitForWireMessage(worker)).resolves.toStrictEqual({ + kind: "ready", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }); + + const privateSourceMarker = "__wire_private_source_marker__"; + const firstPostgresql = await request( + worker, + "postgresql", + 1, + `SELECT 1 AS ${privateSourceMarker}`, + ); + expect(firstPostgresql).toStrictEqual({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 1, + statementKind: "query", + }); + expect(JSON.stringify(firstPostgresql)).not.toContain( + privateSourceMarker, + ); + expect(JSON.stringify(firstPostgresql)).not.toMatch( + /(?:\bast\b|\berror\b|\bmessage\b|\broot\b|\bstack\b)/i, + ); + + await expect( + request(worker, "postgresql", 2, "SELECT 2 AS warm_value"), + ).resolves.toStrictEqual({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 2, + statementKind: "query", + }); + + await expect( + request( + worker, + "bigquery", + 3, + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 3, + statementKind: "query", + }); + + await expect( + request(worker, "postgresql", 4, "SELECT FROM"), + ).resolves.toStrictEqual({ + kind: "syntax-rejected", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId: 4, + }); + + await expect( + request( + worker, + "postgresql", + 5, + "SELECT 1; SELECT 2", + ), + ).resolves.toStrictEqual({ + kind: "unsupported", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + reason: "multiple-statements", + requestId: 5, + }); + + expect( + Object.getOwnPropertyDescriptor( + globalThis, + "NodeSQLParser", + ), + ).toStrictEqual(nodeSqlParserDescriptor); + expect( + Object.getOwnPropertyDescriptor(globalThis, "global"), + ).toStrictEqual(globalDescriptor); + expect(activeMessageWaits).toBe(0); + } finally { + worker?.terminate(); + for (const { descriptor, key } of originalDescriptors) { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.defineProperty(globalThis, key, descriptor); + } + } + } + expect(activeMessageWaits).toBe(0); + }, +); + +test( + "fails closed on an invalid request without reflecting its data", + { timeout: 10_000 }, + async () => { + const worker = createParserWorker(); + try { + await expect(waitForWireMessage(worker)).resolves.toStrictEqual({ + kind: "ready", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }); + const privateMarker = "__invalid_request_private_marker__"; + const responsePromise = waitForWireMessage(worker); + worker.postMessage({ + grammar: "postgresql", + kind: "parse", + privateMarker, + protocolVersion: + NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION + 1, + requestId: 6, + text: "SELECT 1", + }); + const response = await responsePromise; + + expect(response).toStrictEqual({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }); + expect(JSON.stringify(response)).not.toContain(privateMarker); + expect(Reflect.has(response, "requestId")).toBe(false); + expect(activeMessageWaits).toBe(0); + } finally { + worker.terminate(); + } + expect(activeMessageWaits).toBe(0); + }, +); diff --git a/src/vnext/node-sql-parser-browser-worker-endpoint.ts b/src/vnext/node-sql-parser-browser-worker-endpoint.ts new file mode 100644 index 0000000..7023fbe --- /dev/null +++ b/src/vnext/node-sql-parser-browser-worker-endpoint.ts @@ -0,0 +1,343 @@ +import { + createNodeSqlParserBackend, + type NodeSqlParserBackendOutcome, + type NodeSqlParserModuleLoadOutcome, +} from "./node-sql-parser-backend.js"; +import { + decodeNodeSqlParserWireRequest, + encodeNodeSqlParserWireBackendOutcome, + encodeNodeSqlParserWireProtocolError, + encodeNodeSqlParserWireReady, + type NodeSqlParserWireGrammar, + type NodeSqlParserWireRequest, +} from "./node-sql-parser-wire.js"; + +const GUARDED_GLOBAL_KEYS = [ + "NodeSQLParser", + "global", +] as const; + +interface BrowserWorkerMessageEvent { + readonly data: unknown; +} + +type BrowserWorkerMessageListener = ( + event: BrowserWorkerMessageEvent, +) => void; + +export interface NodeSqlParserBrowserWorkerScope { + readonly self: unknown; + readonly addEventListener: ( + type: "message", + listener: BrowserWorkerMessageListener, + ) => void; + readonly removeEventListener?: ( + type: "message", + listener: BrowserWorkerMessageListener, + ) => void; + readonly postMessage: (message: unknown) => void; + readonly close: () => void; +} + +export type NodeSqlParserBrowserWorkerModuleLoaders = Readonly< + Record Promise> +>; + +interface GlobalDescriptorSnapshot { + readonly descriptor: PropertyDescriptor | undefined; + readonly key: (typeof GUARDED_GLOBAL_KEYS)[number]; +} + +interface GuardedBackendRunner { + readonly isPoisoned: () => boolean; + readonly parse: ( + operation: () => Promise, + ) => Promise; +} + +type EndpointState = "active" | "closed" | "idle"; + +function failedModuleLoad( + code: "backend" | "module-load", + retryable: boolean, +): NodeSqlParserModuleLoadOutcome { + return Object.freeze({ + code, + kind: "failed", + retryable, + }); +} + +function descriptorsEqual( + left: PropertyDescriptor | undefined, + right: PropertyDescriptor | undefined, +): boolean { + if (left === undefined || right === undefined) { + return left === right; + } + if ( + left.configurable !== right.configurable || + left.enumerable !== right.enumerable + ) { + return false; + } + if ("value" in left || "value" in right) { + return ( + "value" in left && + "value" in right && + left.writable === right.writable && + Object.is(left.value, right.value) + ); + } + return left.get === right.get && left.set === right.set; +} + +function snapshotGuardedGlobals( + target: object, +): readonly GlobalDescriptorSnapshot[] | null { + const snapshots: GlobalDescriptorSnapshot[] = []; + try { + for (const key of GUARDED_GLOBAL_KEYS) { + snapshots.push({ + descriptor: Object.getOwnPropertyDescriptor(target, key), + key, + }); + } + } catch { + return null; + } + return snapshots; +} + +function restoreGuardedGlobals( + target: object, + snapshots: readonly GlobalDescriptorSnapshot[], +): boolean { + let restored = true; + for (const { descriptor, key } of snapshots) { + try { + if (descriptor === undefined) { + if (!Reflect.deleteProperty(target, key)) { + restored = false; + } + } else { + Object.defineProperty(target, key, descriptor); + } + } catch { + restored = false; + } + + try { + if ( + !descriptorsEqual( + descriptor, + Object.getOwnPropertyDescriptor(target, key), + ) + ) { + restored = false; + } + } catch { + restored = false; + } + } + return restored; +} + +function failedBackend(): NodeSqlParserBackendOutcome { + return Object.freeze({ + code: "backend", + kind: "failed", + retryable: false, + }); +} + +function createModuleLoader( + loadModule: () => Promise, +): () => Promise { + return async () => { + try { + return Object.freeze({ + kind: "loaded" as const, + moduleValue: await loadModule(), + }); + } catch { + return failedModuleLoad("module-load", true); + } + }; +} + +function createGuardedBackendRunner( + target: object, +): GuardedBackendRunner { + let poisoned = false; + + return Object.freeze({ + isPoisoned: () => poisoned, + async parse( + operation: () => Promise, + ): Promise { + if (poisoned) { + return failedBackend(); + } + const snapshots = snapshotGuardedGlobals(target); + if (snapshots === null) { + poisoned = true; + return failedBackend(); + } + + let completed = false; + let outcome: NodeSqlParserBackendOutcome = failedBackend(); + try { + outcome = await operation(); + completed = true; + } catch { + // The wire outcome deliberately carries no raw backend error. + } + + const restored = restoreGuardedGlobals(target, snapshots); + if (!restored) { + poisoned = true; + return failedBackend(); + } + return completed ? outcome : failedBackend(); + }, + }); +} + +function isDedicatedWorkerRealm( + scope: NodeSqlParserBrowserWorkerScope, +): boolean { + try { + return ( + scope.self === scope && + !("window" in scope) && + !("document" in scope) + ); + } catch { + return false; + } +} + +function shouldCloseAfterOutcome( + outcome: NodeSqlParserBackendOutcome, + guard: GuardedBackendRunner, +): boolean { + return ( + guard.isPoisoned() || + (outcome.kind === "failed" && outcome.code === "module-load") + ); +} + +export function installNodeSqlParserBrowserWorkerEndpoint( + scope: NodeSqlParserBrowserWorkerScope, + loaders: NodeSqlParserBrowserWorkerModuleLoaders, +): void { + if (!isDedicatedWorkerRealm(scope)) { + throw new Error( + "node-sql-parser endpoint requires a dedicated worker realm", + ); + } + + let state: EndpointState = "idle"; + let listenerInstalled = false; + + const guard = createGuardedBackendRunner(scope); + const backends = Object.freeze({ + bigquery: createNodeSqlParserBackend( + createModuleLoader(loaders.bigquery), + ), + postgresql: createNodeSqlParserBackend( + createModuleLoader(loaders.postgresql), + ), + } satisfies Record); + + function closeEndpoint(): void { + state = "closed"; + if (listenerInstalled) { + try { + scope.removeEventListener?.("message", onMessage); + } catch { + // Closing the worker is still attempted if listener cleanup fails. + } + } + try { + scope.close(); + } catch { + // Worker settlement must not surface host or test-double errors. + } + } + + function postProtocolErrorAndClose(): void { + state = "closed"; + try { + scope.postMessage(encodeNodeSqlParserWireProtocolError()); + } catch { + // The endpoint closes below even when encoding or posting fails. + } + closeEndpoint(); + } + + async function handleRequest( + request: NodeSqlParserWireRequest, + ): Promise { + try { + const outcome = await guard.parse(() => + backends[request.grammar].parse(request.text), + ); + if (state !== "active") { + return; + } + + const closeAfterSettlement = shouldCloseAfterOutcome( + outcome, + guard, + ); + state = closeAfterSettlement ? "closed" : "idle"; + try { + scope.postMessage( + encodeNodeSqlParserWireBackendOutcome( + request.requestId, + outcome, + ), + ); + } catch { + closeEndpoint(); + return; + } + if (closeAfterSettlement) { + closeEndpoint(); + } + } catch { + closeEndpoint(); + } + } + + function onMessage(event: BrowserWorkerMessageEvent): void { + if (state === "closed") { + return; + } + + const request = decodeNodeSqlParserWireRequest(event.data); + if (request === null || state === "active") { + postProtocolErrorAndClose(); + return; + } + + state = "active"; + void handleRequest(request); + } + + try { + scope.addEventListener("message", onMessage); + listenerInstalled = true; + } catch { + closeEndpoint(); + return; + } + + try { + scope.postMessage(encodeNodeSqlParserWireReady()); + } catch { + closeEndpoint(); + } +} diff --git a/src/vnext/node-sql-parser-browser-worker.ts b/src/vnext/node-sql-parser-browser-worker.ts new file mode 100644 index 0000000..e4a6d79 --- /dev/null +++ b/src/vnext/node-sql-parser-browser-worker.ts @@ -0,0 +1,8 @@ +import { installNodeSqlParserBrowserWorkerEndpoint } from "./node-sql-parser-browser-worker-endpoint.js"; + +installNodeSqlParserBrowserWorkerEndpoint(globalThis, { + bigquery: async () => + await import("node-sql-parser/build/bigquery.js"), + postgresql: async () => + await import("node-sql-parser/build/postgresql.js"), +}); diff --git a/src/vnext/node-sql-parser-wire.ts b/src/vnext/node-sql-parser-wire.ts new file mode 100644 index 0000000..93cc22d --- /dev/null +++ b/src/vnext/node-sql-parser-wire.ts @@ -0,0 +1,457 @@ +import { + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, + type NodeSqlParserBackendOutcome, +} from "./node-sql-parser-backend.js"; +import type { SqlStatementKind } from "./syntax.js"; + +export const NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION = 1 as const; + +// The parse request is the largest closed protocol shape. +const MAX_NODE_SQL_PARSER_WIRE_RECORD_KEYS = 5; + +export type NodeSqlParserWireGrammar = "bigquery" | "postgresql"; + +export type NodeSqlParserWireFailureCode = + | "backend" + | "malformed-output" + | "module-load"; + +export interface NodeSqlParserWireRequest { + readonly protocolVersion: 1; + readonly kind: "parse"; + readonly requestId: number; + readonly grammar: NodeSqlParserWireGrammar; + readonly text: string; +} + +export type NodeSqlParserWireMessage = + | { + readonly protocolVersion: 1; + readonly kind: "ready"; + } + | { + readonly protocolVersion: 1; + readonly kind: "parsed"; + readonly requestId: number; + readonly statementKind: SqlStatementKind; + } + | { + readonly protocolVersion: 1; + readonly kind: "syntax-rejected"; + readonly requestId: number; + } + | { + readonly protocolVersion: 1; + readonly kind: "unsupported"; + readonly requestId: number; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly protocolVersion: 1; + readonly kind: "failed"; + readonly requestId: number; + readonly code: NodeSqlParserWireFailureCode; + } + | { + readonly protocolVersion: 1; + readonly kind: "protocol-error"; + readonly code: "invalid-request"; + }; + +interface InspectedRecord { + readonly values: ReadonlyMap; +} + +function inspectRecord(value: unknown): InspectedRecord | null { + if (typeof value !== "object" || value === null) { + return null; + } + + try { + if ( + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return null; + } + + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_NODE_SQL_PARSER_WIRE_RECORD_KEYS) { + return null; + } + const values = new Map(); + for (const key of keys) { + if (typeof key !== "string") { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return null; + } + values.set(key, descriptor.value); + } + return { values }; + } catch { + return null; + } +} + +function hasExactKeys( + record: InspectedRecord, + keys: readonly string[], +): boolean { + if (record.values.size !== keys.length) { + return false; + } + return keys.every((key) => record.values.has(key)); +} + +function hasCurrentProtocolVersion(record: InspectedRecord): boolean { + return ( + record.values.get("protocolVersion") === + NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION + ); +} + +function isRequestId(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value > 0 + ); +} + +function isGrammar( + value: unknown, +): value is NodeSqlParserWireGrammar { + return value === "bigquery" || value === "postgresql"; +} + +function isStatementKind(value: unknown): value is SqlStatementKind { + switch (value) { + case "alter": + case "create": + case "delete": + case "drop": + case "insert": + case "merge": + case "other": + case "query": + case "transaction": + case "update": + return true; + default: + return false; + } +} + +function isUnsupportedReason( + value: unknown, +): value is "multiple-statements" | "resource-limit" { + return ( + value === "multiple-statements" || value === "resource-limit" + ); +} + +function isFailureCode( + value: unknown, +): value is NodeSqlParserWireFailureCode { + return ( + value === "backend" || + value === "malformed-output" || + value === "module-load" + ); +} + +function isRequestText(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + ); +} + +function requireRequestId(value: number): void { + if (!isRequestId(value)) { + throw new TypeError( + "node-sql-parser wire request ID must be a positive safe integer", + ); + } +} + +function requireGrammar(value: NodeSqlParserWireGrammar): void { + if (!isGrammar(value)) { + throw new TypeError( + "node-sql-parser wire grammar must be a closed grammar ID", + ); + } +} + +function requireRequestText(value: string): void { + if (!isRequestText(value)) { + throw new TypeError( + "node-sql-parser wire text exceeds the statement input limit", + ); + } +} + +function requireStatementKind(value: SqlStatementKind): void { + if (!isStatementKind(value)) { + throw new TypeError( + "node-sql-parser wire statement kind must be closed", + ); + } +} + +function requireUnsupportedReason( + value: "multiple-statements" | "resource-limit", +): void { + if (!isUnsupportedReason(value)) { + throw new TypeError( + "node-sql-parser wire unsupported reason must be closed", + ); + } +} + +function requireFailureCode( + value: NodeSqlParserWireFailureCode, +): void { + if (!isFailureCode(value)) { + throw new TypeError( + "node-sql-parser wire failure code must be closed", + ); + } +} + +export function encodeNodeSqlParserWireRequest( + grammar: NodeSqlParserWireGrammar, + requestId: number, + text: string, +): NodeSqlParserWireRequest { + requireGrammar(grammar); + requireRequestId(requestId); + requireRequestText(text); + return Object.freeze({ + grammar, + kind: "parse", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + text, + }); +} + +export function decodeNodeSqlParserWireRequest( + value: unknown, +): NodeSqlParserWireRequest | null { + const record = inspectRecord(value); + if ( + record === null || + !hasExactKeys(record, [ + "protocolVersion", + "kind", + "requestId", + "grammar", + "text", + ]) || + !hasCurrentProtocolVersion(record) || + record.values.get("kind") !== "parse" + ) { + return null; + } + + const requestId = record.values.get("requestId"); + const grammar = record.values.get("grammar"); + const text = record.values.get("text"); + if ( + !isRequestId(requestId) || + !isGrammar(grammar) || + !isRequestText(text) + ) { + return null; + } + return encodeNodeSqlParserWireRequest(grammar, requestId, text); +} + +export function encodeNodeSqlParserWireReady(): Extract< + NodeSqlParserWireMessage, + { readonly kind: "ready" } +> { + return Object.freeze({ + kind: "ready", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }); +} + +export function encodeNodeSqlParserWireProtocolError(): Extract< + NodeSqlParserWireMessage, + { readonly kind: "protocol-error" } +> { + return Object.freeze({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + }); +} + +export function encodeNodeSqlParserWireBackendOutcome( + requestId: number, + outcome: NodeSqlParserBackendOutcome, +): Exclude< + NodeSqlParserWireMessage, + { readonly kind: "protocol-error" | "ready" } +> { + requireRequestId(requestId); + switch (outcome.kind) { + case "parsed": + requireStatementKind(outcome.statementKind); + return Object.freeze({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + statementKind: outcome.statementKind, + }); + case "syntax-rejected": + return Object.freeze({ + kind: "syntax-rejected", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + }); + case "unsupported": + requireUnsupportedReason(outcome.reason); + return Object.freeze({ + kind: "unsupported", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + reason: outcome.reason, + requestId, + }); + case "failed": + requireFailureCode(outcome.code); + return Object.freeze({ + code: outcome.code, + kind: "failed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + }); + default: + throw new TypeError( + "node-sql-parser wire backend outcome kind must be closed", + ); + } +} + +export function decodeNodeSqlParserWireMessage( + value: unknown, +): NodeSqlParserWireMessage | null { + const record = inspectRecord(value); + if ( + record === null || + !hasCurrentProtocolVersion(record) + ) { + return null; + } + + const kind = record.values.get("kind"); + const requestId = record.values.get("requestId"); + switch (kind) { + case "ready": + return hasExactKeys(record, ["protocolVersion", "kind"]) + ? encodeNodeSqlParserWireReady() + : null; + case "parsed": { + const statementKind = record.values.get("statementKind"); + if ( + !hasExactKeys(record, [ + "protocolVersion", + "kind", + "requestId", + "statementKind", + ]) || + !isRequestId(requestId) || + !isStatementKind(statementKind) + ) { + return null; + } + return Object.freeze({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + statementKind, + }); + } + case "syntax-rejected": + return hasExactKeys(record, [ + "protocolVersion", + "kind", + "requestId", + ]) && isRequestId(requestId) + ? Object.freeze({ + kind: "syntax-rejected", + protocolVersion: + NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + }) + : null; + case "unsupported": { + const reason = record.values.get("reason"); + if ( + !hasExactKeys(record, [ + "protocolVersion", + "kind", + "requestId", + "reason", + ]) || + !isRequestId(requestId) || + !isUnsupportedReason(reason) + ) { + return null; + } + return Object.freeze({ + kind: "unsupported", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + reason, + requestId, + }); + } + case "failed": { + const code = record.values.get("code"); + if ( + !hasExactKeys(record, [ + "protocolVersion", + "kind", + "requestId", + "code", + ]) || + !isRequestId(requestId) || + !isFailureCode(code) + ) { + return null; + } + return Object.freeze({ + code, + kind: "failed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + requestId, + }); + } + case "protocol-error": + return hasExactKeys(record, [ + "protocolVersion", + "kind", + "code", + ]) && record.values.get("code") === "invalid-request" + ? encodeNodeSqlParserWireProtocolError() + : null; + default: + return null; + } +} + +export function isNodeSqlParserWireFailureRetryable( + code: NodeSqlParserWireFailureCode, +): boolean { + requireFailureCode(code); + return code === "module-load"; +} diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 012b8e4..084d7e5 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -2,6 +2,12 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; export default defineConfig({ + optimizeDeps: { + include: [ + "node-sql-parser/build/bigquery.js", + "node-sql-parser/build/postgresql.js", + ], + }, test: { allowOnly: false, browser: { diff --git a/vitest.config.ts b/vitest.config.ts index 3be1a5b..8cdc44f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/**/__tests__/**", "src/**/browser_tests/**", "src/debug.ts", + "src/vnext/node-sql-parser-browser-worker.ts", ], excludeAfterRemap: true, include: ["src/**/*.ts"],