From a6f6c04c8867c7fc609aa42ba24988e78881e145 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 20:37:17 +0800 Subject: [PATCH 01/42] chore: establish vNext quality foundation (#170) Establishes the next-major engineering foundation before production architecture changes. Adds the research and implementation plans, split unit/browser configs, honest aggregate coverage ratchets, 95% changed-code coverage, governed known failures, strict vNext typing, clean builds, deterministic packed-consumer validation, Node floor coverage, and release-gate parity.\n\nValidated locally: lint, integrity, all TypeScript scopes, 587 unit tests plus one governed expected failure, aggregate and changed coverage, Chromium browser smoke, packed consumer/runtime parse, demo build, actionlint, and two independent adversarial review loops. --- ## Summary by cubic Sets up the vNext quality foundation with strict typing, split unit/browser tests, honest coverage gates (aggregate + changed-code), governed known failures, and deterministic package smoke tests that preserve the `pnpm` version. Hardens CI/release and unblocks the next major architecture work. - **New Features** - CI: pin Node 20.19.0 and `pnpm` 10.28.2 with version checks; disable Node-managed PM version enforcement; add concurrency with cancel-in-progress; fetch full git history; add lint and test integrity gates; split unit and headless browser runs; enforce aggregate and changed-code coverage. - Release: add a compatibility job that runs `pnpm pack` and a consumer smoke test; preserve and verify `pnpm` 10.28.2 across the smoke path; `autofix.yml` also runs on `dev-refactor`. - Known failures governed via `test/known-failures.json` with owner and expiry. - Added vNext docs: `SQL_EDITOR_RESEARCH.md` and `implementation.md`. - **Refactors** - Split TS configs and typecheck scripts for source, legacy tests, vNext tests, and demo; vNext tests use stricter rules. - Extracted `vitest` configs; `vite.config.ts` now only builds the demo. - Minor ESM/typing fixes in demo and tests; export test now uses `pnpm pack`. Written for commit ebbeb2fadf58dfac1c2ee20adb2fb6f10a7f3b80. Summary will update on new commits. Review in cubic --- .github/workflows/autofix.yml | 2 +- .github/workflows/release.yml | 54 +- .github/workflows/test.yml | 100 +- SQL_EDITOR_RESEARCH.md | 1681 +++++++++++++++++ _typos.toml | 9 +- demo/custom-renderers.ts | 9 +- demo/data.ts | 2 +- demo/index.ts | 3 +- implementation.md | 826 ++++++++ package.json | 23 +- pnpm-lock.yaml | 55 +- scripts/changed-coverage.mjs | 86 + scripts/check-test-integrity.mjs | 99 + scripts/clean.mjs | 9 + scripts/package-smoke.mjs | 194 ++ src/__tests__/index.test.ts | 18 +- src/__tests__/package-exports.test.ts | 18 +- .../__tests__/completion-extension.test.ts | 5 +- src/sql/__tests__/parser.test.ts | 2 +- test/known-failures.json | 7 + test/package-smoke/package.json | 13 + test/package-smoke/pnpm-lock.yaml | 362 ++++ tsconfig.demo.json | 24 + tsconfig.tests.json | 30 + tsconfig.vnext-tests.json | 12 + vite.config.ts | 44 +- vitest.browser.config.ts | 19 + vitest.config.ts | 32 + 28 files changed, 3645 insertions(+), 93 deletions(-) create mode 100644 SQL_EDITOR_RESEARCH.md create mode 100644 implementation.md create mode 100644 scripts/changed-coverage.mjs create mode 100644 scripts/check-test-integrity.mjs create mode 100644 scripts/clean.mjs create mode 100644 scripts/package-smoke.mjs create mode 100644 test/known-failures.json create mode 100644 test/package-smoke/package.json create mode 100644 test/package-smoke/pnpm-lock.yaml create mode 100644 tsconfig.demo.json create mode 100644 tsconfig.tests.json create mode 100644 tsconfig.vnext-tests.json create mode 100644 vitest.browser.config.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 9f8c011..639fe8e 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -2,7 +2,7 @@ name: autofix.ci # needed to securely identify the workflow on: push: - branches: [main] + branches: [main, dev-refactor] pull_request: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13005dd..95bf921 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,39 @@ on: - 'v*' jobs: + compatibility: + env: + EXPECTED_PNPM_VERSION: "10.28.2" + npm_config_manage_package_manager_versions: "false" + npm_config_package_manager_strict_version: "false" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + with: + package_json_file: test/package-smoke/package.json + version: 10.28.2 + + - name: ⎔ Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 20.19.0 + cache: pnpm + + - name: 🔒 Verify pnpm version + shell: bash + run: test "$(pnpm --version)" = "10.28.2" + + - name: 📥 Install dependencies + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: 📦 Packed Package Smoke Test + run: pnpm run test:package + build: runs-on: ubuntu-latest steps: @@ -27,11 +60,26 @@ jobs: - name: 📥 Install dependencies run: pnpm install --ignore-scripts --frozen-lockfile + - name: 🧹 Lint + run: pnpm exec oxlint + + - name: 🧪 Test Integrity + run: pnpm run test:integrity + - name: 🔍 Type Check run: pnpm run typecheck - - name: 🧪 Test - run: pnpm run test + - name: 🧪 Unit Tests and Coverage + run: pnpm run test:coverage + + - name: 🎭 Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: 🧪 Browser Tests + run: pnpm run test:browser + + - name: 📦 Packed Package Smoke Test + run: pnpm run test:package - name: 🏗️ Build run: pnpm run build @@ -76,7 +124,7 @@ jobs: contents: read publish: - needs: build + needs: [build, compatibility] runs-on: ubuntu-latest permissions: id-token: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2aa0f17..f5afba4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,10 +4,16 @@ on: push: branches: - main + - dev-refactor pull_request: branches: - main + - dev-refactor + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CLICOLOR: 1 @@ -22,6 +28,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: ⎔ Setup pnpm uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 @@ -38,18 +46,104 @@ jobs: - name: 🧹 Lint run: pnpm exec oxlint + - name: 🧪 Test Integrity + run: pnpm run test:integrity + - name: 🔍 Type Check run: pnpm run typecheck - - name: 🧪 Test - run: pnpm run test + - name: 🧪 Unit Tests and Coverage + run: pnpm run test:coverage + + - name: 📈 Changed-Code Coverage + if: github.event_name == 'pull_request' || github.event_name == 'push' + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: >- + pnpm run test:coverage:changed + "${{ github.event.pull_request.base.sha || github.event.before }}" - name: 📊 Report Coverage - if: always() + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository uses: davelosert/vitest-coverage-report-action@3c50566c523e04813df28de8f7c48dd97d663f1c # v2 with: pr-number: auto + browser: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + + - name: ⎔ Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: pnpm + + - name: 📥 Install dependencies + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: 🎭 Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: 🧪 Browser Tests + run: pnpm run test:browser + + package: + env: + EXPECTED_PNPM_VERSION: ${{ matrix.pnpm-version }} + npm_config_manage_package_manager_versions: "false" + npm_config_package_manager_strict_version: "false" + strategy: + fail-fast: false + matrix: + include: + - node-version: "20.19.0" + pnpm-manifest: "test/package-smoke/package.json" + pnpm-version: "10.28.2" + - node-version: "24" + pnpm-manifest: "package.json" + pnpm-version: "11.4.0" + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + with: + package_json_file: ${{ matrix.pnpm-manifest }} + version: ${{ matrix.pnpm-version }} + + - name: ⎔ Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - name: 🔒 Verify pnpm version + shell: bash + run: test "$(pnpm --version)" = "${{ matrix.pnpm-version }}" + + - name: 📥 Install dependencies + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: 📦 Packed Package Smoke Test + run: pnpm run test:package + security: runs-on: ubuntu-latest steps: diff --git a/SQL_EDITOR_RESEARCH.md b/SQL_EDITOR_RESEARCH.md new file mode 100644 index 0000000..a973874 --- /dev/null +++ b/SQL_EDITOR_RESEARCH.md @@ -0,0 +1,1681 @@ +# SQL Editor Research and Gap Analysis + +Date: 2026-07-24 + +## Executive summary + +The repository has a strong foundation, especially after v0.3.0, but the +investigation found several confirmed correctness bugs and one potential +security issue. + +The biggest strategic limitation is that most semantic features are built +around a flat, mostly location-free `node-sql-parser` AST. This makes nested +scopes, accurately positioned diagnostics, dialect fidelity, and multi-catalog +completion harder than they need to be. + +The recommended sequence is: + +1. Ship a correctness and security release. +2. Consolidate parsing and semantic analysis around a shared, location-aware + document model. +3. Build richer completion, schema, formatting, and language-server features on + that model. + +## Highest-priority findings + +### P0: Escape all default hover content + +Default tooltips assemble schema and keyword metadata as HTML and assign it +through `innerHTML`. Table names, column names, completion `detail` and `info`, +keyword descriptions, examples, and metadata are not escaped. + +Relevant code: + +- `src/sql/hover.ts`, around the tooltip DOM construction and `innerHTML` + assignment. +- `src/sql/hover.ts`, in `createNamespaceTooltip`, + `createKeywordTooltip`, `createTableTooltip`, and `createColumnTooltip`. + +If schema descriptions or completion metadata originate from a database or +service, crafted values could inject markup or script-capable elements into the +host application. + +Recommended fix: + +- Build default tooltips with DOM nodes and `textContent`, or escape every value + used by the default renderers. +- Document whether custom renderers return trusted HTML or plain text. +- Add hostile table, column, description, and metadata tests. + +### P1: BigQuery dialect construction is incorrect + +`src/dialects/bigquery.ts` spreads the `PostgreSQL` dialect object rather than +`PostgreSQL.spec`: + +```ts +const BigQuery: SQLDialectSpec = { + ...PostgreSQL, + caseInsensitiveIdentifiers: true, + identifierQuotes: "`", +}; +``` + +Runtime inspection confirmed: + +- PostgreSQL has 831 registered words. +- `BigQueryDialect` has only 295, corresponding to the standard defaults. +- BigQuery-relevant items such as `QUALIFY` are absent. + +At minimum, this should spread `PostgreSQL.spec`. Preferably, the project should +generate and maintain a real BigQuery dialect spec rather than describing it as +a PostgreSQL wrapper. + +The earlier marimo +[BigQuery quoting report](https://github.com/marimo-team/marimo/issues/5419) +also illustrates why dialect-specific quoting and completion need to be +accurate. + +### P1: The statement splitter corrupts valid SQL + +The custom scanner in `src/sql/structure-analyzer.ts` understands single, +double, and backtick quotes plus comments, but not: + +- PostgreSQL dollar-quoted strings +- MSSQL bracket-quoted identifiers +- Backslash escape modes +- Procedural `BEGIN ... END` bodies +- Custom delimiters +- Some dialect-specific multiline strings + +The following was reproduced: + +```sql +SELECT $$a;b$$; SELECT [x;y] FROM t; SELECT 3 +``` + +It was split into: + +```text +SELECT $$a +b$$ +SELECT [x +y] FROM t +SELECT 3 +``` + +This affects linting, gutter state, hover scope, completion, and navigation +simultaneously. + +Recommended fix: + +- Obtain statement boundaries from a tokenizer, parser, or CodeMirror syntax + tree. +- Keep the scanner only as a documented fallback. +- Add a dialect-specific statement-boundary corpus. + +### P1: Semantic diagnostics can point to the wrong text or disappear + +Semantic diagnostic locations are reconstructed by finding the first matching +identifier text in the statement. This can underline an occurrence inside a +comment or string rather than the real table or column reference. + +Confirmed examples: + +```sql +SELECT 'usres' AS note FROM usres +``` + +```sql +-- usres +SELECT * FROM usres +``` + +In both cases, the unknown-table diagnostic points to the first `usres`. + +There is a second issue. `SqlStatement.content` has comments removed before +semantic re-parsing, but comment removal does not preserve whitespace. These +queries silently lose an expected unknown-column warning: + +```sql +SELECT/*x*/bad FROM users +SELECT bad/*x*/FROM users +SELECT bad FROM/*x*/users +``` + +The original statement parses, but semantic analysis reparses concatenated text +such as `SELECTbad`. + +Recommended fix: + +- Store the original statement source and parsed AST on `SqlStatement`. +- Preserve source locations from the parser. +- Never reparse comment-stripped display content. +- Mask comments with spaces when offsets must be preserved. + +### P1: Nested query scopes leak into completion + +`walkAst` flattens tables from every nested query into one `QueryContext`. +Unqualified completion then iterates the entire flattened table list. + +This outer-query completion was reproduced: + +```sql +SELECT | FROM users +WHERE EXISTS (SELECT 1 FROM orders) +``` + +With a schema containing `user_col` and `order_col`, the outer completion +offered both. Only the columns of `users` are visible at that cursor. + +The query model should become a scope tree containing: + +- Parent and child scopes +- Exact source ranges +- Visible CTEs and aliases +- Derived-table output columns +- Correlation rules +- The innermost scope at a cursor position + +This also provides a principled fix for alias shadowing and CTE visibility. + +### P1: State-sensitive parser results are cached only by SQL text + +Both `SqlStructureAnalyzer` and `QueryContextAnalyzer` cache by SQL text alone. +However, `NodeSqlParser.getParserOptions(state)` explicitly allows dialect and +parser options to depend on editor state. + +A targeted reproduction analyzed identical SQL under two states with different +dialect facets. The parser was only called for the first state, and the second +state received the cached result. + +Recommended fix: + +- Add a parser/dialect/configuration identity to cache keys, or +- Invalidate analyzers when relevant facets change. + +### P1: Async gutter results can arrive out of order + +The gutter starts untracked asynchronous analysis after document, selection, or +focus changes and dispatches each result unconditionally. A slower result for an +older state can therefore overwrite the result for newer text. + +Navigation already uses generation checks. The gutter should follow the same +pattern and verify that `view.state` still matches the captured state before +dispatching. + +## Smaller confirmed issues + +### A zero-millisecond lint delay is ignored + +Both linters use: + +```ts +delay: config.delay || DEFAULT_DELAY +``` + +As a result, `delay: 0` becomes the default delay. Use: + +```ts +delay: config.delay ?? DEFAULT_DELAY +``` + +### Hover fuzzy-search documentation and behavior disagree + +The `SqlHoverConfig` documentation says fuzzy matching defaults to `false`, but +`createHoverSource` defaults it to `true`. + +One side should be changed and a default-behavior test added. + +### Missing `@codemirror/lang-sql` peer dependency + +The exported `./dialects` entry requires `@codemirror/lang-sql` at runtime, but +that package is only listed as a development dependency. + +It should be a peer dependency, potentially optional if consumers who never +import `./dialects` should not be required to install it. + +### DuckDB syntax can be declared valid without an AST + +DuckDB statements beginning with `FROM` or containing `macro` are accepted +without an AST. This prevents false syntax errors, but it also silently disables +semantic linting, reliable hover, CTE analysis, and navigation for those +statements. + +Validity and analyzability should be represented separately. + +### The same document is parsed repeatedly + +Linting, semantic linting, gutter analysis, completion, hover, and navigation +often own separate analyzers. Semantic linting additionally parses a valid +statement once in the structure analyzer and again for semantic checks. + +A shared document-analysis service should provide: + +- Statement boundaries +- Original source +- AST and tokens +- Source locations +- Scope information +- Parse diagnostics +- A state-sensitive cache identity + +### Parser bundle size + +The full local `node-sql-parser` installation occupied approximately 88 MB. +Its documentation says that the browser build containing all database parsers +is about 750 KB, versus about 150 KB for a specific database parser. + +Per-dialect imports and parser adapters are worth investigating: + +- [node-sql-parser documentation](https://www.npmjs.com/package/node-sql-parser) + +## Features worth adding + +### 1. Real cursor-scoped completion + +Support: + +- Nested and correlated subqueries +- Derived tables +- CTE `SELECT *` propagation +- `UNION` output schemas +- Correct catalog and schema search paths +- Temporary tables and views created in preceding statements + +The open marimo report about completion with multiple DuckDB databases is +direct product evidence: + +- [marimo #7499](https://github.com/marimo-team/marimo/issues/7499) + +### 2. A richer schema model + +The current `SQLNamespace` representation is useful for CodeMirror completion, +but advanced semantic features need more information: + +- Catalog, schema, table, view, and function distinctions +- Column type, nullability, description, and completion metadata +- Primary and foreign keys +- Relationships +- Default catalog and schema +- Search path +- Asynchronous schema loading with caching and cancellation + +An optional migration helper can convert `SQLNamespace` into this richer model, +but the new major should expose the richer model directly. + +### 3. Grammar-aware completion + +Add context-specific suggestions for: + +- Expected keywords and entity kinds at the cursor +- Functions in expression positions +- `INSERT` target columns +- `UPDATE SET` columns +- `GROUP BY` and `ORDER BY` aliases and ordinals +- Dialect-correct quoting and casing + +### 4. Higher-value editor actions + +Potential features: + +- Join-condition suggestions based on foreign keys +- Expand `*` +- Qualify ambiguous columns +- Generate table aliases +- Function signature help +- Document symbols and statement/CTE folding +- Go-to-definition for physical tables through a host callback + +### 5. Formatting and lint code actions + +Support: + +- Format document +- Format selection +- Apply safe lint fixes +- Configurable keyword case and indentation +- Templating-aware regions for `{...}`, Jinja/dbt, and prepared placeholders + +### 6. Parameter awareness + +Recognize dialect-specific parameters: + +- `?` +- `$1` +- `:name` +- `@name` + +The extension should avoid treating parameters as identifiers and could expose +host callbacks for parameter metadata. + +This complements marimo's prepared-statement discussion: + +- [marimo #9445](https://github.com/marimo-team/marimo/issues/9445) + +## Repositories and projects to study + +### DTStack/dt-sql-parser and monaco-sql-languages + +- [DTStack/dt-sql-parser](https://github.com/DTStack/dt-sql-parser) +- [DTStack/monaco-sql-languages](https://github.com/DTStack/monaco-sql-languages) + +These are the strongest direct inspiration for grammar-aware completion. +`dt-sql-parser` returns expected keywords and entity kinds at the caret and +associates tables with statement contexts. + +Its documentation also acknowledges limitations in nested subquery scenarios, +which is useful design guidance for a scope-tree implementation. + +Limitations for this project's needs: + +- Its documented dialects focus on MySQL, Flink, Spark, Hive, PostgreSQL, + Trino, Impala, and generic SQL. +- It does not directly solve DuckDB or BigQuery support. + +### SQLGlot + +- [tobymao/sqlglot](https://github.com/tobymao/sqlglot) + +SQLGlot is an excellent semantic reference implementation. It provides broad +dialect coverage, qualification, type annotation, star expansion, AST +transformations, and lineage. + +Because it is Python, it is best considered as: + +- A server-side `SqlParser` adapter for hosts such as marimo +- A correctness oracle +- A source of dialect and semantic test cases + +It is not a direct browser dependency. + +### DuckDB native autocomplete and DuckDB UI + +- [DuckDB autocomplete extension](https://duckdb.org/docs/stable/core_extensions/autocomplete.html) +- [duckdb/duckdb-ui](https://github.com/duckdb/duckdb-ui) + +For DuckDB, the strongest completion source is the actual engine through +`sql_auto_complete`. It knows attached catalogs and live schema state, which a +static browser parser cannot infer. + +DuckDB UI is also useful architectural inspiration because its server API +supports SQL tokenization and catalog-update events. + +A host integration could merge native DuckDB suggestions with local CodeMirror +suggestions. + +### SQLFluff + +- [sqlfluff/sqlfluff](https://github.com/sqlfluff/sqlfluff) + +Study: + +- Dialect and templating architecture +- Configurable lint rules +- Automatic fixes +- Behavior for incomplete and templated SQL +- Broad dialect test coverage + +### sqruff + +- [quarylabs/sqruff](https://github.com/quarylabs/sqruff) + +sqruff is a fast Rust formatter and linter with a browser playground. It is +worth evaluating for either a browser/WASM adapter or a host-side lint and +formatting service. + +### sql-formatter + +- [sql-formatter-org/sql-formatter](https://github.com/sql-formatter-org/sql-formatter) + +This is a practical TypeScript option for document and range formatting. It +supports many relevant dialects, configurable casing, and placeholders. + +Its documented limitations include stored procedures and alternate delimiter +types, so integrations should retain escape hatches and avoid presenting it as +a full parser. + +### Bytebase + +- [bytebase/bytebase](https://github.com/bytebase/bytebase) +- [Bytebase SQL Editor documentation](https://docs.bytebase.com/sql-editor/run-queries) + +Bytebase is primarily product-level inspiration: + +- Inline schema details +- SQL review rules +- Visual `EXPLAIN` +- Statement access modes +- History and sharing +- AI-assisted explanation and problem finding + +Not all of these belong in a CodeMirror package, but the package can provide the +editor primitives and host callbacks needed to build them. + +### CodeMirror LSP client + +- [CodeMirror LSP client reference](https://codemirror.net/docs/ref/#lsp-client) + +Rather than implementing every IDE feature locally, the project should provide +a documented composition path for SQL language servers. This gives hosts a +route to: + +- Formatting +- Document symbols +- Signature help +- Code actions +- Server-backed completion and diagnostics + +## Recommended execution order + +### Batch 1: Correctness and security release + +1. Escape default hover content. +2. Fix BigQuery dialect construction. +3. Fix `delay: 0`. +4. Resolve the fuzzy-search default mismatch. +5. Add the missing peer dependency. +6. Add gutter generation and stale-state checks. +7. Add regression tests for each issue. + +### Batch 2: Analysis-core refactor + +Create one shared analysis result containing: + +- Original source +- Statement boundaries +- AST +- Tokens and locations +- Query scopes +- Parse diagnostics +- Parser and dialect cache identity + +Then: + +1. Replace the manual statement splitter where possible. +2. Build a cursor-addressable scope tree. +3. Share analysis across all editor features. +4. Eliminate comment-stripped re-parsing and duplicate parsing. + +### Batch 3: Feature release + +1. Derived-table and CTE output propagation. +2. Multi-catalog and search-path completion. +3. Async rich schema model. +4. Formatting hooks. +5. LSP integration hooks. +6. Native-engine completion adapters, beginning with DuckDB. + +## Verification performed + +The repository was verified with: + +```bash +pnpm test --run +pnpm run typecheck +pnpm exec oxlint +``` + +Results: + +- 21 test files passed. +- 588 tests passed. +- 1 expected failure remained. +- Statement coverage was 93.54%. +- TypeScript typechecking passed. +- oxlint reported no warnings or errors. + +The test suite is broad, but it should add adversarial coverage for: + +- Dialect changes with unchanged document text +- Asynchronous analysis races +- Nested query scopes +- Hostile tooltip metadata +- Dollar-quoted and bracket-quoted semicolons +- Procedural statement bodies +- Comment-adjacent tokens +- Diagnostic locations when names also appear in strings and comments + +## Architectural direction + +The repository should make one major architectural shift: stop letting each +editor feature independently parse and interpret SQL. Instead, it should build +a shared SQL analysis platform and make linting, completion, hover, navigation, +and gutter rendering thin consumers of it. + +The project is currently a collection of capable CodeMirror extensions. To +become an excellent SQL editor foundation, it should become a small language +service. + +### Target architecture + +```mermaid +flowchart TD + CM["CodeMirror document + cursor"] --> COORD["Analysis coordinator"] + DIALECT["Dialect definition"] --> COORD + SCHEMA["Async schema provider"] --> COORD + + COORD --> SNAP["Immutable AnalysisSnapshot"] + + PARSER["Parser backend"] --> SNAP + SNAP --> TOKENS["Statements + tokens + locations"] + SNAP --> SCOPES["Scope graph"] + SNAP --> SYMBOLS["Symbols + references"] + SNAP --> TYPES["Resolved tables + columns + types"] + + SNAP --> LINT["Diagnostics"] + SNAP --> COMPLETE["Completion"] + SNAP --> HOVER["Hover"] + SNAP --> NAV["Definition / references / rename"] + SNAP --> STRUCTURE["Gutter / folding / symbols"] + SNAP --> ACTIONS["Formatting / code actions"] + + NATIVE["Native engine or LSP provider"] --> COMPLETE + NATIVE --> LINT + NATIVE --> ACTIONS +``` + +### 1. Introduce a single immutable analysis snapshot + +This is the most important refactor. + +Every document version should produce one `AnalysisSnapshot`: + +```ts +interface AnalysisSnapshot { + documentVersion: number; + dialectId: string; + schemaVersion?: string; + + statements: SqlStatementNode[]; + tokens: SqlToken[]; + diagnostics: SqlDiagnostic[]; + scopeGraph: SqlScopeGraph; + symbols: SqlSymbolTable; + + ast?: unknown; + capabilities: AnalysisCapabilities; +} +``` + +Each analyzed statement should retain: + +- Original source text +- Absolute document range +- Tokens and exact source locations +- Parsed AST +- Parse errors +- Root scope +- Statement type +- Whether the result is complete, recovered, or opaque + +This would eliminate: + +- Duplicate parsing +- Comment-stripped re-parsing +- Text-search diagnostic positioning +- Inconsistent interpretations between features +- Caches keyed only by SQL text + +The current `SqlStructureAnalyzer`, `QueryContextAnalyzer`, and semantic AST +traversal should gradually collapse into this service. + +### 2. Build a real scope graph + +The semantic model should represent SQL lexical and relational scopes +explicitly rather than flattening them into one query context: + +```ts +interface SqlScope { + id: string; + kind: "statement" | "select" | "cte" | "subquery" | "dml"; + range: SqlRange; + parent?: string; + + sources: SqlRelation[]; + columns: SqlColumnBinding[]; + aliases: SqlSymbol[]; + ctes: SqlSymbol[]; + children: string[]; +} +``` + +A scope graph unlocks: + +- Correct completion inside nested subqueries +- Correlated-reference handling +- Alias shadowing +- Derived-table columns +- CTE output propagation +- Reliable rename and references +- Proper ambiguity diagnostics +- `UNION` output schemas +- Completion based on the cursor's exact scope + +This semantic intermediate representation should be independent of +`node-sql-parser` AST shapes. Parser adapters should translate their ASTs into +the common model. This prevents a parser replacement from requiring every +editor feature to be rewritten. + +### 3. Make parser backends capability-based + +The current `SqlParser` interface implies that every parser can provide roughly +the same behavior. In practice, some DuckDB fallbacks can only establish +validity, while other dialects produce usable ASTs. + +Capabilities should be explicit: + +```ts +interface SqlParserBackend { + readonly id: string; + readonly dialects: readonly string[]; + + analyze( + document: string, + options: AnalyzeOptions, + signal: AbortSignal, + ): Promise; +} + +interface ParserAnalysis { + statements?: ParsedStatement[]; + tokens?: SqlToken[]; + diagnostics: SqlDiagnostic[]; + ast?: unknown; + + capabilities: { + exactLocations: boolean; + recovery: boolean; + scopes: boolean; + formatting: boolean; + }; +} +``` + +The architecture could then support: + +- `node-sql-parser` as the initial browser fallback +- Dialect-specific ANTLR parsers where valuable +- Native DuckDB completion and tokenization through a host +- SQLGlot through a server-side marimo adapter +- LSP-backed analysis +- Future WASM parsers + +Features could degrade honestly based on capabilities instead of treating +"valid but no AST" as fully analyzed. + +### 4. Make dialects first-class configurations + +A dialect should be more than CodeMirror highlighting and a +`node-sql-parser` database name: + +```ts +interface SqlDialectDefinition { + id: string; + codeMirrorDialect: SQLDialect; + parserDialect: string; + + identifier: { + quote: string; + caseSensitivity: "sensitive" | "insensitive"; + normalization: (name: string, quoted: boolean) => string; + }; + + parameters: ParameterStyle[]; + statementRules: StatementBoundaryRules; + keywords: KeywordCatalog; + functions: FunctionCatalog; + formatterDialect?: string; +} +``` + +A dialect registry should centralize: + +- Quoting +- Identifier normalization +- Keywords, types, and functions +- Parameter syntax +- Parser selection +- Formatter selection +- Statement-boundary behavior +- Feature capability tests + +Every dialect should have a conformance corpus containing valid, invalid, +incomplete, and multi-statement examples. BigQuery and DuckDB particularly need +this. + +### 5. Replace `SQLNamespace` as the schema model + +For the next major version, replace `SQLNamespace` at the public service +boundary rather than making it the permanent compatibility shape. Provide a +small, optional conversion helper for migrations, but make the richer catalog +graph the canonical API: + +```ts +interface SqlCatalog { + version: string; + catalogs: SqlCatalogNode[]; +} + +interface SqlRelation { + id: string; + path: IdentifierPath; + kind: "table" | "view" | "cte" | "derived" | "function"; + columns?: SqlColumn[]; +} + +interface SqlColumn { + name: string; + type?: SqlType; + nullable?: boolean; + description?: string; + primaryKey?: boolean; + references?: SqlColumnReference; +} +``` + +This enables: + +- Join-condition suggestions +- Type-aware function completion +- Better hover content +- `*` expansion +- Type mismatch diagnostics +- Search paths +- Multiple catalogs containing identically named tables + +Schema acquisition should be asynchronous and versioned: + +```ts +interface SqlSchemaProvider { + getCatalog( + request: CatalogRequest, + signal: AbortSignal, + ): Promise; + + search?( + query: SchemaSearchRequest, + signal: AbortSignal, + ): Promise; +} +``` + +Large databases should not require loading the entire catalog before the editor +works. + +### 6. Add an analysis scheduler + +Parsing should not run directly from every CodeMirror plugin. A scheduler +should provide: + +- Document-version tracking +- Cancellation through `AbortSignal` +- In-flight request deduplication +- Stale-result rejection +- Debouncing by workload +- LRU analysis caching +- Optional Web Worker execution +- Performance instrumentation + +Suggested behavior: + +- Tokenization and statement boundaries: immediate +- Local completion scope: low latency +- Syntax diagnostics: debounced +- Semantic diagnostics: more heavily debounced +- Remote schema and native completion: cancellable +- Formatting: explicit only + +For large SQL documents, worker execution will matter more than +micro-optimizing individual traversals. + +### 7. Separate language intelligence from CodeMirror + +The core should accept plain text, positions, dialect, and schema. It should not +require `EditorState`: + +```ts +const snapshot = await languageService.analyze({ + text, + version, + dialect, + schema, + signal, +}); + +const completions = languageService.complete(snapshot, position); +const hover = languageService.hover(snapshot, position); +``` + +`src/codemirror/*` should adapt these results into CodeMirror extensions. + +Benefits: + +- Core behavior can be tested without DOM or CodeMirror fixtures. +- Monaco, textarea, CLI, and backend use become possible. +- Parser behavior no longer depends implicitly on arbitrary `EditorState`. +- Browser tests can focus on integration instead of semantic correctness. + +### 8. Compose local, native-engine, and LSP providers + +The package should not try to outperform a live database at understanding its +own dialect and catalog. + +Support layered providers: + +1. Return local snapshot results immediately. +2. Request native engine or LSP results asynchronously. +3. Merge and deduplicate results. +4. Prefer authoritative results when available. + +For DuckDB: + +- Local completion provides immediate keywords and obvious aliases. +- `sql_auto_complete` provides authoritative catalog-aware suggestions. +- Catalog-change events invalidate schema caches. + +For marimo: + +- A SQLGlot-backed service could provide broader dialect parsing and semantic + analysis. +- The browser library should remain functional without a backend. + +### 9. Modularize packaging after the core refactor + +Avoid one mandatory package that ships every parser, dialect catalog, formatter, +and integration. + +A possible package or subpath-export structure is: + +```text +@marimo-team/codemirror-sql + /core + /codemirror + /dialects + /parsers/node-sql-parser + /providers/lsp + /providers/duckdb + /formatters/sql-formatter +``` + +Goals: + +- Core completion users do not pay for semantic linting. +- DuckDB users do not bundle every `node-sql-parser` dialect. +- Formatter and LSP dependencies remain optional. +- Tree-shaking does not depend on dynamic-import heuristics. + +### What not to do + +Avoid: + +- Replacing `node-sql-parser` before defining the common semantic model. That + changes the dependency without fixing the architecture. +- Adding more regular expressions to repair nested scopes and statement + boundaries. +- Expanding `QueryContext` into an ever-larger flat structure. +- Making `SQLNamespace` carry every future semantic concern. +- Letting each feature accept independently configured parsers and analyzers + indefinitely. +- Building formatting, lint rules, and LSP behavior directly into CodeMirror + plugins. + +### Practical overhaul plan + +#### Phase 1: Stabilize + +Fix the security and correctness findings earlier in this document. + +#### Phase 2: Build the new core behind a new-major entry point + +Add: + +```text +src/core/analysis-snapshot.ts +src/core/language-service.ts +src/core/scope-graph.ts +src/core/schema-model.ts +src/core/dialect.ts +src/core/scheduler.ts +src/parsers/node-sql-parser-adapter.ts +``` + +Reuse existing implementation details only when they satisfy the new contracts. +Do not expose the old analyzer types through the new entry point. + +#### Phase 3: Replace features with vertical slices + +Recommended order: + +1. Gutter and statement selection +2. Syntax diagnostics +3. Completion +4. Hover +5. Navigation +6. Semantic diagnostics + +Remove `SqlStructureAnalyzer`, `QueryContextAnalyzer`, and the old feature +configuration surface before the new major release. The new implementation +does not need to coexist with them in the published API. + +#### Phase 4: Add richer backends and features + +- DuckDB native provider +- SQLGlot server adapter +- LSP provider +- Formatter provider +- Rich schema and relationship completion + +The architectural north star is: + +> One document version, one semantic interpretation, many editor features. + +## Independent design review and consolidated direction + +This section records a second-pass review of the findings and architecture +above. Three independent reviews were performed with different priorities: + +1. Language-tooling architecture, correctness, concurrency, and performance +2. Source-level comparison with the upstream projects named in this report +3. The way marimo currently consumes this package in a real application + +The original architectural diagnosis survives review: this project should grow +from a collection of CodeMirror features into a shared SQL language service. +However, the reviewers agreed that the proposed single, eager +`AnalysisSnapshot` is the wrong implementation boundary. Data at different +levels has different dependencies, latency, authority, and invalidation rules. + +The revised north star is: + +> One versioned document and context model, reusable statement-level analysis, +> explicit semantic scopes, and independently cancellable consumers and +> authoritative providers. + +### Major-version assumption + +The remainder of this recommendation assumes the overhaul can ship as the next +major version, with breaking API and behavior changes. Backward compatibility +is not an architectural goal. + +That means the project should: + +- Replace the existing parser/analyzer/configuration API instead of wrapping it + indefinitely. +- Remove obsolete public implementation classes from the new entry point. +- Make rich catalog, dialect, validation, templating, and provider contracts + canonical from day one. +- Choose secure renderers and conservative feature defaults even when this + changes existing behavior. +- Change lint granularity and scheduling only through explicit new contracts. +- Update marimo as the reference migration while the new API is still fluid. + +It should still ship a migration guide, codemod or conversion helpers where +cheap, and integration tests for important consumers. Those protect users from +undocumented or silent changes; they do not constrain the new architecture. + +### Consensus + +All three reviews converged on the following: + +- Reuse analysis across completion, hover, navigation, diagnostics, and gutter + features. +- Keep parser-specific ASTs behind adapters. Public features should consume a + normalized syntax and semantic model. +- Make ranges, source mapping, cancellation, stale-result rejection, and + configuration revisions foundational rather than later refinements. +- Analyze the active statement first and reuse unchanged statement results. +- Treat parsing, engine validation, semantic diagnostics, formatting, and + completion as distinct capabilities. +- Use a richer, partially loaded catalog model as the public service contract. + An optional `SQLNamespace` conversion helper can ease migration without + shaping the new core. +- Keep a high-level CodeMirror extension for ordinary consumers even if the + intelligence core is editor-independent. +- Prove the new design against marimo before freezing it. Marimo already + exercises dynamic dialects, remote validation, interpolation, partial + catalogs, custom completion rendering, and many editors on one page. +- Add correctness, latency, memory, bundle-size, and adversarial-input budgets. + Architecture without measurable budgets will not reliably produce a + performant editor. + +### Important corrections to the earlier research + +The source-level ecosystem review found several places where the earlier +summary should be more precise. + +#### `node-sql-parser` has partial location support + +Current `node-sql-parser` supports `parseOptions.includeLocations`, and a number +of AST productions expose `loc`. This repository does not currently enable that +option. It should be the first experiment for improving diagnostic and +definition ranges before replacing the parser. + +The support is not universal, so a single `exactLocations: boolean` capability +would still be misleading. Track capabilities such as statement, token, and +identifier locations separately, and record the quality of each result. + +Sources: + +- [node-sql-parser parser options and types](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/types.d.ts) +- [location helper](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/pegjs/common/initializer/functions.pegjs) +- [BigQuery grammar location usage](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/pegjs/bigquery.pegjs) + +#### DuckDB native completion is useful, but not scope-authoritative + +DuckDB's autocomplete extension is aware of the live catalog and produces typed +candidates. Its column suggestion path currently scans columns across tables +and views in all schemas; it does not itself constitute a complete model of +which relations are visible in the current query block. + +Use DuckDB as a strong native candidate provider, then filter and rank its +results with the local scope model. Do not assume all native suggestions are +semantically visible at the cursor. + +Sources: + +- [DuckDB autocomplete core](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/src/parser/peg/autocomplete_core.cpp) +- [DuckDB catalog provider](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/src/include/duckdb/parser/peg/autocomplete_catalog_provider.hpp) +- [DuckDB catalog scans](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/extension/autocomplete/autocomplete_extension.cpp) + +#### The official CodeMirror LSP feature set is narrower than stated + +The official client currently includes completion, diagnostics, hover, +signature help, definitions/declarations/implementations, references, rename, +and formatting. It does not currently ship turnkey document-symbol or +code-action modules. + +Use the official client instead of building a parallel protocol client, but +describe unsupported features as custom extensions rather than built-ins. Its +`WorkspaceMapping` and version-gating behavior are particularly relevant: +asynchronous server results can be mapped through local changes or rejected +when they overlap unsafe edits. + +Sources: + +- [CodeMirror LSP source modules](https://github.com/codemirror/lsp-client/tree/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src) +- [CodeMirror LSP client and workspace mappings](https://github.com/codemirror/lsp-client/blob/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src/client.ts) +- [Safe formatting application](https://github.com/codemirror/lsp-client/blob/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src/formatting.ts) + +LSP Markdown/HTML must also be sanitized. A remote language server is another +untrusted rendering boundary. + +#### `sqruff` deserves a real backend evaluation + +`sqruff` now has a direct WASM package, LSP crate, semantic tokens, formatting, +diagnostics, lineage, and SQL inference. Its LSP still uses full-document sync +and does not provide completion or hover, so it is not a full language-service +replacement. It is nevertheless a credible browser formatter, linter, and +tokenizer backend to benchmark. + +Sources: + +- [sqruff WASM API](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lib-wasm/src/lib.rs) +- [sqruff LSP](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lsp/src/lib.rs) +- [sqruff lineage scopes](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lineage/src/scope.rs) + +### What to borrow from the researched repositories + +These recommendations come from source-level inspection, not only feature +lists or READMEs. + +#### SQLGlot: the semantic reference model + +SQLGlot is the strongest reference for query semantics: + +- Explicit root, subquery, derived-table, CTE, union, and UDTF scopes +- Physical tables and child scopes represented as distinct source types +- Separate available sources from sources actually selected by a query +- Sequential CTE construction, so a later CTE can see an earlier one +- Explicit correlated-subquery and external-column handling +- Scope-local traversal that does not flatten nested queries +- Ordered treatment of `FROM`, joins, and lateral sources +- Lazy derived caches with explicit invalidation + +Sources: + +- [SQLGlot scope model](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/optimizer/scope.py) +- [Qualification pipeline](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/optimizer/qualify.py) +- [Schema resolution](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/schema.py) + +SQLGlot's qualification and type annotation can mutate and canonicalize the AST +and carry non-trivial overhead. It is an excellent correctness oracle or server +backend, but should not automatically become the per-keystroke browser hot +path. + +#### DTStack: minimize completion work to the active statement + +DTStack parses enough of the document to locate a small region around the +caret, then performs the expensive `antlr4-c3` completion analysis on that +fragment. It also separates grammar candidates such as “table” or “column” +from the host's concrete catalog candidates. + +Source: + +- [DTStack completion flow](https://github.com/DTStack/dt-sql-parser/blob/1e853da4eac6ba8afd31da2243f3bf4802d0b9a5/src/parser/common/basicSQL.ts) + +Do not copy its mutable “last input” cache, repeat parses, scope-depth +accessibility heuristic, simple fallback statement splitter, or generated-code +package size without careful measurement. + +#### SQLFluff: dual coordinates for templated SQL + +SQLFluff models the original source and rendered SQL separately, maps them with +explicit slices, and carries immutable position markers in both coordinate +systems. That is the right conceptual foundation for marimo `{...}` +expressions, Jinja/dbt templates, and safe formatting or quick fixes. + +Sources: + +- [SQLFluff templated file model](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/templaters/base.py) +- [SQLFluff position markers](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/parser/markers.py) +- [SQLFluff parse context and resource budgets](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/parser/context.py) + +Also borrow its hard parse-depth and node budgets. SQLFluff itself is too +heavyweight for the browser hot path. + +#### DuckDB: typed completion needs and catalog-provider boundaries + +DuckDB's grammar produces typed needs such as keyword, relation, column, +function, and type. A replaceable catalog provider supplies the actual objects. +Results preserve replacement positions, semantic types, scores, and insertion +suffixes. + +The normalized completion model in this project should similarly retain: + +- Exact replacement edit +- Snippet or insertion suffix +- Candidate kind +- Provider provenance +- Provider and local scores +- Confidence/completeness +- Quoting decision + +Reducing provider output to a label discards information required for powerful +and deterministic composition. + +### Marimo consumer findings + +The local marimo checkout is currently pinned to `^0.2.8`. Its integration +shows what a serious consumer already needs from this library. + +Marimo currently combines three intelligence paths: + +1. `@codemirror/lang-sql` highlighting, schema completion, and keyword + completion +2. This package's lint, gutter, and hover extensions +3. Backend parsing and `EXPLAIN` validation through the marimo kernel + +It also supplies a separate completion source for Python expressions inside SQL +`{...}` regions, dynamically changes dialect and connection context, mixes +notebook-local tables with external catalogs, and can mount many SQL editors in +one notebook. + +This is direct evidence for a coordinator/provider design: marimo has already +built an informal one around the current package. + +#### Critical `0.2.8` to `0.3.0` migration hazard + +Marimo's `CustomSqlParser` overrides: + +- `validateSql()` to call backend validation +- `parse()` to return unconditional success with no AST for its internal DuckDB + engine + +Version `0.2.8` linting calls `validateSql()` on the document. Version `0.3.0` +per-statement linting goes through `SqlStructureAnalyzer`, which calls +`parse()`. Upgrading marimo as-is can therefore silently disable its backend +DuckDB syntax diagnostics: every DuckDB statement is reported as valid by the +custom `parse()` implementation. + +This does not require preserving the old API, but it must become a marimo +migration test and an explicit item in the breaking-change guide. +Whole-document and per-statement validation are different provider +capabilities; the new API should make the choice impossible to change +implicitly. + +```ts +interface SqlValidationProvider { + readonly granularity: "document" | "statement"; + validate( + request: SqlValidationRequest, + signal: AbortSignal, + ): Promise; +} +``` + +Marimo's debounce also clears the preceding timer without resolving the Promise +created for the preceding validation. Rapid calls can remain pending forever. +Per-statement parallelism would make this worse. Cancellation should be a +library contract, every request must settle, and debounce state must belong to +an editor session rather than a shared parser. + +#### First-class embedded and templated regions + +Marimo commonly edits SQL such as: + +```sql +SELECT * FROM {df} +WHERE price < {price_threshold.value} +``` + +`NodeSqlParser.ignoreBrackets` currently rewrites braces for parsing while a +separate completion source handles Python variables. The language service +should instead understand embedded regions: + +```ts +interface SqlEmbeddedRegion { + range: SqlRange; + language?: string; + kind: "expression" | "parameter" | "template"; +} +``` + +Adapters can receive a length- and newline-preserving masked document, together +with an explicit source map. Diagnostics, scopes, statements, formatting edits, +and navigation must map back to the original document exactly. + +#### Partial catalogs and dynamic connection context + +Marimo distinguishes unloaded, loading, complete, and failed schema branches. +It also has nested child schemas, local ephemeral relations, tables, views, +columns, primary keys, indexes, types, samples, and host-specific metadata. + +A missing catalog child cannot mean both “empty” and “not fetched.” The +normalized catalog layer should support: + +- Stable entity identities +- Explicit load states +- Lazy path resolution and search +- Pagination +- Push invalidation/subscriptions +- Local and remote precedence +- Connection, session, search-path, and catalog revision keys +- Opaque host metadata for custom UI rendering + +The same document text can be reinterpreted when marimo switches its engine, +dialect, connection, schema, formatter, or validation backend. Every relevant +context change must invalidate the layers that depend on it, even when there is +no CodeMirror `docChanged` transaction. + +#### Preserve host extensibility + +Marimo does not use the package's new completion source wholesale; it combines +schema, keyword, and Python-expression completion. A future high-level +extension must support composition with external completion sources and custom +renderers. + +Marimo also reads `BigQueryDialect.spec.identifierQuotes` outside the editor to +format datasource queries. Stable dialect APIs should expose helpers such as: + +```ts +dialect.quoteIdentifier(name); +dialect.quotePath(parts); +dialect.normalizeIdentifier(name, quoted); +``` + +Consumers should not need to inspect CodeMirror's internal dialect spec. + +Finally, marimo assigns the package's string tooltip renderer to `innerHTML`. +The new major should replace this with a safe DOM-returning renderer. If a +trusted HTML escape hatch exists, it should be separately named, opt-in, and +documented as unsafe for untrusted metadata. + +### Revised architecture + +#### Replace the eager snapshot with layered artifacts + +Use a convenient snapshot facade if it improves the public API, but keep the +internal cache and scheduling model layered: + +```text +Document revision + └─ Embedded-region map + └─ Lexical tokens and statement index + └─ Parse artifacts per statement + └─ Query blocks, relations, and visibility graph + └─ Catalog resolution at a catalog/session epoch + └─ Feature results per cursor, request, and provider +``` + +Examples of independent invalidation: + +- Moving the cursor should not rebuild scopes. +- Refreshing a catalog should not reparse SQL. +- Changing a renderer should not invalidate semantic analysis. +- Editing one statement should reuse unaffected statement artifacts. +- A remote diagnostic should not block local completion. +- Changing dialect lexical rules may invalidate statement boundaries, while a + schema update should invalidate only resolution and dependent results. + +The public slogan “one semantic interpretation” should not imply that a local +partial parser, native engine, LSP, and loading schema always agree. The harder +and more useful invariant is: + +> Every displayed result identifies the exact document/context revision, +> provider, completeness, and interpretation that produced it; conflicts are +> resolved by an explicit feature-specific policy. + +#### Make statement-level reuse the first performance milestone + +Do not promise true incremental parsing while the parser backend reparses whole +strings. The practical and honest first target is incremental document analysis +through statement reuse: + +- Maintain a dialect-aware statement index. +- Map unchanged ranges through CodeMirror change sets. +- Parse only changed statements. +- Prioritize the active statement, then visible statements. +- Run full-document background analysis only when a feature requires it. +- Cache in-flight Promises so simultaneous features share the same work. +- Bound caches by item count and estimated retained size. +- Deprioritize or pause expensive work for hidden and unfocused editors. + +A `StatementBoundaryProvider` and conformance corpus are required. Neither a +generic CodeMirror tree nor a failing full parser is authoritative for all +dialects and procedural blocks. + +#### Model visibility, not only lexical parent scopes + +A parent/child scope tree is insufficient for SQL. The semantic IR should model: + +- Query blocks and set operations +- Input relations and derived output relations +- CTE declaration order and recursive visibility +- Correlation and `LATERAL` edges +- Clause-specific alias visibility +- Qualified and unqualified references +- Output column shapes, including wildcard and unknown shapes +- Definition/reference ranges and provenance +- Explicit partial, recovered, opaque, and failed states + +For example, select-list aliases may be visible in `ORDER BY` but not `WHERE`, +depending on dialect. A typed visibility graph can express this; a simple +lexical parent link cannot. + +Prototype the IR against at least two materially different parsers or +conformance corpora before freezing it, so `node-sql-parser` limitations do not +become permanent neutral interfaces. + +#### Separate providers by concern + +At minimum, define separate contracts for: + +- Statement boundaries +- Parsing +- Validation +- Catalog resolution +- Completion +- Hover and documentation +- Navigation +- Formatting +- LSP/native augmentation + +Capabilities and result quality should be granular and attached to artifacts, +not only static booleans on a backend. “Parse success,” “valid SQL,” “semantic +model available,” and “engine accepted the query” are different states. + +Provider arbitration must be feature-specific: + +- Completion can merge and deterministically rerank candidates. +- Hover can choose one source or display attributed sections. +- Diagnostics require authority and deduplication policies; blind unions create + contradictory errors. +- Formatting should normally have one selected provider. +- Definitions need precedence, confidence, and provenance. + +Remote and native providers should augment a fast local baseline and never +silently overwrite unrelated evidence. + +#### Make source coordinates and request identity non-negotiable + +Every range should be: + +- Absolute in the original document +- Measured in UTF-16 code units, matching CodeMirror +- Half-open: `[from, to)` +- Preserved through comments, interpolation, and other preprocessing + +Every asynchronous request and result should identify: + +- Editor/session identity +- Monotonic document revision +- Dialect and parser configuration identity +- Template/source-map revision +- Connection and session identity when relevant +- Catalog/search-path revision when relevant +- Provider identity and authority + +No result applies unless its complete dependency key is current. + +#### Keep mutable state out of shared providers + +`NodeSqlParser` currently stores `offsetRecord` on the parser instance. Two +concurrent calls can overwrite one another's transformation mapping. Move all +transformation and source-map state into the individual parse request. + +Focus, visibility, debounce timers, request generations, and cancellation +controllers belong to a per-editor analysis session, not a parser or provider +that might be shared across many editors. + +All sessions need `dispose()`: + +- Abort remote requests. +- Prevent dispatch into destroyed views. +- Release bounded caches and workers. +- Unsubscribe from catalog changes. +- Settle or reject pending requests. + +### Proposed stable API shape + +The library should provide both a composable core and a convenient CodeMirror +adapter: + +```ts +const service = createSqlLanguageService({ + dialects: [duckdbDialect(), postgresDialect(), bigQueryDialect()], + parser: nodeSqlParserProvider(), + catalog: catalogProvider, + validators: [engineValidationProvider], + template: braceExpressionTemplate(), +}); + +const extensions = sqlEditor({ + service, + context: (state) => getSqlDocumentContext(state), + features: { + completion: true, + hover: true, + diagnostics: true, + gutter: true, + navigation: true, + }, + externalCompletionSources: [pythonExpressionCompletion], + scheduling: { + remoteDiagnosticsDelay: 300, + analyzeWhenUnfocused: false, + }, + renderers: { + completionInfo: customCompletionRenderer, + hover: customHoverRenderer, + }, +}); +``` + +The CodeMirror adapter should expose facets or state effects for context, +catalog, dialect, scheduling priority, and theme changes. Parser providers +should not receive arbitrary `EditorState`; the adapter should extract explicit +context values. + +The new major should not expose legacy `SqlParser`, `NodeSqlParser`, +`SqlStructureAnalyzer`, or `QueryContextAnalyzer` classes. Public subclassing of +stateful implementation classes is one reason marimo's validation behavior can +diverge between `parse()` and `validateSql()`. + +Replace subclassing with narrow provider interfaces. Keep raw parser adapters +internal or explicitly unstable. A development-only comparison harness remains +useful for validating the rewrite, but it does not need to become a public +compatibility layer. + +### Non-negotiable invariants + +1. Every range is an absolute UTF-16 half-open range in original-document + coordinates. +2. No asynchronous result applies unless its complete revision key is current. +3. Every request settles, rejects, or is observably aborted. +4. Parser transformation state is request-local. +5. Analysis failure is distinct from SQL invalidity. +6. Schema absence or incompleteness cannot produce a definite + unknown-object diagnostic. +7. Untrusted metadata is never interpreted as HTML by default. +8. A statement is parsed at most once for a given content, parser/configuration + identity, template mapping, and environment fingerprint; concurrent + consumers share in-flight work. +9. Every resolved symbol has a range, role, provenance, and completeness state. +10. Provider conflicts are resolved by a documented policy for each feature. + +### Revised execution plan + +#### Phase 0: stabilize and measure + +- Fix the confirmed security and correctness bugs earlier in this report. +- Enable and assess `node-sql-parser` location support. +- Make parser preprocessing and offset state request-local. +- Reject stale results in every asynchronous feature. +- Ensure cancellation settles all requests. +- Add dialect/configuration revisions to current caches. +- Add a marimo migration test proving the new document-level validation + provider executes and stale requests are cancelled. +- Measure current latency, memory, cold-load time, and bundle size. + +#### Phase 1: analysis session and statement index + +- Add one disposable analysis session per editor/context. +- Define a dialect-aware statement-boundary provider. +- Map unchanged statements through CodeMirror changes. +- Cache both in-flight and completed parse artifacts. +- Prioritize active and visible statements. +- Add bounded caches and degradation policies. +- Reuse the current structure analyzer only as temporary scaffolding if useful; + do not retain its API in the new core. +- Compare old and new lint/gutter results in development to discover + regressions, without requiring behavioral parity when the old behavior is + wrong. + +#### Phase 2: ranges, source maps, and parser artifacts + +- Define normalized parse artifacts with granular completeness and location + capabilities. +- Model embedded regions and original/rendered source coordinates. +- Implement the existing parser adapter and at least one alternate/test + adapter. +- Remove downstream text searches for locations. +- Fully migrate statement metadata and syntax diagnostics. + +#### Phase 3: a minimal semantic vertical slice + +Start with well-tested `SELECT` behavior: + +- Query blocks +- CTEs +- Base and derived relations +- Aliases +- Column references +- Output shapes +- Typed visibility edges +- Partial and unknown states + +Migrate completion and hover before aggressive semantic diagnostics. These +features benefit from partial knowledge; diagnostics should only report an +error when the model can prove it with complete relevant scope and schema +information. + +#### Phase 4: catalogs and provider composition + +- Add lazy catalog resolution, stable identities, load states, pagination, and + invalidation. +- Define per-feature provider authority and merge policies. +- Integrate marimo backend validation through the explicit validation contract. +- Prototype DuckDB-native candidate augmentation. +- Integrate the official CodeMirror LSP client where a server is available. +- Benchmark `sqruff` WASM for formatting, diagnostics, and tokenization. + +#### Phase 5: advanced semantics and optional distribution changes + +Only after correctness and profiling: + +- Types and function signatures +- `UNION`, recursive CTEs, `LATERAL`, and DML outputs +- Cross-statement state such as temporary tables and search-path changes +- Worker execution where benchmarks justify it +- Formatter and additional LSP adapters +- Optional subpath or package splits after bundle analysis and API stabilization + +Workers deserve an explicit prototype because a synchronous PEG or ANTLR parse +cannot be interrupted by `AbortSignal` on the main thread. They are not an +automatic win: startup, cloning, duplicate caches, and bundling can make small +documents slower. Apply hard input-size, time, parse-depth, and node-count +budgets regardless. + +### Performance and correctness gates + +Track at least: + +- Keystroke-to-local-completion latency +- Active-statement parse latency +- Large-document edit latency +- Time to first useful result after cold parser load +- Memory after long edit sequences and many editor instances +- Catalog search over large and partially loaded schemas +- Main bundle and parser/dialect chunk sizes +- Stale-result and cancellation behavior under rapid edits + +Add: + +- Property and fuzz tests for statement boundaries and preprocessing +- Nested, correlated, CTE, lateral, and set-operation scope goldens +- Differential tests against SQLGlot and native DuckDB where appropriate +- Adversarial inputs that must not hang, exhaust memory, or crash a worker +- Range invariants across Unicode, comments, multiline strings, and templates +- A dialect conformance matrix instead of a blanket “supported” label + +These gates should decide whether workers, parser replacements, and packaging +changes ship. They should not be retrofitted after those decisions. + +### Consolidated recommendation + +Do not replace `node-sql-parser` with DTStack or another parser as the first +move. First: + +1. Fix the existing security, range, cache, race, and dialect bugs. +2. Enable and measure available parser locations. +3. Build a disposable, revisioned analysis session with statement-level reuse. +4. Define source-mapped templating and a SQLGlot-inspired visibility model. +5. Separate document/statement validation from parsing. +6. Add lazy catalogs and explicit provider authority. +7. Migrate marimo as the reference consumer, using integration tests and + development comparisons to validate the new contracts. +8. Evaluate DuckDB, LSP, SQLGlot, and `sqruff` as composable providers rather + than a single replacement stack. + +This sequence uses the major-version boundary to remove accidental APIs and +incorrect behavioral coupling. It creates the architecture needed for a +powerful SQL editor without committing too early to one parser, worker model, +or package layout. + +### Proposed new-major release strategy + +Treat the next major as a replacement product surface rather than a gradual +deprecation release: + +1. Freeze the current line to critical fixes only. +2. Develop the new language service and CodeMirror adapter behind a separate + entry point or prerelease tag. +3. Migrate marimo early and use it to exercise multi-editor performance, + dynamic connection context, templates, remote validation, and rich catalogs. +4. Publish prereleases with a small number of design partners. +5. Remove legacy exports before the release candidate. +6. Publish benchmarks, a dialect capability matrix, and the migration guide + with the stable major. + +The migration guide should map old concepts to new ones, but the implementation +should not contain permanent adapters unless they are trivial, isolated, and +have no effect on the core design. diff --git a/_typos.toml b/_typos.toml index f03eaed..42336d8 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,6 +1,11 @@ [default] # Use typos:ignore-next-line to ignore the next line in the codebase -extend-ignore-re = ["(#|//)\\s*typos:ignore-next-line\\n.*"] +extend-ignore-re = [ + "(#|//)\\s*typos:ignore-next-line\\n.*", + # Deliberately malformed SQL and identifiers in research reproductions + "\\busres\\b", + "\\bSELECTbad\\b", +] [files] extend-exclude = [ @@ -9,4 +14,4 @@ extend-exclude = [ "src/sql/__tests__/diagnostics.test.ts", # Contains intentionally misspelled identifiers (e.g. usres) to trigger schema diagnostics "src/sql/__tests__/semantic-diagnostics.test.ts", -] \ No newline at end of file +] diff --git a/demo/custom-renderers.ts b/demo/custom-renderers.ts index 207d9a6..3c41331 100644 --- a/demo/custom-renderers.ts +++ b/demo/custom-renderers.ts @@ -1,4 +1,5 @@ import type { NamespaceTooltipData } from "../src/sql/hover.js"; +import { isArrayNamespace } from "../src/sql/namespace-utils.js"; interface ColumnMetadata { type: string; @@ -35,7 +36,11 @@ export type Schema = "users" | "posts" | "orders" | "customers" | "categories" | export const tableTooltipRenderer = (data: NamespaceTooltipData) => { // Show table name, columns, description, primary key, foreign key, index, unique, check, default, comment const table = data.item.path.join("."); - const columns = data.item.namespace?.[table] ?? []; + const namespace = data.item.namespace; + const columns = + namespace && isArrayNamespace(namespace) + ? namespace.map((column) => (typeof column === "string" ? column : column.label)) + : []; // Enhanced table metadata (simulated for demo purposes) const tableMetadata = getTableMetadata(table); @@ -146,7 +151,7 @@ export const tableTooltipRenderer = (data: NamespaceTooltipData) => { // Helper function to get enhanced table metadata function getTableMetadata(tableName: string): TableMetadata { - const metadata: Record = { + const metadata: Record = { users: { description: "User accounts and profile information", rowCount: "1,234", diff --git a/demo/data.ts b/demo/data.ts index 4ac457b..79b3694 100644 --- a/demo/data.ts +++ b/demo/data.ts @@ -1,4 +1,4 @@ -import type { Schema } from "./custom-renderers"; +import type { Schema } from "./custom-renderers.js"; // Default SQL content for the demo export const defaultSqlDoc = `-- Welcome to the SQL Editor Demo! diff --git a/demo/index.ts b/demo/index.ts index 90cca90..025fd77 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -8,6 +8,7 @@ import { defaultSqlHoverTheme, NodeSqlParser, QueryContextAnalyzer, + type SqlKeywordInfo, type SupportedDialects, sqlCompletion, sqlExtension, @@ -63,7 +64,7 @@ const defaultKeymap = [ ]; // e.g. lazily load keyword docs -const getKeywordDocs = async () => { +const getKeywordDocs = async (): Promise> => { const keywords = await import("@marimo-team/codemirror-sql/data/common-keywords.json"); const duckdbKeywords = await import("@marimo-team/codemirror-sql/data/duckdb-keywords.json"); return { diff --git a/implementation.md b/implementation.md new file mode 100644 index 0000000..f8ed438 --- /dev/null +++ b/implementation.md @@ -0,0 +1,826 @@ +# SQL Language Service Overhaul: Implementation Plan + +Date: 2026-07-24 + +## Objective + +Build the next major version of `@marimo-team/codemirror-sql` as a performant, +robust, correct, and powerful SQL language service with a first-class +CodeMirror adapter. + +This is a breaking overhaul. Compatibility with current implementation classes +is not a design constraint. We will still publish a migration guide and use +marimo as the reference consumer. + +Success means: + +- Correct, explicitly bounded dialect support +- Predictable latency and bounded memory +- Honest partial and unsupported results +- Safety under malformed input, rapid edits, cancellation, and hostile metadata +- A small, understandable public API +- High confidence from independent forms of testing + +Before implementation, publish a vNext capability charter defining: + +- Initial dialects and constructs +- Explicit non-goals and unsupported syntax +- Browser, Node, bundler, and peer-dependency support +- Latency, memory, and bundle envelopes +- Which features require local, native, or remote providers + +## Engineering principles + +### Correctness before breadth + +Do not claim support until a construct passes its conformance corpus. Explicit +partial results are better than confident incorrect results. + +### Simple before clever + +Prefer small modules, explicit data flow, pure transformations, and narrow +interfaces. Optimize from profiles. Every abstraction must enforce an +invariant, isolate a dependency, remove duplication, or improve testability. + +### Comments explain why + +Names, types, and structure should explain normal behavior. Comments should +explain an invariant, external limitation, security boundary, performance +tradeoff, or surprising dialect rule. If ordinary flow needs a long comment, +first simplify the design. Public APIs and non-obvious algorithms still need +useful documentation. + +### Types represent the state machine + +The goal is not just to compile. Revision identity, source coordinates, +analysis completeness, catalog loading, cancellation, and provider provenance +should be difficult to omit or combine incorrectly. + +### Tests provide different evidence + +Coverage, contracts, examples, goldens, browser tests, fuzzing, mutation +testing, differential testing, and benchmarks find different failures. No one +metric substitutes for the others. + +## Branch and delivery strategy + +Create `dev-refactor` as the next-major integration branch. Do not implement +everything directly on it. Use short-lived, focused PR branches: + +```text +main + └─ dev-refactor + ├─ refactor/analysis-session + ├─ refactor/source-mapping + ├─ refactor/statement-index + ├─ refactor/catalog-provider + └─ refactor/codemirror-adapter +``` + +Required practices: + +- Every PR into `dev-refactor` leaves it green and runnable. +- Use a merge queue or serialize merges. +- Open a continuous draft PR from `dev-refactor` to `main`. +- Give the branch an owner, a target merge date, and explicit merge/delete + criteria. +- Allow no direct commits. +- Forward-merge `main` on a fixed cadence and document hotfix propagation. +- Keep a continuously runnable demo and marimo integration fixture. +- Build a canary artifact for every accepted integration commit; publish + prereleases only at selected checkpoints after canary gates pass. +- Compare API, coverage, bundle, and performance against both `main` and the + previous `dev-refactor` commit. +- Prefer a sequence of vertical slices to a large rewrite. + +The branch is an integration boundary, not permission to accumulate an +unreviewable diff. If its lifetime or divergence becomes excessive, move vNext +development onto `main` behind an unpublished entry point. + +Before branch protection is enabled, update CI triggers to include pushes and +pull requests targeting `dev-refactor`; the current workflow targets only +`main`. + +## Change sizing and review requirements + +Classify every PR before implementation. + +### Small + +Documentation, test data, mechanical renames, or a local fix with no public, +semantic, concurrency, or performance effect. One normal review is sufficient. + +### Medium + +Any change to: + +- A public/provider interface +- Parsing, source mapping, or semantics +- Scheduling, caching, cancellation, or concurrency +- Catalog resolution +- Completion, diagnostics, hover, navigation, formatting, or gutter behavior +- A dialect rule +- More than one architectural layer + +Two independent adversarial reviews are required. + +Classification is fail-closed. Sensitive paths, public API diffs, dependency +changes, concurrency modules, and cross-layer changes automatically make a PR +medium or large. A maintainer must approve any downgrade to small. + +### Large + +A cross-cutting architecture change, new semantic subsystem, or new parser or +remote/native provider. Split it into an ADR and multiple medium PRs. A large +PR is a planning failure unless the change is generated or indivisible. + +## Phase 0: establish baselines + +Do this before changing production architecture. + +### Behavioral baseline + +- Capture public exports and generated declarations. +- Record representative completion, hover, navigation, lint, and gutter output. +- Add characterization tests for critical migration-risk behavior. +- Put every confirmed issue from `SQL_EDITOR_RESEARCH.md` in an owned + traceability backlog. An unfixed case may use a commit-bound known-failure + fixture with an owner and expiry until its feature slice; the baseline suite + must not be permanently red. +- Add a marimo fixture for document-level DuckDB validation, connection + changes, schema completion, many editors, and `{...}` expressions. +- Record demo workflows in browser tests. + +Label characterization cases: + +```text +preserve intentional behavior +replace behavior intentionally changed in the new major +bug confirmed defect with the desired result documented +unknown needs a semantic or product decision +``` + +Characterization tests document behavior; they do not preserve known bugs. + +### Performance baseline + +Measure fixed document and catalog sizes: + +- Cold load and first parse +- Active-statement edit/reparse +- Completion and hover latency +- Full-document diagnostics +- Rapid-edit and stale-result behavior +- Memory after long edit sequences +- 1, 10, and 50 mounted editors +- Bundle and parser chunk sizes + +Store JSON results and raw samples as CI artifacts. Establish budgets from +measured baselines and product goals, not arbitrary percentages. + +### Quality baseline + +Record: + +- Line, statement, function, and branch coverage +- Unsafe assertions and suppression directives +- Public API surface +- Bundle size and dependency inventory +- Test runtime and flake rate + +Introduce ratchets from this baseline. Do not hide existing problems simply to +make the first gate green. + +## Test strategy + +### Coverage policy + +Targets: + +| Metric | Repository | New or changed core code | +| --- | ---: | ---: | +| Lines | At least 95% | At least 95% per changed file | +| Statements | At least 95% | At least 95% per changed file | +| Functions | At least 95% | At least 95% per changed file | +| Branches | 90%, ratcheting to 95% | At least 95% diff coverage | + +Branch coverage matters for failure, cancellation, partial-result, and dialect +paths. Coverage exclusions are limited to generated data, type-only code, +debug instrumentation, and true exhaustiveness guards. Each exclusion requires +a reviewed reason. + +Coverage is a floor, not an objective. Do not add tests that execute lines +without asserting semantics. Mutation testing must confirm that important +assertions detect meaningful faults. + +Enforcement must define: + +- All production files as the denominator, including files not imported by + tests +- Explicit checked-in include/exclude rules +- Repository thresholds for lines, statements, functions, and branches +- Changed executable lines and branches relative to the PR merge base, + including renamed files +- Diff coverage for lines and branches; statements and functions are enforced + at the changed-file and risk-tier module level rather than inferred + unreliably from a textual diff +- Per-module floors for revisions, ranges, source maps, cancellation, and + provider policy + +Add separate failing `test:coverage` and `coverage:diff` commands. Protect +coverage configuration, exclusions, baselines, and generated-code rules with +CODEOWNERS. Baseline reductions or exclusion growth require explicit approval. + +Apply strict changed-code gates immediately to vNext modules. The repository +target applies to retained/new-major production code by release candidate; do +not manufacture low-value tests for legacy modules scheduled for deletion. +Named invariant tests and mutation evidence are required for critical logic. +Defensive or platform-specific uncovered code needs a narrow reviewed waiver +with an owner and expiry. + +### Unit tests + +Use focused, table-driven tests for: + +- Range and position primitives +- Identifier normalization and quoting +- Statement boundaries +- Original/rendered source mapping +- Cache identity and invalidation +- Catalog load states and lookup +- Scope and visibility rules +- Provider authority, merge, and ranking policies +- Completion replacement edits +- Cancellation, disposal, and error normalization + +### Provider contract tests + +Every provider implementation must pass reusable contracts: + +- Ranges are absolute UTF-16 half-open ranges inside the document. +- Every request settles, rejects, or is observably aborted. +- Aborted or stale work cannot publish results. +- Results identify revision, provider, authority, and completeness. +- Incomplete catalogs cannot create definite unknown-object diagnostics. +- Document validators are never invoked once per statement. +- `dispose()` prevents callbacks and editor dispatch. +- Provider errors degrade according to documented policy. + +### Golden semantic corpus + +Use structured, reviewed fixtures for: + +- Nested and correlated subqueries +- CTE ordering, recursion, and shadowing +- Derived relations and `LATERAL` +- Set operations +- Clause-specific alias visibility +- Qualified, unqualified, and ambiguous references +- Search paths and multiple catalogs +- Quoted identifiers and case folding +- Incomplete input at likely cursor positions +- DML and templates/interpolation + +Each fixture names its dialect and expected capability. Prefer typed structured +outputs to large parser-AST or DOM snapshots. + +### Snapshot tests + +Use snapshots only for stable, high-dimensional normalized results such as a +scope graph or complete feature response. Prefer direct assertions for simple +behavior. Never snapshot parser-specific ASTs, unstable ordering, incidental +error details, or entire DOM trees. CI must not auto-update snapshots. + +### Property and fuzz tests + +Test invariants: + +- Statement ranges remain ordered, non-overlapping, and in bounds. +- Template masking preserves length and newline positions. +- Source-map conversion always yields valid ranges. +- Arbitrary edit sequences never publish stale results. +- Completion edits remain inside the document. +- Identifier quote/normalize behavior round-trips where defined. +- Caches stay bounded. +- Arbitrary input terminates within resource budgets. + +Run a deterministic short suite on every PR and longer randomized campaigns +nightly. Commit a minimal regression fixture and seed for every failure. + +Execute parser fuzz cases in disposable workers or processes. A normal Vitest +timeout cannot interrupt synchronous code that blocks the event loop. The +parent enforces wall-clock and memory limits and records the seed, input, +dialect, dependency versions, and operation sequence. Distinguish crash, +timeout, OOM, invalid range, unhandled rejection, and stale publication. + +### Differential tests + +Compare supported constructs with: + +- SQLGlot for scope and qualification +- DuckDB for DuckDB parsing and selected catalog behavior +- `node-sql-parser` location output while it is a backend +- `sqruff` if used for formatting or linting + +Classify differences as `ours-bug`, `upstream-difference`, +`dialect-ambiguity`, `unsupported`, or `intentional-policy`. Another tool is +evidence, not automatically the oracle. + +### Integration tests + +Use deterministic fake providers and schedulers to test: + +- Edit → invalidate affected layers → publish current feature result +- Dialect/connection changes without text edits +- Catalog refresh without reparsing +- Remote validation cancellation during rapid typing +- Local/native completion composition +- Disposal with work in flight +- Provider timeout and worker crash recovery +- Partial catalogs + +Use fake clocks; do not base correctness assertions on real time. + +Inject failures including malformed payloads, repeated callbacks, callback +after abort/dispose, synchronous throws before Promise creation, rejection +after abort, worker restart, catalog refresh storms, duplicate IDs, +out-of-order results, and exceptions from consumer renderers. + +### Browser and end-to-end tests + +Vitest currently includes a Playwright browser project through the aggregate +test configuration, but the documented `test:browser` script references the +nonexistent `vitest.browser.config.ts`. Repair that script and give the browser +project a named required CI step so configuration changes cannot silently stop +running it. + +Cover: + +- Completion opening, filtering, refreshing, and applying the exact edit +- Safe, attributed hover rendering +- Diagnostic movement and removal after edits +- Current-statement gutter and navigation +- Dialect/connection changes without text edits +- Stale slow-provider rejection +- Keyboard and accessibility behavior + +Require Chromium on PRs. Run Firefox and WebKit nightly until sufficiently +stable and fast. Capture traces and screenshots on failure, not as a broad +pixel-snapshot suite. + +### Consumer and packaging tests + +Maintain a small marimo fixture here and a pinned cross-repository test in +marimo. Cover dynamic context, document validation, many SQL cells, `{...}` +regions, external completion composition, partial/nested catalogs, and custom +renderers. + +Test a packed tarball rather than workspace source. For release candidates: + +- Install it in a minimal CodeMirror consumer. +- Import every documented entry point. +- Build with supported bundlers. +- Run in a real browser and in the marimo fixture. +- Verify ESM, declarations, source maps, peer dependencies, and optional + providers. +- Verify core-only consumers do not bundle optional integrations. + +### Security tests + +Test hostile catalog metadata, LSP Markdown/HTML, deeply nested SQL, extremely +long identifiers/statements, invalid/cyclic provider data, worker crashes, and +pathological regular-expression input. CodeQL and dependency scanning remain +required but are not substitutes for behavior tests. + +### Mutation testing + +Run mutation tests on ranges, semantics, scheduling, caching, and provider +policy. Start nightly. Establish a baseline mutation score, narrowly classify +equivalent mutations, and ratchet upward. High coverage with a weak mutation +score blocks further work in that subsystem. + +Track mutation results per critical module and mutation class. Designated +revision, range, source-map, cancellation, and security modules may have no +surviving high-risk mutants. Equivalent-mutant exclusions require a reviewed +reason and expiry. + +### Invariant traceability and suite integrity + +Maintain a checked-in invariant registry with stable ID, owner, failure mode, +affected modules, required test layers, test references, and applicable +performance/security budget. CI fails if an invariant loses all mapped tests. + +Fail CI on: + +- Zero discovered tests in an expected suite +- Unexpected `.only`, skipped, or todo tests +- Stale snapshots +- Unhandled rejections +- Leaked timers, handles, workers, listeners, or DOM nodes +- Unexpected browser console errors +- Missing fixture provenance + +Print test counts, duration, retry count, and first-attempt failures by category. +Retry success must not silently erase a flake; quarantines require an owner and +deadline. + +### Performance tests + +Report median, p95, worst observed time, and memory where measurable. Separate: + +- Pure core microbenchmarks +- Edit-to-result scenario benchmarks +- Browser/CodeMirror benchmarks +- Bundle-size checks + +Exercise 1/10/100/1,000 statements, small/large/partial catalogs, cold/warm +completion, 1/10/50 editors, rapid edits with delayed providers, and +template-heavy documents. + +PR CI runs stable smoke benchmarks and fails only on meaningful regressions +beyond agreed budgets. Full performance and leak tests run nightly on +controlled runners. Store history to catch gradual degradation. + +For every blocking benchmark, define runner class, warmups, sample count, +interleaved base/head execution, outlier policy, confidence method, minimum +effect size, absolute ceiling, and an inconclusive result for excessive noise. +Record hardware and runtime metadata. Use controlled runners for blocking +latency and memory gates; hosted runners enforce only gross smoke ceilings and +bundle size. Gate tail latency under load and event-loop blocking. Do not use +“worst observed” as a statistical gate; use a defined percentile plus a hard +timeout. + +Memory tests run in isolated processes with fixed edit/mount cycles and explicit +GC where supported. Track heap, DOM/listener retention, worker lifecycle, cache +size, process RSS, and retained-memory slope separately. + +## Type-safety standard + +The repo already enables `strict`, `noUncheckedIndexedAccess`, and +`noUnusedLocals`. Keep these and evaluate: + +- `exactOptionalPropertyTypes` +- `noImplicitOverride` +- `noPropertyAccessFromIndexSignature` +- `verbatimModuleSyntax` + +New-major rules: + +- No explicit `any`. +- No double assertion such as `as unknown as T`. +- No unchecked casts of parser, provider, JSON, or network data. +- No `@ts-ignore`. +- `@ts-expect-error` only in type tests, with the expected reason. +- Use `unknown` at boundaries and narrow or decode it. +- Use discriminated unions for partial, recovered, failed, and cancelled states. +- Use opaque types where document/revision/range identity can be confused. +- Use exhaustive checks for closed unions. +- Prefer `satisfies`; allow useful safe constructs such as `as const`. + +External ASTs are untrusted boundary data. Decode them in one adapter. Feature +code must not scatter parser-specific assertions. + +Automate this with type-aware lint rules, a forbidden-pattern CI check, type +tests, declaration generation, and a public API diff. If a third-party type +forces a narrow assertion, isolate and runtime-check it in an adapter and +require adversarial review; do not weaken global settings. + +The current production `tsconfig.json` excludes tests and the demo. Add separate +typecheck projects for production, unit tests, browser tests, demo, fixtures, +and public type tests. Validate emitted declarations in a clean consumer with +`skipLibCheck: false`. + +Forbidden assertions apply strictly to production. Tests use typed builders +rather than double assertions, but may have a separately audited boundary +registry for unavoidable third-party mocks. Decide each proposed compiler +option in an early ADR; do not leave “evaluate” as a permanent gate. + +## Code-quality standard + +Enforce the dependency direction: + +```text +core primitives + ← document/source mapping + ← syntax/statement analysis + ← semantic model + ← catalog and feature policies + ← providers + ← CodeMirror adapter +``` + +Core cannot import CodeMirror, DOM, remote providers, or parser-specific ASTs. +Automate import-boundary and dependency-cycle checks. + +Set ratcheted review thresholds for complexity, function/file size, nesting, +and parameter count. These are review triggers, not an invitation to split one +bad function into many bad functions. + +Public API rules: + +- Keep the stable surface small. +- Do not export implementation classes for tests. +- Prefer interfaces and factories over subclassing. +- Mark experimental surfaces explicitly. +- Generate and review an API report. +- Require examples and release notes for public behavior changes. + +Every runtime dependency needs bundle, license, security, maintenance, and +alternatives review, and must remain optional where appropriate. + +## Two-reviewer adversarial loop + +Every medium change receives two independent reviews before dependent work +continues. + +### Reviewer A: correctness and safety + +Review SQL semantics, Unicode/ranges, partial-result confidence, concurrency, +cancellation, disposal, security boundaries, and missing negative/fuzz tests. + +### Reviewer B: performance and design + +Review parsing/allocation cost, cache identity, bounded memory, main-thread +work, layer coupling, public API leakage, excessive abstraction, and benchmark +evidence. + +Both receive the same diff, acceptance criteria, invariants, test results, API +diff, and benchmark/bundle deltas. They do not see each other's initial +conclusions. + +Loop: + +1. Implement the scoped change and evidence. +2. Run automated gates. +3. Obtain both reviews. +4. Classify findings as blocking correctness, architecture, performance, test + gap, documentation gap, justified follow-up, or rejected with evidence. +5. Resolve all blocking findings. +6. Rerun gates. Each reviewer rechecks unresolved findings and materially + changed areas against the new commit. +7. Require two full reviews again only when the revision changes public + contracts, architecture, concurrency, or benchmark behavior materially. +8. After two automated resolution cycles, escalate disputes to a human + maintainer rather than looping indefinitely. +9. Store reports and the resolution ledger in the PR. + +Reviews must be tied to the exact commit; a push invalidates prior attestations. +Review agents supplement human maintainers and never authorize a merge. +Prefer reviewers with different prompts or models; two identical agents are +correlated evidence. Independent downstream work may target an accepted +interface contract, but cannot merge until its dependency is accepted. + +### Review automation + +Generate one review packet containing: + +- Base/head commit and diff +- Changed files and risk classification +- Affected invariants and ADRs +- Test/coverage results +- API, benchmark, and bundle deltas + +Add a PR template for change size, risks, evidence, two reports, finding ledger, +and rollback/disable strategy. CI should reject a medium PR missing commit-bound +review attestations or containing unresolved blockers. + +Use independent bot/App identities to create required GitHub checks containing +reviewer identity, model/version, packet digest, head SHA, findings, and +disposition. Validate them through a protected reusable workflow from the base +branch so a PR cannot approve itself. Maintainers approve classification +overrides and rejected blocking findings. + +## CI architecture + +### Required fast PR lane + +Set measured per-job budgets and run independent jobs in parallel. Aim for a +useful result within ten minutes without making that aspirational number a +correctness constraint: + +- Frozen install +- Non-mutating lint +- Strict typecheck and forbidden-practice scan +- Unit, contract, and deterministic short fuzz tests +- Coverage and changed-code coverage +- Build, declarations, and public API diff +- Packed-package validation +- Bundle-size budget +- Required Chromium integration +- Import boundaries and dependency cycles + +Cancel superseded runs. + +Add a CI self-test before branch protection: deliberately failing fixture PRs +must prove every required check appears and blocks merging. + +### Required affected integration lane + +For relevant paths, run full browser tests, marimo fixture, packed consumers, +performance smoke tests, and security fixtures. Core, package, TypeScript, +lockfile, or CI changes conservatively run everything. + +### Nightly lane + +Run randomized fuzzing, mutation tests, the full performance matrix, +memory/leak scenarios, Firefox/WebKit, dependency auditing, differential +testing, and the dialect conformance matrix. + +Nightly failures should open or update a tracking issue with seeds and +artifacts. Avoid duplicate issue spam. Maintain a scheduled-health check that +records suite, source SHA, configuration digest, completion time, and expiry. +Release candidates are blocked by red or stale scheduled health. Define an +owner, triage SLA, and maximum quarantine duration. + +Run reduced mutation, differential, and leak sentinels in PR CI when critical +modules change; nightly evidence alone is too delayed. + +### Release-candidate lane + +Run all scheduled-class suites against the exact tagged SHA. + +Build one `npm pack` artifact from that SHA in a reusable verification workflow +and record its SHA-256 and provenance. Split release evidence: + +- Source/configuration gates against the exact tagged SHA: unit, coverage, + mutation, fuzz, static analysis, API, source-level benchmarks, and dependency + inventory +- Packaging/runtime gates against the exact tarball: minimal consumers, + supported bundlers/peers, browser smoke tests, marimo, declarations, source + maps, bundle composition, and migration examples + +Publishing downloads that immutable artifact and requires successful release +checks and scheduled-class evidence for the tagged SHA. Protect the npm +environment and reject tags not reachable from the protected release branch. +Never rebuild different bits in the publish job. + +Checkpoint prereleases publish the verified canary artifact under a `next` +dist-tag with provenance after their defined subset of source and runtime gates. +Document their versioning and retention policy; ordinary integration commits +remain downloadable CI artifacts rather than permanent npm versions. + +### Supported-runtime matrix + +Choose explicit supported Node, browser, peer-dependency, bundler, and SSR +versions. Test minimum and latest Node and peer sets against the tarball. Narrow +`engines` and compatibility claims to what CI continuously validates. + +## Ratchets + +- Coverage cannot decrease for retained/new-major code and reaches the + repository target by release candidate; changed-code and invariant floors + apply immediately. +- No new unsafe type practice is allowed from the first overhaul commit. +- Existing unsafe practices decrease by milestone. +- Bundle/performance budgets cannot regress without explicit approval. +- Mutation score cannot decrease. +- Flaky tests need an owner and expiry; quarantine is temporary. +- Public API growth requires explicit approval. + +Ratchets prevent new debt immediately while allowing deliberate removal of +existing debt. + +## Definition of done for a medium change + +- Acceptance criteria and affected invariants are satisfied. +- Relevant unit, contract, integration, browser, fuzz, and failure tests pass. +- Changed core code meets coverage targets. +- No forbidden type practice is introduced. +- Lint, typecheck, build, package, and API checks pass. +- Performance and bundle budgets pass or their change is approved. +- Fuzz failures have permanent regression fixtures. +- Both independent reviews have no unresolved blocking findings. +- Docs, ADRs, examples, and capability matrices are current. +- The result is simpler or demonstrably more capable than what it replaces. + +## Implementation sequence + +### 1. Governance and baseline + +Deliver branch protection, PR/ADR templates, review automation, behavior/API/ +coverage/bundle/performance baselines, confirmed-bug tests, and the marimo +fixture. + +Exit when every baseline is reproducible in CI. + +### 2. Quality harness and walking skeleton + +Deliver enforced coverage, contract/golden organization, fuzz/property harness, +mutation pilot, explicit browser CI, package smoke tests, type/import/API +checks, and benchmark/bundle budgets. + +In parallel, build a deliberately thin walking skeleton: + +```text +document revision + → parser adapter + → minimal normalized result + → one completion result + → CodeMirror presentation +``` + +Apply final changed-code gates to this new code immediately. Do not delay +architectural feedback while exhaustively testing legacy code scheduled for +deletion. + +Exit when critical characterization exists, harnesses are reproducible, and CI +catches deliberately introduced stale-result, unsafe-cast, range, bundle, and +API defects. Repository-wide coverage of retained/new-major code reaches the +target by release candidate, not as a waterfall prerequisite. + +### 3. Core primitives and minimal provider contracts + +Deliver revisions, UTF-16 half-open ranges, original/rendered source maps, +embedded regions, statement boundaries, disposable sessions, in-flight +deduplication, and bounded caches. + +Also define the minimum parser, validator, catalog, completion, and provider +result contracts now, including capability, authority, load state, +document-versus-statement granularity, timeout, cancellation, and provenance. +Run early DuckDB, LSP, formatter, and worker feasibility spikes and record +go/no-go ADRs. These contracts cannot be deferred until after features that +depend on them. + +Exit when arbitrary edit properties pass, stale results cannot apply, and +unchanged statements are reused. + +### 4. Parser artifacts and semantic completion slice + +Deliver the parser contract, measured `node-sql-parser` adapter, normalized +artifacts, query blocks, typed visibility, CTE/relation/alias/column bindings, +explicit partial states, and a production-quality completion vertical slice. + +Exit when goldens pass, parser ASTs do not leak, and differences are classified. + +### 5. Feature vertical slices + +After completion proves the end-to-end model, migrate: + +1. Statement selection and gutter +2. Syntax diagnostics +3. Hover +4. Navigation +5. Semantic diagnostics + +Each slice includes CodeMirror integration, browser tests, performance evidence, +and the two-reviewer loop. Delete the old slice after acceptance. + +### 6. Catalog and provider expansion + +Expand the minimal contracts into lazy catalogs, stable +identities/invalidation, production feature-specific authority, native/remote +providers, and the recorded DuckDB/LSP/`sqruff` decisions. + +Exit when optional remote work never blocks the local baseline and all +race/failure/partial-catalog contracts pass. + +### 7. Marimo migration and release + +Migrate marimo, publish performance results and a dialect capability matrix, +resolve prerelease feedback, remove legacy exports, and finalize migration/API +documentation. + +Exit when the exact release tarball passes marimo and minimal-consumer smoke +tests plus all correctness, quality, security, performance, and review gates. + +## Initial automation backlog + +Before semantic implementation: + +1. Add `dev-refactor` CI triggers and prove required checks fail closed. +2. Enforce explicit all-file Vitest coverage and per-file changed-code coverage. +3. Add explicit Chromium browser script and required CI step. +4. Add production/test/demo/fixture/public-consumer typecheck projects. +5. Add package tarball, peer-version, bundler, and minimal-consumer tests. +6. Add API declaration diffing. +7. Add bundle reporting and budgets. +8. Add isolated deterministic property/fuzz infrastructure. +9. Add JSON benchmarks, base/head comparison, and noise policy. +10. Add the hermetic marimo fixture and pinned scheduled canary. +11. Add forbidden type-practice, import-boundary, and cycle checks. +12. Add the invariant registry and test-suite integrity checks. +13. Add PR risk template and review-packet generation. +14. Add protected, commit-bound two-reviewer checks. +15. Add verified-artifact provenance to release CI. +16. Add nightly mutation, fuzz, cross-browser, leak, and performance workflows. +17. Add expiring scheduled-health and deduplicated failure issues. +18. Define the supported runtime and dependency matrix. + +## Final recommendation + +The proposed emphasis on tests, >95% coverage, type safety, simplicity, and +adversarial review is right. The important refinements are: + +- Use coverage as a ratcheted floor, backed by branch coverage, mutation tests, + and semantic corpora so the metric cannot be gamed. +- Establish behavior and performance baselines before replacing code. +- Keep `dev-refactor` green with small vertical PRs. +- Test revisions, provider contracts, partial results, cancellation, and + disposal as aggressively as happy-path SQL. +- Make marimo a continuous reference consumer. +- Tie reviews to exact commits and repeat them after changes. +- Gate latency, memory, bundle size, API growth, and correctness with measurable + budgets. + +Automation should make the demanding path the easiest path while keeping every +change small enough for humans and review agents to understand completely. diff --git a/package.json b/package.json index 3290ca3..3a1722e 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,23 @@ }, "scripts": { "dev": "vite", - "typecheck": "tsc --noEmit", + "typecheck": "pnpm run typecheck:src && pnpm run typecheck:tests && pnpm run typecheck:vnext-tests && pnpm run typecheck:demo", + "typecheck:src": "tsc --project tsconfig.json --noEmit", + "typecheck:tests": "tsc --project tsconfig.tests.json", + "typecheck:vnext-tests": "tsc --project tsconfig.vnext-tests.json", + "typecheck:demo": "tsc --project tsconfig.demo.json", "lint": "oxlint --fix", - "test": "vitest", + "test": "vitest run --config vitest.config.ts", + "test:coverage": "vitest run --config vitest.config.ts --coverage", + "test:coverage:changed": "node ./scripts/changed-coverage.mjs", + "test:browser": "vitest run --config vitest.browser.config.ts", + "test:integrity": "node ./scripts/check-test-integrity.mjs", + "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", - "build": "tsc", + "build": "node ./scripts/clean.mjs && tsc", + "clean": "node ./scripts/clean.mjs", "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build", - "release": "pnpm version", - "test:browser": "vitest --config=vitest.browser.config.ts" + "release": "pnpm version" }, "keywords": [ "codemirror", @@ -31,6 +40,7 @@ "packageManager": "pnpm@11.4.0", "peerDependencies": { "@codemirror/autocomplete": "^6", + "@codemirror/lang-sql": "^6", "@codemirror/lint": "^6", "@codemirror/state": "^6", "@codemirror/view": "^6" @@ -39,6 +49,7 @@ "@codemirror/lang-sql": "6.10.0", "@codemirror/view": "6.43.0", "@testing-library/dom": "10.4.1", + "@types/node": "24.10.1", "@vitest/browser-playwright": "4.1.6", "@vitest/coverage-v8": "4.1.6", "codemirror": "6.0.2", @@ -73,7 +84,7 @@ "types": "./dist/index.d.ts", "type": "module", "engines": { - "node": "*" + "node": ">=20.19" }, "module": "./dist/index.js", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c64485..7fc8e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,12 @@ importers: '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 '@vitest/browser-playwright': specifier: 4.1.6 - version: 4.1.6(playwright@1.61.1)(vite@8.0.13)(vitest@4.1.6) + version: 4.1.6(playwright@1.61.1)(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6) '@vitest/coverage-v8': specifier: 4.1.6 version: 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) @@ -53,10 +56,10 @@ importers: version: 7.0.2 vite: specifier: 8.0.13 - version: 8.0.13 + version: 8.0.13(@types/node@24.10.1) vitest: specifier: 4.1.6 - version: 4.1.6(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13) + version: 4.1.6(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13(@types/node@24.10.1)) packages: @@ -465,6 +468,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/pegjs@0.10.6': resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} @@ -1022,6 +1028,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -1470,6 +1479,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/pegjs@0.10.6': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -1532,29 +1545,29 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/browser-playwright@4.1.6(playwright@1.61.1)(vite@8.0.13)(vitest@4.1.6)': + '@vitest/browser-playwright@4.1.6(playwright@1.61.1)(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6)': dependencies: - '@vitest/browser': 4.1.6(vite@8.0.13)(vitest@4.1.6) - '@vitest/mocker': 4.1.6(vite@8.0.13) + '@vitest/browser': 4.1.6(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.10.1)) playwright: 1.61.1 tinyrainbow: 3.1.0 - vitest: 4.1.6(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13) + vitest: 4.1.6(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13(@types/node@24.10.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.6(vite@8.0.13)(vitest@4.1.6)': + '@vitest/browser@4.1.6(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.6(vite@8.0.13) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.10.1)) '@vitest/utils': 4.1.6 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.6(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13) + vitest: 4.1.6(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13(@types/node@24.10.1)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -1574,9 +1587,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13) + vitest: 4.1.6(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13(@types/node@24.10.1)) optionalDependencies: - '@vitest/browser': 4.1.6(vite@8.0.13)(vitest@4.1.6) + '@vitest/browser': 4.1.6(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6) '@vitest/expect@4.1.6': dependencies: @@ -1587,13 +1600,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.13)': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.10.1))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13 + vite: 8.0.13(@types/node@24.10.1) '@vitest/pretty-format@4.1.6': dependencies: @@ -1994,9 +2007,11 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@7.16.0: {} + undici@7.28.0: {} - vite@8.0.13: + vite@8.0.13(@types/node@24.10.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -2004,12 +2019,13 @@ snapshots: rolldown: 1.0.1 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@4.1.6(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13): + vitest@4.1.6(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13(@types/node@24.10.1)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.10.1)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -2026,10 +2042,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.13 + vite: 8.0.13(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: - '@vitest/browser-playwright': 4.1.6(playwright@1.61.1)(vite@8.0.13)(vitest@4.1.6) + '@types/node': 24.10.1 + '@vitest/browser-playwright': 4.1.6(playwright@1.61.1)(vite@8.0.13(@types/node@24.10.1))(vitest@4.1.6) '@vitest/coverage-v8': 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) jsdom: 29.1.1 transitivePeerDependencies: diff --git a/scripts/changed-coverage.mjs b/scripts/changed-coverage.mjs new file mode 100644 index 0000000..340ef8a --- /dev/null +++ b/scripts/changed-coverage.mjs @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { transformWithOxc } from "vite"; + +const arguments_ = process.argv.slice(2).filter((argument) => argument !== "--"); +if (arguments_.length !== 1) { + throw new Error("Usage: pnpm run test:coverage:changed -- "); +} +let [base] = arguments_; +if (/^0+$/.test(base)) { + const defaultBranch = process.env.DEFAULT_BRANCH ?? "main"; + base = execFileSync( + "git", + ["merge-base", "HEAD", `refs/remotes/origin/${defaultBranch}`], + { encoding: "utf8" }, + ).trim(); +} + +execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { + encoding: "utf8", + stdio: "pipe", +}); + +const changedProductionFiles = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`, "--", "src"], + { encoding: "utf8" }, +) + .trim() + .split("\n") + .filter( + (path) => + path.endsWith(".ts") && + !path.endsWith(".test.ts") && + !path.includes("/__tests__/") && + !path.includes("/browser_tests/") && + path !== "src/debug.ts", + ); +const changedRuntimeFiles = ( + await Promise.all( + changedProductionFiles.map(async (path) => { + const result = await transformWithOxc(readFileSync(path, "utf8"), path); + const measurableCode = result.code + .split("\n") + .filter((line) => !/^\s*(?:import\b|export\s*(?:\{|\*))/.test(line)) + .join("\n") + .trim(); + return measurableCode.length > 0 ? path : null; + }), + ) +).filter((path) => path !== null); + +execFileSync( + "pnpm", + [ + "exec", + "vitest", + "run", + "--config", + "vitest.config.ts", + "--coverage", + `--coverage.changed=${base}`, + "--coverage.thresholds.perFile=true", + "--coverage.thresholds.lines=95", + "--coverage.thresholds.statements=95", + "--coverage.thresholds.functions=95", + "--coverage.thresholds.branches=95", + ], + { + encoding: "utf8", + stdio: "inherit", + }, +); + +if (changedRuntimeFiles.length > 0) { + const summary = JSON.parse(readFileSync("coverage/coverage-summary.json", "utf8")); + const totals = summary.total; + if ( + !totals || + (totals.lines.total === 0 && totals.statements.total === 0) + ) { + throw new Error( + `Changed runtime files produced no measurable coverage: ${changedRuntimeFiles.join(", ")}`, + ); + } +} diff --git a/scripts/check-test-integrity.mjs b/scripts/check-test-integrity.mjs new file mode 100644 index 0000000..bf12bf0 --- /dev/null +++ b/scripts/check-test-integrity.mjs @@ -0,0 +1,99 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const forbiddenModifier = + /\b(?:describe|it|test)(?:\.[A-Za-z_$][\w$]*)*\.(?:only|skip|skipIf|todo|runIf)\s*\(/g; +const expectedFailure = + /\b(?:it|test)(?:\.[A-Za-z_$][\w$]*)*\.fails\s*\(\s*["'`]\[known-failure: ([^\]]+)\]/g; +const anyExpectedFailure = + /\b(?:it|test)(?:\.[A-Za-z_$][\w$]*)*\.fails\s*\(/g; +const violations = []; +const knownFailures = JSON.parse(readFileSync("test/known-failures.json", "utf8")); +const usedKnownFailures = new Set(); + +const bypassFixtures = [ + "describe.concurrent.skip('suite', () => {})", + "test.skipIf(true)('case', () => {})", + "it.runIf(false)('case', () => {})", +]; +for (const fixture of bypassFixtures) { + if (!forbiddenModifier.test(fixture)) { + throw new Error(`Integrity scanner failed its bypass fixture: ${fixture}`); + } + forbiddenModifier.lastIndex = 0; +} + +const expectedFailureFixtures = [ + "it.fails('[known-failure: direct] case', () => {})", + "it.concurrent.fails('[known-failure: chained] case', () => {})", +]; +for (const fixture of expectedFailureFixtures) { + if (!expectedFailure.test(fixture) || !anyExpectedFailure.test(fixture)) { + throw new Error(`Integrity scanner failed its expected-failure fixture: ${fixture}`); + } + expectedFailure.lastIndex = 0; + anyExpectedFailure.lastIndex = 0; +} + +function checkDirectory(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + checkDirectory(path); + continue; + } + if (!entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx")) { + continue; + } + + const source = readFileSync(path, "utf8"); + for (const match of source.matchAll(forbiddenModifier)) { + const line = source.slice(0, match.index).split("\n").length; + violations.push(`${path}:${line}: ${match[0]}`); + } + + for (const match of source.matchAll(expectedFailure)) { + const id = match[1]; + const entry = knownFailures[id]; + if (!entry) { + violations.push(`${path}: unregistered known failure '${id}'`); + continue; + } + if ( + typeof entry.owner !== "string" || + typeof entry.expires !== "string" || + typeof entry.trackingIssue !== "string" + ) { + violations.push(`${path}: incomplete governance for known failure '${id}'`); + continue; + } + if (entry.expires < new Date().toISOString().slice(0, 10)) { + violations.push(`${path}: known failure '${id}' expired on ${entry.expires}`); + } + usedKnownFailures.add(id); + } + + const expectedFailureCount = [...source.matchAll(anyExpectedFailure)].length; + const registeredFailureCount = [...source.matchAll(expectedFailure)].length; + if (expectedFailureCount !== registeredFailureCount) { + violations.push(`${path}: every expected failure must use a registered ID`); + } + } +} + +checkDirectory("src"); +checkDirectory("test"); + +for (const id of Object.keys(knownFailures)) { + if (!usedKnownFailures.has(id)) { + violations.push(`test/known-failures.json: unused known failure '${id}'`); + } +} + +if (violations.length > 0) { + console.error("Test integrity violations:"); + for (const violation of violations) { + console.error(` ${violation}`); + } + process.exitCode = 1; +} diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..28297b9 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,9 @@ +import { rmSync } from "node:fs"; +import { basename, resolve } from "node:path"; + +const outputDirectory = resolve("dist"); +if (basename(outputDirectory) !== "dist") { + throw new Error(`Refusing to clean unexpected path: ${outputDirectory}`); +} + +rmSync(outputDirectory, { force: true, recursive: true }); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs new file mode 100644 index 0000000..f551265 --- /dev/null +++ b/scripts/package-smoke.mjs @@ -0,0 +1,194 @@ +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryDirectory = mkdtempSync(join(tmpdir(), "codemirror-sql-package-")); +const packageManagerExecutable = process.env.npm_execpath; +if (!packageManagerExecutable) { + throw new Error("Package smoke must run through a package-manager script"); +} +const packageManagerEnvironment = { + ...process.env, + npm_config_manage_package_manager_versions: "false", + npm_config_package_manager_strict_version: "false", +}; + +function run(command, args, cwd) { + execFileSync(command, args, { + cwd, + encoding: "utf8", + env: { + ...packageManagerEnvironment, + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + }, + stdio: "inherit", + }); +} + +function runPackageManager(args, cwd) { + run(process.execPath, [packageManagerExecutable, ...args], cwd); +} + +try { + const packageManagerVersion = execFileSync( + process.execPath, + [packageManagerExecutable, "--version"], + { + cwd: repository, + encoding: "utf8", + env: packageManagerEnvironment, + }, + ).trim(); + if ( + process.env.EXPECTED_PNPM_VERSION && + packageManagerVersion !== process.env.EXPECTED_PNPM_VERSION + ) { + throw new Error( + `Package-manager child switched from ${process.env.EXPECTED_PNPM_VERSION} to ${packageManagerVersion}`, + ); + } + + const packOutput = JSON.parse( + execFileSync( + process.execPath, + [ + packageManagerExecutable, + "pack", + "--json", + "--pack-destination", + temporaryDirectory, + ], + { + cwd: repository, + encoding: "utf8", + env: packageManagerEnvironment, + }, + ), + ); + const manifest = Array.isArray(packOutput) ? packOutput[0] : packOutput; + if (!manifest || typeof manifest.filename !== "string") { + throw new Error("pnpm pack did not return an archive filename"); + } + + const archive = join(temporaryDirectory, basename(manifest.filename)); + const fixture = join(repository, "test", "package-smoke"); + copyFileSync(join(fixture, "package.json"), join(temporaryDirectory, "package.json")); + copyFileSync(join(fixture, "pnpm-lock.yaml"), join(temporaryDirectory, "pnpm-lock.yaml")); + + runPackageManager( + ["install", "--frozen-lockfile", "--ignore-scripts"], + temporaryDirectory, + ); + + const packageDirectory = join( + temporaryDirectory, + "node_modules", + "@marimo-team", + "codemirror-sql", + ); + mkdirSync(packageDirectory, { recursive: true }); + run( + "tar", + ["-xzf", archive, "-C", packageDirectory, "--strip-components=1"], + temporaryDirectory, + ); + const packedPackage = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), + ); + if (typeof packedPackage.dependencies?.["node-sql-parser"] !== "string") { + throw new Error("Packed manifest does not declare node-sql-parser"); + } + + writeFileSync( + join(temporaryDirectory, "consumer.mts"), + `import type { Extension } from "@codemirror/state"; +import { + NodeSqlParser, + sqlCompletion, + sqlExtension, +} from "@marimo-team/codemirror-sql"; +import { + BigQueryDialect, + DremioDialect, + DuckDBDialect, +} from "@marimo-team/codemirror-sql/dialects"; +import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; +import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; + +const extensions: Extension[] = [ + sqlCompletion({ dialect: DuckDBDialect }), + sqlExtension(), +]; +const parser = new NodeSqlParser(); + +void extensions; +void parser; +void BigQueryDialect; +void DremioDialect; +void commonKeywords; +void duckdbKeywords; +`, + ); + + writeFileSync( + join(temporaryDirectory, "consumer.mjs"), + `import { EditorState } from "@codemirror/state"; +import * as api from "@marimo-team/codemirror-sql"; +import * as dialects from "@marimo-team/codemirror-sql/dialects"; +import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; +import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; + +if (typeof api.sqlExtension !== "function" || typeof api.NodeSqlParser !== "function") { + throw new Error("Root package exports are incomplete"); +} +if (!dialects.BigQueryDialect || !dialects.DremioDialect || !dialects.DuckDBDialect) { + throw new Error("Dialect package exports are incomplete"); +} +if (!commonKeywords.keywords || !duckdbKeywords.keywords) { + throw new Error("Keyword data exports are incomplete"); +} + +const state = EditorState.create({ doc: "SELECT 1" }); +const parseResult = await new api.NodeSqlParser().parse("SELECT 1", { state }); +if (!parseResult.success || !parseResult.ast) { + throw new Error("The packaged parser could not load its runtime dependency"); +} +`, + ); + + writeFileSync( + join(temporaryDirectory, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + skipLibCheck: false, + strict: true, + target: "ES2022", + }, + include: ["consumer.mts"], + }, + null, + 2, + ), + ); + + runPackageManager(["exec", "tsc", "--project", "tsconfig.json"], temporaryDirectory); + run(process.execPath, ["consumer.mjs"], temporaryDirectory); +} finally { + if (basename(temporaryDirectory).startsWith("codemirror-sql-package-")) { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index a784b86..a319e71 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { readdir } from "node:fs/promises"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import * as dialects from "../dialects"; @@ -40,15 +40,17 @@ describe("index.ts exports", () => { }); }); -describe("keywords", async () => { - it("should have the correct structure, keywords.keywords should be an object", async () => { - const dataDir = join(__dirname, "../data"); - const files = await readdir(dataDir); - const keywordFiles = files.filter((file) => file.endsWith("-keywords.json")); +describe("keywords", () => { + it("should have the correct structure, keywords.keywords should be an object", () => { + const dataDirectory = join(__dirname, "../data"); + const keywordFiles = readdirSync(dataDirectory).filter((file) => + file.endsWith("-keywords.json"), + ); + expect(keywordFiles.length).toBeGreaterThan(0); for (const file of keywordFiles) { - const keywords = await import(`../data/${file}`); - expect(typeof keywords.keywords).toBe("object"); + const data = JSON.parse(readFileSync(join(dataDirectory, file), "utf8")); + expect(typeof data.keywords).toBe("object"); } }); }); diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index c33d7de..901d5f9 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -9,6 +9,10 @@ interface PackedFile { path: string; } +interface PackedManifest { + files: PackedFile[]; +} + /** * Collect every string file path referenced anywhere in the package.json * `exports` map (recursing through conditional-export objects like @@ -29,9 +33,17 @@ describe("published package", () => { // Run the real `npm pack` so we assert against the actual tarball contents, // not the source tree. Regression guard for the `./data/*` exports that // shipped dead in 0.2.5–0.2.7 because `src/data/` was missing at publish time. - const packed: PackedFile[] = JSON.parse( - execSync("npm pack --dry-run --json", { cwd: repoRoot, encoding: "utf8" }), - )[0].files; + const packOutput: PackedManifest | PackedManifest[] = JSON.parse( + execSync("pnpm pack --dry-run --json", { + cwd: repoRoot, + encoding: "utf8", + }), + ); + const manifest = Array.isArray(packOutput) ? packOutput[0] : packOutput; + if (!manifest) { + throw new Error("pnpm pack returned no package manifest"); + } + const packed = manifest.files; const packedPaths = new Set(packed.map((f) => f.path)); const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); diff --git a/src/sql/__tests__/completion-extension.test.ts b/src/sql/__tests__/completion-extension.test.ts index ea4741a..ec0af68 100644 --- a/src/sql/__tests__/completion-extension.test.ts +++ b/src/sql/__tests__/completion-extension.test.ts @@ -7,7 +7,10 @@ import { sqlCompletion } from "../completion-extension.js"; const schema = { users: ["id", "name", "email"] }; /** Collect the `autocomplete` completion sources registered on the language. */ -function autocompleteSources(...extensions: Parameters[0]["extensions"][]) { +type EditorStateConfig = NonNullable[0]>; +type EditorExtension = NonNullable; + +function autocompleteSources(...extensions: EditorExtension[]) { const state = EditorState.create({ doc: "SELECT ", extensions: [sql({ dialect: PostgreSQL, schema }), ...extensions], diff --git a/src/sql/__tests__/parser.test.ts b/src/sql/__tests__/parser.test.ts index b7bed12..e9000d3 100644 --- a/src/sql/__tests__/parser.test.ts +++ b/src/sql/__tests__/parser.test.ts @@ -445,7 +445,7 @@ describe("replaceBracketsWithQuotes", () => { expect(result.offsetRecord).toEqual({}); }); - it.fails("should handle escaped quotes", () => { + it.fails("[known-failure: parser-escaped-quotes] should handle escaped quotes", () => { const sql = "SELECT \\'{id}\\' FROM users WHERE id = \\'{id}\\'"; const result = replaceBracketsWithQuotes(sql); expect(result.sql).toBe("SELECT \\'{id}\\' FROM users WHERE id = \\'{id}\\'"); diff --git a/test/known-failures.json b/test/known-failures.json new file mode 100644 index 0000000..584bef4 --- /dev/null +++ b/test/known-failures.json @@ -0,0 +1,7 @@ +{ + "parser-escaped-quotes": { + "owner": "codemirror-sql-maintainers", + "expires": "2026-08-31", + "trackingIssue": "https://github.com/marimo-team/codemirror-sql/issues/169" + } +} diff --git a/test/package-smoke/package.json b/test/package-smoke/package.json new file mode 100644 index 0000000..16ddfa7 --- /dev/null +++ b/test/package-smoke/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@codemirror/autocomplete": "6.20.3", + "@codemirror/lang-sql": "6.10.0", + "@codemirror/lint": "6.9.7", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.0", + "node-sql-parser": "5.4.0", + "typescript": "7.0.2" + } +} diff --git a/test/package-smoke/pnpm-lock.yaml b/test/package-smoke/pnpm-lock.yaml new file mode 100644 index 0000000..135e7f6 --- /dev/null +++ b/test/package-smoke/pnpm-lock.yaml @@ -0,0 +1,362 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/autocomplete': + specifier: 6.20.3 + version: 6.20.3 + '@codemirror/lang-sql': + specifier: 6.10.0 + version: 6.10.0 + '@codemirror/lint': + specifier: 6.9.7 + version: 6.9.7 + '@codemirror/state': + specifier: 6.7.1 + version: 6.7.1 + '@codemirror/view': + specifier: 6.43.0 + version: 6.43.0 + node-sql-parser: + specifier: 5.4.0 + version: 5.4.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + +packages: + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.0': + resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@types/pegjs@0.10.6': + resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + node-sql-parser@5.4.0: + resolution: {integrity: sha512-jVe6Z61gPcPjCElPZ6j8llB3wnqGcuQzefim1ERsqIakxnEy5JlzV7XKdO1KmacRG5TKwPc4vJTgSRQ0LfkbFw==} + engines: {node: '>=8'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + +snapshots: + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.0 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.0': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.3': {} + + '@types/pegjs@0.10.6': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + big-integer@1.6.52: {} + + crelt@1.0.7: {} + + node-sql-parser@5.4.0: + dependencies: + '@types/pegjs': 0.10.6 + big-integer: 1.6.52 + + style-mod@4.1.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + w3c-keyname@2.2.8: {} diff --git a/tsconfig.demo.json b/tsconfig.demo.json new file mode 100644 index 0000000..6cadccb --- /dev/null +++ b/tsconfig.demo.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "inlineSources": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "rootDir": ".", + "sourceMap": false + }, + "include": [ + "./demo/**/*.ts", + "./src/**/*.ts" + ], + "exclude": [ + "./dist", + "./node_modules", + "./src/**/*.test.ts", + "./src/**/__tests__/**" + ] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..33f3cc2 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "inlineSources": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + // Legacy tests temporarily retain these two exceptions. All vNext tests + // are checked by tsconfig.vnext-tests.json with both rules enabled. + "noUncheckedIndexedAccess": false, + "noUnusedLocals": false, + "rootDir": ".", + "sourceMap": false, + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*.ts", + "./vitest.browser.config.ts", + "./vitest.config.ts" + ], + "exclude": [ + "./dist", + "./node_modules" + ] +} diff --git a/tsconfig.vnext-tests.json b/tsconfig.vnext-tests.json new file mode 100644 index 0000000..8acbc12 --- /dev/null +++ b/tsconfig.vnext-tests.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.tests.json", + "compilerOptions": { + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true + }, + "include": [ + "./src/vnext/**/*.ts", + "./vitest.browser.config.ts", + "./vitest.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts index a789965..ca31d73 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,46 +1,6 @@ -import { playwright } from "@vitest/browser-playwright"; -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite"; export default defineConfig({ - root: process.env.VITEST ? "." : "demo", - test: { - watch: false, - coverage: { - enabled: true, - provider: "v8", - reportOnFailure: true, - reporter: ["text", "json", "html", "json-summary"], - include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts", "src/**/__tests__/**", "src/debug.ts"], - }, - projects: [ - { - test: { - environment: "jsdom", - exclude: ["src/**/browser_tests/**/*.test.ts"], - include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], - } - }, - { - test: { - name: "browser", - browser: { - enabled: true, - provider: playwright({ - launchOptions: process.env.CI ? { channel: "chrome" } : undefined, - }), - // https://vitest.dev/guide/browser/playwright - instances: [ - { browser: 'chromium' }, - ], - ui: false, - headless: true, - }, - include: ["src/**/browser_tests/**/*.test.ts"], - testTimeout: 5000, - }, - } - ] - }, + root: "demo", base: "/codemirror-sql/", }); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 0000000..012b8e4 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,19 @@ +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + allowOnly: false, + browser: { + enabled: true, + headless: true, + instances: [{ browser: "chromium" }], + provider: playwright(), + ui: false, + }, + include: ["src/**/browser_tests/**/*.test.ts"], + passWithNoTests: false, + testTimeout: 5000, + watch: false, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3be1a5b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + allowOnly: false, + coverage: { + enabled: false, + exclude: [ + "src/**/*.test.ts", + "src/**/__tests__/**", + "src/**/browser_tests/**", + "src/debug.ts", + ], + excludeAfterRemap: true, + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json", "html", "json-summary"], + reportOnFailure: true, + thresholds: { + branches: 85.85, + functions: 90.94, + lines: 91.52, + statements: 91.61, + }, + }, + environment: "jsdom", + exclude: ["src/**/browser_tests/**/*.test.ts"], + include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], + passWithNoTests: false, + watch: false, + }, +}); From c96bbebfe1830d52f28b1028cb78be07412c4170 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 20:51:46 +0800 Subject: [PATCH 02/42] docs: define vNext language-service contracts (#171) Defines the accepted vNext capability charter and ADR 0001 before runtime implementation. The decision establishes a shared framework-independent language service, one disposable session per document, atomic revision-guarded updates, original-document UTF-16 ranges, explicit request outcomes, scoped catalog epochs, provider source forms, and a thin CodeMirror lifecycle adapter.\n\nThe contracts were derived from the repository research and current marimo integration, then iterated through three fresh-context design reviews and two blocker-resolution loops. Local lint, typecheck, test integrity, workflow validation, diff checks, and aggregate coverage pass. --- ## Summary by cubic Adds ADR 0001 and a capability charter defining the vNext SQL language-service contracts. Establishes a framework-agnostic service with per-document sessions, atomic revision-guarded updates, original-document UTF-16 ranges, unified request outcomes, and a thin `CodeMirror` adapter ahead of runtime work. - **New Features** - Shared `SqlLanguageService` and per-document `SqlDocumentSession` with atomic text/context updates. - Opaque session revisions; all results carry a revision and require a currency check before apply. - Standard UTF-16 half-open original-document ranges and validated incremental changes. - Unified request outcomes (ready, unavailable, cancelled, failed) with clear cancellation and provider reports. - Narrow async provider boundary (no editor/DOM), bounded sync budget, worker-friendly guidance. - Catalog epochs with scoped invalidation; template masking/mapping preserving original offsets; thin `CodeMirror` adapter; walking-skeleton scope (relation completion first). Written for commit 66853b3d7a4af12afa01c06a9b7b7113459c4ab5. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 592 ++++++++++++++++++ docs/vnext/capability-charter.md | 280 +++++++++ 2 files changed, 872 insertions(+) create mode 100644 docs/adr/0001-language-service-and-session.md create mode 100644 docs/vnext/capability-charter.md diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md new file mode 100644 index 0000000..add3243 --- /dev/null +++ b/docs/adr/0001-language-service-and-session.md @@ -0,0 +1,592 @@ +# ADR 0001: Shared Language Service and Per-Document Sessions + +Status: accepted +Date: 2026-07-24 + +## Context + +The v0.x public API exposes mutable parser and analyzer implementations. +Parsers receive arbitrary CodeMirror `EditorState`, features can use different +parser and schema configurations, and asynchronous work has no common revision +or cancellation contract. + +Marimo demonstrates the resulting pressure: + +- It subclasses `NodeSqlParser` to mix local parsing with remote validation. +- The subclass stores focus, timer, and validation state on the parser. +- Replacing a debounce timer can leave the earlier promise unsettled. +- DuckDB `parse()` and `validateSql()` deliberately report different evidence. +- Connection, dialect, schema, and Python-template completion are wired through + separate paths. +- Many editor instances can share configuration while requiring independent + mutable state. + +The next major may break compatibility. The API should therefore model the +actual lifecycle instead of preserving these implementation classes. + +## Decision + +vNext has one shareable, framework-independent `SqlLanguageService` and one +disposable `SqlDocumentSession` per open document/editor. + +The service owns providers, workers, shared bounded caches, and immutable +dialect/catalog resources. A session owns document and context revisions, +request generations, relevant subscriptions, cancellation, and +current-document caches. The CodeMirror adapter owns editor focus, visibility, +debounce, dispatch, and session disposal. + +Shared providers contain no request-local mutable state. + +## Stable introductory shape + +The target consumer shape is: + +```ts +interface MarimoSqlContext extends SqlDocumentContext { + readonly engine: string; + readonly sqlMode: "default" | "validate"; +} + +const duckdb = duckdbDialect(); + +const service = createSqlLanguageService({ + dialects: [duckdb, postgresDialect(), bigQueryDialect()], + syntax: nodeSqlParserSyntax(), + catalog: marimoCatalogProvider, + validators: [ + marimoValidationProvider({ + authority: "authoritative", + granularity: "document", + input: "original", + }), + ], +}); + +const session = service.openDocument({ + text: "SELECT * FROM users", + context: { + dialect: duckdb.id, + engine: "__marimo__", + sqlMode: "validate", + }, +}); + +const completion = await session.complete({ + position: 19, + trigger: { kind: "invoked" }, + signal, +}); + +if ( + completion.status === "ready" && + session.isCurrent(completion.revision) +) { + render(completion.value.items); +} + +session.dispose(); +service.dispose(); +``` + +`syntax` is an opaque module accepted by the stable service factory. Its parser +and semantic-model SPI is not stable in this ADR. Official adapters can use an +experimental subpath until two materially different backends validate the +normalized artifacts. + +## Document context + +The service is generic over a host context that extends the standard document +context: + +```ts +interface SqlDocumentContext { + readonly dialect: SqlDialectId; + readonly catalog?: { + readonly scope: string; + readonly searchPath?: readonly string[]; + }; +} +``` + +The service resolves dialect IDs through its immutable registry and rejects +unknown or duplicate IDs. Duplicate registration is rejected even when the +definitions appear equivalent. Dialect identity includes an internal +configuration revision; calling a dialect factory again does not change the +identity of an already registered dialect. + +Hosts can add fields such as engine or SQL mode. Contexts must be +structured-cloneable plain data. On open/update, the session creates and owns a +structured clone before accepting the change. Clone failure rejects the +complete operation without mutation. The accepted snapshot is recursively +frozen and is the only context observed by providers. Caller mutation therefore +cannot change an in-flight request. Providers never read ambient editor state. + +The session updates text and context atomically: + +```ts +session.update({ + baseRevision: session.revision, + document: { + changes: [{ from: 14, to: 19, insert: "customers" }], + }, + context: nextContext, +}); +``` + +A full-text replacement is available for simple consumers. Incremental changes +are ordered, non-overlapping, absolute UTF-16 ranges in the current document. +The service validates the base revision and every range before mutating state. + +A context-only update is valid and also requires `baseRevision`. Accepted +updates always create a new revision, including an `A → B → A` text cycle, so a +previous result never becomes current again. A stale base rejects the complete +update without partial mutation. + +## Revision and applicability + +The public revision is an opaque immutable service-generated token. Callers +cannot construct one or supply their own version number. + +Internally, the revision records: + +- Session identity +- Monotonic sequence +- Document revision +- Context and template revision +- Relevant environment/catalog epoch + +The public model is deliberately global per session: any accepted text, +context, template, relevant catalog, or provider-configuration invalidation +advances the session revision and makes every earlier public result stale. +Selective invalidation refers only to internal cache reuse; for example, a +catalog change can retain statement parse artifacts. If feature-specific +applicability is later required, it gets a separately named token. + +Consumers use only: + +```ts +session.isCurrent(result.revision); +``` + +Every request captures one revision, and every result carries it. A session +change settles prior public requests as cancelled/superseded once provider +invocation has returned control, even if an underlying promise ignores +cancellation. A ready result can become stale after settlement, so currency is +checked again immediately before application. + +## Ranges + +The stable range is an ergonomic readonly object: + +```ts +interface SqlTextRange { + readonly from: number; + readonly to: number; +} +``` + +All public ranges are absolute original-document UTF-16 half-open coordinates. +They satisfy: + +```text +0 <= from <= to <= document.length +``` + +Line and column values are derived presentation data. Internally, absolute +document offsets and statement-relative offsets use distinct branded types. + +Statement lookup uses explicit cursor affinity at boundaries and EOF. It must +not reintroduce the v0.x inclusive-end behavior. + +## Request outcomes + +Every session feature request settles with the same top-level union: + +```ts +type SqlRequestResult = + | { + readonly status: "ready"; + readonly revision: SqlRevision; + readonly value: T; + readonly sources: readonly SqlProviderReport[]; + } + | { + readonly status: "unavailable"; + readonly revision: SqlRevision; + readonly reason: SqlUnavailableReason; + readonly retryable: boolean; + } + | { + readonly status: "cancelled"; + readonly revision: SqlRevision; + readonly reason: "caller" | "superseded" | "disposed"; + } + | { + readonly status: "failed"; + readonly revision: SqlRevision; + readonly failure: SqlServiceFailure; + }; +``` + +SQL invalidity is a ready analysis result containing diagnostics, not a service +failure. Unsupported syntax can be opaque or partial. Provider rejection, +timeout, malformed output, and cancellation remain distinguishable. + +Feature-specific values describe their own completeness. For example, +completion uses `isIncomplete`, while diagnostics carry coverage, authority, +and document/statement scope. One generic completeness flag is not used to +mean unrelated things. + +If one provider fails but useful local evidence exists, the top-level result +can remain ready with a failed provider report. Top-level failed means no +usable answer could be produced. + +Raw `Error` and parser/provider payloads do not cross the stable boundary. + +## Feature methods + +Explicit methods are preferred over one generic `request({ kind })` API: + +```ts +interface SqlDocumentSession { + readonly revision: SqlRevision; + + update(update: SqlDocumentUpdate): SqlRevision; + + complete( + request: SqlCompletionRequest, + ): Promise>; + + diagnostics( + request?: SqlDiagnosticsRequest, + ): Promise>; + + hover( + request: SqlPositionRequest, + ): Promise>; + + definition( + request: SqlPositionRequest, + ): Promise>; + + references( + request: SqlPositionRequest, + ): Promise>; + + format( + request?: SqlFormatRequest, + ): Promise>; + + isCurrent(revision: SqlRevision): boolean; + dispose(): void; +} +``` + +The walking skeleton exposes only implemented features. Adding a method is +backward-compatible; returning placeholder success from an unimplemented method +is not. + +## Provider rules + +Providers are narrow and async-only. Pure range, change, identifier, and +statement-index primitives remain synchronous. + +Provider requests contain: + +- Immutable source snapshot with distinctly named `originalText`, + `analysisText`, and source mapping +- Explicit document context +- Provider configuration identity +- `AbortSignal` + +Each validator declares both `granularity: "document" | "statement"` and +`input: "original" | "analysis"`. Regardless of input form, returned public +ranges use original-document coordinates. + +They do not contain: + +- `EditorState` or `EditorView` +- DOM or CodeMirror completion values +- Mutable session objects +- Caller-controlled revision stamps + +The service catches both synchronous throws during provider invocation and +asynchronous rejection. Provider invocation must return control within a +strict bounded synchronous budget. CPU-heavy or untrusted work runs in a worker +or process because an `AbortSignal` cannot interrupt a blocked event loop. Once +a promise exists, the service races public settlement against cancellation. It +validates ranges and result limits before arbitration. + +Authority and arbitration are feature-specific: + +- Completion merges, deduplicates, and deterministically ranks. +- Diagnostics use coverage and authority; they are not blindly unioned. +- Hover can select or attribute sections. +- Definitions use precedence, confidence, and provenance. +- Formatting normally selects one provider. + +Provider completion order cannot affect final ordering. + +## Parser-neutral artifacts + +The walking skeleton normalizes only evidence needed by its first vertical +slice: + +- Statement ranges and state +- Tokens +- Diagnostics +- Normalized relation references +- Cursor-context facts +- Exact completion replacement range + +It does not expose `ast?: unknown` or attempt a universal SQL AST. + +The later semantic IR will model query blocks, set operations, relation +bindings, column shapes, CTE order, correlation and `LATERAL`, clause-specific +visibility, definitions/references, provenance, and explicit unknown shapes. +That IR is prototyped against at least two parser implementations or materially +different conformance corpora before stabilization. + +## Catalog contract + +Catalog access is lazy and bounded. Providers search and resolve within an +explicit scope, prefix, object-kind set, and service-clamped result limit. +Responses carry stable entity IDs, provider epoch, pagination, and coverage. + +Empty-complete and empty-loading are distinct. A miss supports a definite +unknown-object diagnostic only when the response proves complete coverage for +the searched scope. + +Catalog subscriptions are scoped and disposable. An invalidation identifies +provider ID, affected scope, and: + +```ts +interface SqlCatalogEpoch { + readonly generation: number; + readonly token: string; +} +``` + +Generation is monotonic within one provider and scope; token identifies the +provider snapshot. Lower out-of-order generations are discarded and equal +generations are duplicates. Only sessions subscribed to the affected scope +advance their public revision, and session disposal removes the subscription. +Internal unchanged parse artifacts remain reusable. + +## Template contract + +The no-template default is an identity mapping. A source transformer produces a +validated virtual document with copied, masked/opaque, or generated segments. + +Marimo braces initially use masking that preserves the exact UTF-16 length and +every CR/LF code unit. Every other masked code unit becomes lexically inert +whitespace, including each half of an astral character, so masking cannot join +adjacent SQL tokens. More complex transformers use a versioned map whose +segments are ordered, non-overlapping, and validated for original/generated +coverage. + +Edits crossing an ambiguous or unmapped boundary are rejected. Mapping failure +is unavailable analysis, not invalid SQL. + +## CodeMirror adapter + +The adapter creates one session per view and disposes it in +`ViewPlugin.destroy()`. It translates `ChangeSet` values into one atomic session +update and checks the result revision immediately before dispatch. + +It owns: + +- Focus and visibility scheduling +- Debounce timers +- Current request handles +- CodeMirror facets and effects +- Safe DOM rendering +- Composition with external completion sources + +Context updates are explicit: + +```ts +const support = sqlEditor({ + service, + initialContext, + features: { + completion: true, + diagnostics: true, + hover: true, + navigation: true, + gutter: true, + }, + completion: { + externalSources: [pythonExpressionCompletion], + }, +}); + +const view = new EditorView({ + doc, + extensions: [support.extension], +}); + +// Context-only convenience: +support.setContext(view, nextContext); + +// Or update text and context atomically: +view.dispatch({ + changes, + effects: support.contextEffect.of(nextContext), +}); +``` + +The adapter installs one coherent `autocompletion` configuration. Package and +external sources have deterministic ordering, deduplication, and tie-breaking. +The support object contains no cross-view mutable session; `setContext` is a +context-only convenience that dispatches `contextEffect` to the target view. +Consumers use that public typed effect when text and context must change +together. The adapter converts a document change and context effect in one +CodeMirror transaction into one atomic session update, and highlighting and +service interpretation change to the same registered dialect ID. + +A callback form may be added only with an explicit stable equality/key +contract. Cursor motion must not accidentally invalidate context. + +Stable documentation data is plain text or Markdown. Default renderers never +use provider or catalog values as `innerHTML`. A custom trusted renderer returns +a DOM `Node` at the CodeMirror boundary. + +## Cache and cancellation model + +Revision identity and content reuse are separate. + +Recommended cache layers: + +```text +document + → source transform + → statement index + → statement parse + → semantic artifact + → catalog resolution + → feature request +``` + +Parser artifacts use statement-relative coordinates so an unchanged moved +statement can be reused and remapped. Keys include content fingerprint, +dialect, parser/configuration, and template identities. Catalog changes do not +invalidate parsing. Renderer, theme, focus, and cursor changes do not invalidate +semantic artifacts. + +In-flight deduplication uses the same complete key. Cancelling one consumer +detaches it; shared underlying work is aborted only when no consumers remain. +Caches are bounded by both item count and estimated retained bytes. + +`AbortSignal` cannot interrupt any synchronous provider blocking the event +loop. CPU-heavy or untrusted providers must run in a worker or process. + +## Lifecycle invariants + +- `dispose()` is idempotent. +- Session disposal prevents publication immediately. +- Pending requests settle exactly once as cancelled/disposed. +- Late provider results are drained and discarded. +- Catalog subscriptions, timers, and workers are released. +- Service disposal disposes all child sessions before owned providers/workers. +- One session's edit, context, focus, cancellation, or disposal cannot affect + another session sharing the service. + +## Walking skeleton + +The first implementation slice contains: + +1. Opaque revisions and runtime-validated ranges/changes. +2. Session open, atomic update, current-revision, and disposal. +3. Dialect-aware statement indexing with explicit cursor affinity. +4. One internal parser adapter producing narrow normalized syntax evidence. +5. One bounded relation-search catalog contract. +6. `FROM`/`JOIN` relation completion with an exact edit range and provenance. +7. A thin CodeMirror transaction/completion adapter. + +Deferred from this slice: + +- Full semantic scope graph +- Multi-provider arbitration +- Remote validation +- Workers +- Non-identity template transforms +- Formatting +- Streaming +- Broad catalog materialization + +## Required contract tests + +- Revisions are monotonic through `A → B → A`. +- Revisions from other sessions are never current. +- Update ranges reject negative, fractional, reversed, overlapping, stale-base, + and out-of-bounds changes without partial mutation. +- UTF-16 behavior is correct around astral characters, combining characters, + unpaired surrogates, CRLF, EOF, and empty ranges. +- Text and context can update atomically. +- Non-cloneable context rejects without changing text, context, or revision. +- Caller mutation after open/update cannot change the owned context snapshot. +- Context-only dialect/connection changes supersede dependent results. +- Catalog invalidation does not reparse unchanged SQL. +- Caller abort, supersession, and disposal settle promptly when a provider + ignores its signal. +- Late resolve/reject causes no dispatch or unhandled rejection. +- Provider throw and rejection normalize identically. +- Invalid SQL, unsupported syntax, unavailable analysis, failure, and + cancellation remain distinct. +- Invalid provider ranges are rejected without corrupting valid contributions. +- Partial catalog misses never produce definite unknown-object diagnostics. +- Duplicate/lower catalog generations are ignored per provider and scope. +- Duplicate dialect IDs are rejected at service construction. +- Identical SQL in different sessions/contexts cannot contaminate caches. +- Shared work deduplicates without cross-consumer cancellation. +- Moving the cursor does not reparse. +- Fifty editors dispose without retained session state. +- Packed consumer declarations compile with `skipLibCheck: false`. +- The marimo fixture leaves no unresolved debounce promise. + +## Alternatives rejected + +### Stateless `analyze(text, context)` + +Simple, but it loses session lifecycle, bounded incremental caches, shared-work +coordination, subscriptions, and a natural stale-result boundary. + +### One generic request method + +Small in line count but weakly discoverable, poorly narrowed, and easy to +misuse. Explicit feature methods provide clearer types. + +### One mega-provider + +Optional methods obscure capability and authority, encourage shared mutable +state, and couple unrelated features. Providers remain narrow. + +### Public universal AST + +It would either leak the first parser's shapes or become an unstable lowest +common denominator. Only narrow normalized evidence is exposed initially. + +### Provider-owned revisions + +A stale provider could stamp a result as current. Only the service owns result +revisions. + +### Parser receives `EditorState` + +This makes dependencies implicit and couples the core to CodeMirror. The +adapter extracts explicit context instead. + +## Consequences + +The architecture requires more deliberate lifecycle and result types than +v0.x. In exchange, it makes stale publication, incomplete catalog evidence, +provider failure, and cross-editor state leakage testable invariants. + +Marimo can delete `CustomSqlParser`: remote validation becomes a +document-diagnostics provider, focus becomes scheduling policy, engine/dialect +changes become context updates, braces become a source transformer, and Python +completion remains an external CodeMirror completion source. + +Legacy `SqlParser`, `NodeSqlParser`, `SqlStructureAnalyzer`, and +`QueryContextAnalyzer` are not part of the next-major stable API. diff --git a/docs/vnext/capability-charter.md b/docs/vnext/capability-charter.md new file mode 100644 index 0000000..bb66f06 --- /dev/null +++ b/docs/vnext/capability-charter.md @@ -0,0 +1,280 @@ +# vNext Capability Charter + +Status: accepted +Date: 2026-07-24 + +## Product boundary + +vNext is a framework-independent SQL language service with a first-class +CodeMirror 6 adapter. It provides local editor intelligence and composes +optional catalog, database-native, formatter, and remote validation providers. +It does not execute queries. + +The service must remain useful in a browser with no backend. Native and remote +providers augment that local baseline; they do not silently replace unrelated +local evidence. + +## Initial dialect tiers + +Dialect support is capability-scoped. A dialect is not described as +"supported" without naming the feature. + +### Conformance targets + +The first release candidate targets: + +- DuckDB +- PostgreSQL +- BigQuery + +Each target needs checked-in valid, invalid, incomplete, templated, and +multi-statement corpora. Syntax status, statement boundaries, identifier rules, +relation and column resolution, and feature availability are tested +independently. + +DuckDB is the reference native-provider integration. BigQuery is the reference +for multi-part identifiers and non-PostgreSQL quoting. PostgreSQL is the +reference baseline for broadly implemented relational syntax. + +### Compatibility dialects + +Dremio, SQLite, MySQL, and other existing parser dialects may ship with a +smaller declared capability set. Highlighting or parser acceptance alone does +not imply semantic diagnostics, navigation, formatting, or complete +autocomplete. + +A compatibility dialect graduates to a conformance target only after it has an +owned corpus and explicit feature matrix. Hosts can register experimental +dialects without making them part of the stable compatibility promise. + +## Initial SQL constructs + +The first release candidate targets: + +- Multiple statements with dialect-aware boundaries +- `SELECT`, `FROM`, joins, filters, grouping, ordering, and limits +- CTEs, including declaration-order visibility +- Derived tables and nested query blocks +- Set operations +- Qualified and unqualified relation and column references +- Aliases with clause-specific visibility +- Common parameters and quoted identifiers +- Comments, strings, incomplete input, and error recovery +- Template regions that can be masked or mapped without corrupting offsets + +DDL, procedural SQL, macros, table functions, vendor extensions, recursive +CTEs, `LATERAL`, `PIVOT`, and nested/structured types must report their actual +analysis quality. They are not assumed semantically complete merely because a +parser accepts them. + +## Feature target + +The stable session API is intended to support: + +- Keyword, relation, column, function, and snippet completion +- Syntax, semantic, and host-provided diagnostics +- Hover documentation +- Definition, references, highlight, and rename +- Statement and structure indicators +- Explicit formatting through a selected formatter provider + +The walking skeleton implements relation completion first. Subsequent features +reuse the same session, revision, range, cancellation, provenance, and result +contracts. No feature gets a separate parser or schema configuration. + +## Correctness contract + +Every public source range is: + +- Absolute in the original document +- Measured in UTF-16 code units +- Half-open: `[from, to)` +- Validated before it can reach a consumer + +Every asynchronous result carries a service-generated opaque revision. Text, +dialect, connection, template, relevant catalog, or provider-configuration +changes advance the affected session revision. The service does not settle a +request as ready if it is already superseded at settlement. Consumers, and +always the CodeMirror adapter, check currency again immediately before applying +a ready result. + +The service distinguishes: + +- Invalid SQL +- Recovered or partial analysis +- Unsupported capability +- Loading or incomplete catalog evidence +- Provider failure +- Caller cancellation +- Supersession +- Disposal + +An absent or partial catalog cannot justify a definite unknown-object +diagnostic. + +## Provider boundary + +Providers are separated by feature concern. They receive immutable plain-data +requests and an `AbortSignal`; they never receive `EditorState`, `EditorView`, +DOM values, mutable sessions, or caller-controlled revisions. + +Providers declare whether they consume original or transformed analysis text. +All public ranges are mapped to the original document. + +Provider invocation must return control within a bounded synchronous budget. +After a promise is obtained, the service races publication against cancellation +even when the underlying promise ignores its signal. CPU-heavy or untrusted +work runs in a worker or process. + +The stable external extension points are expected to include: + +- Catalog search and resolution +- Completion augmentation +- Document and statement diagnostics +- Formatter selection +- Hover and navigation augmentation + +Parser, statement-boundary, worker, and semantic-IR interfaces remain +experimental until conformance tests cover at least two materially different +implementations. Parser-specific AST types are never part of the stable root +API. + +Document-granularity and statement-granularity validators are distinct +contracts. A scheduler must not silently invoke a whole-document engine +validator once per statement. + +## Catalog boundary + +The canonical catalog API is asynchronous, bounded, versioned, and explicit +about coverage. It does not require a host to materialize one complete nested +`SQLNamespace`. + +Catalog requests must support scoped search and resolution with result limits. +Responses distinguish loading, partial, complete, failed, and paginated +coverage. Stable entity identity and provider epochs prevent evidence from +different catalog states being combined as authoritative. + +Catalog invalidation identifies provider, affected scope, and an epoch +containing a provider/scope-monotonic generation plus opaque snapshot token. +Lower generations are discarded and equal generations are duplicates. Only +subscribed sessions advance their public revision. Internal unchanged statement +parse artifacts remain reusable. + +## Template boundary + +The original document remains the canonical coordinate space. A template +transformer produces either: + +- A length-preserving masked analysis document, or +- A validated, versioned source map + +Completion edits crossing ambiguous mappings are rejected. Diagnostics wholly +inside generated text are dropped or explicitly anchored. Mapping failure is +an unavailable analysis capability, not evidence that the SQL is invalid. + +Length-preserving masks retain every UTF-16 code unit count and every CR/LF code +unit. Each other masked code unit becomes lexically inert whitespace so adjacent +tokens cannot be joined accidentally. + +Mapped segments are ordered, non-overlapping, and validated for original and +generated coverage. Marimo `{...}` expressions are the first template +conformance target. + +## Runtime and packaging support + +The release target is: + +- ESM packages +- Node.js 20.19 and newer for package import and non-DOM core use +- CodeMirror 6 peer dependencies +- Current evergreen Chromium, Firefox, and WebKit families +- Browser bundlers that honor package `exports` +- SSR-safe import of core modules + +Exact minimum peer versions, browser versions, and supported bundler releases +are recorded in the release capability matrix and exercised through packed +consumer fixtures. A declared runtime is not supported unless its fixture runs +in CI. + +The exact subpath layout is deferred to a packaging ADR after the walking +skeleton produces bundle and packed-consumer evidence. Packaging must provide: + +- An SSR-safe, framework-independent core entry +- An explicit CodeMirror entry +- Explicit dialect entry points +- Independently importable optional parser/provider integrations +- No accidental transitive import of optional parsers or providers into core + +## Provisional performance envelopes + +These are product envelopes, not claims about the current implementation. +Benchmark baselines and raw samples must replace provisional values before the +release candidate. + +For a warm local service, a 10 KiB document, and a 10,000-relation indexed +catalog: + +- Keystroke bookkeeping: p95 under 8 ms +- Active-statement local analysis: p95 under 16 ms +- Local completion response: p95 under 50 ms +- Local diagnostics response: p95 under 150 ms +- No routine main-thread task over 50 ms + +For a 1 MiB document, the service may degrade to active-statement intelligence, +but editing must remain responsive and the degraded state must be explicit. + +Fifty mounted editors must have bounded retained memory after disposal and must +share immutable dialect/catalog data. Cache budgets are enforced by both entry +count and estimated retained bytes. + +Provisional compressed bundle budgets: + +- Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB +- Each ordinary dialect module: 25 KiB +- Optional `node-sql-parser` chunk: no regression from its recorded baseline + without an approved ADR + +## Security and robustness + +- Default renderers do not insert provider or catalog strings with + `innerHTML`. +- Stable documentation values are plain text or Markdown data. +- Provider ranges, edits, continuation tokens, and result sizes are validated. +- Catalog and completion result counts are clamped. +- Provider throws and rejections are normalized; raw errors do not cross the + stable boundary. +- After provider invocation returns a promise, cancellation settles publication + promptly even when that promise ignores its signal. +- Late provider resolution is drained without dispatch or unhandled rejection. +- `dispose()` is idempotent and settles all pending requests. + +## Explicit non-goals + +vNext is not: + +- A SQL execution engine +- A query optimizer +- A complete database type checker +- A guarantee that one browser parser accepts every vendor extension +- An LSP transport implementation +- A mandatory full-catalog loader +- A generic universal SQL AST +- A compatibility wrapper around mutable v0.x parser/analyzer classes + +Legacy behavior can be retained in migration helpers, but it does not constrain +the next-major architecture. + +## Release evidence + +Before vNext is stable: + +- Every conformance target has an explicit feature matrix and corpus. +- Repository coverage meets the thresholds in `implementation.md`. +- Critical revision, range, mapping, cancellation, and arbitration logic has + mutation evidence. +- Browser, package, SSR, marimo, fuzz, leak, and benchmark gates pass. +- Bundle and latency budgets compare against both `main` and the previous + `dev-refactor` checkpoint. +- The marimo fixture proves remote DuckDB validation, connection changes, + partial catalogs, Python-expression completion, rapid edits, and disposal. +- Unsupported and partial states are documented and visible to consumers. From 846c90b33995fb4e4763bf4747be3db8f52ae545 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 21:50:05 +0800 Subject: [PATCH 03/42] feat(vnext): add atomic document sessions (#172) ## Summary - add the framework-independent `@marimo-team/codemirror-sql/vnext` entry point - implement immutable dialect registration, one session per document, opaque revision identity/metadata, discriminated atomic updates, and terminal disposal - validate and own bounded plain-data contexts; normalize hostile JavaScript inputs and guard reentrant update/open lifecycle races - add compile-only API tests, 83 runtime invariant tests, current-API documentation, and packed isolation smoke coverage without CodeMirror or node-sql-parser available ## Evidence - `pnpm run typecheck` - `pnpm exec oxlint` - `pnpm run test:coverage` (670 passed, 1 governed expected failure) - changed vNext runtime coverage: 99% statements, 98.47% branches, 100% functions, 98.99% lines - `pnpm run test:browser` - `pnpm run test:package` - `pnpm run test:integrity` - `pnpm run demo` ## Adversarial review Two independent reviewers challenged correctness and API design. The first loop found and reproduced reentrant revision overwrite, service-open disposal, dialect TOCTOU, variance, and optional-property issues. The implementation and tests were redesigned; both reviewers now approve with no blocker/high findings. --- ## Summary by cubic Adds vNext atomic document sessions in `@marimo-team/codemirror-sql/vnext` with immutable dialects, per-document sessions, and strict revision/lifecycle invariants. Strengthens smoke tests with dependency isolation and rollback checks, and exports `./vnext` for consumers. - **New Features** - Public service/session operations are exposed as readonly, bound function fields to remain safe when passed as callbacks or destructured. - Packaging/docs/tests: new session primitives guide, `package.json` `./vnext` subpath export, and smoke tests that run without `@codemirror` or `node-sql-parser`, verify `vnext` exports, revision identity, and validate isolation rollback. - **Bug Fixes** - Hardened API invariants: reentrant-update guard, early stale-revision checks before context inspection, idempotent terminal disposal for session/service, stricter optional-field handling (`exactOptionalPropertyTypes`), and consistent `SqlSessionError` codes when normalizing accessors/proxies. Written for commit ce6d09ab879078731a75b40add326218a266a297. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 40 +- docs/vnext/session-primitives.md | 78 ++ implementation.md | 22 +- package.json | 6 + scripts/package-smoke.mjs | 138 ++ src/vnext/__tests__/session.test.ts | 1236 +++++++++++++++++ src/vnext/index.ts | 23 + src/vnext/session.ts | 917 ++++++++++++ src/vnext/types.ts | 129 ++ test/vnext-types/session.test-d.ts | 82 ++ tsconfig.vnext-tests.json | 2 + 11 files changed, 2651 insertions(+), 22 deletions(-) create mode 100644 docs/vnext/session-primitives.md create mode 100644 src/vnext/__tests__/session.test.ts create mode 100644 src/vnext/index.ts create mode 100644 src/vnext/session.ts create mode 100644 src/vnext/types.ts create mode 100644 test/vnext-types/session.test-d.ts diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index add3243..5cc9e43 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -125,8 +125,10 @@ The session updates text and context atomically: ```ts session.update({ + kind: "document", baseRevision: session.revision, document: { + kind: "changes", changes: [{ from: 14, to: 19, insert: "customers" }], }, context: nextContext, @@ -147,13 +149,15 @@ update without partial mutation. The public revision is an opaque immutable service-generated token. Callers cannot construct one or supply their own version number. -Internally, the revision records: +Internally, the session snapshot associated with the revision tracks: -- Session identity - Monotonic sequence - Document revision - Context and template revision -- Relevant environment/catalog epoch + +Relevant environment/catalog epochs will join that snapshot when those +subsystems are implemented. The revision token itself remains only an opaque +immutable identity; it does not duplicate snapshot metadata. The public model is deliberately global per session: any accepted text, context, template, relevant catalog, or provider-configuration invalidation @@ -251,34 +255,34 @@ Explicit methods are preferred over one generic `request({ kind })` API: interface SqlDocumentSession { readonly revision: SqlRevision; - update(update: SqlDocumentUpdate): SqlRevision; + readonly update: (update: SqlDocumentUpdate) => SqlRevision; - complete( + readonly complete: ( request: SqlCompletionRequest, - ): Promise>; + ) => Promise>; - diagnostics( + readonly diagnostics: ( request?: SqlDiagnosticsRequest, - ): Promise>; + ) => Promise>; - hover( + readonly hover: ( request: SqlPositionRequest, - ): Promise>; + ) => Promise>; - definition( + readonly definition: ( request: SqlPositionRequest, - ): Promise>; + ) => Promise>; - references( + readonly references: ( request: SqlPositionRequest, - ): Promise>; + ) => Promise>; - format( + readonly format: ( request?: SqlFormatRequest, - ): Promise>; + ) => Promise>; - isCurrent(revision: SqlRevision): boolean; - dispose(): void; + readonly isCurrent: (revision: SqlRevision) => boolean; + readonly dispose: () => void; } ``` diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md new file mode 100644 index 0000000..fb5cf69 --- /dev/null +++ b/docs/vnext/session-primitives.md @@ -0,0 +1,78 @@ +# vNext Session Primitives + +Status: experimental walking skeleton +Import: `@marimo-team/codemirror-sql/vnext` + +This entry point currently provides document ownership, atomic text/context +updates, opaque revisions, dialect registration, and lifecycle management. It +does not yet provide parsing, completion, diagnostics, hover, or navigation. +Those methods will be added only with working vertical slices. + +## Example + +```ts +import { + createSqlLanguageService, + defineSqlDialect, + type SqlDocumentContext, +} from "@marimo-team/codemirror-sql/vnext"; + +interface AppSqlContext extends SqlDocumentContext { + readonly engine: string; +} + +const service = createSqlLanguageService({ + dialects: [ + defineSqlDialect({ id: "duckdb", displayName: "DuckDB" }), + ], +}); + +const session = service.openDocument({ + text: "SELECT * FROM users", + context: { dialect: "duckdb", engine: "local" }, +}); + +const revision = session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 14, to: 19, insert: "customers" }], + }, +}); + +if (session.isCurrent(revision)) { + // Results produced for this revision may still be applied. +} + +session.dispose(); +service.dispose(); +``` + +Use `{ kind: "replace", text }` for full replacement and +`{ kind: "context", baseRevision, context }` for a context-only update. + +## Contract + +- Every accepted update creates a new frozen, service-issued revision. +- `baseRevision` is checked by identity and cannot come from another session. +- Incremental ranges are ordered, non-overlapping, half-open UTF-16 offsets in + the pre-update document. +- Text and context are validated completely before the session snapshot changes. +- Context is structured-cloned and recursively frozen. Accepted values are + finite primitives, arrays, and string-keyed plain objects. Cycles and shared + references are retained. Accessors, symbols, functions, class instances, + typed collections, and non-finite numbers are rejected. +- Public service and session operations remain bound when passed as callbacks + or destructured. +- Session and service disposal are idempotent and terminal. +- Synchronous public failures use `SqlSessionError` and a stable `code`; caught + platform errors are not exposed as causes. + +The safety envelope currently permits at most 10,000 changes in one update, a +16 Mi-code-unit document, 1,000 registered dialects, and bounded context graph +depth, size, properties, and string data. These are defensive walking-skeleton +limits, not release performance claims. + +The `/vnext` name is provisional until the packaging ADR fixes the next-major +subpath layout. diff --git a/implementation.md b/implementation.md index f8ed438..58582b9 100644 --- a/implementation.md +++ b/implementation.md @@ -590,6 +590,19 @@ disposition. Validate them through a protected reusable workflow from the base branch so a PR cannot approve itself. Maintainers approve classification overrides and rejected blocking findings. +Request GitHub Copilot review on every medium or large PR after the head commit +is ready for review: + +```bash +gh pr edit --add-reviewer @copilot +``` + +Copilot is an additional advisory review, not one of the two independent +commit-bound adversarial attestations and not a substitute for human approval. +Resolve or explicitly disposition its actionable comments in the finding +ledger. Re-request review after a material rewrite when the prior comments no +longer cover the current head. + ## CI architecture ### Required fast PR lane @@ -801,10 +814,11 @@ Before semantic implementation: 12. Add the invariant registry and test-suite integrity checks. 13. Add PR risk template and review-packet generation. 14. Add protected, commit-bound two-reviewer checks. -15. Add verified-artifact provenance to release CI. -16. Add nightly mutation, fuzz, cross-browser, leak, and performance workflows. -17. Add expiring scheduled-health and deduplicated failure issues. -18. Define the supported runtime and dependency matrix. +15. Automate GitHub Copilot review requests for medium and large PRs. +16. Add verified-artifact provenance to release CI. +17. Add nightly mutation, fuzz, cross-browser, leak, and performance workflows. +18. Add expiring scheduled-health and deduplicated failure issues. +19. Define the supported runtime and dependency matrix. ## Final recommendation diff --git a/package.json b/package.json index 3a1722e..d702564 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,12 @@ "default": "./dist/dialects/index.js" } }, + "./vnext": { + "import": { + "types": "./dist/vnext/index.d.ts", + "default": "./dist/vnext/index.js" + } + }, "./data/common-keywords.json": "./src/data/common-keywords.json", "./data/duckdb-keywords.json": "./src/data/duckdb-keywords.json" }, diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index f551265..70c70e1 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -1,9 +1,11 @@ import { execFileSync } from "node:child_process"; import { copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -39,7 +41,66 @@ function runPackageManager(args, cwd) { run(process.execPath, [packageManagerExecutable, ...args], cwd); } +function withRenamedPaths(paths, operation) { + const renamedPaths = []; + let operationError; + let operationFailed = false; + try { + for (const path of paths) { + renameSync(path, `${path}.isolated`); + renamedPaths.push(path); + } + operation(); + } catch (error) { + operationFailed = true; + operationError = error; + } + + const restorationErrors = []; + for (const path of renamedPaths.reverse()) { + try { + renameSync(`${path}.isolated`, path); + } catch (error) { + restorationErrors.push(error); + } + } + if (restorationErrors.length > 0) { + throw new AggregateError( + operationFailed + ? [operationError, ...restorationErrors] + : restorationErrors, + "Package isolation failed and could not restore every dependency", + ); + } + if (operationFailed) { + throw operationError; + } +} + +function verifyRenameRollback(directory) { + const existingPath = join(directory, "isolation-rollback"); + mkdirSync(existingPath); + let failed = false; + try { + withRenamedPaths( + [existingPath, join(directory, "missing-isolation-path")], + () => {}, + ); + } catch { + failed = true; + } + if ( + !failed || + !existsSync(existingPath) || + existsSync(`${existingPath}.isolated`) + ) { + throw new Error("Package isolation did not roll back a partial rename"); + } + rmSync(existingPath, { recursive: true }); +} + try { + verifyRenameRollback(temporaryDirectory); const packageManagerVersion = execFileSync( process.execPath, [packageManagerExecutable, "--version"], @@ -109,6 +170,29 @@ try { throw new Error("Packed manifest does not declare node-sql-parser"); } + writeFileSync( + join(temporaryDirectory, "vnext-consumer.mjs"), + `import { + createSqlLanguageService, + defineSqlDialect, +} from "@marimo-team/codemirror-sql/vnext"; + +const service = createSqlLanguageService({ + dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "SELECT 1", +}); +session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, +}); +service.dispose(); +`, + ); + writeFileSync( join(temporaryDirectory, "consumer.mts"), `import type { Extension } from "@codemirror/state"; @@ -122,17 +206,39 @@ import { DremioDialect, DuckDBDialect, } from "@marimo-team/codemirror-sql/dialects"; +import { + createSqlLanguageService, + defineSqlDialect, + type SqlDocumentContext, +} from "@marimo-team/codemirror-sql/vnext"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; +interface HostContext extends SqlDocumentContext { + readonly engine: string; +} + const extensions: Extension[] = [ sqlCompletion({ dialect: DuckDBDialect }), sqlExtension(), ]; const parser = new NodeSqlParser(); +const service = createSqlLanguageService({ + dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], +}); +const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "SELECT 1", +}); +session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: [{ from: 7, insert: "2", to: 8 }] }, +}); void extensions; void parser; +void session; void BigQueryDialect; void DremioDialect; void commonKeywords; @@ -145,6 +251,7 @@ void duckdbKeywords; `import { EditorState } from "@codemirror/state"; import * as api from "@marimo-team/codemirror-sql"; import * as dialects from "@marimo-team/codemirror-sql/dialects"; +import * as vnext from "@marimo-team/codemirror-sql/vnext"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; @@ -154,6 +261,12 @@ if (typeof api.sqlExtension !== "function" || typeof api.NodeSqlParser !== "func if (!dialects.BigQueryDialect || !dialects.DremioDialect || !dialects.DuckDBDialect) { throw new Error("Dialect package exports are incomplete"); } +if ( + typeof vnext.createSqlLanguageService !== "function" || + typeof vnext.defineSqlDialect !== "function" +) { + throw new Error("vNext package exports are incomplete"); +} if (!commonKeywords.keywords || !duckdbKeywords.keywords) { throw new Error("Keyword data exports are incomplete"); } @@ -163,6 +276,24 @@ const parseResult = await new api.NodeSqlParser().parse("SELECT 1", { state }); if (!parseResult.success || !parseResult.ast) { throw new Error("The packaged parser could not load its runtime dependency"); } + +const service = vnext.createSqlLanguageService({ + dialects: [vnext.defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "SELECT 1", +}); +const originalRevision = session.revision; +const updatedRevision = session.update({ + kind: "document", + baseRevision: originalRevision, + document: { kind: "replace", text: "SELECT 2" }, +}); +if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { + throw new Error("The packaged vNext session violated revision identity"); +} +service.dispose(); `, ); @@ -186,6 +317,13 @@ if (!parseResult.success || !parseResult.ast) { ); runPackageManager(["exec", "tsc", "--project", "tsconfig.json"], temporaryDirectory); + const isolatedDependencies = [ + join(temporaryDirectory, "node_modules", "node-sql-parser"), + join(temporaryDirectory, "node_modules", "@codemirror"), + ]; + withRenamedPaths(isolatedDependencies, () => { + run(process.execPath, ["vnext-consumer.mjs"], temporaryDirectory); + }); run(process.execPath, ["consumer.mjs"], temporaryDirectory); } finally { if (basename(temporaryDirectory).startsWith("codemirror-sql-package-")) { diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts new file mode 100644 index 0000000..dd52062 --- /dev/null +++ b/src/vnext/__tests__/session.test.ts @@ -0,0 +1,1236 @@ +import { describe, expect, it } from "vitest"; +import { + createSqlLanguageService, + defineSqlDialect, + SqlSessionError, +} from "../index.js"; +import { DefaultSqlLanguageService } from "../session.js"; +import type { + SqlDocumentContext, + SqlDocumentReplacement, +} from "../types.js"; + +interface TestContext extends SqlDocumentContext { + readonly engine: string; + readonly settings?: { + readonly flags: readonly boolean[]; + }; +} + +const duckdb = defineSqlDialect({ + displayName: "DuckDB", + id: "duckdb", +}); +const postgres = defineSqlDialect({ + displayName: "PostgreSQL", + id: "postgres", +}); + +function createService() { + return new DefaultSqlLanguageService({ + dialects: [duckdb, postgres], + }); +} + +function openSession(text = "SELECT * FROM users") { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + return { service, session }; +} + +function expectSessionError(code: SqlSessionError["code"], callback: () => unknown) { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(SqlSessionError); + expect((error as SqlSessionError).code).toBe(code); + return; + } + throw new Error(`Expected SqlSessionError: ${code}`); +} + +describe("dialect definitions", () => { + it("creates immutable definitions", () => { + expect(duckdb).toEqual({ displayName: "DuckDB", id: "duckdb" }); + expect(Object.isFrozen(duckdb)).toBe(true); + }); + + it.each([ + [{ displayName: "DuckDB", id: " " }, "SQL dialect id must contain 1 to 256"], + [ + { displayName: " ", id: "duckdb" }, + "SQL dialect display name must contain 1 to 1024", + ], + ])("rejects invalid definitions", (definition, message) => { + expect(() => defineSqlDialect(definition)).toThrow(message); + }); + + it("rejects duplicate IDs", () => { + expectSessionError("duplicate-dialect", () => { + createSqlLanguageService({ + dialects: [duckdb, { displayName: "Other", id: "duckdb" }], + }); + }); + }); + + it("normalizes malformed definitions without invoking accessors", () => { + let invoked = false; + expectSessionError("invalid-dialect", () => { + defineSqlDialect({ + displayName: "DuckDB", + get id() { + invoked = true; + return "duckdb"; + }, + }); + }); + expect(invoked).toBe(false); + expectSessionError("invalid-dialect", () => { + defineSqlDialect({ displayName: "DuckDB", id: 42 } as never); + }); + expectSessionError("invalid-dialect", () => { + defineSqlDialect(null as never); + }); + expectSessionError("invalid-dialect", () => { + defineSqlDialect( + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ) as never, + ); + }); + }); + + it("returns only normalized dialect fields", () => { + const extendedDefinition = { + displayName: "DuckDB", + id: "duckdb", + internal: true, + }; + const definition = defineSqlDialect(extendedDefinition); + expect(definition).toEqual({ displayName: "DuckDB", id: "duckdb" }); + }); +}); + +describe("document revisions", () => { + it("starts with a frozen current revision", () => { + const { session } = openSession(); + expect(Object.isFrozen(session.revision)).toBe(true); + expect(session.isCurrent(session.revision)).toBe(true); + }); + + it("advances monotonically, including A to B to A", () => { + const { session } = openSession("A"); + const first = session.revision; + const second = session.update({ + kind: "document", + baseRevision: first, + document: { kind: "replace", text: "B" }, + }); + const third = session.update({ + kind: "document", + baseRevision: second, + document: { kind: "replace", text: "A" }, + }); + + expect(session.isCurrent(first)).toBe(false); + expect(session.isCurrent(second)).toBe(false); + expect(session.isCurrent(third)).toBe(true); + expect(third).not.toBe(first); + }); + + it("never accepts another session's revision", () => { + const first = openSession(); + const second = openSession(); + + expect(first.session.isCurrent(second.session.revision)).toBe(false); + expectSessionError("stale-revision", () => { + first.session.update({ + kind: "document", + baseRevision: second.session.revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + }); + }); + + it("rejects fabricated revisions", () => { + const { session } = openSession(); + expect(session.isCurrent({} as never)).toBe(false); + }); + + it("makes an empty accepted update a new revision", () => { + const { session } = openSession(); + const first = session.revision; + const second = session.update({ + kind: "document", + baseRevision: first, + document: { kind: "changes", changes: [] }, + }); + expect(second).not.toBe(first); + expect(session.isCurrent(second)).toBe(true); + }); + + it("makes a same-text replacement a new revision", () => { + const { session } = openSession("SELECT 1"); + const first = session.revision; + const second = session.update({ + kind: "document", + baseRevision: first, + document: { kind: "replace", text: "SELECT 1" }, + }); + expect(second).not.toBe(first); + }); +}); + +describe("document changes", () => { + it("applies ordered changes in pre-update coordinates", () => { + const { session } = openSession("SELECT users.id FROM users"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 7, insert: "customers", to: 12 }, + { from: 21, insert: "customers", to: 26 }, + ], + }, + }); + + expect(session.snapshotForTesting.text).toBe( + "SELECT customers.id FROM customers", + ); + }); + + it("uses JavaScript UTF-16 offsets", () => { + const { session } = openSession("A😀B"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 1, insert: "X", to: 3 }], + }, + }); + expect(session.snapshotForTesting.text).toBe("AXB"); + }); + + it("keeps same-position insertions ordered", () => { + const { session } = openSession("AB"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 1, insert: "1", to: 1 }, + { from: 1, insert: "2", to: 1 }, + ], + }, + }); + expect(session.snapshotForTesting.text).toBe("A12B"); + }); + + it("supports adjacent replacements and document boundaries", () => { + const { session } = openSession("ABCDE"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 0, insert: "X", to: 1 }, + { from: 1, insert: "Y", to: 3 }, + { from: 3, insert: "Z", to: 5 }, + { from: 5, insert: "!", to: 5 }, + ], + }, + }); + expect(session.snapshotForTesting.text).toBe("XYZ!"); + }); + + it("deletes the entire document", () => { + const { session } = openSession("SELECT 1"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: [{ from: 0, insert: "", to: 8 }] }, + }); + expect(session.snapshotForTesting.text).toBe(""); + }); + + it("allows edits at every UTF-16 boundary", () => { + const { session } = openSession("A😀e\u0301\r\nZ\uD800"); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 2, insert: "X", to: 3 }, + { from: 5, insert: "", to: 6 }, + { from: 8, insert: "!", to: 8 }, + ], + }, + }); + expect(session.snapshotForTesting.text).toBe("A\uD83DXe\u0301\nZ!\uD800"); + }); + + it.each([ + { from: -1, insert: "", to: 0 }, + { from: 0, insert: "", to: -1 }, + { from: 0.5, insert: "", to: 1 }, + { from: Number.NaN, insert: "", to: 1 }, + { from: 0, insert: "", to: Number.POSITIVE_INFINITY }, + { from: Number.MAX_SAFE_INTEGER + 1, insert: "", to: 1 }, + { from: 2, insert: "", to: 1 }, + { from: 0, insert: "", to: 100 }, + ])("rejects invalid range $from..$to atomically", (change) => { + const { session } = openSession("ABC"); + const revision = session.revision; + + expectSessionError("invalid-change", () => { + session.update({ + kind: "document", + baseRevision: revision, + document: { kind: "changes", changes: [change] }, + }); + }); + + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("ABC"); + }); + + it("rejects unordered and overlapping changes atomically", () => { + const { session } = openSession("ABCDE"); + const revision = session.revision; + expectSessionError("invalid-change", () => { + session.update({ + kind: "document", + baseRevision: revision, + document: { + kind: "changes", + changes: [ + { from: 2, insert: "", to: 4 }, + { from: 1, insert: "", to: 2 }, + ], + }, + }); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("ABCDE"); + }); + + it("rejects non-string inserts from JavaScript callers", () => { + const { session } = openSession("ABC"); + expectSessionError("invalid-change", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 1, insert: 42, to: 2 }], + }, + } as never); + }); + }); + + it.each([ + { document: {} }, + { document: { kind: "changes", changes: [], text: "SELECT 1" } }, + { document: { kind: "changes", changes: "invalid" } }, + { document: { kind: "replace", text: 42 } }, + { document: "invalid" }, + { document: null }, + ])("rejects an ambiguous JavaScript mutation", ({ document }) => { + const { session } = openSession("ABC"); + const revision = session.revision; + expectSessionError("invalid-update", () => { + session.update({ kind: "document", baseRevision: revision, document } as never); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("ABC"); + }); + + it("rejects an empty JavaScript update", () => { + const { session } = openSession("ABC"); + expectSessionError("invalid-update", () => { + session.update({ kind: "document", baseRevision: session.revision } as never); + }); + }); + + it("rejects unknown update and document kinds", () => { + const { session } = openSession("ABC"); + expectSessionError("invalid-update", () => { + session.update({ + kind: "unknown", + baseRevision: session.revision, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "unknown" }, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "ABC", changes: [] }, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + kind: "context", + baseRevision: session.revision, + context: undefined, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + kind: "context", + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "local" }, + document: { kind: "replace", text: "lost" }, + } as never); + }); + }); + + it("normalizes proxy inspection failures and resets the update guard", () => { + const { session } = openSession("ABC"); + const update = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ); + expectSessionError("invalid-update", () => { + session.update(update as never); + }); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "recovered" }, + }); + expect(session.snapshotForTesting.text).toBe("recovered"); + }); + + it.each([null, "invalid", 42])( + "rejects a non-object JavaScript update", + (update) => { + const { session } = openSession("ABC"); + expectSessionError("invalid-update", () => { + session.update(update as never); + }); + }, + ); + + it("rejects missing and accessor update fields without invoking accessors", () => { + const { session } = openSession("ABC"); + let invoked = false; + const accessor = { + get baseRevision() { + invoked = true; + return session.revision; + }, + document: { kind: "replace", text: "changed" }, + }; + + expectSessionError("invalid-update", () => { + session.update({ document: { kind: "replace", text: "changed" } } as never); + }); + expectSessionError("invalid-update", () => { + session.update(accessor as never); + }); + expect(invoked).toBe(false); + expect(session.snapshotForTesting.text).toBe("ABC"); + }); + + it("rejects undefined and accessor optional update fields", () => { + const { session } = openSession("ABC"); + const revision = session.revision; + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: revision, + context: undefined, + document: { kind: "replace", text: "changed" }, + } as never); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("ABC"); + + let invoked = false; + const update = { + baseRevision: session.revision, + document: { kind: "replace", text: "changed" }, + kind: "document", + get context() { + invoked = true; + return { dialect: "postgres", engine: "warehouse" }; + }, + }; + expectSessionError("invalid-update", () => { + session.update(update as never); + }); + expect(invoked).toBe(false); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("ABC"); + }); + + it("rejects accessor document fields without invoking them", () => { + const { session } = openSession("ABC"); + let invoked = false; + const document: SqlDocumentReplacement = { + kind: "replace", + get text() { + invoked = true; + return "changed"; + }, + }; + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document, + }); + }); + expect(invoked).toBe(false); + }); + + it("rejects malformed JavaScript change entries", () => { + const { session } = openSession("ABC"); + for (const change of [ + null, + {}, + { from: 0, insert: "", to: 1, get extra() { + return true; + } }, + ]) { + if (change && "from" in change) { + Object.defineProperty(change, "from", { get: () => 0 }); + } + expectSessionError("invalid-change", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: [change] }, + } as never); + }); + } + }); + + it("rejects reentrant updates without losing the outer transaction", () => { + const { session } = openSession("ABC"); + const originalRevision = session.revision; + let nestedError: SqlSessionError | undefined; + let attempted = false; + const target: SqlDocumentReplacement = { + kind: "replace", + text: "outer", + }; + const document = new Proxy( + target, + { + getOwnPropertyDescriptor(target, property) { + if (property === "text" && !attempted) { + attempted = true; + try { + session.update({ + kind: "document", + baseRevision: originalRevision, + document: { kind: "replace", text: "nested" }, + }); + } catch (error) { + if (error instanceof SqlSessionError) { + nestedError = error; + } + } + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + const revision = session.update({ + kind: "document", + baseRevision: originalRevision, + document, + }); + + expect(nestedError?.code).toBe("reentrant-update"); + expect(session.isCurrent(revision)).toBe(true); + expect(session.snapshotForTesting.text).toBe("outer"); + }); + + it("cannot commit an update after reentrant disposal", () => { + const { session } = openSession("ABC"); + const revision = session.revision; + const target: SqlDocumentReplacement = { + kind: "replace", + text: "changed", + }; + let disposed = false; + const document = new Proxy(target, { + getOwnPropertyDescriptor(value, property) { + if (!disposed) { + disposed = true; + session.dispose(); + } + return Reflect.getOwnPropertyDescriptor(value, property); + }, + }); + + expectSessionError("session-disposed", () => { + session.update({ + kind: "document", + baseRevision: revision, + document, + }); + }); + expect(session.isCurrent(revision)).toBe(false); + expect(session.snapshotForTesting.text).toBe("ABC"); + }); + + it("bounds fragmented and oversized document updates", () => { + const { session } = openSession(""); + const tooManyChanges: { from: number; insert: string; to: number }[] = []; + tooManyChanges.length = 10_001; + expectSessionError("invalid-change", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: tooManyChanges }, + }); + }); + + expectSessionError("invalid-document", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { + from: 0, + insert: "x".repeat(16 * 1024 * 1024 + 1), + to: 0, + }, + ], + }, + }); + }); + expect(session.snapshotForTesting.text).toBe(""); + }); +}); + +describe("document context", () => { + it("owns and deeply freezes a structured clone", () => { + const flags = [true]; + const context = { + dialect: "duckdb", + engine: "local", + settings: { flags }, + } satisfies TestContext; + const service = createService(); + const session = service.openDocument({ context, text: "" }); + + flags.push(false); + + expect(session.snapshotForTesting.context).toEqual({ + dialect: "duckdb", + engine: "local", + settings: { flags: [true] }, + }); + expect(Object.isFrozen(session.snapshotForTesting.context)).toBe(true); + expect(Object.isFrozen(session.snapshotForTesting.context.settings)).toBe(true); + expect(Object.isFrozen(session.snapshotForTesting.context.settings?.flags)).toBe( + true, + ); + }); + + it("accepts finite numeric and bigint context data", () => { + const context = { + dialect: "duckdb", + engine: "local", + generation: 2, + identifier: 3n, + }; + const session = new DefaultSqlLanguageService({ + dialects: [duckdb], + }).openDocument({ context, text: "" }); + expect(session.snapshotForTesting.context.generation).toBe(2); + expect(session.snapshotForTesting.context.identifier).toBe(3n); + }); + + it("preserves cycles and shared references in the owned clone", () => { + interface GraphContext extends TestContext { + left: { value: bigint }; + right: { value: bigint }; + self?: GraphContext; + } + + const shared = { value: 1n }; + const context: GraphContext = { + dialect: "duckdb", + engine: "local", + left: shared, + right: shared, + }; + context.self = context; + + const session = new DefaultSqlLanguageService({ + dialects: [duckdb], + }).openDocument({ context, text: "" }); + const owned = session.snapshotForTesting.context; + + expect(owned).not.toBe(context); + expect(owned.left).toBe(owned.right); + expect(owned.self).toBe(owned); + expect(Object.isFrozen(owned.self)).toBe(true); + }); + + it("accepts null-prototype objects as plain data", () => { + const metadata = Object.assign(Object.create(null), { value: "ok" }); + const context = { dialect: "duckdb", engine: "local", metadata }; + const session = new DefaultSqlLanguageService({ + dialects: [duckdb], + }).openDocument({ context, text: "" }); + expect(session.snapshotForTesting.context.metadata.value).toBe("ok"); + }); + + it("requires dialect to be an own context property", () => { + const originalDialect = Object.getOwnPropertyDescriptor( + Object.prototype, + "dialect", + ); + Object.defineProperty(Object.prototype, "dialect", { + configurable: true, + value: "duckdb", + }); + try { + expectSessionError("invalid-dialect", () => { + createService().openDocument({ + context: { engine: "local" } as TestContext, + text: "", + }); + }); + } finally { + if (originalDialect) { + Object.defineProperty(Object.prototype, "dialect", originalDialect); + } else { + Reflect.deleteProperty(Object.prototype, "dialect"); + } + } + }); + + it("updates text and context atomically", () => { + const { session } = openSession("SELECT 1"); + session.update({ + kind: "document", + baseRevision: session.revision, + context: { dialect: "postgres", engine: "warehouse" }, + document: { kind: "replace", text: "SELECT 2" }, + }); + + expect(session.snapshotForTesting).toMatchObject({ + context: { dialect: "postgres", engine: "warehouse" }, + text: "SELECT 2", + }); + + const snapshot = session.snapshotForTesting; + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: snapshot.revision, + context: { dialect: "duckdb", engine: "remote" }, + document: { kind: "replace", text: 42 }, + } as never); + }); + expect(session.snapshotForTesting).toBe(snapshot); + }); + + it("supports context-only updates", () => { + const { session } = openSession("SELECT 1"); + session.update({ + kind: "context", + baseRevision: session.revision, + context: { dialect: "postgres", engine: "warehouse" }, + }); + expect(session.snapshotForTesting.text).toBe("SELECT 1"); + expect(session.snapshotForTesting.context.dialect).toBe("postgres"); + }); + + it("retains the owned context for document-only updates", () => { + const { session } = openSession(); + const context = session.snapshotForTesting.context; + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + expect(session.snapshotForTesting.context).toBe(context); + }); + + it("clones a supplied context again on every update", () => { + const { session } = openSession(); + const context: TestContext = { dialect: "duckdb", engine: "local" }; + session.update({ kind: "context", baseRevision: session.revision, context }); + const firstOwned = session.snapshotForTesting.context; + session.update({ kind: "context", baseRevision: session.revision, context }); + expect(session.snapshotForTesting.context).not.toBe(firstOwned); + }); + + it.each([ + { + context: { dialect: "unknown", engine: "local" }, + error: "invalid-dialect", + }, + { + context: { dialect: "duckdb", engine: Number.NaN }, + error: "invalid-context", + }, + { + context: { dialect: "duckdb", engine: new Date() }, + error: "invalid-context", + }, + { + context: { dialect: "duckdb", engine: () => "local" }, + error: "invalid-context", + }, + ])("rejects invalid context without mutation: $error", ({ context, error }) => { + const { session } = openSession("SELECT 1"); + const revision = session.revision; + expectSessionError(error as SqlSessionError["code"], () => { + session.update({ + kind: "document", + baseRevision: revision, + context, + document: { kind: "replace", text: "SELECT 2" }, + } as never); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.text).toBe("SELECT 1"); + }); + + it("rejects accessors without invoking them", () => { + let invoked = false; + const context = { + dialect: "duckdb", + get engine() { + invoked = true; + return "local"; + }, + }; + + expectSessionError("invalid-context", () => { + createService().openDocument({ context, text: "" }); + }); + expect(invoked).toBe(false); + }); + + it("rejects symbol keys", () => { + const context = { + dialect: "duckdb", + engine: "local", + [Symbol("hidden")]: true, + }; + expectSessionError("invalid-context", () => { + createService().openDocument({ context, text: "" }); + }); + }); + + it("rejects custom array properties", () => { + const flags = [true]; + Object.defineProperty(flags, "custom", { value: true }); + expectSessionError("invalid-context", () => { + createService().openDocument({ + context: { + dialect: "duckdb", + engine: "local", + settings: { flags }, + }, + text: "", + }); + }); + }); + + it("rejects malformed property descriptors from proxies", () => { + const context = new Proxy( + { dialect: "duckdb", engine: "local" }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "engine") { + return; + } + return Object.getOwnPropertyDescriptor(target, property); + }, + }, + ); + expectSessionError("invalid-context", () => { + createService().openDocument({ context, text: "" }); + }); + }); + + it("does not read array length through a proxy", () => { + let invoked = false; + const flags = new Proxy([true], { + get(target, property, receiver) { + if (property === "length") { + invoked = true; + } + return Reflect.get(target, property, receiver); + }, + }); + expectSessionError("invalid-context", () => { + createService().openDocument({ + context: { + dialect: "duckdb", + engine: "local", + settings: { flags }, + }, + text: "", + }); + }); + expect(invoked).toBe(false); + }); + + it("enforces depth for nested paths", () => { + const root: Record = { + dialect: "duckdb", + engine: "local", + }; + let cursor: Record = {}; + root.path = cursor; + for (let index = 0; index < 150; index += 1) { + const next: Record = {}; + cursor.next = next; + cursor = next; + } + + expectSessionError("invalid-context", () => { + createService().openDocument({ context: root, text: "" } as never); + }); + }); + + it("rejects non-object context from JavaScript", () => { + expectSessionError("invalid-context", () => { + createService().openDocument({ context: "duckdb", text: "" } as never); + }); + expectSessionError("invalid-context", () => { + createService().openDocument({ context: [], text: "" } as never); + }); + }); + + it("bounds aggregate context string data", () => { + expectSessionError("invalid-context", () => { + createService().openDocument({ + context: { + dialect: "duckdb", + engine: "x".repeat(1_000_001), + }, + text: "", + }); + }); + }); + + it("bounds context array length and property-name data", () => { + interface BoundedContext extends TestContext { + readonly metadata?: Readonly>; + readonly sparse?: readonly boolean[]; + } + const service = new DefaultSqlLanguageService({ + dialects: [duckdb], + }); + const sparse: boolean[] = []; + sparse.length = 50_001; + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + sparse, + }, + text: "", + }); + }); + + const longKey = "k".repeat(1_000_001); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + metadata: { [longKey]: true }, + }, + text: "", + }); + }); + }); + + it("normalizes structured-clone failures", () => { + const context = new Proxy( + { dialect: "duckdb", engine: "local" }, + {}, + ); + expectSessionError("invalid-context", () => { + createService().openDocument({ context, text: "" }); + }); + }); + + it("checks a stale base before inspecting candidate context", () => { + const { session } = openSession(); + const stale = session.revision; + session.update({ kind: "document", baseRevision: stale, document: { kind: "replace", text: "SELECT 1" } }); + let inspected = false; + const context = { + dialect: "duckdb", + get engine() { + inspected = true; + return "local"; + }, + }; + expectSessionError("stale-revision", () => { + session.update({ kind: "context", baseRevision: stale, context }); + }); + expect(inspected).toBe(false); + }); +}); + +describe("lifecycle", () => { + it("supports detached public operations", () => { + const service = createService(); + const { openDocument } = service; + const session = openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "", + }); + const { dispose, isCurrent, update } = session; + const revision = update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + + expect(isCurrent(revision)).toBe(true); + dispose(); + expect(isCurrent(revision)).toBe(false); + + const disposeService = service.dispose; + disposeService(); + expectSessionError("service-disposed", () => { + openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "", + }); + }); + }); + + it("makes session disposal idempotent and terminal", () => { + const { session } = openSession(); + const revision = session.revision; + session.dispose(); + session.dispose(); + + expect(session.isCurrent(revision)).toBe(false); + expectSessionError("session-disposed", () => { + session.update({ + kind: "document", + baseRevision: revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + }); + }); + + it("service disposal closes sessions and is idempotent", () => { + const service = createService(); + const first = service.openDocument({ + context: { dialect: "duckdb", engine: "one" }, + text: "", + }); + const second = service.openDocument({ + context: { dialect: "postgres", engine: "two" }, + text: "", + }); + + service.dispose(); + service.dispose(); + + expect(first.isCurrent(first.revision)).toBe(false); + expect(second.isCurrent(second.revision)).toBe(false); + expectSessionError("service-disposed", () => { + service.openDocument({ + context: { dialect: "duckdb", engine: "three" }, + text: "", + }); + }); + }); + + it("does not let a disposed session affect its service", () => { + const service = createService(); + const first = service.openDocument({ + context: { dialect: "duckdb", engine: "one" }, + text: "", + }); + first.dispose(); + + const second = service.openDocument({ + context: { dialect: "duckdb", engine: "two" }, + text: "", + }); + expect(second.isCurrent(second.revision)).toBe(true); + }); + + it("does not register a failed document open", () => { + const service = createService(); + expectSessionError("invalid-dialect", () => { + service.openDocument({ + context: { dialect: "unknown", engine: "local" }, + text: "", + }); + }); + const valid = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "", + }); + expect(valid.isCurrent(valid.revision)).toBe(true); + }); + + it("rejects non-string initial text from JavaScript", () => { + expectSessionError("invalid-document", () => { + createService().openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: 42, + } as never); + }); + }); + + it("rejects malformed and oversized open inputs", () => { + const service = createService(); + expectSessionError("invalid-document", () => { + service.openDocument(null as never); + }); + expectSessionError("invalid-document", () => { + service.openDocument({ context: undefined, text: "" } as never); + }); + expectSessionError("invalid-document", () => { + service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "x".repeat(16 * 1024 * 1024 + 1), + }); + }); + expectSessionError("invalid-document", () => { + service.openDocument( + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ) as never, + ); + }); + }); + + it("rejects open inputs with accessors without invoking them", () => { + const service = createService(); + let invoked = false; + expectSessionError("invalid-document", () => { + service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + get text() { + invoked = true; + return "SELECT 1"; + }, + }); + }); + expect(invoked).toBe(false); + }); + + it("cannot return a session after reentrant service disposal", () => { + const service = createService(); + let disposed = false; + const context = new Proxy( + { dialect: "duckdb", engine: "local" }, + { + ownKeys(target) { + if (!disposed) { + disposed = true; + service.dispose(); + } + return Reflect.ownKeys(target); + }, + }, + ); + + expectSessionError("service-disposed", () => { + service.openDocument({ context, text: "SELECT 1" }); + }); + expectSessionError("service-disposed", () => { + service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "", + }); + }); + }); + + it("cannot return a session when the open-input proxy disposes the service", () => { + const service = createService(); + let disposed = false; + const input = new Proxy( + { + context: { dialect: "duckdb", engine: "local" }, + text: "SELECT 1", + }, + { + getOwnPropertyDescriptor(target, property) { + if (!disposed) { + disposed = true; + service.dispose(); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + expectSessionError("service-disposed", () => { + service.openDocument(input); + }); + }); + + it.each([null, {}, { dialects: null }, { dialects: [] }])( + "normalizes invalid service options", + (options) => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService(options as never); + }); + }, + ); + + it("normalizes hostile service options", () => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService( + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ) as never, + ); + }); + }); +}); diff --git a/src/vnext/index.ts b/src/vnext/index.ts new file mode 100644 index 0000000..f709526 --- /dev/null +++ b/src/vnext/index.ts @@ -0,0 +1,23 @@ +export { + createSqlLanguageService, + defineSqlDialect, +} from "./session.js"; +export type { + OpenSqlDocument, + SqlCatalogContext, + SqlContextInput, + SqlDialectDefinition, + SqlDocumentChanges, + SqlDocumentContext, + SqlDocumentEdit, + SqlDocumentReplacement, + SqlDocumentSession, + SqlDocumentUpdate, + SqlLanguageService, + SqlLanguageServiceOptions, + SqlPlainData, + SqlRevision, + SqlSessionErrorCode, + SqlTextChange, +} from "./types.js"; +export { SqlSessionError } from "./types.js"; diff --git a/src/vnext/session.ts b/src/vnext/session.ts new file mode 100644 index 0000000..6d687f4 --- /dev/null +++ b/src/vnext/session.ts @@ -0,0 +1,917 @@ +import type { + OpenSqlDocument, + SqlDialectDefinition, + SqlDocumentContext, + SqlDocumentSession, + SqlDocumentUpdate, + SqlLanguageService, + SqlLanguageServiceOptions, + SqlRevision, + SqlTextChange, +} from "./types.js"; +import { createSqlRevisionToken, SqlSessionError } from "./types.js"; + +const MAX_CONTEXT_DEPTH = 100; +const MAX_CONTEXT_NODES = 10_000; +const MAX_CONTEXT_PROPERTIES = 50_000; +const MAX_CONTEXT_KEY_LENGTH = 1_000_000; +const MAX_CONTEXT_STRING_LENGTH = 1_000_000; +const MAX_CONTEXT_ARRAY_LENGTH = 50_000; +const MAX_DOCUMENT_LENGTH = 16 * 1024 * 1024; +const MAX_CHANGES_PER_UPDATE = 10_000; +const MAX_DIALECTS = 1_000; +const MAX_DIALECT_ID_LENGTH = 256; +const MAX_DIALECT_DISPLAY_NAME_LENGTH = 1_024; + +interface PendingContextValue { + readonly depth: number; + readonly value: unknown; +} + +interface DataProperties { + readonly keyLength: number; + readonly values: readonly unknown[]; +} + +function getDataProperties(value: object): DataProperties { + const values: unknown[] = []; + const isArray = Array.isArray(value); + const arrayLength = isArray + ? readArrayLength(value, "invalid-context", "SQL document context array") + : undefined; + if (arrayLength !== undefined && arrayLength > MAX_CONTEXT_ARRAY_LENGTH) { + throw new SqlSessionError( + "invalid-context", + "SQL document context arrays are too large", + ); + } + let keyLength = 0; + for (const key of Reflect.ownKeys(value)) { + if (isArray && key === "length") { + continue; + } + if (typeof key !== "string") { + throw new SqlSessionError( + "invalid-context", + "SQL document context cannot contain symbol keys", + ); + } + keyLength += key.length; + if ( + arrayLength !== undefined && + (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= arrayLength) + ) { + throw new SqlSessionError( + "invalid-context", + "SQL document context arrays cannot contain custom properties", + ); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new SqlSessionError( + "invalid-context", + "SQL document context cannot contain accessors", + ); + } + values.push(descriptor.value); + } + return { keyLength, values }; +} + +function validatePlainData(root: unknown): void { + const pending: PendingContextValue[] = [{ depth: 0, value: root }]; + const seen = new WeakSet(); + let nodeCount = 0; + let propertyCount = 0; + let keyLength = 0; + let stringLength = 0; + + for (const current of pending) { + const value = current.value; + if ( + value === null || + value === undefined || + typeof value === "boolean" || + typeof value === "bigint" + ) { + continue; + } + if (typeof value === "string") { + stringLength += value.length; + if (stringLength > MAX_CONTEXT_STRING_LENGTH) { + throw new SqlSessionError( + "invalid-context", + "SQL document context contains too much string data", + ); + } + continue; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new SqlSessionError( + "invalid-context", + "SQL document context numbers must be finite", + ); + } + continue; + } + if (typeof value !== "object") { + throw new SqlSessionError( + "invalid-context", + "SQL document context must contain only plain data", + ); + } + if (current.depth > MAX_CONTEXT_DEPTH) { + throw new SqlSessionError( + "invalid-context", + "SQL document context is too deeply nested", + ); + } + if (seen.has(value)) { + continue; + } + seen.add(value); + nodeCount += 1; + if (nodeCount > MAX_CONTEXT_NODES) { + throw new SqlSessionError( + "invalid-context", + "SQL document context contains too many objects", + ); + } + + if (!Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new SqlSessionError( + "invalid-context", + "SQL document context must contain only plain objects", + ); + } + } + + const properties = getDataProperties(value); + keyLength += properties.keyLength; + if (keyLength > MAX_CONTEXT_KEY_LENGTH) { + throw new SqlSessionError( + "invalid-context", + "SQL document context contains too much property-name data", + ); + } + propertyCount += properties.values.length; + if (propertyCount > MAX_CONTEXT_PROPERTIES) { + throw new SqlSessionError( + "invalid-context", + "SQL document context contains too many properties", + ); + } + for (const property of properties.values) { + pending.push({ depth: current.depth + 1, value: property }); + } + } +} + +function deepFreeze(root: T): T { + const pending: object[] = [root]; + const seen = new WeakSet(); + for (const value of pending) { + if (seen.has(value)) { + continue; + } + seen.add(value); + for (const property of getDataProperties(value).values) { + if (property !== null && typeof property === "object") { + pending.push(property); + } + } + Object.freeze(value); + } + return root; +} + +function cloneContext(context: Context): Context { + try { + if ( + context === null || + typeof context !== "object" || + Array.isArray(context) + ) { + throw new SqlSessionError( + "invalid-context", + "SQL document context must be a plain object", + ); + } + validatePlainData(context); + const clone = structuredClone(context); + validatePlainData(clone); + return deepFreeze(clone); + } catch (error) { + if (error instanceof SqlSessionError) { + throw error; + } + throw new SqlSessionError( + "invalid-context", + "SQL document context must be structured-cloneable plain data", + ); + } +} + +function validateDialect( + context: SqlDocumentContext, + dialects: ReadonlySet, +): void { + const dialect = readRequiredDataProperty( + context, + "dialect", + "invalid-dialect", + "SQL document context", + ); + if (typeof dialect !== "string" || !dialects.has(dialect)) { + throw new SqlSessionError( + "invalid-dialect", + typeof dialect === "string" + ? `Unknown SQL dialect: ${dialect}` + : "SQL document context dialect must be a string", + ); + } +} + +interface MissingDataProperty { + readonly found: false; +} + +interface FoundDataProperty { + readonly found: true; + readonly value: Value; +} + +type DataProperty = MissingDataProperty | FoundDataProperty; + +function readOwnDataProperty( + value: Value, + key: Key, + code: SqlSessionError["code"], + subject: string, +): DataProperty; +function readOwnDataProperty( + value: object, + key: PropertyKey, + code: SqlSessionError["code"], + subject: string, +): DataProperty; +function readOwnDataProperty( + value: object, + key: PropertyKey, + code: SqlSessionError["code"], + subject: string, +): DataProperty { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) { + return { found: false }; + } + if (!("value" in descriptor)) { + throw new SqlSessionError( + code, + `${subject} property ${String(key)} cannot be an accessor`, + ); + } + return { found: true, value: descriptor.value }; +} + +function readRequiredDataProperty( + value: Value, + key: Key, + code: SqlSessionError["code"], + subject: string, +): Value[Key]; +function readRequiredDataProperty( + value: object, + key: PropertyKey, + code: SqlSessionError["code"], + subject: string, +): unknown; +function readRequiredDataProperty( + value: object, + key: PropertyKey, + code: SqlSessionError["code"], + subject: string, +): unknown { + const property = readOwnDataProperty(value, key, code, subject); + if (!property.found) { + throw new SqlSessionError( + code, + `${subject} requires a data property named ${String(key)}`, + ); + } + return property.value; +} + +function validateDocumentLength(text: string): void { + if (text.length > MAX_DOCUMENT_LENGTH) { + throw new SqlSessionError( + "invalid-document", + `SQL documents cannot exceed ${MAX_DOCUMENT_LENGTH} UTF-16 code units`, + ); + } +} + +function readArrayLength( + value: readonly unknown[], + code: SqlSessionError["code"], + subject: string, +): number { + const length = readRequiredDataProperty(value, "length", code, subject); + if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0) { + throw new SqlSessionError(code, `${subject} has an invalid length`); + } + return length; +} + +function normalizeChanges(text: string, changes: readonly unknown[]): SqlTextChange[] { + const changeCount = readArrayLength( + changes, + "invalid-change", + "SQL document changes", + ); + if (changeCount > MAX_CHANGES_PER_UPDATE) { + throw new SqlSessionError( + "invalid-change", + `SQL document updates cannot contain more than ${MAX_CHANGES_PER_UPDATE} changes`, + ); + } + const normalized: SqlTextChange[] = []; + let previousEnd = 0; + let nextLength = text.length; + + for (let index = 0; index < changeCount; index += 1) { + const change = readRequiredDataProperty( + changes, + index, + "invalid-change", + "SQL document changes", + ); + if (change === null || typeof change !== "object") { + throw new SqlSessionError( + "invalid-change", + `SQL change ${index} must be an object`, + ); + } + const from = readRequiredDataProperty( + change, + "from", + "invalid-change", + `SQL change ${index}`, + ); + const to = readRequiredDataProperty( + change, + "to", + "invalid-change", + `SQL change ${index}`, + ); + const insert = readRequiredDataProperty( + change, + "insert", + "invalid-change", + `SQL change ${index}`, + ); + if ( + typeof from !== "number" || + typeof to !== "number" || + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + from < 0 || + from > to || + to > text.length + ) { + throw new SqlSessionError( + "invalid-change", + `Invalid UTF-16 range in SQL change ${index}`, + ); + } + if (from < previousEnd) { + throw new SqlSessionError( + "invalid-change", + "SQL document changes must be ordered and non-overlapping", + ); + } + if (typeof insert !== "string") { + throw new SqlSessionError("invalid-change", "SQL change insert must be a string"); + } + nextLength += insert.length - (to - from); + if (nextLength > MAX_DOCUMENT_LENGTH) { + throw new SqlSessionError( + "invalid-document", + `SQL documents cannot exceed ${MAX_DOCUMENT_LENGTH} UTF-16 code units`, + ); + } + normalized.push( + Object.freeze({ + from, + insert, + to, + }), + ); + previousEnd = to; + } + + return normalized; +} + +function applyChanges(text: string, changes: readonly SqlTextChange[]): string { + let cursor = 0; + const output: string[] = []; + for (const change of changes) { + output.push(text.slice(cursor, change.from), change.insert); + cursor = change.to; + } + output.push(text.slice(cursor)); + return output.join(""); +} + +interface SessionSnapshot { + readonly contextSequence: number; + readonly context: Context; + readonly documentSequence: number; + readonly revision: SqlRevision; + readonly sequence: number; + readonly text: string; +} + +export class DefaultSqlDocumentSession + implements SqlDocumentSession +{ + readonly #dialects: ReadonlySet; + readonly #onDispose: () => void; + #disposed = false; + #snapshot: SessionSnapshot; + #updating = false; + + constructor( + text: string, + context: Context, + dialects: ReadonlySet, + onDispose: () => void, + ) { + this.#dialects = dialects; + this.#onDispose = onDispose; + const sequence = 0; + const contextSequence = 0; + const documentSequence = 0; + this.#snapshot = Object.freeze({ + contextSequence, + context, + documentSequence, + revision: createSqlRevisionToken(), + sequence, + text, + }); + } + + get revision(): SqlRevision { + return this.#snapshot.revision; + } + + get snapshotForTesting(): SessionSnapshot { + return this.#snapshot; + } + + readonly update = (update: SqlDocumentUpdate): SqlRevision => { + if (this.#disposed) { + throw new SqlSessionError("session-disposed", "SQL document session is disposed"); + } + if (this.#updating) { + throw new SqlSessionError( + "reentrant-update", + "SQL document updates cannot be reentrant", + ); + } + this.#updating = true; + try { + return this.#applyUpdate(update); + } catch (error) { + if (error instanceof SqlSessionError) { + throw error; + } + throw new SqlSessionError( + "invalid-update", + "SQL document update could not be inspected safely", + ); + } finally { + this.#updating = false; + } + }; + + #applyUpdate(update: SqlDocumentUpdate): SqlRevision { + if (update === null || typeof update !== "object") { + throw new SqlSessionError( + "invalid-update", + "SQL document update must be an object", + ); + } + const kind = readRequiredDataProperty( + update, + "kind", + "invalid-update", + "SQL document update", + ); + const baseRevision = readRequiredDataProperty( + update, + "baseRevision", + "invalid-update", + "SQL document update", + ); + if (baseRevision !== this.#snapshot.revision) { + throw new SqlSessionError("stale-revision", "SQL document revision is stale"); + } + if (kind !== "document" && kind !== "context") { + throw new SqlSessionError( + "invalid-update", + "SQL document update kind must be document or context", + ); + } + + let nextContextSequence = this.#snapshot.contextSequence; + let nextContext = this.#snapshot.context; + let nextDocumentSequence = this.#snapshot.documentSequence; + let nextText = this.#snapshot.text; + + if (kind === "context") { + if ( + readOwnDataProperty( + update, + "document", + "invalid-update", + "SQL context update", + ).found + ) { + throw new SqlSessionError( + "invalid-update", + "SQL context update cannot contain a document mutation", + ); + } + const context = readRequiredDataProperty( + update, + "context", + "invalid-update", + "SQL context update", + ); + if (context === undefined) { + throw new SqlSessionError( + "invalid-update", + "SQL context update requires a context value", + ); + } + nextContext = cloneContext(context); + nextContextSequence += 1; + } else { + const document = readRequiredDataProperty( + update, + "document", + "invalid-update", + "SQL document update", + ); + const context = readOwnDataProperty( + update, + "context", + "invalid-update", + "SQL document update", + ); + if (context.found) { + if (context.value === undefined) { + throw new SqlSessionError( + "invalid-update", + "SQL document update context cannot be undefined", + ); + } + nextContext = cloneContext(context.value); + nextContextSequence += 1; + } + if (document === null || typeof document !== "object") { + throw new SqlSessionError( + "invalid-update", + "SQL document mutation must be an object", + ); + } + const documentKind = readRequiredDataProperty( + document, + "kind", + "invalid-update", + "SQL document mutation", + ); + if (documentKind === "replace") { + if ( + readOwnDataProperty( + document, + "changes", + "invalid-update", + "SQL document replacement", + ).found + ) { + throw new SqlSessionError( + "invalid-update", + "SQL document replacement cannot also contain changes", + ); + } + const text = readRequiredDataProperty( + document, + "text", + "invalid-update", + "SQL document replacement", + ); + if (typeof text !== "string") { + throw new SqlSessionError( + "invalid-update", + "SQL replacement text must be a string", + ); + } + validateDocumentLength(text); + nextText = text; + } else if (documentKind === "changes") { + if ( + readOwnDataProperty( + document, + "text", + "invalid-update", + "SQL document changes", + ).found + ) { + throw new SqlSessionError( + "invalid-update", + "SQL document changes cannot also contain replacement text", + ); + } + const changes = readRequiredDataProperty( + document, + "changes", + "invalid-update", + "SQL document changes", + ); + if (!Array.isArray(changes)) { + throw new SqlSessionError( + "invalid-update", + "SQL document changes must be an array", + ); + } + const normalizedChanges = normalizeChanges(nextText, changes); + nextText = applyChanges(nextText, normalizedChanges); + } else { + throw new SqlSessionError( + "invalid-update", + "SQL document mutation kind must be replace or changes", + ); + } + nextDocumentSequence += 1; + } + + validateDialect(nextContext, this.#dialects); + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session was disposed during the update", + ); + } + const sequence = this.#snapshot.sequence + 1; + const revision = createSqlRevisionToken(); + this.#snapshot = Object.freeze({ + contextSequence: nextContextSequence, + context: nextContext, + documentSequence: nextDocumentSequence, + revision, + sequence, + text: nextText, + }); + return revision; + } + + readonly isCurrent = (revision: SqlRevision): boolean => { + return !this.#disposed && revision === this.#snapshot.revision; + }; + + readonly dispose = (): void => { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#onDispose(); + }; +} + +export class DefaultSqlLanguageService + implements SqlLanguageService +{ + readonly #dialects: ReadonlySet; + readonly #sessions = new Set>(); + #disposed = false; + + constructor(options: SqlLanguageServiceOptions) { + try { + if (options === null || typeof options !== "object") { + throw new SqlSessionError( + "invalid-service-options", + "SQL language service options must be an object", + ); + } + const configuredDialects = readRequiredDataProperty( + options, + "dialects", + "invalid-service-options", + "SQL language service options", + ); + if (!Array.isArray(configuredDialects)) { + throw new SqlSessionError( + "invalid-service-options", + "SQL language service dialects must be an array", + ); + } + const dialectCount = readArrayLength( + configuredDialects, + "invalid-service-options", + "SQL language service dialects", + ); + if (dialectCount === 0 || dialectCount > MAX_DIALECTS) { + throw new SqlSessionError( + "invalid-service-options", + `SQL language service requires between 1 and ${MAX_DIALECTS} dialects`, + ); + } + + const dialects = new Set(); + for (let index = 0; index < dialectCount; index += 1) { + const dialect = readRequiredDataProperty( + configuredDialects, + index, + "invalid-service-options", + "SQL language service dialects", + ); + const definition = defineSqlDialect(dialect); + if (dialects.has(definition.id)) { + throw new SqlSessionError( + "duplicate-dialect", + `Duplicate SQL dialect: ${definition.id}`, + ); + } + dialects.add(definition.id); + } + this.#dialects = dialects; + } catch (error) { + if (error instanceof SqlSessionError) { + throw error; + } + throw new SqlSessionError( + "invalid-service-options", + "SQL language service options could not be inspected safely", + ); + } + } + + readonly openDocument = ( + input: OpenSqlDocument, + ): DefaultSqlDocumentSession => { + if (this.#disposed) { + throw new SqlSessionError("service-disposed", "SQL language service is disposed"); + } + + try { + if (input === null || typeof input !== "object") { + throw new SqlSessionError( + "invalid-document", + "Open SQL document input must be an object", + ); + } + const text = readRequiredDataProperty( + input, + "text", + "invalid-document", + "Open SQL document input", + ); + if (typeof text !== "string") { + throw new SqlSessionError( + "invalid-document", + "SQL document text must be a string", + ); + } + validateDocumentLength(text); + const candidateContext = readRequiredDataProperty( + input, + "context", + "invalid-document", + "Open SQL document input", + ); + if (candidateContext === undefined) { + throw new SqlSessionError( + "invalid-document", + "Open SQL document input requires a context value", + ); + } + const context = cloneContext(candidateContext); + validateDialect(context, this.#dialects); + + let session: DefaultSqlDocumentSession; + session = new DefaultSqlDocumentSession( + text, + context, + this.#dialects, + () => { + this.#sessions.delete(session); + }, + ); + if (this.#disposed) { + session.dispose(); + throw new SqlSessionError( + "service-disposed", + "SQL language service was disposed while opening the document", + ); + } + this.#sessions.add(session); + return session; + } catch (error) { + if (this.#disposed) { + throw new SqlSessionError( + "service-disposed", + "SQL language service was disposed while opening the document", + ); + } + if (error instanceof SqlSessionError) { + throw error; + } + throw new SqlSessionError( + "invalid-document", + "Open SQL document input could not be inspected safely", + ); + } + }; + + readonly dispose = (): void => { + if (this.#disposed) { + return; + } + this.#disposed = true; + for (const session of this.#sessions) { + session.dispose(); + } + this.#sessions.clear(); + }; +} + +/** Validates and normalizes one immutable dialect registration. */ +export function defineSqlDialect( + definition: SqlDialectDefinition, +): SqlDialectDefinition { + try { + if (definition === null || typeof definition !== "object") { + throw new SqlSessionError( + "invalid-dialect", + "SQL dialect definition must be an object", + ); + } + const id = readRequiredDataProperty( + definition, + "id", + "invalid-dialect", + "SQL dialect definition", + ); + const displayName = readRequiredDataProperty( + definition, + "displayName", + "invalid-dialect", + "SQL dialect definition", + ); + if ( + typeof id !== "string" || + id.length === 0 || + id.length > MAX_DIALECT_ID_LENGTH || + id.trim().length === 0 + ) { + throw new SqlSessionError( + "invalid-dialect", + `SQL dialect id must contain 1 to ${MAX_DIALECT_ID_LENGTH} code units`, + ); + } + if ( + typeof displayName !== "string" || + displayName.length === 0 || + displayName.length > MAX_DIALECT_DISPLAY_NAME_LENGTH || + displayName.trim().length === 0 + ) { + throw new SqlSessionError( + "invalid-dialect", + `SQL dialect display name must contain 1 to ${MAX_DIALECT_DISPLAY_NAME_LENGTH} code units`, + ); + } + return Object.freeze({ displayName, id }); + } catch (error) { + if (error instanceof SqlSessionError) { + throw error; + } + throw new SqlSessionError( + "invalid-dialect", + "SQL dialect definition could not be inspected safely", + ); + } +} + +/** Creates a framework-independent SQL service with an immutable dialect registry. */ +export function createSqlLanguageService< + Context extends SqlDocumentContext = SqlDocumentContext, +>(options: SqlLanguageServiceOptions): SqlLanguageService { + return new DefaultSqlLanguageService(options); +} diff --git a/src/vnext/types.ts b/src/vnext/types.ts new file mode 100644 index 0000000..6f9ed4c --- /dev/null +++ b/src/vnext/types.ts @@ -0,0 +1,129 @@ +const revisionBrand: unique symbol = Symbol("SqlRevision"); + +/** Immutable identity issued by a document session. */ +export interface SqlRevision { + readonly [revisionBrand]: "SqlRevision"; +} + +export function createSqlRevisionToken(): SqlRevision { + const revision: SqlRevision = { + [revisionBrand]: "SqlRevision", + }; + Object.freeze(revision); + return revision; +} + +export interface SqlCatalogContext { + readonly scope: string; + readonly searchPath?: readonly string[]; +} + +export interface SqlDocumentContext { + readonly dialect: string; + readonly catalog?: SqlCatalogContext; +} + +/** Recursively maps a type to the plain-data values accepted at runtime. */ +export type SqlPlainData = + Value extends null | undefined | string | number | boolean | bigint + ? Value + : Value extends (...arguments_: never[]) => unknown + ? never + : Value extends readonly unknown[] + ? { readonly [Key in keyof Value]: SqlPlainData } + : Value extends object + ? { + readonly [Key in keyof Value]: Key extends string + ? SqlPlainData + : never; + } + : never; + +export type SqlContextInput = + Context & SqlPlainData; + +export interface SqlDialectDefinition { + readonly id: string; + readonly displayName: string; +} + +/** One half-open UTF-16 edit in pre-update document coordinates. */ +export interface SqlTextChange { + readonly from: number; + readonly to: number; + readonly insert: string; +} + +export interface SqlDocumentReplacement { + readonly kind: "replace"; + readonly text: string; +} + +export interface SqlDocumentChanges { + readonly kind: "changes"; + readonly changes: readonly SqlTextChange[]; +} + +export type SqlDocumentEdit = SqlDocumentReplacement | SqlDocumentChanges; + +/** An atomic document or context transaction against one base revision. */ +export type SqlDocumentUpdate = + | { + readonly kind: "document"; + readonly baseRevision: SqlRevision; + readonly document: SqlDocumentEdit; + readonly context?: SqlContextInput; + } + | { + readonly kind: "context"; + readonly baseRevision: SqlRevision; + readonly context: SqlContextInput; + }; + +export interface OpenSqlDocument { + readonly text: string; + readonly context: SqlContextInput; +} + +/** Owns all mutable state for one open SQL document. */ +export interface SqlDocumentSession { + readonly revision: SqlRevision; + readonly update: (update: SqlDocumentUpdate) => SqlRevision; + readonly isCurrent: (revision: SqlRevision) => boolean; + readonly dispose: () => void; +} + +/** Shareable service configuration and lifecycle for multiple documents. */ +export interface SqlLanguageService { + readonly openDocument: ( + input: OpenSqlDocument, + ) => SqlDocumentSession; + readonly dispose: () => void; +} + +export interface SqlLanguageServiceOptions { + readonly dialects: readonly SqlDialectDefinition[]; +} + +export type SqlSessionErrorCode = + | "duplicate-dialect" + | "invalid-change" + | "invalid-context" + | "invalid-dialect" + | "invalid-document" + | "invalid-service-options" + | "invalid-update" + | "reentrant-update" + | "service-disposed" + | "session-disposed" + | "stale-revision"; + +export class SqlSessionError extends Error { + readonly code: SqlSessionErrorCode; + + constructor(code: SqlSessionErrorCode, message: string) { + super(message); + this.name = "SqlSessionError"; + this.code = code; + } +} diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts new file mode 100644 index 0000000..74a71ee --- /dev/null +++ b/test/vnext-types/session.test-d.ts @@ -0,0 +1,82 @@ +import { + createSqlLanguageService, + defineSqlDialect, + type SqlDocumentContext, + type SqlDocumentSession, + type SqlLanguageService, + type SqlRevision, + type SqlTextChange, +} from "../../src/vnext/index.js"; + +interface HostContext extends SqlDocumentContext { + readonly engine: string; +} + +interface DateContext extends HostContext { + readonly lastUsed: Date; +} + +const dialect = defineSqlDialect({ displayName: "DuckDB", id: "duckdb" }); +const service = createSqlLanguageService({ dialects: [dialect] }); +const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "", +}); +const revision: SqlRevision = session.revision; + +// @ts-expect-error host service cannot be widened and fed a weaker context +const widenedService: SqlLanguageService = service; +// @ts-expect-error host session cannot be widened and fed a weaker context +const widenedSession: SqlDocumentSession = session; + +session.update({ + kind: "context", + baseRevision: revision, + context: { dialect: "duckdb", engine: "remote" }, +}); +session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, +}); +session.update({ + kind: "document", + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "local" }, + document: { kind: "changes", changes: [{ from: 0, insert: "SELECT 1", to: 0 }] }, +}); +// @ts-expect-error an explicitly undefined context is not an omitted context +session.update({ + kind: "document", + baseRevision: session.revision, + context: undefined, + document: { kind: "replace", text: "SELECT 1" }, +}); + +const change: SqlTextChange = { from: 0, insert: "", to: 0 }; + +// @ts-expect-error revisions are service-issued opaque values +const objectRevision: SqlRevision = {}; +// @ts-expect-error revisions cannot be numbers +const numberRevision: SqlRevision = 1; +// @ts-expect-error updates require document or context +session.update({ kind: "document", baseRevision: revision }); +// @ts-expect-error document mutation forms are mutually exclusive +session.update({ kind: "document", baseRevision: revision, document: { kind: "changes", changes: [], text: "" } }); +// @ts-expect-error host context requires engine +service.openDocument({ context: { dialect: "duckdb" }, text: "" }); +const dateService = createSqlLanguageService({ dialects: [dialect] }); +dateService.openDocument({ + // @ts-expect-error Date is structured-cloneable but is not plain context data + context: { dialect: "duckdb", engine: "local", lastUsed: new Date() }, + text: "", +}); +// @ts-expect-error changes are readonly +change.from = 1; +// @ts-expect-error session revision is readonly +session.revision = revision; + +void objectRevision; +void numberRevision; +void widenedService; +void widenedSession; diff --git a/tsconfig.vnext-tests.json b/tsconfig.vnext-tests.json index 8acbc12..8cf4d35 100644 --- a/tsconfig.vnext-tests.json +++ b/tsconfig.vnext-tests.json @@ -1,11 +1,13 @@ { "extends": "./tsconfig.tests.json", "compilerOptions": { + "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true }, "include": [ "./src/vnext/**/*.ts", + "./test/vnext-types/**/*.ts", "./vitest.browser.config.ts", "./vitest.config.ts" ] From 359cc18265d506be73b89b865662eb49ae86a0b7 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 22:12:07 +0800 Subject: [PATCH 04/42] feat(vnext): add source coordinate primitives (#173) ## Summary - publish the minimal `SqlTextRange` UTF-16 contract and make `SqlTextChange` extend it - add internal immutable identity/masked source snapshots with explicit `originalText` and `analysisText` - validate and freeze bounded embedded regions without invoking accessors or Proxy `get` traps - preserve length and every CR/LF offset while masking every other UTF-16 code unit - store source snapshots in document sessions, reusing them only for context-only updates - document the intentional boundary: no public transformer or source-map SPI yet - request Copilot once per PR, without later re-requests ## Invariants and risk - ranges are safe-integer, in-bounds, half-open UTF-16 offsets - embedded regions are non-empty, ordered, non-overlapping, and capped at 10,000 - source text is capped at 16 Mi UTF-16 code units - masking uses bounded 64 Ki-code-unit chunks and cannot allocate per newline - arbitrary values thrown by hostile Proxy traps are normalized without inspecting them - document updates create a new source snapshot; context-only updates retain the exact source identity ## Evidence - `pnpm run typecheck` - `pnpm exec oxlint` - `pnpm run test:coverage` (713 passed, 1 governed expected failure) - changed runtime coverage: 98.78% statements, 97.75% branches, 100% functions, 98.76% lines - deterministic randomized UTF-16 masking/range round trips - full 16,777,216-code-unit CRLF mask and 10,000-region smoke cases - full-limit newline mask succeeds under `node --max-old-space-size=128` - `pnpm run test:browser` - `pnpm run test:package` - `pnpm run test:integrity` - `pnpm run demo` ## Adversarial review Two independent exact-head reviewers challenged correctness and performance/API design. The first loop found a newline-density memory amplification and hostile thrown-Proxy escape. Both were redesigned and regression-tested. Both reviewers approve exact SHA `1993a6cdca85ec9d3b8a1ddaab5dda0188b85fab` with no blocker, high, or medium findings. --- ## Summary by cubic Add internal source coordinate primitives and length-preserving masking to support safe UTF-16 ranges and embedded-region analysis. Export `SqlTextRange` and update sessions to use immutable source snapshots, reusing them for context-only updates. - New Features - Export `SqlTextRange`; make `SqlTextChange` extend it. - Add immutable source snapshots with `originalText` and `analysisText`, plus length-preserving masking of embedded regions (CR/LF offsets preserved). No transformer or source-map SPI yet. - Validate and freeze inputs with strict caps (16 Mi code units, 10k regions) and accessor-safe normalization. - Store source snapshots in document sessions and reuse them on context-only updates. Written for commit 1993a6cdca85ec9d3b8a1ddaab5dda0188b85fab. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 6 + docs/vnext/session-primitives.md | 3 + docs/vnext/source-coordinates.md | 46 ++ implementation.md | 11 +- scripts/package-smoke.mjs | 3 + src/vnext/__tests__/session.test.ts | 62 ++- src/vnext/__tests__/source.test.ts | 427 ++++++++++++++++++ src/vnext/index.ts | 1 + src/vnext/session.ts | 93 ++-- src/vnext/source.ts | 362 +++++++++++++++ src/vnext/types.ts | 8 +- test/vnext-types/session.test-d.ts | 5 + 12 files changed, 956 insertions(+), 71 deletions(-) create mode 100644 docs/vnext/source-coordinates.md create mode 100644 src/vnext/__tests__/source.test.ts create mode 100644 src/vnext/source.ts diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index 5cc9e43..fbc3d82 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -389,6 +389,12 @@ adjacent SQL tokens. More complex transformers use a versioned map whose segments are ordered, non-overlapping, and validated for original/generated coverage. +The initial internal source primitive implements identity snapshots and this +length-preserving masking only. It does not expose a transformer or source-map +SPI. Sessions create a new source snapshot for document updates and reuse the +existing snapshot for context-only updates. Non-identity generated/reordered +source remains deferred until a concrete consumer validates the segment model. + Edits crossing an ambiguous or unmapped boundary are rejected. Mapping failure is unavailable analysis, not invalid SQL. diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index fb5cf69..8ac74ce 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -8,6 +8,9 @@ updates, opaque revisions, dialect registration, and lifecycle management. It does not yet provide parsing, completion, diagnostics, hover, or navigation. Those methods will be added only with working vertical slices. +See [source coordinates](./source-coordinates.md) for the shared UTF-16 range +contract and the internal immutable source-snapshot model. + ## Example ```ts diff --git a/docs/vnext/source-coordinates.md b/docs/vnext/source-coordinates.md new file mode 100644 index 0000000..6db46d9 --- /dev/null +++ b/docs/vnext/source-coordinates.md @@ -0,0 +1,46 @@ +# vNext Source Coordinates + +Status: experimental core primitive + +`SqlTextRange` is the public coordinate primitive: + +```ts +interface SqlTextRange { + readonly from: number; + readonly to: number; +} +``` + +Ranges are half-open UTF-16 offsets. Both ends are safe integers and satisfy +`0 <= from <= to <= document.length`. Empty ranges, including the range at EOF, +are valid. `SqlTextChange` extends this shape with `insert` and continues to use +pre-update document coordinates. + +The service internally owns an immutable source snapshot with separately named +`originalText` and `analysisText`. A document update creates a new identity +snapshot; a context-only update reuses the same source object. This separation +allows later analysis transforms without making providers depend on editor +state or ambiguous coordinate spaces. + +## Length-preserving masking + +The first internal transform masks explicit embedded regions: + +- Regions are non-empty, ordered, non-overlapping, and in bounds. +- Region and range inputs are copied into fresh frozen values. +- Each non-CR/LF UTF-16 code unit becomes one space. +- Every CR and LF code unit stays at its exact offset. +- Astral characters therefore become two spaces, while a lone surrogate becomes + one space. +- `analysisText.length` always equals `originalText.length`, so range mapping is + identity-preserving while still returning a fresh validated range. + +At most 10,000 regions and 16 Mi UTF-16 code units are accepted. Masking uses +bounded 64 Ki-code-unit chunks, including for newline-dense input, rather than +a per-code-unit or per-newline array. + +The source snapshot, embedded-region model, masking factory, and mapping +functions remain internal. This slice intentionally does not publish a source +transformer or source-map SPI. Generated or reordered source requires a +versioned segment-map design and evidence from real consumers before becoming +public. diff --git a/implementation.md b/implementation.md index 58582b9..445aaed 100644 --- a/implementation.md +++ b/implementation.md @@ -590,8 +590,8 @@ disposition. Validate them through a protected reusable workflow from the base branch so a PR cannot approve itself. Maintainers approve classification overrides and rejected blocking findings. -Request GitHub Copilot review on every medium or large PR after the head commit -is ready for review: +Request GitHub Copilot review exactly once on every medium or large PR, after +the PR has a coherent head that is ready for review: ```bash gh pr edit --add-reviewer @copilot @@ -600,8 +600,9 @@ gh pr edit --add-reviewer @copilot Copilot is an additional advisory review, not one of the two independent commit-bound adversarial attestations and not a substitute for human approval. Resolve or explicitly disposition its actionable comments in the finding -ledger. Re-request review after a material rewrite when the prior comments no -longer cover the current head. +ledger. Do not re-request Copilot after later pushes or material rewrites; the +commit-bound adversarial reviewers and required CI provide exact-head +revalidation. ## CI architecture @@ -814,7 +815,7 @@ Before semantic implementation: 12. Add the invariant registry and test-suite integrity checks. 13. Add PR risk template and review-packet generation. 14. Add protected, commit-bound two-reviewer checks. -15. Automate GitHub Copilot review requests for medium and large PRs. +15. Automate one GitHub Copilot review request per medium or large PR. 16. Add verified-artifact provenance to release CI. 17. Add nightly mutation, fuzz, cross-browser, leak, and performance workflows. 18. Add expiring scheduled-health and deduplicated failure issues. diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 70c70e1..263f885 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -210,6 +210,7 @@ import { createSqlLanguageService, defineSqlDialect, type SqlDocumentContext, + type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; @@ -230,6 +231,7 @@ const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, text: "SELECT 1", }); +const range: SqlTextRange = { from: 0, to: 6 }; session.update({ kind: "document", baseRevision: session.revision, @@ -238,6 +240,7 @@ session.update({ void extensions; void parser; +void range; void session; void BigQueryDialect; void DremioDialect; diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index dd52062..c0a3313 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -187,6 +187,28 @@ describe("document revisions", () => { }); expect(second).not.toBe(first); }); + + it("reuses source snapshots only for context-only updates", () => { + const { session } = openSession("SELECT 1"); + const initialSource = session.snapshotForTesting.source; + expect(Object.isFrozen(initialSource)).toBe(true); + expect(initialSource.analysisText).toBe(initialSource.originalText); + + session.update({ + kind: "context", + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "remote" }, + }); + expect(session.snapshotForTesting.source).toBe(initialSource); + + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: [] }, + }); + expect(session.snapshotForTesting.source).not.toBe(initialSource); + expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); + }); }); describe("document changes", () => { @@ -204,7 +226,7 @@ describe("document changes", () => { }, }); - expect(session.snapshotForTesting.text).toBe( + expect(session.snapshotForTesting.source.originalText).toBe( "SELECT customers.id FROM customers", ); }); @@ -219,7 +241,7 @@ describe("document changes", () => { changes: [{ from: 1, insert: "X", to: 3 }], }, }); - expect(session.snapshotForTesting.text).toBe("AXB"); + expect(session.snapshotForTesting.source.originalText).toBe("AXB"); }); it("keeps same-position insertions ordered", () => { @@ -235,7 +257,7 @@ describe("document changes", () => { ], }, }); - expect(session.snapshotForTesting.text).toBe("A12B"); + expect(session.snapshotForTesting.source.originalText).toBe("A12B"); }); it("supports adjacent replacements and document boundaries", () => { @@ -253,7 +275,7 @@ describe("document changes", () => { ], }, }); - expect(session.snapshotForTesting.text).toBe("XYZ!"); + expect(session.snapshotForTesting.source.originalText).toBe("XYZ!"); }); it("deletes the entire document", () => { @@ -263,7 +285,7 @@ describe("document changes", () => { baseRevision: session.revision, document: { kind: "changes", changes: [{ from: 0, insert: "", to: 8 }] }, }); - expect(session.snapshotForTesting.text).toBe(""); + expect(session.snapshotForTesting.source.originalText).toBe(""); }); it("allows edits at every UTF-16 boundary", () => { @@ -280,7 +302,9 @@ describe("document changes", () => { ], }, }); - expect(session.snapshotForTesting.text).toBe("A\uD83DXe\u0301\nZ!\uD800"); + expect(session.snapshotForTesting.source.originalText).toBe( + "A\uD83DXe\u0301\nZ!\uD800", + ); }); it.each([ @@ -305,7 +329,7 @@ describe("document changes", () => { }); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); it("rejects unordered and overlapping changes atomically", () => { @@ -325,7 +349,7 @@ describe("document changes", () => { }); }); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("ABCDE"); + expect(session.snapshotForTesting.source.originalText).toBe("ABCDE"); }); it("rejects non-string inserts from JavaScript callers", () => { @@ -356,7 +380,7 @@ describe("document changes", () => { session.update({ kind: "document", baseRevision: revision, document } as never); }); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); it("rejects an empty JavaScript update", () => { @@ -423,7 +447,7 @@ describe("document changes", () => { baseRevision: session.revision, document: { kind: "replace", text: "recovered" }, }); - expect(session.snapshotForTesting.text).toBe("recovered"); + expect(session.snapshotForTesting.source.originalText).toBe("recovered"); }); it.each([null, "invalid", 42])( @@ -454,7 +478,7 @@ describe("document changes", () => { session.update(accessor as never); }); expect(invoked).toBe(false); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); it("rejects undefined and accessor optional update fields", () => { @@ -469,7 +493,7 @@ describe("document changes", () => { } as never); }); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); let invoked = false; const update = { @@ -486,7 +510,7 @@ describe("document changes", () => { }); expect(invoked).toBe(false); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); it("rejects accessor document fields without invoking them", () => { @@ -571,7 +595,7 @@ describe("document changes", () => { expect(nestedError?.code).toBe("reentrant-update"); expect(session.isCurrent(revision)).toBe(true); - expect(session.snapshotForTesting.text).toBe("outer"); + expect(session.snapshotForTesting.source.originalText).toBe("outer"); }); it("cannot commit an update after reentrant disposal", () => { @@ -600,7 +624,7 @@ describe("document changes", () => { }); }); expect(session.isCurrent(revision)).toBe(false); - expect(session.snapshotForTesting.text).toBe("ABC"); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); it("bounds fragmented and oversized document updates", () => { @@ -631,7 +655,7 @@ describe("document changes", () => { }, }); }); - expect(session.snapshotForTesting.text).toBe(""); + expect(session.snapshotForTesting.source.originalText).toBe(""); }); }); @@ -746,7 +770,7 @@ describe("document context", () => { expect(session.snapshotForTesting).toMatchObject({ context: { dialect: "postgres", engine: "warehouse" }, - text: "SELECT 2", + source: { originalText: "SELECT 2" }, }); const snapshot = session.snapshotForTesting; @@ -768,7 +792,7 @@ describe("document context", () => { baseRevision: session.revision, context: { dialect: "postgres", engine: "warehouse" }, }); - expect(session.snapshotForTesting.text).toBe("SELECT 1"); + expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); expect(session.snapshotForTesting.context.dialect).toBe("postgres"); }); @@ -821,7 +845,7 @@ describe("document context", () => { } as never); }); expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.text).toBe("SELECT 1"); + expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); }); it("rejects accessors without invoking them", () => { diff --git a/src/vnext/__tests__/source.test.ts b/src/vnext/__tests__/source.test.ts new file mode 100644 index 0000000..ce8c2db --- /dev/null +++ b/src/vnext/__tests__/source.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from "vitest"; +import { + createIdentitySqlSource, + createMaskedSqlSource, + mapAnalysisRangeToOriginal, + mapOriginalRangeToAnalysis, + MAX_SQL_EMBEDDED_REGIONS, + MAX_SQL_SOURCE_LENGTH, + normalizeSqlTextRange, + SqlSourceError, +} from "../source.js"; + +function expectSourceError( + code: SqlSourceError["code"], + operation: () => unknown, +): void { + try { + operation(); + } catch (error) { + if (!(error instanceof SqlSourceError)) { + throw error; + } + expect(error.code).toBe(code); + return; + } + throw new Error(`Expected SqlSourceError with code ${code}`); +} + +describe("SQL text ranges", () => { + it("normalizes frozen half-open UTF-16 ranges", () => { + const input = { from: 1, to: 3 }; + const range = normalizeSqlTextRange(input, 3); + + expect(range).toEqual(input); + expect(range).not.toBe(input); + expect(Object.isFrozen(range)).toBe(true); + expect(normalizeSqlTextRange({ from: 3, to: 3 }, 3)).toEqual({ + from: 3, + to: 3, + }); + }); + + it.each([ + null, + "", + {}, + { from: 0 }, + { from: -1, to: 0 }, + { from: 1, to: 0 }, + { from: 0.5, to: 1 }, + { from: 0, to: 2 }, + { from: Number.NaN, to: 0 }, + { from: 0, to: Number.POSITIVE_INFINITY }, + ])("rejects malformed ranges %#", (range) => { + expectSourceError("invalid-range", () => { + normalizeSqlTextRange(range, 1); + }); + }); + + it.each([-1, 0.5, Number.NaN, MAX_SQL_SOURCE_LENGTH + 1])( + "rejects invalid source length %s", + (sourceLength) => { + expectSourceError("invalid-range", () => { + normalizeSqlTextRange({ from: 0, to: 0 }, sourceLength); + }); + }, + ); + + it("does not invoke accessors or proxy get traps", () => { + let accessorInvoked = false; + expectSourceError("invalid-source", () => { + normalizeSqlTextRange( + { + get from() { + accessorInvoked = true; + return 0; + }, + to: 0, + }, + 0, + ); + }); + expect(accessorInvoked).toBe(false); + + let getInvoked = false; + const range = new Proxy( + { from: 0, to: 0 }, + { + get(target, property, receiver) { + getInvoked = true; + return Reflect.get(target, property, receiver); + }, + }, + ); + expect(normalizeSqlTextRange(range, 0)).toEqual({ from: 0, to: 0 }); + expect(getInvoked).toBe(false); + }); + + it("normalizes hostile proxy failures", () => { + expectSourceError("invalid-source", () => { + normalizeSqlTextRange( + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ), + 0, + ); + }); + }); + + it("does not inspect arbitrary values thrown by proxies", () => { + const hostileError = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("secondary"); + }, + }, + ); + expectSourceError("invalid-source", () => { + normalizeSqlTextRange( + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw hostileError; + }, + }, + ), + 0, + ); + }); + }); +}); + +describe("SQL source snapshots", () => { + it("creates frozen identity snapshots", () => { + const source = createIdentitySqlSource("SELECT 😀"); + + expect(source.originalText).toBe("SELECT 😀"); + expect(source.analysisText).toBe(source.originalText); + expect(source.embeddedRegions).toEqual([]); + expect(Object.isFrozen(source)).toBe(true); + expect(Object.isFrozen(source.embeddedRegions)).toBe(true); + }); + + it("bounds and validates source text", () => { + expectSourceError("invalid-source", () => { + createIdentitySqlSource(42); + }); + expectSourceError("invalid-source", () => { + createIdentitySqlSource("x".repeat(MAX_SQL_SOURCE_LENGTH + 1)); + }); + }); + + it("masks every non-newline UTF-16 code unit", () => { + const originalText = "a😀\r\nb\uD800c"; + const source = createMaskedSqlSource(originalText, [ + { from: 1, language: "python", to: 7 }, + ]); + + expect(source.originalText).toBe(originalText); + expect(source.analysisText).toBe("a \r\n c"); + expect(source.analysisText.length).toBe(originalText.length); + expect(source.embeddedRegions).toEqual([ + { from: 1, language: "python", to: 7 }, + ]); + expect(Object.isFrozen(source)).toBe(true); + expect(Object.isFrozen(source.embeddedRegions)).toBe(true); + expect(Object.isFrozen(source.embeddedRegions[0])).toBe(true); + }); + + it("owns normalized embedded regions", () => { + const first = { from: 0, language: "python", to: 1 }; + const regions = [first, { from: 2, language: "jinja", to: 3 }]; + const source = createMaskedSqlSource("abc", regions); + + first.from = 1; + regions.push({ from: 1, language: "other", to: 2 }); + expect(source.embeddedRegions).toEqual([ + { from: 0, language: "python", to: 1 }, + { from: 2, language: "jinja", to: 3 }, + ]); + expect(source.analysisText).toBe(" b "); + }); + + it.each([ + null, + {}, + [42], + [{ from: 0, language: "python", to: 0 }], + [{ from: -1, language: "python", to: 1 }], + [{ from: 0, language: "", to: 1 }], + [{ from: 0, language: "x".repeat(257), to: 1 }], + [ + { from: 1, language: "python", to: 2 }, + { from: 0, language: "python", to: 1 }, + ], + [ + { from: 0, language: "python", to: 2 }, + { from: 1, language: "python", to: 3 }, + ], + ])("rejects malformed embedded regions %#", (regions) => { + expectSourceError("invalid-region", () => { + createMaskedSqlSource("abc", regions); + }); + }); + + it("rejects sparse, oversized, and extended region arrays", () => { + const sparse: unknown[] = []; + sparse.length = 1; + expectSourceError("invalid-source", () => { + createMaskedSqlSource("a", sparse); + }); + + const oversized: unknown[] = []; + oversized.length = MAX_SQL_EMBEDDED_REGIONS + 1; + expectSourceError("invalid-region", () => { + createMaskedSqlSource("a", oversized); + }); + + const extended = [{ from: 0, language: "python", to: 1 }]; + Object.defineProperty(extended, "custom", { value: true }); + expectSourceError("invalid-region", () => { + createMaskedSqlSource("a", extended); + }); + }); + + it("does not invoke region accessors or array get traps", () => { + let accessorInvoked = false; + expectSourceError("invalid-source", () => { + createMaskedSqlSource("a", [ + { + get from() { + accessorInvoked = true; + return 0; + }, + language: "python", + to: 1, + }, + ]); + }); + expect(accessorInvoked).toBe(false); + + expectSourceError("invalid-source", () => { + createMaskedSqlSource("a", [ + { + from: 0, + get language() { + accessorInvoked = true; + return "python"; + }, + to: 1, + }, + ]); + }); + expect(accessorInvoked).toBe(false); + + let getInvoked = false; + const regions = new Proxy([{ from: 0, language: "python", to: 1 }], { + get(target, property, receiver) { + getInvoked = true; + return Reflect.get(target, property, receiver); + }, + }); + expect(createMaskedSqlSource("a", regions).analysisText).toBe(" "); + expect(getInvoked).toBe(false); + }); + + it("normalizes hostile region proxy failures", () => { + expectSourceError("invalid-source", () => { + createMaskedSqlSource( + "a", + new Proxy( + [], + { + ownKeys() { + throw new Error("hostile"); + }, + }, + ), + ); + }); + }); + + it("normalizes hostile values thrown while inspecting regions", () => { + const hostileError = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("secondary"); + }, + }, + ); + expectSourceError("invalid-source", () => { + createMaskedSqlSource( + "a", + new Proxy( + [], + { + ownKeys() { + throw hostileError; + }, + }, + ), + ); + }); + }); + + it("maps ranges with fresh frozen values", () => { + const source = createMaskedSqlSource("a😀b", [ + { from: 1, language: "python", to: 3 }, + ]); + const originalRange = { from: 1, to: 3 }; + const analysisRange = mapOriginalRangeToAnalysis(source, originalRange); + const roundTrip = mapAnalysisRangeToOriginal(source, analysisRange); + + expect(analysisRange).toEqual(originalRange); + expect(analysisRange).not.toBe(originalRange); + expect(roundTrip).toEqual(originalRange); + expect(roundTrip).not.toBe(analysisRange); + expect(Object.isFrozen(analysisRange)).toBe(true); + expect(Object.isFrozen(roundTrip)).toBe(true); + }); + + it("preserves masking invariants across deterministic random cases", () => { + let state = 0x51_0f_aa_7d; + const next = (limit: number): number => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state % limit; + }; + const tokens = ["a", " ", "\r", "\n", "😀", "\uD800"]; + + for (let iteration = 0; iteration < 250; iteration += 1) { + let text = ""; + const tokenCount = next(50) + 1; + for (let index = 0; index < tokenCount; index += 1) { + const token = tokens[next(tokens.length)]; + if (token === undefined) { + throw new Error("Random token index was out of bounds"); + } + text += token; + } + + const regions: Array<{ + from: number; + language: string; + to: number; + }> = []; + let cursor = 0; + while (cursor < text.length) { + cursor += next(4); + if (cursor >= text.length) { + break; + } + const to = Math.min(text.length, cursor + next(5) + 1); + regions.push({ from: cursor, language: "embedded", to }); + cursor = to + next(3); + } + + const source = createMaskedSqlSource(text, regions); + expect(source.analysisText.length).toBe(text.length); + for (let offset = 0; offset < text.length; offset += 1) { + const masked = regions.some( + (region) => region.from <= offset && offset < region.to, + ); + const original = text.slice(offset, offset + 1); + const expected = + masked && original !== "\r" && original !== "\n" ? " " : original; + expect(source.analysisText.slice(offset, offset + 1)).toBe(expected); + } + + const from = next(text.length + 1); + const to = from + next(text.length - from + 1); + expect( + mapAnalysisRangeToOriginal( + source, + mapOriginalRangeToAnalysis(source, { from, to }), + ), + ).toEqual({ from, to }); + } + }); + + it("masks newline-dense text at the source limit", () => { + const text = "\r\n".repeat(MAX_SQL_SOURCE_LENGTH / 2); + const start = performance.now(); + const source = createMaskedSqlSource(text, [ + { from: 0, language: "embedded", to: text.length }, + ]); + const duration = performance.now() - start; + + expect(source.analysisText).toBe(text); + expect(source.analysisText.length).toBe(text.length); + expect(duration).toBeLessThan(2_000); + }); + + it("masks alternating text and the maximum region count", () => { + const alternatingText = "x\n".repeat(512 * 1024); + const alternatingStart = performance.now(); + const alternating = createMaskedSqlSource(alternatingText, [ + { from: 0, language: "embedded", to: alternatingText.length }, + ]); + const alternatingDuration = performance.now() - alternatingStart; + expect(alternating.analysisText).toBe(" \n".repeat(512 * 1024)); + + const fragmentedText = "xy".repeat(MAX_SQL_EMBEDDED_REGIONS); + const regions = Array.from( + { length: MAX_SQL_EMBEDDED_REGIONS }, + (_, index) => ({ + from: index * 2, + language: "embedded", + to: index * 2 + 1, + }), + ); + const fragmentedStart = performance.now(); + const fragmented = createMaskedSqlSource(fragmentedText, regions); + const fragmentedDuration = performance.now() - fragmentedStart; + expect(fragmented.analysisText).toBe(" y".repeat(MAX_SQL_EMBEDDED_REGIONS)); + + expect(alternatingDuration).toBeLessThan(2_000); + expect(fragmentedDuration).toBeLessThan(2_000); + }); +}); diff --git a/src/vnext/index.ts b/src/vnext/index.ts index f709526..8febd34 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -19,5 +19,6 @@ export type { SqlRevision, SqlSessionErrorCode, SqlTextChange, + SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 6d687f4..8a9f215 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -8,7 +8,15 @@ import type { SqlLanguageServiceOptions, SqlRevision, SqlTextChange, + SqlTextRange, } from "./types.js"; +import { + createIdentitySqlSource, + isSqlSourceError, + MAX_SQL_SOURCE_LENGTH, + normalizeSqlTextRange, + type SqlSourceSnapshot, +} from "./source.js"; import { createSqlRevisionToken, SqlSessionError } from "./types.js"; const MAX_CONTEXT_DEPTH = 100; @@ -17,7 +25,6 @@ const MAX_CONTEXT_PROPERTIES = 50_000; const MAX_CONTEXT_KEY_LENGTH = 1_000_000; const MAX_CONTEXT_STRING_LENGTH = 1_000_000; const MAX_CONTEXT_ARRAY_LENGTH = 50_000; -const MAX_DOCUMENT_LENGTH = 16 * 1024 * 1024; const MAX_CHANGES_PER_UPDATE = 10_000; const MAX_DIALECTS = 1_000; const MAX_DIALECT_ID_LENGTH = 256; @@ -306,10 +313,10 @@ function readRequiredDataProperty( } function validateDocumentLength(text: string): void { - if (text.length > MAX_DOCUMENT_LENGTH) { + if (text.length > MAX_SQL_SOURCE_LENGTH) { throw new SqlSessionError( "invalid-document", - `SQL documents cannot exceed ${MAX_DOCUMENT_LENGTH} UTF-16 code units`, + `SQL documents cannot exceed ${MAX_SQL_SOURCE_LENGTH} UTF-16 code units`, ); } } @@ -355,39 +362,29 @@ function normalizeChanges(text: string, changes: readonly unknown[]): SqlTextCha `SQL change ${index} must be an object`, ); } - const from = readRequiredDataProperty( - change, - "from", - "invalid-change", - `SQL change ${index}`, - ); - const to = readRequiredDataProperty( - change, - "to", - "invalid-change", - `SQL change ${index}`, - ); + let range: SqlTextRange; + try { + range = normalizeSqlTextRange( + change, + text.length, + `SQL change ${index}`, + ); + } catch (error) { + if (!isSqlSourceError(error)) { + throw error; + } + throw new SqlSessionError( + "invalid-change", + `Invalid UTF-16 range in SQL change ${index}`, + ); + } const insert = readRequiredDataProperty( change, "insert", "invalid-change", `SQL change ${index}`, ); - if ( - typeof from !== "number" || - typeof to !== "number" || - !Number.isSafeInteger(from) || - !Number.isSafeInteger(to) || - from < 0 || - from > to || - to > text.length - ) { - throw new SqlSessionError( - "invalid-change", - `Invalid UTF-16 range in SQL change ${index}`, - ); - } - if (from < previousEnd) { + if (range.from < previousEnd) { throw new SqlSessionError( "invalid-change", "SQL document changes must be ordered and non-overlapping", @@ -396,21 +393,21 @@ function normalizeChanges(text: string, changes: readonly unknown[]): SqlTextCha if (typeof insert !== "string") { throw new SqlSessionError("invalid-change", "SQL change insert must be a string"); } - nextLength += insert.length - (to - from); - if (nextLength > MAX_DOCUMENT_LENGTH) { + nextLength += insert.length - (range.to - range.from); + if (nextLength > MAX_SQL_SOURCE_LENGTH) { throw new SqlSessionError( "invalid-document", - `SQL documents cannot exceed ${MAX_DOCUMENT_LENGTH} UTF-16 code units`, + `SQL documents cannot exceed ${MAX_SQL_SOURCE_LENGTH} UTF-16 code units`, ); } normalized.push( Object.freeze({ - from, + from: range.from, insert, - to, + to: range.to, }), ); - previousEnd = to; + previousEnd = range.to; } return normalized; @@ -433,7 +430,7 @@ interface SessionSnapshot { readonly documentSequence: number; readonly revision: SqlRevision; readonly sequence: number; - readonly text: string; + readonly source: SqlSourceSnapshot; } export class DefaultSqlDocumentSession @@ -446,7 +443,7 @@ export class DefaultSqlDocumentSession #updating = false; constructor( - text: string, + source: SqlSourceSnapshot, context: Context, dialects: ReadonlySet, onDispose: () => void, @@ -462,7 +459,7 @@ export class DefaultSqlDocumentSession documentSequence, revision: createSqlRevisionToken(), sequence, - text, + source, }); } @@ -532,7 +529,7 @@ export class DefaultSqlDocumentSession let nextContextSequence = this.#snapshot.contextSequence; let nextContext = this.#snapshot.context; let nextDocumentSequence = this.#snapshot.documentSequence; - let nextText = this.#snapshot.text; + let nextSource = this.#snapshot.source; if (kind === "context") { if ( @@ -624,7 +621,7 @@ export class DefaultSqlDocumentSession ); } validateDocumentLength(text); - nextText = text; + nextSource = createIdentitySqlSource(text); } else if (documentKind === "changes") { if ( readOwnDataProperty( @@ -651,8 +648,13 @@ export class DefaultSqlDocumentSession "SQL document changes must be an array", ); } - const normalizedChanges = normalizeChanges(nextText, changes); - nextText = applyChanges(nextText, normalizedChanges); + const normalizedChanges = normalizeChanges( + nextSource.originalText, + changes, + ); + nextSource = createIdentitySqlSource( + applyChanges(nextSource.originalText, normalizedChanges), + ); } else { throw new SqlSessionError( "invalid-update", @@ -677,7 +679,7 @@ export class DefaultSqlDocumentSession documentSequence: nextDocumentSequence, revision, sequence, - text: nextText, + source: nextSource, }); return revision; } @@ -790,6 +792,7 @@ export class DefaultSqlLanguageService ); } validateDocumentLength(text); + const source = createIdentitySqlSource(text); const candidateContext = readRequiredDataProperty( input, "context", @@ -807,7 +810,7 @@ export class DefaultSqlLanguageService let session: DefaultSqlDocumentSession; session = new DefaultSqlDocumentSession( - text, + source, context, this.#dialects, () => { diff --git a/src/vnext/source.ts b/src/vnext/source.ts new file mode 100644 index 0000000..c6f5bbb --- /dev/null +++ b/src/vnext/source.ts @@ -0,0 +1,362 @@ +import type { SqlTextRange } from "./types.js"; + +export const MAX_SQL_SOURCE_LENGTH = 16 * 1024 * 1024; +export const MAX_SQL_EMBEDDED_REGIONS = 10_000; + +const MAX_EMBEDDED_LANGUAGE_LENGTH = 256; +const MASK_CHUNK_LENGTH = 64 * 1024; +const EMPTY_EMBEDDED_REGIONS: readonly SqlEmbeddedRegion[] = Object.freeze([]); +const sourceErrors = new WeakSet(); + +export type SqlSourceErrorCode = + | "invalid-source" + | "invalid-range" + | "invalid-region"; + +export class SqlSourceError extends Error { + readonly code: SqlSourceErrorCode; + + constructor(code: SqlSourceErrorCode, message: string) { + super(message); + this.name = "SqlSourceError"; + this.code = code; + sourceErrors.add(this); + } +} + +export function isSqlSourceError(error: unknown): error is SqlSourceError { + return ( + error !== null && + typeof error === "object" && + sourceErrors.has(error) + ); +} + +export interface SqlEmbeddedRegion extends SqlTextRange { + readonly language: string; +} + +export interface SqlSourceSnapshot { + readonly analysisText: string; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + readonly originalText: string; +} + +interface MissingDataProperty { + readonly found: false; +} + +interface FoundDataProperty { + readonly found: true; + readonly value: unknown; +} + +type DataProperty = MissingDataProperty | FoundDataProperty; + +function readOwnDataProperty( + value: object, + key: PropertyKey, + subject: string, +): DataProperty { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) { + return { found: false }; + } + if (!("value" in descriptor)) { + throw new SqlSourceError( + "invalid-source", + `${subject} property ${String(key)} cannot be an accessor`, + ); + } + return { found: true, value: descriptor.value }; +} + +function readRequiredDataProperty( + value: object, + key: PropertyKey, + subject: string, + missingCode: SqlSourceErrorCode = "invalid-source", +): unknown { + const property = readOwnDataProperty(value, key, subject); + if (!property.found) { + throw new SqlSourceError( + missingCode, + `${subject} requires a data property named ${String(key)}`, + ); + } + return property.value; +} + +function normalizeSourceError(error: unknown, message: string): never { + if (isSqlSourceError(error)) { + throw error; + } + throw new SqlSourceError("invalid-source", message); +} + +function normalizeSourceText(text: unknown): string { + if (typeof text !== "string") { + throw new SqlSourceError("invalid-source", "SQL source text must be a string"); + } + if (text.length > MAX_SQL_SOURCE_LENGTH) { + throw new SqlSourceError( + "invalid-source", + `SQL source cannot exceed ${MAX_SQL_SOURCE_LENGTH} UTF-16 code units`, + ); + } + return text; +} + +export function normalizeSqlTextRange( + range: unknown, + sourceLength: number, + subject = "SQL text range", +): SqlTextRange { + try { + if ( + !Number.isSafeInteger(sourceLength) || + sourceLength < 0 || + sourceLength > MAX_SQL_SOURCE_LENGTH + ) { + throw new SqlSourceError( + "invalid-range", + "SQL range source length is invalid", + ); + } + if (range === null || typeof range !== "object") { + throw new SqlSourceError( + "invalid-range", + `${subject} must be an object`, + ); + } + const from = readRequiredDataProperty( + range, + "from", + subject, + "invalid-range", + ); + const to = readRequiredDataProperty(range, "to", subject, "invalid-range"); + if ( + typeof from !== "number" || + typeof to !== "number" || + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + from < 0 || + from > to || + to > sourceLength + ) { + throw new SqlSourceError( + "invalid-range", + `${subject} must be an in-bounds half-open UTF-16 range`, + ); + } + return Object.freeze({ from, to }); + } catch (error) { + return normalizeSourceError(error, `${subject} could not be inspected safely`); + } +} + +function readArrayLength(value: readonly unknown[]): number { + const length = readRequiredDataProperty( + value, + "length", + "SQL embedded regions", + ); + if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0) { + throw new SqlSourceError( + "invalid-region", + "SQL embedded regions have an invalid length", + ); + } + return length; +} + +function validateRegionArrayKeys( + regions: readonly unknown[], + length: number, +): void { + for (const key of Reflect.ownKeys(regions)) { + if (key === "length") { + continue; + } + if ( + typeof key !== "string" || + !/^(0|[1-9]\d*)$/.test(key) || + Number(key) >= length + ) { + throw new SqlSourceError( + "invalid-region", + "SQL embedded regions cannot contain custom properties", + ); + } + } +} + +function normalizeEmbeddedRegions( + regions: unknown, + sourceLength: number, +): readonly SqlEmbeddedRegion[] { + try { + if (!Array.isArray(regions)) { + throw new SqlSourceError( + "invalid-region", + "SQL embedded regions must be an array", + ); + } + const length = readArrayLength(regions); + if (length > MAX_SQL_EMBEDDED_REGIONS) { + throw new SqlSourceError( + "invalid-region", + `SQL source cannot contain more than ${MAX_SQL_EMBEDDED_REGIONS} embedded regions`, + ); + } + validateRegionArrayKeys(regions, length); + + const normalized: SqlEmbeddedRegion[] = []; + let previousEnd = 0; + for (let index = 0; index < length; index += 1) { + const candidate = readRequiredDataProperty( + regions, + index, + `SQL embedded region ${index}`, + ); + if (candidate === null || typeof candidate !== "object") { + throw new SqlSourceError( + "invalid-region", + `SQL embedded region ${index} must be an object`, + ); + } + let range: SqlTextRange; + try { + range = normalizeSqlTextRange( + candidate, + sourceLength, + `SQL embedded region ${index}`, + ); + } catch (error) { + if ( + !isSqlSourceError(error) || + error.code !== "invalid-range" + ) { + throw error; + } + throw new SqlSourceError( + "invalid-region", + `SQL embedded region ${index} has an invalid range`, + ); + } + if (range.from === range.to) { + throw new SqlSourceError( + "invalid-region", + `SQL embedded region ${index} cannot be empty`, + ); + } + if (range.from < previousEnd) { + throw new SqlSourceError( + "invalid-region", + "SQL embedded regions must be ordered and non-overlapping", + ); + } + const language = readRequiredDataProperty( + candidate, + "language", + `SQL embedded region ${index}`, + ); + if ( + typeof language !== "string" || + language.length === 0 || + language.length > MAX_EMBEDDED_LANGUAGE_LENGTH + ) { + throw new SqlSourceError( + "invalid-region", + `SQL embedded region ${index} has an invalid language`, + ); + } + normalized.push( + Object.freeze({ + from: range.from, + language, + to: range.to, + }), + ); + previousEnd = range.to; + } + return Object.freeze(normalized); + } catch (error) { + return normalizeSourceError( + error, + "SQL embedded regions could not be inspected safely", + ); + } +} + +function maskRegion(text: string, from: number, to: number): string { + let output = ""; + for (let cursor = from; cursor < to; cursor += MASK_CHUNK_LENGTH) { + const chunk = text.slice(cursor, Math.min(to, cursor + MASK_CHUNK_LENGTH)); + output += chunk.replace(/[^\r\n]+/g, (value) => " ".repeat(value.length)); + } + return output; +} + +export function createIdentitySqlSource(text: unknown): SqlSourceSnapshot { + const originalText = normalizeSourceText(text); + return Object.freeze({ + analysisText: originalText, + embeddedRegions: EMPTY_EMBEDDED_REGIONS, + originalText, + }); +} + +export function createMaskedSqlSource( + text: unknown, + regions: unknown, +): SqlSourceSnapshot { + const originalText = normalizeSourceText(text); + const embeddedRegions = normalizeEmbeddedRegions( + regions, + originalText.length, + ); + if (embeddedRegions.length === 0) { + return createIdentitySqlSource(originalText); + } + + const output: string[] = []; + let cursor = 0; + for (const region of embeddedRegions) { + output.push( + originalText.slice(cursor, region.from), + maskRegion(originalText, region.from, region.to), + ); + cursor = region.to; + } + output.push(originalText.slice(cursor)); + const analysisText = output.join(""); + return Object.freeze({ + analysisText, + embeddedRegions, + originalText, + }); +} + +export function mapAnalysisRangeToOriginal( + source: SqlSourceSnapshot, + range: unknown, +): SqlTextRange { + return normalizeSqlTextRange( + range, + source.analysisText.length, + "SQL analysis range", + ); +} + +export function mapOriginalRangeToAnalysis( + source: SqlSourceSnapshot, + range: unknown, +): SqlTextRange { + return normalizeSqlTextRange( + range, + source.originalText.length, + "SQL original range", + ); +} diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 6f9ed4c..08ca522 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -47,10 +47,14 @@ export interface SqlDialectDefinition { readonly displayName: string; } -/** One half-open UTF-16 edit in pre-update document coordinates. */ -export interface SqlTextChange { +/** One half-open UTF-16 range in document coordinates. */ +export interface SqlTextRange { readonly from: number; readonly to: number; +} + +/** One half-open UTF-16 edit in pre-update document coordinates. */ +export interface SqlTextChange extends SqlTextRange { readonly insert: string; } diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 74a71ee..5d3e9f7 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -6,6 +6,7 @@ import { type SqlLanguageService, type SqlRevision, type SqlTextChange, + type SqlTextRange, } from "../../src/vnext/index.js"; interface HostContext extends SqlDocumentContext { @@ -54,6 +55,7 @@ session.update({ }); const change: SqlTextChange = { from: 0, insert: "", to: 0 }; +const range: SqlTextRange = change; // @ts-expect-error revisions are service-issued opaque values const objectRevision: SqlRevision = {}; @@ -73,10 +75,13 @@ dateService.openDocument({ }); // @ts-expect-error changes are readonly change.from = 1; +// @ts-expect-error ranges are readonly +range.to = 1; // @ts-expect-error session revision is readonly session.revision = revision; void objectRevision; void numberRevision; +void range; void widenedService; void widenedSession; From aa846cf5959a6454e345a2a5c7244e5951d71d30 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 22:57:06 +0800 Subject: [PATCH 05/42] feat(vnext): add dialect-aware statement index (#174) ## Summary - add an internal synchronous parser-free statement partition and binary-search cursor lookup - preserve explicit half-open ownership for trivia, terminators, empty slots, EOF, and left/right affinity - add bounded PostgreSQL, DuckDB, BigQuery, and Dremio lexical profiles with opaque fail-closed suffixes - keep incremental reuse, session attachment, parser semantics, and public APIs out of this slice ## Safety and performance - cap materialized slots at 10,000 and collapse the remainder into one opaque slot - bound dollar delimiters and retain no statement substrings - conservatively handle Unicode dollar tags and prefix boundaries - fail closed for BigQuery procedural bodies, custom delimiters, and PostgreSQL BEGIN ATOMIC bodies - 16 MiB scans under a 128 MiB heap: about 44 ms ASCII, 61 ms Latin, 47 ms astral ## Evidence - exact head: 56b57ea0928e68062864d029892c427ba8bbb78f - focused statement-index tests: 82 passed - full suite: 795 passed, 1 governed expected failure - changed coverage: 98.76% statements, 96.97% branches, 100% functions, 98.75% lines - strict typecheck, non-mutating lint, test integrity, demo build, packed-package smoke, and Chromium passed - lexical/correctness reviewer: APPROVE exact head, no blocker/high/medium findings - performance/API reviewer: APPROVE exact head, no blocker/high/medium findings ## Public API No vNext public exports or generated root declarations are added. The full scanner remains the correctness oracle for a later independently reviewed incremental slice. --- ## Summary by cubic Adds an internal, dialect-aware SQL statement index for vNext. It partitions `analysisText` into exact half-open slots with binary-search cursor lookup and bounded resources, with no public API changes. - **New Features** - Exact slot partitioning and ownership: terminators associate left; trailing trivia moves to the next slot; explicit empty slots are preserved. - Dialect profiles for PostgreSQL, DuckDB, BigQuery, and Dremio; protects strings/comments (e.g., dollar-quoting, E-strings, triple/raw/backtick, nested comments) and fails closed for custom delimiters, BigQuery procedural blocks, and PostgreSQL BEGIN ATOMIC. - Left/right-affinity lookup via binary search with explicit EOF behavior for terminated vs open statements. - Bounded resources: up to 10,000 materialized slots; bounded dollar-quote tag length; no statement substring copies; frozen partition data. - Reports unterminated strings/comments without exposing internal semicolons; operates on `analysisText` so UTF-16 offsets match the original text under the current masking transform; dialect behavior is keyed by internal profile identity, not a caller-controlled ID. Written for commit 2ca3c25a95e65245937d6d4c8a934bbcdd6eabef. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 14 + docs/vnext/source-coordinates.md | 4 + docs/vnext/statement-index.md | 79 ++ src/vnext/__tests__/statement-index.test.ts | 745 ++++++++++++++ src/vnext/statement-index.ts | 925 ++++++++++++++++++ 5 files changed, 1767 insertions(+) create mode 100644 docs/vnext/statement-index.md create mode 100644 src/vnext/__tests__/statement-index.test.ts create mode 100644 src/vnext/statement-index.ts diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index fbc3d82..0920e47 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -202,6 +202,20 @@ document offsets and statement-relative offsets use distinct branded types. Statement lookup uses explicit cursor affinity at boundaries and EOF. It must not reintroduce the v0.x inclusive-end behavior. +The first statement-index implementation is an internal synchronous full-scan +oracle. Exact slot extents partition the analysis text, terminators associate +left, and trivia after a terminator associates with the following slot. Empty +documents and terminal delimiters retain explicit empty slots. Lookup requires +left or right affinity and uses binary search. + +The index does not contain statement text, line numbers, inferred kinds, parser +validity, or AST data. It materializes at most 10,000 slots and collapses an +unscanned remainder into an opaque slot on resource limits or unsupported +delimiter/procedural constructs. Opaque slots cannot be passed to later parsing +as exact source. Dialect lexical behavior uses internal configuration identity, +never a caller-controlled dialect ID. Incremental reuse and session attachment +remain a separate change tested against the full-scan oracle. + ## Request outcomes Every session feature request settles with the same top-level union: diff --git a/docs/vnext/source-coordinates.md b/docs/vnext/source-coordinates.md index 6db46d9..9a4932e 100644 --- a/docs/vnext/source-coordinates.md +++ b/docs/vnext/source-coordinates.md @@ -44,3 +44,7 @@ functions remain internal. This slice intentionally does not publish a source transformer or source-map SPI. Generated or reordered source requires a versioned segment-map design and evidence from real consumers before becoming public. + +The internal [statement index](./statement-index.md) scans `analysisText` and +uses its length-preserving offsets without publishing analysis-coordinate +ranges. diff --git a/docs/vnext/statement-index.md b/docs/vnext/statement-index.md new file mode 100644 index 0000000..92e4db8 --- /dev/null +++ b/docs/vnext/statement-index.md @@ -0,0 +1,79 @@ +# vNext Statement Index + +Status: internal full-scan correctness oracle + +The statement index is a synchronous, parser-free partition of +`analysisText`. It does not classify, parse, validate, or copy statements, and +it has no public `/vnext` export yet. + +Each exact slot contains: + +- An `extent` that participates in a contiguous partition of the complete text. +- A `source` range that includes leading and trailing trivia but excludes the + terminator. +- An optional one-code-unit `terminator`, owned by the slot on its left. +- `hasCode`, which distinguishes code from whitespace/comment-only slots. +- A lexical end state for incomplete quoted strings or block comments. + +An empty document has one empty slot. A document ending exactly in a semicolon +has an explicit trailing empty slot; trailing trivia instead forms the final +trivia-only slot. Consecutive semicolons retain their empty slots. + +The scanner returns an opaque suffix instead of guessed boundaries when it +encounters an unsupported custom delimiter, an unsupported BigQuery procedural +body, or a resource limit. Opaque slots omit `source`, `terminator`, and +`hasCode`, so later layers cannot accidentally parse them as exact statements. +At most 10,000 slots are materialized; a semicolon-dense remainder collapses +into one opaque slot. + +## Cursor affinity + +Point lookup always requires `left` or `right` affinity. At a shared extent +boundary, left selects the preceding slot and right selects the following slot. +At position zero both select the first slot. At EOF after a terminator, left +selects the terminated slot and right selects the explicit trailing empty slot. +At EOF in an unterminated final statement, both select that statement. + +The lookup uses binary search. It deliberately does not implement a hidden +"nearest code statement" fallback; completion, hover, gutter, and future +run-current-statement commands need different policies. + +## Dialect profiles + +Lexical behavior is carried by immutable internal profile identity. It is never +inferred from a caller-controlled dialect ID. This first oracle owns profiles +for: + +- PostgreSQL: doubled quotes, `E'...'`, dollar-quoted strings, and nested block + comments, following the + [PostgreSQL lexical contract](https://www.postgresql.org/docs/current/sql-syntax-lexical.html). + Dollar-tag and literal-prefix boundaries conservatively treat every + non-ASCII code point as identifier-like, covering the engines' permissive + Unicode behavior without exposing internal semicolons. SQL routine bodies + introduced by `BEGIN ATOMIC` are opaque in this slice. +- DuckDB: doubled quotes, escape strings, tagged dollar-quoted strings, and + nested comments. Dollar quoting follows + [DuckDB literal types](https://duckdb.org/docs/current/sql/data_types/literal_types). +- BigQuery: single, double, triple, raw, bytes, and raw-bytes strings; backtick + identifiers; `#` comments; and non-nesting block comments, following the + [GoogleSQL lexical contract](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/lexical). + Procedural bodies, including labeled loops, are opaque in this slice. +- Dremio: a compatibility profile limited to verified single-quoted strings, + double-quoted identifiers, and standard comments. It does not silently + inherit PostgreSQL extensions. + +Unterminated lexical constructs consume the remainder and report their opening +offset. Regular BigQuery strings also fail closed at a line break, because only +triple-quoted strings may span lines. + +## Complexity and sequencing + +A full build is linear in UTF-16 code units, retains only slot records and a +bounded dollar delimiter, and creates no statement substrings. Point lookup is +logarithmic in slot count. The scanner operates on `analysisText`; the current +length-preserving source transform makes its analysis ranges valid at the same +offsets in `originalText`. + +This implementation remains the correctness oracle. Incremental rescanning, +change mapping, cache reuse, and session attachment belong to a later slice and +must be tested against a fresh full build after arbitrary edits. diff --git a/src/vnext/__tests__/statement-index.test.ts b/src/vnext/__tests__/statement-index.test.ts new file mode 100644 index 0000000..0a17bd1 --- /dev/null +++ b/src/vnext/__tests__/statement-index.test.ts @@ -0,0 +1,745 @@ +import { describe, expect, it } from "vitest"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + findSqlStatementSlot, + MAX_SQL_STATEMENT_SLOTS, + POSTGRESQL_SQL_LEXICAL_PROFILE, + type SqlLexicalProfile, + type SqlStatementIndex, + type SqlStatementSlot, +} from "../statement-index.js"; +import { createMaskedSqlSource } from "../source.js"; + +function ranges(index: SqlStatementIndex) { + return index.slots.map((slot) => { + if (slot.boundaryQuality === "opaque") { + return { + extent: [slot.extent.from, slot.extent.to], + quality: slot.boundaryQuality, + reason: slot.endState.reason, + }; + } + return { + extent: [slot.extent.from, slot.extent.to], + hasCode: slot.hasCode, + quality: slot.boundaryQuality, + source: [slot.source.from, slot.source.to], + terminator: slot.terminator + ? [slot.terminator.from, slot.terminator.to] + : null, + }; + }); +} + +function exactSlot(slot: SqlStatementSlot) { + expect(slot.boundaryQuality).toBe("exact"); + if (slot.boundaryQuality === "opaque") { + throw new Error("Expected an exact SQL statement slot"); + } + return slot; +} + +function itemAt(items: readonly Value[], index: number): Value { + const item = items[index]; + if (item === undefined) { + throw new Error(`Expected test item at index ${index}`); + } + return item; +} + +function expectPartition(text: string, index: SqlStatementIndex): void { + expect(index.slots.length).toBeGreaterThan(0); + let cursor = 0; + for (const slot of index.slots) { + expect(slot.extent.from).toBe(cursor); + expect(slot.extent.to).toBeGreaterThanOrEqual(slot.extent.from); + expect(slot.extent.to).toBeLessThanOrEqual(text.length); + expect(Object.isFrozen(slot)).toBe(true); + expect(Object.isFrozen(slot.extent)).toBe(true); + if (slot.boundaryQuality === "exact") { + expect(slot.source.from).toBe(slot.extent.from); + expect(slot.source.to).toBeLessThanOrEqual(slot.extent.to); + if (slot.terminator) { + expect(slot.terminator.from).toBe(slot.source.to); + expect(slot.terminator.to).toBe(slot.extent.to); + expect(text.slice(slot.terminator.from, slot.terminator.to)).toBe(";"); + } else { + expect(slot.source.to).toBe(slot.extent.to); + } + } + cursor = slot.extent.to; + } + expect(cursor).toBe(text.length); + expect(Object.isFrozen(index)).toBe(true); + expect(Object.isFrozen(index.slots)).toBe(true); +} + +describe("statement partition", () => { + it.each([ + [ + "", + [ + { + extent: [0, 0], + hasCode: false, + quality: "exact", + source: [0, 0], + terminator: null, + }, + ], + ], + [ + "SELECT 1", + [ + { + extent: [0, 8], + hasCode: true, + quality: "exact", + source: [0, 8], + terminator: null, + }, + ], + ], + [ + "SELECT 1;", + [ + { + extent: [0, 9], + hasCode: true, + quality: "exact", + source: [0, 8], + terminator: [8, 9], + }, + { + extent: [9, 9], + hasCode: false, + quality: "exact", + source: [9, 9], + terminator: null, + }, + ], + ], + [ + "SELECT 1; \r\n -- next\n SELECT 2", + [ + { + extent: [0, 9], + hasCode: true, + quality: "exact", + source: [0, 8], + terminator: [8, 9], + }, + { + extent: [9, 30], + hasCode: true, + quality: "exact", + source: [9, 30], + terminator: null, + }, + ], + ], + [ + ";;", + [ + { + extent: [0, 1], + hasCode: false, + quality: "exact", + source: [0, 0], + terminator: [0, 1], + }, + { + extent: [1, 2], + hasCode: false, + quality: "exact", + source: [1, 1], + terminator: [1, 2], + }, + { + extent: [2, 2], + hasCode: false, + quality: "exact", + source: [2, 2], + terminator: null, + }, + ], + ], + ])("partitions %j without trimming or copying semantics", (text, expected) => { + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(ranges(index)).toEqual(expected); + expectPartition(text, index); + }); + + it("keeps trailing trivia in a following empty-code slot", () => { + const text = "SELECT 1; \n/* after */"; + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(ranges(index)).toEqual([ + { + extent: [0, 9], + hasCode: true, + quality: "exact", + source: [0, 8], + terminator: [8, 9], + }, + { + extent: [9, text.length], + hasCode: false, + quality: "exact", + source: [9, text.length], + terminator: null, + }, + ]); + }); + + it("preserves UTF-16 coordinates around astral and lone-surrogate text", () => { + const text = "SELECT '🦆;\ud800';\r\nSELECT 2"; + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(2); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe(13); + expect(exactSlot(itemAt(index.slots, 1)).source.from).toBe(14); + expectPartition(text, index); + }); +}); + +describe("cursor affinity", () => { + it("selects the requested side of a shared boundary", () => { + const text = "SELECT 1;SELECT 2"; + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + + expect(findSqlStatementSlot(index, 0, "left")).toBe(index.slots[0]); + expect(findSqlStatementSlot(index, 0, "right")).toBe(index.slots[0]); + expect(findSqlStatementSlot(index, 8, "left")).toBe(index.slots[0]); + expect(findSqlStatementSlot(index, 9, "left")).toBe(index.slots[0]); + expect(findSqlStatementSlot(index, 9, "right")).toBe(index.slots[1]); + expect(findSqlStatementSlot(index, text.length, "left")).toBe(index.slots[1]); + expect(findSqlStatementSlot(index, text.length, "right")).toBe(index.slots[1]); + }); + + it("distinguishes EOF after a terminator from EOF in an open statement", () => { + const terminated = buildSqlStatementIndex( + "SELECT 1;", + DUCKDB_SQL_LEXICAL_PROFILE, + ); + expect(findSqlStatementSlot(terminated, 9, "left")).toBe(terminated.slots[0]); + expect(findSqlStatementSlot(terminated, 9, "right")).toBe(terminated.slots[1]); + + const open = buildSqlStatementIndex("SELECT 1", DUCKDB_SQL_LEXICAL_PROFILE); + expect(findSqlStatementSlot(open, 8, "left")).toBe(open.slots[0]); + expect(findSqlStatementSlot(open, 8, "right")).toBe(open.slots[0]); + }); + + it("validates internal positions", () => { + const index = buildSqlStatementIndex("", DUCKDB_SQL_LEXICAL_PROFILE); + expect(() => findSqlStatementSlot(index, -1, "left")).toThrow(RangeError); + expect(() => findSqlStatementSlot(index, 1, "right")).toThrow(RangeError); + expect(() => findSqlStatementSlot(index, Number.NaN, "right")).toThrow( + RangeError, + ); + expect(() => + Reflect.apply(findSqlStatementSlot, undefined, [index, 0, "center"]), + ).toThrow(TypeError); + }); +}); + +describe("comments", () => { + it.each([ + "SELECT 1 -- ; hidden\r\n;SELECT 2", + "SELECT 1 /* ; hidden */;SELECT 2", + "/* comment only ; */;", + ])("ignores protected semicolons in %j", (text) => { + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(2); + expectPartition(text, index); + }); + + it("supports nested PostgreSQL and DuckDB block comments", () => { + const text = "SELECT 1 /* outer /* ; inner */ ; outer */; SELECT 2"; + for (const profile of [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + ]) { + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(2); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe(42); + } + }); + + it("supports BigQuery hash comments and its non-nesting block comments", () => { + const hash = buildSqlStatementIndex( + "SELECT 1 # ; hidden\n;SELECT 2", + BIGQUERY_SQL_LEXICAL_PROFILE, + ); + expect(hash.slots).toHaveLength(2); + + const nonNested = buildSqlStatementIndex( + "/* outer /* inner */;SELECT 2", + BIGQUERY_SQL_LEXICAL_PROFILE, + ); + expect(nonNested.slots).toHaveLength(2); + }); + + it("does not borrow BigQuery hash comments in other profiles", () => { + const index = buildSqlStatementIndex( + "SELECT 1 # ; SELECT 2", + DUCKDB_SQL_LEXICAL_PROFILE, + ); + expect(index.slots).toHaveLength(2); + }); +}); + +describe("PostgreSQL and DuckDB literals", () => { + it.each([ + "SELECT 'a;''b';SELECT 2", + 'SELECT "a;""b";SELECT 2', + "SELECT E'a\\';b';SELECT 2", + "SELECT $$a;b$$;SELECT 2", + "SELECT $tag$a;$$;b$tag$;SELECT 2", + ])("protects semicolons in %j", (text) => { + for (const profile of [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + ]) { + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(2); + expect(index.quality).toBe("exact"); + expectPartition(text, index); + } + }); + + it("requires a legal token boundary and case-matched dollar tag", () => { + const attached = buildSqlStatementIndex( + "SELECT value$tag$a;b$tag$;SELECT 2", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(attached.slots.length).toBeGreaterThan(2); + + const mismatched = buildSqlStatementIndex( + "SELECT $tag$a;b$TAG$;SELECT 2", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(mismatched.slots).toHaveLength(1); + expect(mismatched.endState).toMatchObject({ + construct: "dollar-quoted-string", + kind: "unterminated", + }); + }); + + it("uses conservative Unicode rules for dollar tags and boundaries", () => { + for (const text of [ + "SELECT $é$a;b$é$;SELECT 2", + "SELECT $e\u0301$a;b$e\u0301$;SELECT 2", + "SELECT $𐐀$a;b$𐐀$;SELECT 2", + "SELECT $🦆$a;b$🦆$;SELECT 2", + "SELECT $—$a;b$—$;SELECT 2", + "SELECT $\u0301$a;b$\u0301$;SELECT 2", + ]) { + for (const profile of [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + ]) { + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(2); + expect(index.quality).toBe("exact"); + } + } + + for (const text of [ + "SELECT é$tag$a;b$tag$;SELECT 2", + "SELECT e\u0301$tag$a;b$tag$;SELECT 2", + "SELECT 𐐀$tag$a;b$tag$;SELECT 2", + "SELECT 🦆$tag$a;b$tag$;SELECT 2", + ]) { + for (const profile of [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + ]) { + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(3); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe( + text.indexOf(";"), + ); + } + } + }); + + it("does not apply E-string backslash escaping to an attached identifier", () => { + for (const text of [ + "SELECT nameE'a\\';SELECT 2", + "SELECT éE'a\\';SELECT 2", + "SELECT e\u0301E'a\\';SELECT 2", + "SELECT 𐐀E'a\\';SELECT 2", + "SELECT 🦆E'a\\';SELECT 2", + ]) { + for (const profile of [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + ]) { + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(2); + } + } + + const reviewedReproduction = "SELECT éE'a\\';b';SELECT 2"; + const index = buildSqlStatementIndex( + reviewedReproduction, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe( + reviewedReproduction.indexOf(";"), + ); + }); +}); + +describe("BigQuery literals", () => { + it.each([ + "SELECT 'a\\';b';SELECT 2", + 'SELECT "a\\";b";SELECT 2', + "SELECT '''a;\n'b''';SELECT 2", + 'SELECT """a;\n"b""";SELECT 2', + "SELECT r'a\\';SELECT 2", + "SELECT br'''a\\;b''';SELECT 2", + "SELECT rb\"a\\;b\";SELECT 2", + "SELECT `project;a`;SELECT 2", + "SELECT `project\\`;a`;SELECT 2", + ])("protects semicolons in %j", (text) => { + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(2); + expect(index.quality).toBe("exact"); + expectPartition(text, index); + }); + + it("does not borrow BigQuery literal forms in Dremio", () => { + const index = buildSqlStatementIndex( + "SELECT `a;b`;SELECT 2", + DREMIO_SQL_LEXICAL_PROFILE, + ); + expect(index.slots).toHaveLength(3); + }); +}); + +describe("Dremio compatibility profile", () => { + it.each([ + "SELECT 'a;''b';SELECT 2", + 'SELECT "a;""b";SELECT 2', + "SELECT 1 -- ; hidden\n;SELECT 2", + "SELECT 1 /* ; hidden */;SELECT 2", + ])("protects verified lexical form %j", (text) => { + const index = buildSqlStatementIndex(text, DREMIO_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(2); + expect(index.quality).toBe("exact"); + }); + + it.each([ + ["SELECT 'a;b", "single-quoted-string"], + ['SELECT "a;b', "double-quoted-identifier"], + ["SELECT 1 /* a;b", "block-comment"], + ])("reports incomplete form %j", (text, construct) => { + const index = buildSqlStatementIndex(text, DREMIO_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(1); + expect(index.endState).toMatchObject({ construct, kind: "unterminated" }); + }); +}); + +describe("opaque boundaries", () => { + it.each([ + ["DELIMITER $$\nSELECT 1$$", "custom-delimiter"], + ["IF condition THEN\n SELECT 1;\nEND IF;", "procedural-block"], + ["LOOP\n SELECT 1;\nEND LOOP;", "procedural-block"], + ["WHILE condition DO\n SELECT 1;\nEND WHILE;", "procedural-block"], + ["REPEAT\n SELECT 1;\nUNTIL done\nEND REPEAT;", "procedural-block"], + ["FOR item IN (SELECT 1) DO\n SELECT item;\nEND FOR;", "procedural-block"], + ["BEGIN\n SELECT 1;\nEND;", "procedural-block"], + ["label: BEGIN\n SELECT 1;\nEND;", "procedural-block"], + ["label: FOR x IN (SELECT 1) DO\n SELECT x;\nEND FOR;", "procedural-block"], + ["é: FOR x IN (SELECT 1) DO\n SELECT x;\nEND FOR;", "procedural-block"], + ["𐐀: LOOP\n SELECT 1;\nEND LOOP;", "procedural-block"], + ["`label`: LOOP\n SELECT 1;\nEND LOOP;", "procedural-block"], + ["`label`: FOR x IN (SELECT 1) DO\n SELECT x;\nEND FOR;", "procedural-block"], + ["CREATE PROCEDURE p() BEGIN SELECT 1; END", "procedural-block"], + ["CREATE TEMP PROCEDURE p() BEGIN SELECT 1; END", "procedural-block"], + ["CREATE OR REPLACE TEMP PROCEDURE p() BEGIN SELECT 1; END", "procedural-block"], + ["BEGIN WORK;", "procedural-block"], + ])("fails closed for %j", (text, reason) => { + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.quality).toBe("opaque"); + expect(index.slots).toHaveLength(1); + expect(ranges(index)[0]).toMatchObject({ + extent: [0, text.length], + quality: "opaque", + reason, + }); + expectPartition(text, index); + }); + + it.each(["BEGIN;", "BEGIN TRANSACTION;"])( + "keeps documented transaction form %j exact", + (text) => { + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.quality).toBe("exact"); + expect(index.slots).toHaveLength(2); + }, + ); + + it("preserves exact preceding slots before an opaque suffix", () => { + const text = "SELECT 1; IF condition THEN SELECT 2; END IF;"; + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(2); + expect(itemAt(index.slots, 0).boundaryQuality).toBe("exact"); + expect(itemAt(index.slots, 1).boundaryQuality).toBe("opaque"); + expectPartition(text, index); + }); + + it.each([ + `CREATE FUNCTION f() RETURNS TABLE(x int) +LANGUAGE SQL +BEGIN /* body */ ATOMIC + SELECT 1; + SELECT 2; +END; +SELECT 3`, + `CREATE OR REPLACE PROCEDURE p() +LANGUAGE SQL +BEGIN ATOMIC + INSERT INTO t VALUES (1); +END; +SELECT 2`, + ])("fails closed for PostgreSQL BEGIN ATOMIC routine bodies", (text) => { + const index = buildSqlStatementIndex( + text, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(index.quality).toBe("opaque"); + expect(index.slots).toHaveLength(1); + expect(index.endState).toMatchObject({ + kind: "opaque", + reason: "procedural-block", + }); + }); + + it.each([ + `CREATE FUNCTION begin.atomic() +RETURNS int +LANGUAGE SQL +RETURN 1; +SELECT 2`, + `CREATE FUNCTION f(begin atomic) +RETURNS int +LANGUAGE SQL +RETURN 1; +SELECT 2`, + ])("does not confuse routine header identifiers with BEGIN ATOMIC", (text) => { + const index = buildSqlStatementIndex( + text, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(index.quality).toBe("exact"); + expect(index.slots).toHaveLength(2); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe( + text.indexOf(";"), + ); + }); + + it("caps materialized slots under a semicolon storm", () => { + const text = ";".repeat(MAX_SQL_STATEMENT_SLOTS + 100); + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(MAX_SQL_STATEMENT_SLOTS); + expect(index.quality).toBe("opaque"); + expect(index.slots.at(-1)).toMatchObject({ + boundaryQuality: "opaque", + endState: { kind: "opaque", reason: "resource-limit" }, + }); + expectPartition(text, index); + }); + + it("bounds dollar-quote delimiter retention", () => { + const text = `$${"a".repeat(300)}$payload`; + const index = buildSqlStatementIndex(text, POSTGRESQL_SQL_LEXICAL_PROFILE); + expect(index.quality).toBe("opaque"); + expect(index.endState).toMatchObject({ + kind: "opaque", + reason: "resource-limit", + }); + }); + + it("does not degrade a long dollar-prefixed identifier without a delimiter", () => { + const text = `$${"a".repeat(300)};SELECT 2`; + const index = buildSqlStatementIndex(text, POSTGRESQL_SQL_LEXICAL_PROFILE); + expect(index.quality).toBe("exact"); + expect(index.slots).toHaveLength(2); + }); +}); + +describe("incomplete input", () => { + it.each([ + ["SELECT 'a;b", "single-quoted-string"], + ['SELECT "a;b', "double-quoted-identifier"], + ["SELECT $$a;b", "dollar-quoted-string"], + ["SELECT 1 /* a;b", "block-comment"], + ])("reports %s without exposing internal semicolons", (text, construct) => { + const index = buildSqlStatementIndex(text, POSTGRESQL_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(1); + expect(index.quality).toBe("exact"); + expect(index.endState).toMatchObject({ + construct, + kind: "unterminated", + }); + expectPartition(text, index); + }); + + it.each([ + ["SELECT `a;b", "backtick-quoted-identifier"], + ["SELECT '''a;b", "triple-single-quoted-string"], + ['SELECT """a;b', "triple-double-quoted-string"], + ['SELECT "a;b', "double-quoted-string"], + ])("reports BigQuery %s", (text, construct) => { + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.endState).toMatchObject({ + construct, + kind: "unterminated", + }); + expect(index.slots).toHaveLength(1); + }); + + it("fails closed when a regular BigQuery string crosses a line", () => { + for (const text of [ + "SELECT 'a\n;b';SELECT 2", + "SELECT 'a\\\n;b';SELECT 2", + 'SELECT "a\\\r\n;b";SELECT 2', + ]) { + const index = buildSqlStatementIndex(text, BIGQUERY_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(1); + expect(index.endState).toMatchObject({ + kind: "unterminated", + }); + } + }); + + it("keeps a comment-only unterminated slot code-free", () => { + const index = buildSqlStatementIndex( + "/* comment ;", + DUCKDB_SQL_LEXICAL_PROFILE, + ); + expect(exactSlot(itemAt(index.slots, 0)).hasCode).toBe(false); + }); +}); + +describe("source masking", () => { + it("indexes analysis text while retaining original UTF-16 coordinates", () => { + const originalText = 'SELECT * FROM {fn("a;b")}; SELECT 2'; + const embeddedFrom = originalText.indexOf("{"); + const embeddedTo = originalText.indexOf("}") + 1; + const source = createMaskedSqlSource(originalText, [ + { from: embeddedFrom, language: "python", to: embeddedTo }, + ]); + const index = buildSqlStatementIndex( + source.analysisText, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + + expect(index.slots).toHaveLength(2); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe(25); + expect(source.originalText.slice(0, 26)).toBe( + 'SELECT * FROM {fn("a;b")};', + ); + expectPartition(originalText, index); + }); +}); + +describe("deterministic properties", () => { + it("never promotes protected generated semicolons to terminators", () => { + const cases: readonly [ + SqlLexicalProfile, + (payload: string) => string, + ][] = [ + [POSTGRESQL_SQL_LEXICAL_PROFILE, (payload) => `'${payload}'`], + [POSTGRESQL_SQL_LEXICAL_PROFILE, (payload) => `"${payload}"`], + [POSTGRESQL_SQL_LEXICAL_PROFILE, (payload) => `$tag$${payload}$tag$`], + [DUCKDB_SQL_LEXICAL_PROFILE, (payload) => `E'${payload}'`], + [DUCKDB_SQL_LEXICAL_PROFILE, (payload) => `$d$${payload}$d$`], + [BIGQUERY_SQL_LEXICAL_PROFILE, (payload) => `'${payload}'`], + [BIGQUERY_SQL_LEXICAL_PROFILE, (payload) => `"""${payload}"""`], + [BIGQUERY_SQL_LEXICAL_PROFILE, (payload) => `r'${payload}'`], + [BIGQUERY_SQL_LEXICAL_PROFILE, (payload) => `\`${payload}\``], + [DREMIO_SQL_LEXICAL_PROFILE, (payload) => `'${payload}'`], + [DREMIO_SQL_LEXICAL_PROFILE, (payload) => `"${payload}"`], + ]; + + for (let length = 0; length < 40; length += 1) { + const payload = `${"a".repeat(length)};${"b".repeat(39 - length)}`; + for (const [profile, protect] of cases) { + const text = `SELECT ${protect(payload)};SELECT 2`; + const index = buildSqlStatementIndex(text, profile); + expect(index.slots).toHaveLength(2); + expect(exactSlot(itemAt(index.slots, 0)).terminator?.from).toBe( + text.lastIndexOf(";"), + ); + } + } + }); + + it("always returns a total frozen partition and total cursor lookup", () => { + let seed = 0x12_34_56_78; + const next = (limit: number) => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed % limit; + }; + const alphabet = [ + 0, + 9, + 10, + 13, + 32, + 34, + 35, + 36, + 39, + 42, + 45, + 47, + 59, + 65, + 92, + 96, + 0xd800, + 0xdc00, + ]; + const profiles: readonly SqlLexicalProfile[] = [ + POSTGRESQL_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + BIGQUERY_SQL_LEXICAL_PROFILE, + DREMIO_SQL_LEXICAL_PROFILE, + ]; + + for (let iteration = 0; iteration < 250; iteration += 1) { + const codes = Array.from( + { length: next(80) }, + () => itemAt(alphabet, next(alphabet.length)), + ); + const text = String.fromCharCode(...codes); + const index = buildSqlStatementIndex( + text, + itemAt(profiles, next(profiles.length)), + ); + expectPartition(text, index); + for (let position = 0; position <= text.length; position += 1) { + expect(findSqlStatementSlot(index, position, "left")).toBeDefined(); + expect(findSqlStatementSlot(index, position, "right")).toBeDefined(); + } + expect( + ranges(buildSqlStatementIndex(text, itemAt(profiles, 0))), + ).toEqual( + ranges(buildSqlStatementIndex(text, itemAt(profiles, 0))), + ); + } + }); + + it("scans a full-size plain document without proportional temporary records", () => { + const text = "x".repeat(16 * 1024 * 1024); + const index = buildSqlStatementIndex(text, DUCKDB_SQL_LEXICAL_PROFILE); + expect(index.slots).toHaveLength(1); + expect(exactSlot(itemAt(index.slots, 0)).source.to).toBe(text.length); + }); +}); diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts new file mode 100644 index 0000000..e328024 --- /dev/null +++ b/src/vnext/statement-index.ts @@ -0,0 +1,925 @@ +const analysisRangeBrand: unique symbol = Symbol("SqlAnalysisRange"); + +export const MAX_SQL_STATEMENT_SLOTS = 10_000; +const MAX_DOLLAR_QUOTE_DELIMITER_LENGTH = 256; +const MAX_PREFIX_TOKENS = 6; + +export interface SqlAnalysisRange { + readonly [analysisRangeBrand]: "SqlAnalysisRange"; + readonly from: number; + readonly to: number; +} + +export type SqlCursorAffinity = "left" | "right"; + +export type SqlUnterminatedConstruct = + | "backtick-quoted-identifier" + | "block-comment" + | "dollar-quoted-string" + | "double-quoted-identifier" + | "double-quoted-string" + | "single-quoted-string" + | "triple-double-quoted-string" + | "triple-single-quoted-string"; + +export type SqlOpaqueBoundaryReason = + | "custom-delimiter" + | "procedural-block" + | "resource-limit"; + +export type SqlLexicalEndState = + | { + readonly kind: "normal"; + } + | { + readonly construct: SqlUnterminatedConstruct; + readonly from: number; + readonly kind: "unterminated"; + } + | { + readonly from: number; + readonly kind: "opaque"; + readonly reason: SqlOpaqueBoundaryReason; + }; + +export interface ExactSqlStatementSlot { + readonly boundaryQuality: "exact"; + readonly endState: Exclude; + readonly extent: SqlAnalysisRange; + readonly hasCode: boolean; + readonly source: SqlAnalysisRange; + readonly terminator: SqlAnalysisRange | null; +} + +export interface OpaqueSqlStatementSlot { + readonly boundaryQuality: "opaque"; + readonly endState: Extract; + readonly extent: SqlAnalysisRange; +} + +export type SqlStatementSlot = + | ExactSqlStatementSlot + | OpaqueSqlStatementSlot; + +export interface SqlStatementIndex { + readonly endState: SqlLexicalEndState; + readonly quality: "exact" | "opaque"; + readonly slots: readonly SqlStatementSlot[]; +} + +type SqlSingleQuoteBackslash = "always" | "e-prefix" | "never"; +type SqlProceduralGuards = "bigquery" | "none" | "postgresql"; + +export interface SqlLexicalProfile { + readonly backtickQuotedIdentifiers: boolean; + readonly bigQueryStrings: boolean; + readonly dollarQuotedStrings: boolean; + readonly hashLineComments: boolean; + readonly nestedBlockComments: boolean; + readonly proceduralGuards: SqlProceduralGuards; + readonly singleQuoteBackslash: SqlSingleQuoteBackslash; +} + +export const POSTGRESQL_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: true, + hashLineComments: false, + nestedBlockComments: true, + proceduralGuards: "postgresql", + singleQuoteBackslash: "e-prefix", +}); + +export const DUCKDB_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: true, + hashLineComments: false, + nestedBlockComments: true, + proceduralGuards: "none", + singleQuoteBackslash: "e-prefix", +}); + +export const BIGQUERY_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: true, + bigQueryStrings: true, + dollarQuotedStrings: false, + hashLineComments: true, + nestedBlockComments: false, + proceduralGuards: "bigquery", + singleQuoteBackslash: "always", +}); + +export const DREMIO_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: false, + hashLineComments: false, + nestedBlockComments: false, + proceduralGuards: "none", + singleQuoteBackslash: "never", +}); + +const NORMAL_END_STATE: Extract< + SqlLexicalEndState, + { readonly kind: "normal" } +> = Object.freeze({ kind: "normal" }); + +function createAnalysisRange(from: number, to: number): SqlAnalysisRange { + const range: SqlAnalysisRange = { + [analysisRangeBrand]: "SqlAnalysisRange", + from, + to, + }; + return Object.freeze(range); +} + +function createUnterminatedEndState( + construct: SqlUnterminatedConstruct, + from: number, +): Extract { + return Object.freeze({ construct, from, kind: "unterminated" }); +} + +function createOpaqueEndState( + reason: SqlOpaqueBoundaryReason, + from: number, +): Extract { + return Object.freeze({ from, kind: "opaque", reason }); +} + +function createExactSlot( + from: number, + sourceTo: number, + extentTo: number, + hasCode: boolean, + endState: ExactSqlStatementSlot["endState"], +): ExactSqlStatementSlot { + return Object.freeze({ + boundaryQuality: "exact", + endState, + extent: createAnalysisRange(from, extentTo), + hasCode, + source: createAnalysisRange(from, sourceTo), + terminator: + sourceTo === extentTo ? null : createAnalysisRange(sourceTo, extentTo), + }); +} + +function createOpaqueSlot( + from: number, + to: number, + reason: SqlOpaqueBoundaryReason, + detectedAt: number, +): OpaqueSqlStatementSlot { + return Object.freeze({ + boundaryQuality: "opaque", + endState: createOpaqueEndState(reason, detectedAt), + extent: createAnalysisRange(from, to), + }); +} + +function isAsciiIdentifierStart(code: number): boolean { + return ( + code === 95 || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) + ); +} + +function isAsciiIdentifierContinue(code: number): boolean { + return isAsciiIdentifierStart(code) || (code >= 48 && code <= 57); +} + +function codePointLengthAt(text: string, index: number): 0 | 1 | 2 { + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + return 0; + } + return codePoint > 0xffff ? 2 : 1; +} + +function sqlIdentifierStartLengthAt(text: string, index: number): 0 | 1 | 2 { + const code = text.charCodeAt(index); + if (code <= 0x7f) { + return isAsciiIdentifierStart(code) ? 1 : 0; + } + return codePointLengthAt(text, index); +} + +function sqlIdentifierContinueLengthAt( + text: string, + index: number, +): 0 | 1 | 2 { + const code = text.charCodeAt(index); + if (code <= 0x7f) { + return isAsciiIdentifierContinue(code) ? 1 : 0; + } + return codePointLengthAt(text, index); +} + +function previousCodePointIndex(text: string, index: number): number { + if (index <= 0) { + return -1; + } + const last = text.charCodeAt(index - 1); + if ( + last >= 0xdc00 && + last <= 0xdfff && + index >= 2 + ) { + const first = text.charCodeAt(index - 2); + if (first >= 0xd800 && first <= 0xdbff) { + return index - 2; + } + } + return index - 1; +} + +function hasSqlIdentifierBefore(text: string, index: number): boolean { + const previous = previousCodePointIndex(text, index); + if (previous < 0) { + return false; + } + return ( + text.charCodeAt(previous) === 36 || + sqlIdentifierContinueLengthAt(text, previous) > 0 + ); +} + +function isSqlWhitespace(code: number): boolean { + return ( + code === 9 || + code === 10 || + code === 11 || + code === 12 || + code === 13 || + code === 32 + ); +} + +function hasPrefixAtTokenBoundary( + text: string, + quoteAt: number, + prefix: string, +): boolean { + const prefixFrom = quoteAt - prefix.length; + if (prefixFrom < 0) { + return false; + } + for (let index = 0; index < prefix.length; index += 1) { + const actual = text.charCodeAt(prefixFrom + index) | 32; + if (actual !== prefix.charCodeAt(index)) { + return false; + } + } + return ( + prefixFrom === 0 || + !hasSqlIdentifierBefore(text, prefixFrom) + ); +} + +function hasEscapeStringPrefix(text: string, quoteAt: number): boolean { + return hasPrefixAtTokenBoundary(text, quoteAt, "e"); +} + +function isBigQueryRawString(text: string, quoteAt: number): boolean { + return ( + hasPrefixAtTokenBoundary(text, quoteAt, "r") || + hasPrefixAtTokenBoundary(text, quoteAt, "br") || + hasPrefixAtTokenBoundary(text, quoteAt, "rb") + ); +} + +interface QuoteScanResult { + readonly closed: boolean; + readonly to: number; +} + +function scanQuoted( + text: string, + from: number, + quote: number, + quoteLength: 1 | 3, + backslashEscapes: boolean, + doubledQuoteEscapes: boolean, + stopAtLineBreak: boolean, +): QuoteScanResult { + let cursor = from + quoteLength; + while (cursor < text.length) { + const code = text.charCodeAt(cursor); + if (stopAtLineBreak && (code === 10 || code === 13)) { + return { closed: false, to: text.length }; + } + if (backslashEscapes && code === 92) { + const escapedCode = text.charCodeAt(cursor + 1); + if ( + stopAtLineBreak && + (escapedCode === 10 || escapedCode === 13) + ) { + return { closed: false, to: text.length }; + } + cursor += Math.min(2, text.length - cursor); + continue; + } + if (code !== quote) { + cursor += 1; + continue; + } + if (quoteLength === 3) { + if ( + text.charCodeAt(cursor + 1) === quote && + text.charCodeAt(cursor + 2) === quote + ) { + return { closed: true, to: cursor + 3 }; + } + cursor += 1; + continue; + } + if (doubledQuoteEscapes && text.charCodeAt(cursor + 1) === quote) { + cursor += 2; + continue; + } + return { closed: true, to: cursor + 1 }; + } + return { closed: false, to: text.length }; +} + +interface BlockCommentScanResult { + readonly closed: boolean; + readonly to: number; +} + +function scanBlockComment( + text: string, + from: number, + nested: boolean, +): BlockCommentScanResult { + let cursor = from + 2; + let depth = 1; + while (cursor < text.length) { + const code = text.charCodeAt(cursor); + const next = text.charCodeAt(cursor + 1); + if (nested && code === 47 && next === 42) { + depth += 1; + cursor += 2; + continue; + } + if (code === 42 && next === 47) { + depth -= 1; + cursor += 2; + if (depth === 0) { + return { closed: true, to: cursor }; + } + continue; + } + cursor += 1; + } + return { closed: false, to: text.length }; +} + +interface DollarQuoteScanResult { + readonly detectedAt: number; + readonly endState: ExactSqlStatementSlot["endState"] | null; + readonly opaqueReason: SqlOpaqueBoundaryReason | null; + readonly to: number; +} + +function scanDollarQuote( + text: string, + from: number, +): DollarQuoteScanResult | null { + if (hasSqlIdentifierBefore(text, from)) { + return null; + } + const first = text.charCodeAt(from + 1); + let cursor = from + 1; + let delimiterTooLong = false; + if (first !== 36) { + const firstLength = sqlIdentifierStartLengthAt(text, cursor); + if (firstLength === 0) { + return null; + } + cursor += firstLength; + while (cursor < text.length) { + const continueLength = sqlIdentifierContinueLengthAt(text, cursor); + if (continueLength === 0) { + break; + } + if (cursor - from + 1 > MAX_DOLLAR_QUOTE_DELIMITER_LENGTH) { + delimiterTooLong = true; + } + cursor += continueLength; + } + if (text.charCodeAt(cursor) !== 36) { + return null; + } + if (delimiterTooLong) { + return { + detectedAt: from, + endState: null, + opaqueReason: "resource-limit", + to: text.length, + }; + } + } + const delimiterTo = cursor + 1; + const delimiter = text.slice(from, delimiterTo); + const closeAt = text.indexOf(delimiter, delimiterTo); + if (closeAt < 0) { + return { + detectedAt: from, + endState: createUnterminatedEndState("dollar-quoted-string", from), + opaqueReason: null, + to: text.length, + }; + } + return { + detectedAt: from, + endState: NORMAL_END_STATE, + opaqueReason: null, + to: closeAt + delimiter.length, + }; +} + +class SqlPrefixGuard { + readonly #mode: SqlProceduralGuards; + readonly #tokens: string[] = []; + #labelColonAt: number | null = null; + #parenthesisDepth = 0; + #postgresRoutine = false; + #previousRoutineWord: string | null = null; + #previousRoutineWordAt = 0; + #reason: SqlOpaqueBoundaryReason | null = null; + #reasonAt = 0; + + constructor(mode: SqlProceduralGuards) { + this.#mode = mode; + } + + reset(): void { + this.#tokens.length = 0; + this.#labelColonAt = null; + this.#parenthesisDepth = 0; + this.#postgresRoutine = false; + this.#previousRoutineWord = null; + this.#previousRoutineWordAt = 0; + this.#reason = null; + this.#reasonAt = 0; + } + + recordWord(text: string, from: number, to: number): void { + if (this.#reason !== null) { + return; + } + if (this.#postgresRoutine) { + const token = + to - from <= 32 ? text.slice(from, to).toUpperCase() : ""; + if (this.#previousRoutineWord === "BEGIN" && token === "ATOMIC") { + if (this.#parenthesisDepth === 0) { + this.#setReason("procedural-block", this.#previousRoutineWordAt); + return; + } + } + this.#previousRoutineWord = token; + this.#previousRoutineWordAt = from; + return; + } + if (this.#tokens.length >= MAX_PREFIX_TOKENS) { + return; + } + const token = + to - from <= 32 ? text.slice(from, to).toUpperCase() : ""; + this.#tokens.push(token); + + if (this.#tokens.length === 1 && token === "DELIMITER") { + this.#setReason("custom-delimiter", from); + return; + } + if ( + this.#mode === "postgresql" && + isCreatePostgresqlRoutinePrefix(this.#tokens) + ) { + this.#postgresRoutine = true; + this.#previousRoutineWord = token; + this.#previousRoutineWordAt = from; + return; + } + if (this.#mode !== "bigquery") { + return; + } + if ( + this.#tokens.length === 1 && + (token === "FOR" || + token === "IF" || + token === "LOOP" || + token === "REPEAT" || + token === "WHILE") + ) { + this.#setReason("procedural-block", from); + return; + } + if ( + this.#tokens.length === 2 && + this.#tokens[0] === "BEGIN" && + token !== "TRANSACTION" + ) { + this.#setReason("procedural-block", from); + return; + } + if ( + this.#labelColonAt !== null && + this.#tokens.length === 2 && + (token === "BEGIN" || + token === "FOR" || + token === "LOOP" || + token === "REPEAT" || + token === "WHILE") + ) { + this.#setReason("procedural-block", this.#labelColonAt); + return; + } + if (isCreateProcedurePrefix(this.#tokens)) { + this.#setReason("procedural-block", from); + } + } + + recordQuotedIdentifier(): void { + if ( + this.#reason === null && + this.#mode === "bigquery" && + this.#tokens.length < MAX_PREFIX_TOKENS + ) { + this.#tokens.push(""); + } + } + + recordNonWord(code: number): void { + if (this.#mode !== "postgresql" || !this.#postgresRoutine) { + return; + } + if (code === 40) { + this.#parenthesisDepth += 1; + } else if (code === 41 && this.#parenthesisDepth > 0) { + this.#parenthesisDepth -= 1; + } + this.#previousRoutineWord = null; + } + + recordColon(at: number): void { + if ( + this.#mode === "bigquery" && + this.#reason === null && + this.#tokens.length === 1 + ) { + this.#labelColonAt = at; + } + } + + get reason(): SqlOpaqueBoundaryReason | null { + return this.#reason; + } + + get reasonAt(): number { + return this.#reasonAt; + } + + #setReason(reason: SqlOpaqueBoundaryReason, at: number): void { + this.#reason = reason; + this.#reasonAt = at; + } +} + +function isCreatePostgresqlRoutinePrefix(tokens: readonly string[]): boolean { + if (tokens[0] !== "CREATE") { + return false; + } + if (tokens[1] === "FUNCTION" || tokens[1] === "PROCEDURE") { + return true; + } + return ( + tokens[1] === "OR" && + tokens[2] === "REPLACE" && + (tokens[3] === "FUNCTION" || tokens[3] === "PROCEDURE") + ); +} + +function isCreateProcedurePrefix(tokens: readonly string[]): boolean { + if (tokens[0] !== "CREATE") { + return false; + } + if (tokens[1] === "PROCEDURE") { + return true; + } + if ( + (tokens[1] === "TEMP" || tokens[1] === "TEMPORARY") && + tokens[2] === "PROCEDURE" + ) { + return true; + } + if (tokens[1] !== "OR" || tokens[2] !== "REPLACE") { + return false; + } + return ( + tokens[3] === "PROCEDURE" || + ((tokens[3] === "TEMP" || tokens[3] === "TEMPORARY") && + tokens[4] === "PROCEDURE") + ); +} + +function quoteConstruct( + quote: number, + quoteLength: 1 | 3, + bigQueryStrings: boolean, +): SqlUnterminatedConstruct { + if (quoteLength === 3) { + return quote === 39 + ? "triple-single-quoted-string" + : "triple-double-quoted-string"; + } + if (quote === 39) { + return "single-quoted-string"; + } + return bigQueryStrings + ? "double-quoted-string" + : "double-quoted-identifier"; +} + +/** Builds the bounded, parser-free statement partition for one analysis text. */ +export function buildSqlStatementIndex( + analysisText: string, + profile: SqlLexicalProfile, +): SqlStatementIndex { + const slots: SqlStatementSlot[] = []; + const prefixGuard = new SqlPrefixGuard(profile.proceduralGuards); + let slotFrom = 0; + let hasCode = false; + let cursor = 0; + let finalEndState: ExactSqlStatementSlot["endState"] = NORMAL_END_STATE; + + const finishOpaque = ( + reason: SqlOpaqueBoundaryReason, + detectedAt: number, + ): SqlStatementIndex => { + const slot = createOpaqueSlot( + slotFrom, + analysisText.length, + reason, + detectedAt, + ); + slots.push(slot); + return Object.freeze({ + endState: slot.endState, + quality: "opaque", + slots: Object.freeze(slots), + }); + }; + + while (cursor < analysisText.length) { + const code = analysisText.charCodeAt(cursor); + const next = analysisText.charCodeAt(cursor + 1); + + if (isSqlWhitespace(code)) { + cursor += 1; + continue; + } + if (code === 45 && next === 45) { + cursor += 2; + while ( + cursor < analysisText.length && + analysisText.charCodeAt(cursor) !== 10 && + analysisText.charCodeAt(cursor) !== 13 + ) { + cursor += 1; + } + continue; + } + if (profile.hashLineComments && code === 35) { + cursor += 1; + while ( + cursor < analysisText.length && + analysisText.charCodeAt(cursor) !== 10 && + analysisText.charCodeAt(cursor) !== 13 + ) { + cursor += 1; + } + continue; + } + if (code === 47 && next === 42) { + const comment = scanBlockComment( + analysisText, + cursor, + profile.nestedBlockComments, + ); + if (!comment.closed) { + finalEndState = createUnterminatedEndState("block-comment", cursor); + } + cursor = comment.to; + continue; + } + + if ( + profile.dollarQuotedStrings && + code === 36 + ) { + const dollarQuote = scanDollarQuote(analysisText, cursor); + if (dollarQuote) { + hasCode = true; + prefixGuard.recordNonWord(code); + if (dollarQuote.opaqueReason !== null) { + return finishOpaque( + dollarQuote.opaqueReason, + dollarQuote.detectedAt, + ); + } + if (dollarQuote.endState?.kind === "unterminated") { + finalEndState = dollarQuote.endState; + } + cursor = dollarQuote.to; + continue; + } + } + + if ( + code === 39 || + code === 34 || + (profile.backtickQuotedIdentifiers && code === 96) + ) { + hasCode = true; + if (code === 96) { + prefixGuard.recordQuotedIdentifier(); + prefixGuard.recordNonWord(code); + const quote = scanQuoted( + analysisText, + cursor, + code, + 1, + true, + false, + false, + ); + if (!quote.closed) { + finalEndState = createUnterminatedEndState( + "backtick-quoted-identifier", + cursor, + ); + } + cursor = quote.to; + continue; + } + + const isTriple = + profile.bigQueryStrings && + analysisText.charCodeAt(cursor + 1) === code && + analysisText.charCodeAt(cursor + 2) === code; + const quoteLength = isTriple ? 3 : 1; + const rawBigQueryString = + profile.bigQueryStrings && isBigQueryRawString(analysisText, cursor); + const backslashEscapes = + !rawBigQueryString && + (profile.bigQueryStrings || + (code === 39 && + (profile.singleQuoteBackslash === "always" || + (profile.singleQuoteBackslash === "e-prefix" && + hasEscapeStringPrefix(analysisText, cursor))))); + const doubledQuoteEscapes = !profile.bigQueryStrings; + if (code === 34 && !profile.bigQueryStrings) { + prefixGuard.recordQuotedIdentifier(); + } + prefixGuard.recordNonWord(code); + const quote = scanQuoted( + analysisText, + cursor, + code, + quoteLength, + backslashEscapes, + doubledQuoteEscapes, + profile.bigQueryStrings && quoteLength === 1, + ); + if (!quote.closed) { + finalEndState = createUnterminatedEndState( + quoteConstruct(code, quoteLength, profile.bigQueryStrings), + cursor, + ); + } + cursor = quote.to; + continue; + } + + const wordStartLength = sqlIdentifierStartLengthAt(analysisText, cursor); + if (wordStartLength > 0) { + const wordFrom = cursor; + cursor += wordStartLength; + while (cursor < analysisText.length) { + const continueLength = sqlIdentifierContinueLengthAt( + analysisText, + cursor, + ); + if (continueLength === 0) { + break; + } + cursor += continueLength; + } + prefixGuard.recordWord(analysisText, wordFrom, cursor); + hasCode = true; + if (prefixGuard.reason !== null) { + return finishOpaque(prefixGuard.reason, prefixGuard.reasonAt); + } + continue; + } + + if (code === 58) { + prefixGuard.recordColon(cursor); + } + prefixGuard.recordNonWord(code); + if (code === 59) { + if (slots.length >= MAX_SQL_STATEMENT_SLOTS - 1) { + return finishOpaque("resource-limit", cursor); + } + slots.push( + createExactSlot( + slotFrom, + cursor, + cursor + 1, + hasCode, + NORMAL_END_STATE, + ), + ); + slotFrom = cursor + 1; + hasCode = false; + finalEndState = NORMAL_END_STATE; + prefixGuard.reset(); + cursor += 1; + continue; + } + + hasCode = true; + cursor += 1; + } + + slots.push( + createExactSlot( + slotFrom, + analysisText.length, + analysisText.length, + hasCode, + finalEndState, + ), + ); + return Object.freeze({ + endState: finalEndState, + quality: "exact", + slots: Object.freeze(slots), + }); +} + +/** Finds one slot with explicit behavior at shared extent boundaries. */ +export function findSqlStatementSlot( + index: SqlStatementIndex, + position: number, + affinity: SqlCursorAffinity, +): SqlStatementSlot { + const slots = index.slots; + const documentLength = getStatementSlot(slots, slots.length - 1).extent.to; + if ( + !Number.isSafeInteger(position) || + position < 0 || + position > documentLength + ) { + throw new RangeError("SQL statement position is outside the document"); + } + if (affinity !== "left" && affinity !== "right") { + throw new TypeError("SQL cursor affinity must be left or right"); + } + + let low = 0; + let high = slots.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (getStatementSlot(slots, middle).extent.from <= position) { + low = middle + 1; + } else { + high = middle; + } + } + let slotIndex = Math.max(0, low - 1); + if ( + affinity === "left" && + position > 0 && + getStatementSlot(slots, slotIndex).extent.from === position + ) { + slotIndex -= 1; + } + return getStatementSlot(slots, Math.max(0, slotIndex)); +} + +function getStatementSlot( + slots: readonly SqlStatementSlot[], + index: number, +): SqlStatementSlot { + const slot = slots[index]; + if (!slot) { + throw new Error("SQL statement index must contain at least one slot"); + } + return slot; +} From 99dbd846232044324cc6a79f2f2b872e03f654af Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Fri, 24 Jul 2026 23:38:22 +0800 Subject: [PATCH 06/42] feat(vnext): add incremental statement indexing (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - update the dialect-aware statement index incrementally from trusted analysis-coordinate edits while preserving the full scan as the oracle - add a private lazy per-session index cache with atomic invalidation/reuse rules - replace structural dialect definitions with opaque built-in factory handles, keeping lexical profiles and indexes out of the public API ## Correctness and safety - conservative restart at the old slot at or left of the earliest edit - convergence only at an exact terminated boundary after the final edit - exact full-oracle fallback for inconsistent metadata or absent convergence - global 10,000-slot and opaque-suffix behavior preserved - monotonic convergence cursor keeps resource-cap recovery linear - no source text or history retained by the cache ## Validation - 833 unit tests passed; 1 governed expected failure - 33 incremental tests with deterministic differential sequences across all four profiles - 80,000 additional adversarial differential edits matched the full oracle - changed coverage: 98.78% statements, 97.00% branches, 100% functions, 98.77% lines - typecheck, oxlint, integrity, browser, demo, and packed-package smoke passed - 1 MiB benchmark: full scan about 2.2-2.5 ms, middle edit about 0.028 ms, prefix shift about 0.11 ms - resource-cap recovery about 1.35-1.46 ms after fixing the reviewed quadratic path ## Review Two independent exact-SHA reviewers approve 2e83d17ad1decf91cc731f3bcaf657cbc6f75327 with no unresolved blocker, high, or medium findings. --- ## Summary by cubic Adds incremental statement indexing with a lazy, per-session cache so edits update indexes in microseconds, while a full scan remains the correctness fallback. Replaces caller-defined dialect objects with built-in opaque dialect handles that bind lexical behavior internally. - New Features - Incremental updates from trusted analysis-coordinate edits; reuse unchanged prefix and reuse/shift suffix at exact terminated boundaries, with full-oracle fallback. - Private per-session statement-index cache: reused on context-only updates (same profile) and same-text replacements; incrementally updated on trusted identity-source changes; cleared on profile change or changed replacements; no source text or history retained. - Built-in dialect factories return frozen opaque singletons: `duckdbDialect()`, `postgresDialect()`, `bigQueryDialect()`, `dremioDialect()`; handles are validated, local to the package instance, and don’t cross workers/processes. - Bench and tests: `pnpm run bench:statement-index`; extensive unit, randomized differential, and session cache tests. - Migration - Replace `defineSqlDialect(...)` and `SqlDialectDefinition` with built-in handles and `SqlDialect`. - Before: `createSqlLanguageService({ dialects: [defineSqlDialect({ id: "duckdb", displayName: "DuckDB" })] })` - After: `createSqlLanguageService({ dialects: [duckdbDialect()] })` - Keep using the handle’s `.id` string in document context: `{ dialect: dialect.id }`. - Do not copy/serialize/fabricate dialects; pass authentic handles from the same `@marimo-team/codemirror-sql/vnext` instance (handles don’t cross workers/processes). Written for commit ce9417634c5c00a10bd379e68f045a24ecd83f13. Summary will update on new commits. Review in cubic --- SQL_EDITOR_RESEARCH.md | 6 +- docs/adr/0001-language-service-and-session.md | 40 +- docs/vnext/session-primitives.md | 32 +- docs/vnext/source-coordinates.md | 13 + docs/vnext/statement-index.md | 49 +- package.json | 1 + scripts/package-smoke.mjs | 15 +- .../incremental-statement-index.test.ts | 474 ++++++++++++++++++ src/vnext/__tests__/session.test.ts | 257 ++++++++-- src/vnext/__tests__/statement-index.bench.ts | 74 +++ src/vnext/index.ts | 7 +- src/vnext/session.ts | 269 +++++++--- src/vnext/statement-index.ts | 321 ++++++++++-- src/vnext/types.ts | 23 +- test/vnext-types/session.test-d.ts | 13 +- 15 files changed, 1405 insertions(+), 189 deletions(-) create mode 100644 src/vnext/__tests__/incremental-statement-index.test.ts create mode 100644 src/vnext/__tests__/statement-index.bench.ts diff --git a/SQL_EDITOR_RESEARCH.md b/SQL_EDITOR_RESEARCH.md index a973874..e04725c 100644 --- a/SQL_EDITOR_RESEARCH.md +++ b/SQL_EDITOR_RESEARCH.md @@ -708,8 +708,12 @@ Features could degrade honestly based on capabilities instead of treating A dialect should be more than CodeMirror highlighting and a `node-sql-parser` database name: +The stable API should expose a package-owned opaque dialect handle; the +following is the kind of internal bundle that handle resolves to, not a +caller-constructed public object: + ```ts -interface SqlDialectDefinition { +interface InternalSqlDialectBundle { id: string; codeMirrorDialect: SQLDialect; parserDialect: string; diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index 0920e47..763a1d9 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -110,9 +110,17 @@ interface SqlDocumentContext { The service resolves dialect IDs through its immutable registry and rejects unknown or duplicate IDs. Duplicate registration is rejected even when the -definitions appear equivalent. Dialect identity includes an internal -configuration revision; calling a dialect factory again does not change the -identity of an already registered dialect. +definitions appear equivalent. The built-in dialect factories return frozen +opaque singletons backed by package-owned `WeakMap` metadata; repeated calls to +one factory return the same handle and internal configuration identity. A +copied, serialized, fabricated, or different-package-instance handle is +rejected. + +Dialect handles are in-process service configuration and do not cross cloning, +worker, or provider boundaries. Serializable context contains only the +registered handle's string ID. Another realm or package instance reconstructs +configuration with its own built-in factory. IDs perform registry lookup but +never infer lexical behavior. Hosts can add fields such as engine or SQL mode. Contexts must be structured-cloneable plain data. On open/update, the session creates and owns a @@ -213,8 +221,15 @@ validity, or AST data. It materializes at most 10,000 slots and collapses an unscanned remainder into an opaque slot on resource limits or unsupported delimiter/procedural constructs. Opaque slots cannot be passed to later parsing as exact source. Dialect lexical behavior uses internal configuration identity, -never a caller-controlled dialect ID. Incremental reuse and session attachment -remain a separate change tested against the full-scan oracle. +never a caller-controlled dialect ID. + +The full scanner remains the correctness oracle. Incremental indexing restarts +conservatively at the old slot at or to the left of the earliest trusted +analysis-coordinate change and converges only at an exact terminated boundary +mapped to an unchanged old suffix. It reuses the unchanged prefix and either +reuses or shifts the suffix. Inconsistent metadata falls back to the full +oracle; absent convergence scans through EOF, and resource or +unsupported-syntax opacity remains fail-closed. ## Request outcomes @@ -409,6 +424,12 @@ SPI. Sessions create a new source snapshot for document updates and reuse the existing snapshot for context-only updates. Non-identity generated/reordered source remains deferred until a concrete consumer validates the segment model. +Current session edits use identity sources, so validated original-document +changes are also trusted analysis-coordinate changes. A future transformed +source must provide its own validated analysis-coordinate changes for +incremental statement indexing. Without them, changed analysis text +invalidates the index and is rebuilt lazily. + Edits crossing an ambiguous or unmapped boundary are rejected. Mapping failure is unavailable analysis, not invalid SQL. @@ -498,6 +519,15 @@ dialect, parser/configuration, and template identities. Catalog changes do not invalidate parsing. Renderer, theme, focus, and cursor changes do not invalidate semantic artifacts. +The current statement-index cache is private, lazy, and per session. It stores +only the current index, document sequence, and lexical-profile identity. +Context-only updates reuse it for the same profile; equal analysis text reuses +it across a new document revision; trusted identity-source changes update it +incrementally. A changed replacement, profile change, or transformed source +without trusted analysis changes clears it for a later full build. Invalid +updates do not mutate it, disposal clears it, and it retains no source text or +revision history. + In-flight deduplication uses the same complete key. Cancelling one consumer detaches it; shared underlying work is aborted only when no consumers remain. Caches are bounded by both item count and estimated retained bytes. diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 8ac74ce..e337e02 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -16,7 +16,7 @@ contract and the internal immutable source-snapshot model. ```ts import { createSqlLanguageService, - defineSqlDialect, + duckdbDialect, type SqlDocumentContext, } from "@marimo-team/codemirror-sql/vnext"; @@ -24,15 +24,14 @@ interface AppSqlContext extends SqlDocumentContext { readonly engine: string; } +const dialect = duckdbDialect(); const service = createSqlLanguageService({ - dialects: [ - defineSqlDialect({ id: "duckdb", displayName: "DuckDB" }), - ], + dialects: [dialect], }); const session = service.openDocument({ text: "SELECT * FROM users", - context: { dialect: "duckdb", engine: "local" }, + context: { dialect: dialect.id, engine: "local" }, }); const revision = session.update({ @@ -55,6 +54,20 @@ service.dispose(); Use `{ kind: "replace", text }` for full replacement and `{ kind: "context", baseRevision, context }` for a context-only update. +## Dialect registration + +`duckdbDialect()`, `postgresDialect()`, `bigQueryDialect()`, and +`dremioDialect()` return frozen, opaque built-in handles. Each factory returns +the same singleton on repeated calls. The service resolves a handle through +package-owned runtime metadata, so a copied, serialized, fabricated, or +different-package-instance handle is rejected. + +Handles are local service configuration, not transport data. Document context +contains only the handle's serializable string `id`; a worker or separate +package instance must call its own built-in factory rather than receive a +handle through cloning or messaging. The ID selects a registered handle but +does not itself infer lexical behavior. + ## Contract - Every accepted update creates a new frozen, service-issued revision. @@ -72,6 +85,15 @@ Use `{ kind: "replace", text }` for full replacement and - Synchronous public failures use `SqlSessionError` and a stable `code`; caught platform errors are not exposed as causes. +The internal statement index is built lazily per session. Context-only updates +reuse it when the lexical-profile identity is unchanged. A no-op document +update or same-text replacement advances the public revision and document +sequence but can reuse the index. Trusted incremental identity-source changes +update a populated cache; a changed replacement, profile change, or changed +transformed source without analysis-coordinate changes clears it for a later +full build. Invalid updates leave both the session snapshot and cache +unchanged, and disposal clears the cache. + The safety envelope currently permits at most 10,000 changes in one update, a 16 Mi-code-unit document, 1,000 registered dialects, and bounded context graph depth, size, properties, and string data. These are defensive walking-skeleton diff --git a/docs/vnext/source-coordinates.md b/docs/vnext/source-coordinates.md index 9a4932e..c9a7a0c 100644 --- a/docs/vnext/source-coordinates.md +++ b/docs/vnext/source-coordinates.md @@ -48,3 +48,16 @@ public. The internal [statement index](./statement-index.md) scans `analysisText` and uses its length-preserving offsets without publishing analysis-coordinate ranges. + +Statement-index reuse depends on `analysisText` value and internal lexical +profile identity, not source-object identity. A document update therefore +still creates a fresh source snapshot and public revision even when equal +analysis text permits reuse. The index does not retain either the old or +current source text. + +Current sessions create identity sources, so their validated document changes +are also trusted analysis-coordinate changes. The masking primitive is not yet +attached to session updates. A future transformed-source pipeline must produce +validated analysis-coordinate changes before it can use incremental indexing; +otherwise a changed analysis document invalidates the cache and receives a +fresh full build on demand. diff --git a/docs/vnext/statement-index.md b/docs/vnext/statement-index.md index 92e4db8..7a7b393 100644 --- a/docs/vnext/statement-index.md +++ b/docs/vnext/statement-index.md @@ -1,6 +1,6 @@ # vNext Statement Index -Status: internal full-scan correctness oracle +Status: internal full-scan oracle with incremental session reuse The statement index is a synchronous, parser-free partition of `analysisText`. It does not classify, parse, validate, or copy statements, and @@ -41,8 +41,9 @@ run-current-statement commands need different policies. ## Dialect profiles Lexical behavior is carried by immutable internal profile identity. It is never -inferred from a caller-controlled dialect ID. This first oracle owns profiles -for: +inferred from a caller-controlled dialect ID. Frozen built-in dialect +singletons select package-owned profiles through private runtime metadata. This +first oracle owns profiles for: - PostgreSQL: doubled quotes, `E'...'`, dollar-quoted strings, and nested block comments, following the @@ -66,7 +67,36 @@ Unterminated lexical constructs consume the remainder and report their opening offset. Regular BigQuery strings also fail closed at a line break, because only triple-quoted strings may span lines. -## Complexity and sequencing +## Incremental updates + +The full build remains the correctness oracle. When a session already has an +index and receives trusted ordered changes in analysis coordinates, the +incremental path restarts conservatively at the beginning of the old slot at or +to the left of the earliest change. Slot starts are safe checkpoints because +lexical and prefix state is normal there. + +Scanning continues until an exact terminated boundary maps to the start of an +unchanged old suffix after the final change. The unchanged prefix is retained; +the suffix is reused directly when its offset is unchanged or copied with +shifted frozen ranges when the net edit length changes. Convergence is rejected +if the combined result would violate the slot limit or make a prior +resource-limit suffix unsafe to reuse. + +Inconsistent change metadata falls back to a fresh full build. If no safe +boundary converges, scanning continues through EOF. Unsupported procedural or +custom-delimiter syntax and resource limits remain scanner-produced opaque +suffixes; the incremental layer never guesses a boundary or creates a new +opacity reason. + +## Session cache and complexity + +The index cache is private, lazy, and per session. It retains only the current +index, document sequence, and lexical-profile identity, not source text or +history. Context-only updates reuse it for the same profile. Equal analysis +text reuses it across a new document revision. Trusted identity-source changes +update it incrementally; changed replacements, profile changes, and transformed +sources without trusted analysis-coordinate changes invalidate it. Disposal +clears it. A full build is linear in UTF-16 code units, retains only slot records and a bounded dollar delimiter, and creates no statement substrings. Point lookup is @@ -74,6 +104,11 @@ logarithmic in slot count. The scanner operates on `analysisText`; the current length-preserving source transform makes its analysis ranges valid at the same offsets in `originalText`. -This implementation remains the correctness oracle. Incremental rescanning, -change mapping, cache reuse, and session attachment belong to a later slice and -must be tested against a fresh full build after arbitrary edits. +Incremental work is proportional to the rescanned region plus any shifted +suffix records, with a worst case equal to a full linear scan. Randomized edit +tests compare every incremental result with a fresh oracle build. + +`pnpm run bench:statement-index` measures full and incremental paths on a +roughly 1 MiB, 1,000-statement document, including a local middle edit and a +prefix insertion that shifts the reusable suffix. It also measures recovery +from a resource-limited 10,000-slot index to guard the linear convergence path. diff --git a/package.json b/package.json index d702564..0417463 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:coverage:changed": "node ./scripts/changed-coverage.mjs", "test:browser": "vitest run --config vitest.browser.config.ts", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", "build": "node ./scripts/clean.mjs && tsc", diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 263f885..0f3ed1c 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -174,11 +174,11 @@ try { join(temporaryDirectory, "vnext-consumer.mjs"), `import { createSqlLanguageService, - defineSqlDialect, + duckdbDialect, } from "@marimo-team/codemirror-sql/vnext"; const service = createSqlLanguageService({ - dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], + dialects: [duckdbDialect()], }); const session = service.openDocument({ context: { dialect: "duckdb" }, @@ -208,7 +208,7 @@ import { } from "@marimo-team/codemirror-sql/dialects"; import { createSqlLanguageService, - defineSqlDialect, + duckdbDialect, type SqlDocumentContext, type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; @@ -225,7 +225,7 @@ const extensions: Extension[] = [ ]; const parser = new NodeSqlParser(); const service = createSqlLanguageService({ - dialects: [defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], + dialects: [duckdbDialect()], }); const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, @@ -266,7 +266,10 @@ if (!dialects.BigQueryDialect || !dialects.DremioDialect || !dialects.DuckDBDial } if ( typeof vnext.createSqlLanguageService !== "function" || - typeof vnext.defineSqlDialect !== "function" + typeof vnext.bigQueryDialect !== "function" || + typeof vnext.dremioDialect !== "function" || + typeof vnext.duckdbDialect !== "function" || + typeof vnext.postgresDialect !== "function" ) { throw new Error("vNext package exports are incomplete"); } @@ -281,7 +284,7 @@ if (!parseResult.success || !parseResult.ast) { } const service = vnext.createSqlLanguageService({ - dialects: [vnext.defineSqlDialect({ displayName: "DuckDB", id: "duckdb" })], + dialects: [vnext.duckdbDialect()], }); const session = service.openDocument({ context: { dialect: "duckdb" }, diff --git a/src/vnext/__tests__/incremental-statement-index.test.ts b/src/vnext/__tests__/incremental-statement-index.test.ts new file mode 100644 index 0000000..a0dbfae --- /dev/null +++ b/src/vnext/__tests__/incremental-statement-index.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from "vitest"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + MAX_SQL_STATEMENT_SLOTS, + POSTGRESQL_SQL_LEXICAL_PROFILE, + type SqlLexicalProfile, + type SqlStatementIndex, + type SqlStatementSlot, + updateSqlStatementIndex, +} from "../statement-index.js"; +import type { SqlTextChange } from "../types.js"; + +const PROFILES = [ + { name: "PostgreSQL", profile: POSTGRESQL_SQL_LEXICAL_PROFILE }, + { name: "DuckDB", profile: DUCKDB_SQL_LEXICAL_PROFILE }, + { name: "BigQuery", profile: BIGQUERY_SQL_LEXICAL_PROFILE }, + { name: "Dremio", profile: DREMIO_SQL_LEXICAL_PROFILE }, +] as const; + +function itemAt(items: readonly Value[], index: number): Value { + const item = items[index]; + if (item === undefined) { + throw new Error(`Expected test item at index ${index}`); + } + return item; +} + +function applyChanges( + text: string, + changes: readonly SqlTextChange[], +): string { + let cursor = 0; + const output: string[] = []; + for (const change of changes) { + output.push(text.slice(cursor, change.from), change.insert); + cursor = change.to; + } + output.push(text.slice(cursor)); + return output.join(""); +} + +function replaceFirst( + text: string, + search: string, + insert: string, +): SqlTextChange { + const from = text.indexOf(search); + if (from < 0) { + throw new Error(`Expected ${JSON.stringify(search)} in test SQL`); + } + return { from, insert, to: from + search.length }; +} + +function replaceLast( + text: string, + search: string, + insert: string, +): SqlTextChange { + const from = text.lastIndexOf(search); + if (from < 0) { + throw new Error(`Expected ${JSON.stringify(search)} in test SQL`); + } + return { from, insert, to: from + search.length }; +} + +function expectDeepFrozen(index: SqlStatementIndex): void { + expect(Object.isFrozen(index)).toBe(true); + expect(Object.isFrozen(index.slots)).toBe(true); + for (const slot of index.slots) { + expect(Object.isFrozen(slot)).toBe(true); + expect(Object.isFrozen(slot.extent)).toBe(true); + expect(Object.isFrozen(slot.endState)).toBe(true); + if (slot.boundaryQuality === "exact") { + expect(Object.isFrozen(slot.source)).toBe(true); + if (slot.terminator) { + expect(Object.isFrozen(slot.terminator)).toBe(true); + } + } + } +} + +function expectIncrementalMatchesOracle( + previousText: string, + changes: readonly SqlTextChange[], + profile: SqlLexicalProfile, +): { + readonly nextIndex: SqlStatementIndex; + readonly nextText: string; + readonly previousIndex: SqlStatementIndex; +} { + const previousIndex = buildSqlStatementIndex(previousText, profile); + const nextText = applyChanges(previousText, changes); + const nextIndex = updateSqlStatementIndex( + previousIndex, + nextText, + changes, + profile, + ); + expect(nextIndex).toEqual(buildSqlStatementIndex(nextText, profile)); + expectDeepFrozen(nextIndex); + return { nextIndex, nextText, previousIndex }; +} + +function expectSlot( + slots: readonly SqlStatementSlot[], + index: number, +): SqlStatementSlot { + return itemAt(slots, index); +} + +describe("incremental statement index lexical transitions", () => { + const cases: readonly { + readonly change: (text: string) => SqlTextChange; + readonly name: string; + readonly previousText: string; + readonly profile: SqlLexicalProfile; + }[] = [ + { + change: (text) => replaceFirst(text, "b;", "b';"), + name: "PostgreSQL closes an escape string", + previousText: "SELECT E'a;b; SELECT 2", + profile: POSTGRESQL_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => replaceLast(text, "$tag$", ""), + name: "PostgreSQL opens a tagged dollar quote", + previousText: "SELECT $tag$a;b$tag$; SELECT 2", + profile: POSTGRESQL_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => ({ + from: text.length, + insert: " */; SELECT 3", + to: text.length, + }), + name: "DuckDB closes a nested block comment", + previousText: "SELECT 1 /* outer /* inner */ ; SELECT 2", + profile: DUCKDB_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => replaceFirst(text, "$d$", ""), + name: "DuckDB removes a dollar-quote opener", + previousText: "SELECT $d$a;b$d$; SELECT 2", + profile: DUCKDB_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => ({ + from: text.length, + insert: '"""; SELECT 3', + to: text.length, + }), + name: "BigQuery closes a raw triple-quoted string", + previousText: 'SELECT r"""a;b; SELECT 2', + profile: BIGQUERY_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => replaceFirst(text, "#", ""), + name: "BigQuery removes a hash-comment opener", + previousText: "SELECT 1 # hidden; still hidden\nSELECT 2", + profile: BIGQUERY_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => replaceFirst(text, "b;", "b';"), + name: "Dremio closes a standard string", + previousText: "SELECT 'a;b; SELECT 2", + profile: DREMIO_SQL_LEXICAL_PROFILE, + }, + { + change: (text) => replaceFirst(text, "/*", ""), + name: "Dremio removes a block-comment opener", + previousText: "SELECT 1 /* hidden; */ SELECT 2; SELECT 3", + profile: DREMIO_SQL_LEXICAL_PROFILE, + }, + ]; + + it.each(cases)("$name", ({ change, previousText, profile }) => { + expectIncrementalMatchesOracle(previousText, [change(previousText)], profile); + }); +}); + +describe("incremental statement index edit boundaries", () => { + it.each(PROFILES)( + "handles empty documents, statement boundaries, and EOF for $name", + ({ profile }) => { + let text = ""; + let index = buildSqlStatementIndex(text, profile); + const edits: readonly SqlTextChange[][] = [ + [{ from: 0, insert: ";", to: 0 }], + [{ from: 1, insert: "SELECT 1;", to: 1 }], + [{ from: 1, insert: "\n", to: 1 }], + [{ from: 10, insert: "", to: 11 }], + [{ from: 0, insert: "", to: 1 }], + ]; + + for (const changes of edits) { + const nextText = applyChanges(text, changes); + index = updateSqlStatementIndex(index, nextText, changes, profile); + expect(index).toEqual(buildSqlStatementIndex(nextText, profile)); + text = nextText; + } + }, + ); + + it.each(PROFILES)( + "matches a full scan after ordered multi-edits for $name", + ({ profile }) => { + const text = "SELECT 1; SELECT 2; SELECT 3;"; + const changes: readonly SqlTextChange[] = [ + { from: 0, insert: "-- lead\n", to: 0 }, + { + from: text.indexOf("2"), + insert: "'x;y'", + to: text.indexOf("2") + 1, + }, + { from: text.length - 1, insert: "", to: text.length }, + ]; + expectIncrementalMatchesOracle(text, changes, profile); + }, + ); + + it("falls back to the oracle for inconsistent trusted-change metadata", () => { + const profile = DUCKDB_SQL_LEXICAL_PROFILE; + const previousText = "SELECT 1; SELECT 2"; + const previousIndex = buildSqlStatementIndex(previousText, profile); + const nextText = "SELECT 10; SELECT 2"; + const inconsistentChanges = [ + { from: 7, insert: "10", to: 8 }, + { from: 4, insert: "x", to: 4 }, + ]; + const nextIndex = updateSqlStatementIndex( + previousIndex, + nextText, + inconsistentChanges, + profile, + ); + expect(nextIndex).toEqual(buildSqlStatementIndex(nextText, profile)); + }); +}); + +describe("incremental statement index opaque and bounded results", () => { + it("transitions from a BigQuery procedural opaque suffix to exact slots", () => { + const text = "SELECT 1; BEGIN WORK; SELECT 2"; + const change = replaceFirst(text, "BEGIN WORK", "SELECT 0"); + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + [change], + BIGQUERY_SQL_LEXICAL_PROFILE, + ); + expect(previousIndex.quality).toBe("opaque"); + expect(nextIndex.quality).toBe("exact"); + }); + + it("transitions from exact PostgreSQL SQL to an opaque routine body", () => { + const text = + "CREATE FUNCTION f() RETURNS int LANGUAGE SQL RETURN 1; SELECT 2"; + const change = replaceFirst(text, "RETURN 1", "BEGIN ATOMIC SELECT 1"); + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + [change], + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(previousIndex.quality).toBe("exact"); + expect(nextIndex.quality).toBe("opaque"); + expect(nextIndex.endState).toMatchObject({ + kind: "opaque", + reason: "procedural-block", + }); + }); + + it("recomputes a BigQuery custom-delimiter opaque result", () => { + const text = "SELECT 1; DELIMITER $$\nSELECT 2$$"; + const change = replaceFirst(text, "DELIMITER", "SELECT"); + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + [change], + BIGQUERY_SQL_LEXICAL_PROFILE, + ); + expect(previousIndex.quality).toBe("opaque"); + expect(nextIndex).toEqual( + buildSqlStatementIndex( + applyChanges(text, [change]), + BIGQUERY_SQL_LEXICAL_PROFILE, + ), + ); + }); + + it("matches the capped oracle when edits change a semicolon storm", () => { + const text = ";".repeat(MAX_SQL_STATEMENT_SLOTS + 100); + const changes = [ + { from: 1, insert: "SELECT 1;", to: 1 }, + { + from: MAX_SQL_STATEMENT_SLOTS - 2, + insert: "", + to: MAX_SQL_STATEMENT_SLOTS + 2, + }, + ]; + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + changes, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + expect(previousIndex.slots).toHaveLength(MAX_SQL_STATEMENT_SLOTS); + expect(nextIndex.slots).toHaveLength(MAX_SQL_STATEMENT_SLOTS); + expect(nextIndex.endState).toMatchObject({ + kind: "opaque", + reason: "resource-limit", + }); + }); + + it("recovers from a resource-limited suffix after separators are removed", () => { + const text = ";".repeat(MAX_SQL_STATEMENT_SLOTS + 5); + const changes = [ + { + from: 0, + insert: "", + to: 10, + }, + ]; + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + changes, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + expect(previousIndex.quality).toBe("opaque"); + expect(nextIndex.quality).toBe("exact"); + expect(nextIndex.slots).toHaveLength( + MAX_SQL_STATEMENT_SLOTS - 4, + ); + }); + + it("matches the resource-limited dollar-delimiter oracle", () => { + const text = `SELECT 1; $${"a".repeat(300)}$payload`; + const change = replaceFirst(text, "SELECT 1", "SELECT 100"); + const { nextIndex } = expectIncrementalMatchesOracle( + text, + [change], + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(nextIndex.endState).toMatchObject({ + kind: "opaque", + reason: "resource-limit", + }); + }); +}); + +describe("incremental statement index reuse", () => { + it.each(PROFILES)( + "returns the previous index for an unchanged $name document", + ({ profile }) => { + const text = "SELECT 1; SELECT 2"; + const previousIndex = buildSqlStatementIndex(text, profile); + expect(updateSqlStatementIndex(previousIndex, text, [], profile)).toBe( + previousIndex, + ); + }, + ); + + it("reuses unchanged prefix and zero-delta suffix slot identities", () => { + const profile = DUCKDB_SQL_LEXICAL_PROFILE; + const text = "SELECT 1; SELECT 2; SELECT 3"; + const change = replaceFirst(text, "2", "9"); + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + [change], + profile, + ); + expect(expectSlot(nextIndex.slots, 0)).toBe( + expectSlot(previousIndex.slots, 0), + ); + expect(expectSlot(nextIndex.slots, 1)).not.toBe( + expectSlot(previousIndex.slots, 1), + ); + expect(expectSlot(nextIndex.slots, 2)).toBe( + expectSlot(previousIndex.slots, 2), + ); + }); + + it("creates deeply frozen shifted suffixes for non-zero deltas", () => { + const profile = POSTGRESQL_SQL_LEXICAL_PROFILE; + const text = "SELECT 1; SELECT 'unterminated"; + const change = replaceFirst(text, "1", "1000"); + const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( + text, + [change], + profile, + ); + const previousSuffix = expectSlot(previousIndex.slots, 1); + const nextSuffix = expectSlot(nextIndex.slots, 1); + expect(nextSuffix).not.toBe(previousSuffix); + expect(nextSuffix.extent.from).toBe(previousSuffix.extent.from + 3); + expect(nextSuffix.extent.to).toBe(previousSuffix.extent.to + 3); + expect(nextSuffix.endState).toMatchObject({ + from: + previousSuffix.endState.kind === "unterminated" + ? previousSuffix.endState.from + 3 + : undefined, + kind: "unterminated", + }); + expectDeepFrozen(nextIndex); + }); +}); + +describe("incremental statement index deterministic differential sequences", () => { + it.each(PROFILES)( + "matches the full oracle through randomized edits for $name", + ({ name, profile }) => { + let seed = + 0x51_7a_2d_09 ^ + Array.from(name).reduce( + (value, character) => value + character.charCodeAt(0), + 0, + ); + const next = (limit: number): number => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed % limit; + }; + const fragments = [ + "", + ";", + "'", + '"', + "`", + "$tag$", + "/*", + "*/", + "--", + "#", + "\n", + "\\", + "BEGIN", + " ATOMIC", + "DELIMITER $$", + "E'", + "r'''", + "𐐀", + ] as const; + + let text = + "SELECT E'a;b'; SELECT $tag$c;d$tag$; SELECT r'''e;f'''; " + + "SELECT `g;h`; /* i;j */ SELECT 5"; + let index = buildSqlStatementIndex(text, profile); + + for (let iteration = 0; iteration < 120; iteration += 1) { + const changeCount = 1 + next(3); + const points = Array.from( + { length: changeCount * 2 }, + () => next(text.length + 1), + ).sort((left, right) => left - right); + const changes: SqlTextChange[] = []; + for (let changeIndex = 0; changeIndex < changeCount; changeIndex += 1) { + const from = itemAt(points, changeIndex * 2); + const to = itemAt(points, changeIndex * 2 + 1); + const insert = + text.length > 500 + ? "" + : itemAt(fragments, next(fragments.length)); + changes.push({ from, insert, to }); + } + + const nextText = applyChanges(text, changes); + index = updateSqlStatementIndex(index, nextText, changes, profile); + expect( + index, + `${name} differential mismatch at iteration ${iteration}`, + ).toEqual(buildSqlStatementIndex(nextText, profile)); + expectDeepFrozen(index); + text = nextText; + } + }, + ); +}); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index c0a3313..16b47b3 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it } from "vitest"; import { + bigQueryDialect, createSqlLanguageService, - defineSqlDialect, + dremioDialect, + duckdbDialect, + postgresDialect, SqlSessionError, } from "../index.js"; import { DefaultSqlLanguageService } from "../session.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "../statement-index.js"; import type { SqlDocumentContext, SqlDocumentReplacement, @@ -17,14 +27,8 @@ interface TestContext extends SqlDocumentContext { }; } -const duckdb = defineSqlDialect({ - displayName: "DuckDB", - id: "duckdb", -}); -const postgres = defineSqlDialect({ - displayName: "PostgreSQL", - id: "postgres", -}); +const duckdb = duckdbDialect(); +const postgres = postgresDialect(); function createService() { return new DefaultSqlLanguageService({ @@ -53,69 +57,220 @@ function expectSessionError(code: SqlSessionError["code"], callback: () => unkno } describe("dialect definitions", () => { - it("creates immutable definitions", () => { - expect(duckdb).toEqual({ displayName: "DuckDB", id: "duckdb" }); + it("creates immutable singleton definitions", () => { + expect(duckdb).toEqual({ + displayName: "DuckDB", + id: "duckdb", + }); expect(Object.isFrozen(duckdb)).toBe(true); - }); - - it.each([ - [{ displayName: "DuckDB", id: " " }, "SQL dialect id must contain 1 to 256"], - [ - { displayName: " ", id: "duckdb" }, - "SQL dialect display name must contain 1 to 1024", - ], - ])("rejects invalid definitions", (definition, message) => { - expect(() => defineSqlDialect(definition)).toThrow(message); + expect(duckdbDialect()).toBe(duckdb); + expect(postgresDialect()).toBe(postgres); + expect(bigQueryDialect()).toBe(bigQueryDialect()); + expect(dremioDialect()).toBe(dremioDialect()); }); it("rejects duplicate IDs", () => { expectSessionError("duplicate-dialect", () => { createSqlLanguageService({ - dialects: [duckdb, { displayName: "Other", id: "duckdb" }], + dialects: [duckdb, duckdbDialect()], }); }); }); - it("normalizes malformed definitions without invoking accessors", () => { + it("rejects copied and fabricated definitions without invoking traps", () => { let invoked = false; expectSessionError("invalid-dialect", () => { - defineSqlDialect({ - displayName: "DuckDB", - get id() { - invoked = true; - return "duckdb"; - }, + createSqlLanguageService({ + dialects: [{ ...duckdb }], }); }); - expect(invoked).toBe(false); expectSessionError("invalid-dialect", () => { - defineSqlDialect({ displayName: "DuckDB", id: 42 } as never); + createSqlLanguageService({ + dialects: [{ displayName: "DuckDB", id: "duckdb" } as never], + }); }); expectSessionError("invalid-dialect", () => { - defineSqlDialect(null as never); + createSqlLanguageService({ dialects: [null as never] }); }); expectSessionError("invalid-dialect", () => { - defineSqlDialect( - new Proxy( - {}, - { + createSqlLanguageService({ + dialects: [ + new Proxy(duckdb, { + get() { + invoked = true; + throw new Error("hostile"); + }, getOwnPropertyDescriptor() { + invoked = true; + throw new Error("hostile"); + }, + ownKeys() { + invoked = true; throw new Error("hostile"); }, }, - ) as never, + ) as never, + ], + }); + }); + expect(invoked).toBe(false); + }); +}); + +describe("statement-index session cache", () => { + it("binds lexical behavior through authentic dialect handles", () => { + const service = new DefaultSqlLanguageService({ + dialects: [ + bigQueryDialect(), + dremioDialect(), + duckdb, + postgres, + ], + }); + const cases = [ + { + dialect: "bigquery", + profile: BIGQUERY_SQL_LEXICAL_PROFILE, + text: "# hidden; still hidden\nSELECT r'''a;b''';", + }, + { + dialect: "dremio", + profile: DREMIO_SQL_LEXICAL_PROFILE, + text: "# code; SELECT $$a;b$$;", + }, + { + dialect: "duckdb", + profile: DUCKDB_SQL_LEXICAL_PROFILE, + text: "SELECT $$a;b$$; /* outer /* inner; */ done */", + }, + { + dialect: "postgresql", + profile: POSTGRESQL_SQL_LEXICAL_PROFILE, + text: "SELECT E'a\\';b'; SELECT $tag$c;d$tag$;", + }, + ] as const; + + for (const testCase of cases) { + const session = service.openDocument({ + context: { + dialect: testCase.dialect, + engine: "warehouse", + }, + text: testCase.text, + }); + expect(session.getStatementIndexForTesting()).toEqual( + buildSqlStatementIndex(testCase.text, testCase.profile), ); + } + }); + + it("builds lazily and reuses the current cache", () => { + const { session } = openSession("SELECT 1; SELECT 2"); + expect(session.cachedStatementIndexForTesting).toBeNull(); + + const index = session.getStatementIndexForTesting(); + expect(session.cachedStatementIndexForTesting).toBe(index); + expect(session.getStatementIndexForTesting()).toBe(index); + }); + + it("retains the index for unrelated context changes", () => { + const { session } = openSession("SELECT 1; SELECT 2"); + const index = session.getStatementIndexForTesting(); + session.update({ + kind: "context", + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "remote" }, }); + expect(session.cachedStatementIndexForTesting).toBe(index); }); - it("returns only normalized dialect fields", () => { - const extendedDefinition = { - displayName: "DuckDB", - id: "duckdb", - internal: true, - }; - const definition = defineSqlDialect(extendedDefinition); - expect(definition).toEqual({ displayName: "DuckDB", id: "duckdb" }); + it("invalidates the index when lexical profile identity changes", () => { + const { session } = openSession("SELECT $$a;b$$;"); + const index = session.getStatementIndexForTesting(); + session.update({ + kind: "context", + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expect(session.getStatementIndexForTesting()).not.toBe(index); + }); + + it("reuses the index across no-op document mutations", () => { + const { session } = openSession("SELECT 1"); + const initialSource = session.snapshotForTesting.source; + const index = session.getStatementIndexForTesting(); + + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "changes", changes: [] }, + }); + expect(session.snapshotForTesting.source).not.toBe(initialSource); + expect(session.cachedStatementIndexForTesting).toBe(index); + + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + expect(session.cachedStatementIndexForTesting).toBe(index); + }); + + it("updates incrementally to the full-scan oracle", () => { + const { session } = openSession("SELECT 1; SELECT 2; SELECT 3"); + const previous = session.getStatementIndexForTesting(); + session.update({ + kind: "document", + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 17, insert: "20", to: 18 }], + }, + }); + + const cached = session.cachedStatementIndexForTesting; + expect(cached).not.toBeNull(); + expect(cached).toEqual( + buildSqlStatementIndex( + "SELECT 1; SELECT 20; SELECT 3", + DUCKDB_SQL_LEXICAL_PROFILE, + ), + ); + expect(cached).not.toBe(previous); + expect(cached?.slots[0]).toBe(previous.slots[0]); + }); + + it("clears changed replacements and preserves the cache on failure", () => { + const { session } = openSession("SELECT 1"); + const index = session.getStatementIndexForTesting(); + const revision = session.revision; + expectSessionError("stale-revision", () => { + session.update({ + kind: "document", + baseRevision: {} as never, + document: { kind: "replace", text: "SELECT 2" }, + }); + }); + expect(session.revision).toBe(revision); + expect(session.cachedStatementIndexForTesting).toBe(index); + + session.update({ + kind: "document", + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); + }); + + it("releases the cache on disposal", () => { + const { session } = openSession(); + session.getStatementIndexForTesting(); + session.dispose(); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expectSessionError("session-disposed", () => { + session.getStatementIndexForTesting(); + }); }); }); @@ -502,7 +657,7 @@ describe("document changes", () => { kind: "document", get context() { invoked = true; - return { dialect: "postgres", engine: "warehouse" }; + return { dialect: "postgresql", engine: "warehouse" }; }, }; expectSessionError("invalid-update", () => { @@ -764,12 +919,12 @@ describe("document context", () => { session.update({ kind: "document", baseRevision: session.revision, - context: { dialect: "postgres", engine: "warehouse" }, + context: { dialect: "postgresql", engine: "warehouse" }, document: { kind: "replace", text: "SELECT 2" }, }); expect(session.snapshotForTesting).toMatchObject({ - context: { dialect: "postgres", engine: "warehouse" }, + context: { dialect: "postgresql", engine: "warehouse" }, source: { originalText: "SELECT 2" }, }); @@ -790,10 +945,10 @@ describe("document context", () => { session.update({ kind: "context", baseRevision: session.revision, - context: { dialect: "postgres", engine: "warehouse" }, + context: { dialect: "postgresql", engine: "warehouse" }, }); expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); - expect(session.snapshotForTesting.context.dialect).toBe("postgres"); + expect(session.snapshotForTesting.context.dialect).toBe("postgresql"); }); it("retains the owned context for document-only updates", () => { @@ -1084,7 +1239,7 @@ describe("lifecycle", () => { text: "", }); const second = service.openDocument({ - context: { dialect: "postgres", engine: "two" }, + context: { dialect: "postgresql", engine: "two" }, text: "", }); diff --git a/src/vnext/__tests__/statement-index.bench.ts b/src/vnext/__tests__/statement-index.bench.ts new file mode 100644 index 0000000..42fceb3 --- /dev/null +++ b/src/vnext/__tests__/statement-index.bench.ts @@ -0,0 +1,74 @@ +import { bench, describe } from "vitest"; +import { + buildSqlStatementIndex, + DUCKDB_SQL_LEXICAL_PROFILE, + MAX_SQL_STATEMENT_SLOTS, + updateSqlStatementIndex, +} from "../statement-index.js"; + +const padding = "x".repeat(1_000); +const oneMegabyteDocument = Array.from( + { length: 1_000 }, + (_, index) => `SELECT ${index} AS value /* ${padding} */`, +).join(";\n"); +const previousIndex = buildSqlStatementIndex( + oneMegabyteDocument, + DUCKDB_SQL_LEXICAL_PROFILE, +); +const middleFrom = oneMegabyteDocument.indexOf("500 AS value"); +const localChanges = [ + { + from: middleFrom, + insert: "501", + to: middleFrom + 3, + }, +] as const; +const localDocument = + oneMegabyteDocument.slice(0, middleFrom) + + "501" + + oneMegabyteDocument.slice(middleFrom + 3); +const shiftedChanges = [{ from: 0, insert: "-- lead\n", to: 0 }] as const; +const shiftedDocument = `-- lead\n${oneMegabyteDocument}`; +const cappedDocument = ";".repeat(MAX_SQL_STATEMENT_SLOTS + 5); +const cappedIndex = buildSqlStatementIndex( + cappedDocument, + DUCKDB_SQL_LEXICAL_PROFILE, +); +const capRecoveryChanges = [{ from: 0, insert: "", to: 10 }] as const; +const recoveredDocument = cappedDocument.slice(10); + +describe("statement index", () => { + bench("full 1 MiB scan", () => { + buildSqlStatementIndex( + oneMegabyteDocument, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + }); + + bench("incremental 1 MiB middle edit", () => { + updateSqlStatementIndex( + previousIndex, + localDocument, + localChanges, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + }); + + bench("incremental 1 MiB prefix shift", () => { + updateSqlStatementIndex( + previousIndex, + shiftedDocument, + shiftedChanges, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + }); + + bench("incremental resource-cap recovery", () => { + updateSqlStatementIndex( + cappedIndex, + recoveredDocument, + capRecoveryChanges, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + }); +}); diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 8febd34..674e5e5 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -1,12 +1,15 @@ export { + bigQueryDialect, createSqlLanguageService, - defineSqlDialect, + dremioDialect, + duckdbDialect, + postgresDialect, } from "./session.js"; export type { OpenSqlDocument, SqlCatalogContext, SqlContextInput, - SqlDialectDefinition, + SqlDialect, SqlDocumentChanges, SqlDocumentContext, SqlDocumentEdit, diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 8a9f215..f18ef0b 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -1,6 +1,5 @@ import type { OpenSqlDocument, - SqlDialectDefinition, SqlDocumentContext, SqlDocumentSession, SqlDocumentUpdate, @@ -10,6 +9,16 @@ import type { SqlTextChange, SqlTextRange, } from "./types.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, + type SqlLexicalProfile, + type SqlStatementIndex, + updateSqlStatementIndex, +} from "./statement-index.js"; import { createIdentitySqlSource, isSqlSourceError, @@ -17,7 +26,12 @@ import { normalizeSqlTextRange, type SqlSourceSnapshot, } from "./source.js"; -import { createSqlRevisionToken, SqlSessionError } from "./types.js"; +import { + createSqlDialect, + createSqlRevisionToken, + type SqlDialect, + SqlSessionError, +} from "./types.js"; const MAX_CONTEXT_DEPTH = 100; const MAX_CONTEXT_NODES = 10_000; @@ -27,8 +41,74 @@ const MAX_CONTEXT_STRING_LENGTH = 1_000_000; const MAX_CONTEXT_ARRAY_LENGTH = 50_000; const MAX_CHANGES_PER_UPDATE = 10_000; const MAX_DIALECTS = 1_000; -const MAX_DIALECT_ID_LENGTH = 256; -const MAX_DIALECT_DISPLAY_NAME_LENGTH = 1_024; + +interface SqlDialectRuntime { + readonly dialect: SqlDialect; + readonly lexicalProfile: SqlLexicalProfile; +} + +const sqlDialectRuntimes = new WeakMap(); + +function createBuiltinSqlDialect( + id: string, + displayName: string, + lexicalProfile: SqlLexicalProfile, +): SqlDialect { + const dialect = createSqlDialect(id, displayName); + sqlDialectRuntimes.set( + dialect, + Object.freeze({ dialect, lexicalProfile }), + ); + return dialect; +} + +const BIGQUERY_DIALECT = createBuiltinSqlDialect( + "bigquery", + "BigQuery", + BIGQUERY_SQL_LEXICAL_PROFILE, +); +const DREMIO_DIALECT = createBuiltinSqlDialect( + "dremio", + "Dremio", + DREMIO_SQL_LEXICAL_PROFILE, +); +const DUCKDB_DIALECT = createBuiltinSqlDialect( + "duckdb", + "DuckDB", + DUCKDB_SQL_LEXICAL_PROFILE, +); +const POSTGRES_DIALECT = createBuiltinSqlDialect( + "postgresql", + "PostgreSQL", + POSTGRESQL_SQL_LEXICAL_PROFILE, +); + +/** Returns the package-owned BigQuery dialect handle. */ +export function bigQueryDialect(): SqlDialect { + return BIGQUERY_DIALECT; +} + +/** Returns the package-owned Dremio dialect handle. */ +export function dremioDialect(): SqlDialect { + return DREMIO_DIALECT; +} + +/** Returns the package-owned DuckDB dialect handle. */ +export function duckdbDialect(): SqlDialect { + return DUCKDB_DIALECT; +} + +/** Returns the package-owned PostgreSQL dialect handle. */ +export function postgresDialect(): SqlDialect { + return POSTGRES_DIALECT; +} + +function getSqlDialectRuntime(candidate: unknown): SqlDialectRuntime | null { + if (typeof candidate !== "object" || candidate === null) { + return null; + } + return sqlDialectRuntimes.get(candidate) ?? null; +} interface PendingContextValue { readonly depth: number; @@ -222,17 +302,19 @@ function cloneContext(context: Context): Con } } -function validateDialect( +function resolveDialectRuntime( context: SqlDocumentContext, - dialects: ReadonlySet, -): void { + dialects: ReadonlyMap, +): SqlDialectRuntime { const dialect = readRequiredDataProperty( context, "dialect", "invalid-dialect", "SQL document context", ); - if (typeof dialect !== "string" || !dialects.has(dialect)) { + const runtime = + typeof dialect === "string" ? dialects.get(dialect) : undefined; + if (!runtime) { throw new SqlSessionError( "invalid-dialect", typeof dialect === "string" @@ -240,6 +322,7 @@ function validateDialect( : "SQL document context dialect must be a string", ); } + return runtime; } interface MissingDataProperty { @@ -427,25 +510,33 @@ function applyChanges(text: string, changes: readonly SqlTextChange[]): string { interface SessionSnapshot { readonly contextSequence: number; readonly context: Context; + readonly dialect: SqlDialectRuntime; readonly documentSequence: number; readonly revision: SqlRevision; readonly sequence: number; readonly source: SqlSourceSnapshot; } +interface StatementIndexCache { + readonly documentSequence: number; + readonly index: SqlStatementIndex; + readonly lexicalProfile: SqlLexicalProfile; +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { - readonly #dialects: ReadonlySet; + readonly #dialects: ReadonlyMap; readonly #onDispose: () => void; #disposed = false; #snapshot: SessionSnapshot; + #statementIndexCache: StatementIndexCache | null = null; #updating = false; constructor( source: SqlSourceSnapshot, context: Context, - dialects: ReadonlySet, + dialects: ReadonlyMap, onDispose: () => void, ) { this.#dialects = dialects; @@ -453,9 +544,11 @@ export class DefaultSqlDocumentSession const sequence = 0; const contextSequence = 0; const documentSequence = 0; + const dialect = resolveDialectRuntime(context, dialects); this.#snapshot = Object.freeze({ contextSequence, context, + dialect, documentSequence, revision: createSqlRevisionToken(), sequence, @@ -471,6 +564,37 @@ export class DefaultSqlDocumentSession return this.#snapshot; } + get cachedStatementIndexForTesting(): SqlStatementIndex | null { + return this.#statementIndexCache?.index ?? null; + } + + getStatementIndexForTesting(): SqlStatementIndex { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const cached = this.#statementIndexCache; + if ( + cached && + cached.documentSequence === this.#snapshot.documentSequence && + cached.lexicalProfile === this.#snapshot.dialect.lexicalProfile + ) { + return cached.index; + } + const index = buildSqlStatementIndex( + this.#snapshot.source.analysisText, + this.#snapshot.dialect.lexicalProfile, + ); + this.#statementIndexCache = Object.freeze({ + documentSequence: this.#snapshot.documentSequence, + index, + lexicalProfile: this.#snapshot.dialect.lexicalProfile, + }); + return index; + } + readonly update = (update: SqlDocumentUpdate): SqlRevision => { if (this.#disposed) { throw new SqlSessionError("session-disposed", "SQL document session is disposed"); @@ -530,6 +654,8 @@ export class DefaultSqlDocumentSession let nextContext = this.#snapshot.context; let nextDocumentSequence = this.#snapshot.documentSequence; let nextSource = this.#snapshot.source; + let documentMutation: "changes" | "none" | "replace" = "none"; + let trustedAnalysisChanges: readonly SqlTextChange[] | null = null; if (kind === "context") { if ( @@ -622,6 +748,7 @@ export class DefaultSqlDocumentSession } validateDocumentLength(text); nextSource = createIdentitySqlSource(text); + documentMutation = "replace"; } else if (documentKind === "changes") { if ( readOwnDataProperty( @@ -652,9 +779,11 @@ export class DefaultSqlDocumentSession nextSource.originalText, changes, ); + trustedAnalysisChanges = normalizedChanges; nextSource = createIdentitySqlSource( applyChanges(nextSource.originalText, normalizedChanges), ); + documentMutation = "changes"; } else { throw new SqlSessionError( "invalid-update", @@ -664,7 +793,11 @@ export class DefaultSqlDocumentSession nextDocumentSequence += 1; } - validateDialect(nextContext, this.#dialects); + const nextDialect = resolveDialectRuntime( + nextContext, + this.#dialects, + ); + const nextLexicalProfile = nextDialect.lexicalProfile; if (this.#disposed) { throw new SqlSessionError( "session-disposed", @@ -673,14 +806,48 @@ export class DefaultSqlDocumentSession } const sequence = this.#snapshot.sequence + 1; const revision = createSqlRevisionToken(); - this.#snapshot = Object.freeze({ + const nextSnapshot = Object.freeze({ contextSequence: nextContextSequence, context: nextContext, + dialect: nextDialect, documentSequence: nextDocumentSequence, revision, sequence, source: nextSource, }); + let nextStatementIndexCache = this.#statementIndexCache; + if ( + nextStatementIndexCache && + nextLexicalProfile !== this.#snapshot.dialect.lexicalProfile + ) { + nextStatementIndexCache = null; + } else if (nextStatementIndexCache && documentMutation !== "none") { + let nextIndex: SqlStatementIndex | null = null; + if ( + nextSource.analysisText === this.#snapshot.source.analysisText + ) { + nextIndex = nextStatementIndexCache.index; + } else if ( + documentMutation === "changes" && + trustedAnalysisChanges + ) { + nextIndex = updateSqlStatementIndex( + nextStatementIndexCache.index, + nextSource.analysisText, + trustedAnalysisChanges, + nextLexicalProfile, + ); + } + nextStatementIndexCache = nextIndex + ? Object.freeze({ + documentSequence: nextDocumentSequence, + index: nextIndex, + lexicalProfile: nextLexicalProfile, + }) + : null; + } + this.#snapshot = nextSnapshot; + this.#statementIndexCache = nextStatementIndexCache; return revision; } @@ -693,6 +860,7 @@ export class DefaultSqlDocumentSession return; } this.#disposed = true; + this.#statementIndexCache = null; this.#onDispose(); }; } @@ -700,7 +868,7 @@ export class DefaultSqlDocumentSession export class DefaultSqlLanguageService implements SqlLanguageService { - readonly #dialects: ReadonlySet; + readonly #dialects: ReadonlyMap; readonly #sessions = new Set>(); #disposed = false; @@ -736,7 +904,7 @@ export class DefaultSqlLanguageService ); } - const dialects = new Set(); + const dialects = new Map(); for (let index = 0; index < dialectCount; index += 1) { const dialect = readRequiredDataProperty( configuredDialects, @@ -744,14 +912,20 @@ export class DefaultSqlLanguageService "invalid-service-options", "SQL language service dialects", ); - const definition = defineSqlDialect(dialect); - if (dialects.has(definition.id)) { + const runtime = getSqlDialectRuntime(dialect); + if (!runtime) { + throw new SqlSessionError( + "invalid-dialect", + "SQL dialects must be created by a built-in dialect factory from this package instance", + ); + } + if (dialects.has(runtime.dialect.id)) { throw new SqlSessionError( "duplicate-dialect", - `Duplicate SQL dialect: ${definition.id}`, + `Duplicate SQL dialect: ${runtime.dialect.id}`, ); } - dialects.add(definition.id); + dialects.set(runtime.dialect.id, runtime); } this.#dialects = dialects; } catch (error) { @@ -806,7 +980,7 @@ export class DefaultSqlLanguageService ); } const context = cloneContext(candidateContext); - validateDialect(context, this.#dialects); + resolveDialectRuntime(context, this.#dialects); let session: DefaultSqlDocumentSession; session = new DefaultSqlDocumentSession( @@ -855,63 +1029,6 @@ export class DefaultSqlLanguageService }; } -/** Validates and normalizes one immutable dialect registration. */ -export function defineSqlDialect( - definition: SqlDialectDefinition, -): SqlDialectDefinition { - try { - if (definition === null || typeof definition !== "object") { - throw new SqlSessionError( - "invalid-dialect", - "SQL dialect definition must be an object", - ); - } - const id = readRequiredDataProperty( - definition, - "id", - "invalid-dialect", - "SQL dialect definition", - ); - const displayName = readRequiredDataProperty( - definition, - "displayName", - "invalid-dialect", - "SQL dialect definition", - ); - if ( - typeof id !== "string" || - id.length === 0 || - id.length > MAX_DIALECT_ID_LENGTH || - id.trim().length === 0 - ) { - throw new SqlSessionError( - "invalid-dialect", - `SQL dialect id must contain 1 to ${MAX_DIALECT_ID_LENGTH} code units`, - ); - } - if ( - typeof displayName !== "string" || - displayName.length === 0 || - displayName.length > MAX_DIALECT_DISPLAY_NAME_LENGTH || - displayName.trim().length === 0 - ) { - throw new SqlSessionError( - "invalid-dialect", - `SQL dialect display name must contain 1 to ${MAX_DIALECT_DISPLAY_NAME_LENGTH} code units`, - ); - } - return Object.freeze({ displayName, id }); - } catch (error) { - if (error instanceof SqlSessionError) { - throw error; - } - throw new SqlSessionError( - "invalid-dialect", - "SQL dialect definition could not be inspected safely", - ); - } -} - /** Creates a framework-independent SQL service with an immutable dialect registry. */ export function createSqlLanguageService< Context extends SqlDocumentContext = SqlDocumentContext, diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index e328024..171048c 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -1,3 +1,5 @@ +import type { SqlTextChange } from "./types.js"; + const analysisRangeBrand: unique symbol = Symbol("SqlAnalysisRange"); export const MAX_SQL_STATEMENT_SLOTS = 10_000; @@ -645,16 +647,37 @@ function quoteConstruct( : "double-quoted-identifier"; } -/** Builds the bounded, parser-free statement partition for one analysis text. */ -export function buildSqlStatementIndex( +interface SqlStatementScanOptions { + readonly from: number; + readonly prefixSlots: readonly SqlStatementSlot[]; + readonly tryReuseSuffix?: ( + boundary: number, + scannedSlots: readonly SqlStatementSlot[], + ) => SqlStatementIndex | null; +} + +function createStatementIndex( + slots: SqlStatementSlot[], +): SqlStatementIndex { + const finalSlot = getStatementSlot(slots, slots.length - 1); + return Object.freeze({ + endState: finalSlot.endState, + quality: + finalSlot.boundaryQuality === "opaque" ? "opaque" : "exact", + slots: Object.freeze(slots), + }); +} + +function scanSqlStatementIndex( analysisText: string, profile: SqlLexicalProfile, + options: SqlStatementScanOptions, ): SqlStatementIndex { - const slots: SqlStatementSlot[] = []; + const slots: SqlStatementSlot[] = [...options.prefixSlots]; const prefixGuard = new SqlPrefixGuard(profile.proceduralGuards); - let slotFrom = 0; + let slotFrom = options.from; let hasCode = false; - let cursor = 0; + let cursor = options.from; let finalEndState: ExactSqlStatementSlot["endState"] = NORMAL_END_STATE; const finishOpaque = ( @@ -668,11 +691,7 @@ export function buildSqlStatementIndex( detectedAt, ); slots.push(slot); - return Object.freeze({ - endState: slot.endState, - quality: "opaque", - slots: Object.freeze(slots), - }); + return createStatementIndex(slots); }; while (cursor < analysisText.length) { @@ -850,6 +869,10 @@ export function buildSqlStatementIndex( finalEndState = NORMAL_END_STATE; prefixGuard.reset(); cursor += 1; + const reused = options.tryReuseSuffix?.(slotFrom, slots); + if (reused) { + return reused; + } continue; } @@ -866,32 +889,25 @@ export function buildSqlStatementIndex( finalEndState, ), ); - return Object.freeze({ - endState: finalEndState, - quality: "exact", - slots: Object.freeze(slots), + return createStatementIndex(slots); +} + +/** Builds the bounded, parser-free statement partition for one analysis text. */ +export function buildSqlStatementIndex( + analysisText: string, + profile: SqlLexicalProfile, +): SqlStatementIndex { + return scanSqlStatementIndex(analysisText, profile, { + from: 0, + prefixSlots: [], }); } -/** Finds one slot with explicit behavior at shared extent boundaries. */ -export function findSqlStatementSlot( - index: SqlStatementIndex, +function statementSlotIndexAt( + slots: readonly SqlStatementSlot[], position: number, affinity: SqlCursorAffinity, -): SqlStatementSlot { - const slots = index.slots; - const documentLength = getStatementSlot(slots, slots.length - 1).extent.to; - if ( - !Number.isSafeInteger(position) || - position < 0 || - position > documentLength - ) { - throw new RangeError("SQL statement position is outside the document"); - } - if (affinity !== "left" && affinity !== "right") { - throw new TypeError("SQL cursor affinity must be left or right"); - } - +): number { let low = 0; let high = slots.length; while (low < high) { @@ -910,7 +926,248 @@ export function findSqlStatementSlot( ) { slotIndex -= 1; } - return getStatementSlot(slots, Math.max(0, slotIndex)); + return Math.max(0, slotIndex); +} + +function statementSlotAtOrAfter( + slots: readonly SqlStatementSlot[], + position: number, +): number { + let low = 0; + let high = slots.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const from = getStatementSlot(slots, middle).extent.from; + if (from < position) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +} + +function shiftEndState( + endState: ExactSqlStatementSlot["endState"], + delta: number, +): ExactSqlStatementSlot["endState"]; +function shiftEndState( + endState: OpaqueSqlStatementSlot["endState"], + delta: number, +): OpaqueSqlStatementSlot["endState"]; +function shiftEndState( + endState: SqlLexicalEndState, + delta: number, +): SqlLexicalEndState { + if (endState.kind === "normal") { + return endState; + } + return endState.kind === "opaque" + ? createOpaqueEndState(endState.reason, endState.from + delta) + : createUnterminatedEndState( + endState.construct, + endState.from + delta, + ); +} + +function shiftStatementSlot( + slot: SqlStatementSlot, + delta: number, +): SqlStatementSlot { + if (slot.boundaryQuality === "opaque") { + return Object.freeze({ + boundaryQuality: "opaque", + endState: shiftEndState(slot.endState, delta), + extent: createAnalysisRange( + slot.extent.from + delta, + slot.extent.to + delta, + ), + }); + } + return Object.freeze({ + boundaryQuality: "exact", + endState: shiftEndState(slot.endState, delta), + extent: createAnalysisRange( + slot.extent.from + delta, + slot.extent.to + delta, + ), + hasCode: slot.hasCode, + source: createAnalysisRange( + slot.source.from + delta, + slot.source.to + delta, + ), + terminator: slot.terminator + ? createAnalysisRange( + slot.terminator.from + delta, + slot.terminator.to + delta, + ) + : null, + }); +} + +function normalizeTrustedChanges( + changes: readonly SqlTextChange[], + previousLength: number, + nextLength: number, +): { + readonly delta: number; + readonly first: SqlTextChange; + readonly oldStableFrom: number; +} | null { + const first = changes[0]; + if (!first) { + return null; + } + let previousEnd = 0; + let delta = 0; + for (const change of changes) { + if ( + !Number.isSafeInteger(change.from) || + !Number.isSafeInteger(change.to) || + change.from < previousEnd || + change.from < 0 || + change.from > change.to || + change.to > previousLength || + typeof change.insert !== "string" + ) { + return null; + } + delta += change.insert.length - (change.to - change.from); + previousEnd = change.to; + } + const last = changes[changes.length - 1]; + if (!last || previousLength + delta !== nextLength) { + return null; + } + return { delta, first, oldStableFrom: last.to }; +} + +/** + * Updates an index from trusted, normalized analysis-coordinate changes. + * Falls back to the full oracle whenever the change metadata is inconsistent. + */ +export function updateSqlStatementIndex( + previousIndex: SqlStatementIndex, + nextAnalysisText: string, + changes: readonly SqlTextChange[], + profile: SqlLexicalProfile, +): SqlStatementIndex { + const previousSlots = previousIndex.slots; + const previousLength = getStatementSlot( + previousSlots, + previousSlots.length - 1, + ).extent.to; + if (changes.length === 0) { + return previousLength === nextAnalysisText.length + ? previousIndex + : buildSqlStatementIndex(nextAnalysisText, profile); + } + const normalized = normalizeTrustedChanges( + changes, + previousLength, + nextAnalysisText.length, + ); + if (!normalized) { + return buildSqlStatementIndex(nextAnalysisText, profile); + } + + const restartIndex = statementSlotIndexAt( + previousSlots, + normalized.first.from, + "left", + ); + const restartFrom = getStatementSlot( + previousSlots, + restartIndex, + ).extent.from; + const prefixSlots = previousSlots.slice(0, restartIndex); + const newStableFrom = normalized.oldStableFrom + normalized.delta; + let oldSuffixCursor = statementSlotAtOrAfter( + previousSlots, + normalized.oldStableFrom, + ); + const previousFinalSlot = getStatementSlot( + previousSlots, + previousSlots.length - 1, + ); + const previousHasResourceLimit = + previousFinalSlot.boundaryQuality === "opaque" && + previousFinalSlot.endState.reason === "resource-limit"; + + return scanSqlStatementIndex(nextAnalysisText, profile, { + from: restartFrom, + prefixSlots, + tryReuseSuffix: (newBoundary, scannedSlots) => { + if (newBoundary < newStableFrom) { + return null; + } + const oldBoundary = newBoundary - normalized.delta; + while ( + oldSuffixCursor < previousSlots.length && + getStatementSlot( + previousSlots, + oldSuffixCursor, + ).extent.from < oldBoundary + ) { + oldSuffixCursor += 1; + } + if ( + oldSuffixCursor >= previousSlots.length || + getStatementSlot( + previousSlots, + oldSuffixCursor, + ).extent.from !== oldBoundary + ) { + return null; + } + const oldSuffixLength = previousSlots.length - oldSuffixCursor; + if ( + scannedSlots.length + oldSuffixLength > + MAX_SQL_STATEMENT_SLOTS + ) { + return null; + } + if ( + previousHasResourceLimit && + scannedSlots.length !== oldSuffixCursor + ) { + return null; + } + const oldSuffix = previousSlots.slice(oldSuffixCursor); + const suffix = + normalized.delta === 0 + ? oldSuffix + : oldSuffix.map((slot) => + shiftStatementSlot(slot, normalized.delta), + ); + return createStatementIndex([...scannedSlots, ...suffix]); + }, + }); +} + +/** Finds one slot with explicit behavior at shared extent boundaries. */ +export function findSqlStatementSlot( + index: SqlStatementIndex, + position: number, + affinity: SqlCursorAffinity, +): SqlStatementSlot { + const slots = index.slots; + const documentLength = getStatementSlot(slots, slots.length - 1).extent.to; + if ( + !Number.isSafeInteger(position) || + position < 0 || + position > documentLength + ) { + throw new RangeError("SQL statement position is outside the document"); + } + if (affinity !== "left" && affinity !== "right") { + throw new TypeError("SQL cursor affinity must be left or right"); + } + + return getStatementSlot( + slots, + statementSlotIndexAt(slots, position, affinity), + ); } function getStatementSlot( diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 08ca522..1848304 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -42,11 +42,30 @@ export type SqlPlainData = export type SqlContextInput = Context & SqlPlainData; -export interface SqlDialectDefinition { +const sqlDialectBrand: unique symbol = Symbol("SqlDialect"); + +/** Opaque in-process configuration for one built-in SQL dialect. */ +export interface SqlDialect { + readonly [sqlDialectBrand]: "SqlDialect"; readonly id: string; readonly displayName: string; } +export function createSqlDialect( + id: string, + displayName: string, +): SqlDialect { + const dialect: SqlDialect = { + [sqlDialectBrand]: "SqlDialect", + displayName, + id, + }; + Object.defineProperty(dialect, sqlDialectBrand, { + enumerable: false, + }); + return Object.freeze(dialect); +} + /** One half-open UTF-16 range in document coordinates. */ export interface SqlTextRange { readonly from: number; @@ -106,7 +125,7 @@ export interface SqlLanguageService { } export interface SqlLanguageServiceOptions { - readonly dialects: readonly SqlDialectDefinition[]; + readonly dialects: readonly SqlDialect[]; } export type SqlSessionErrorCode = diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 5d3e9f7..531c762 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -1,6 +1,7 @@ import { createSqlLanguageService, - defineSqlDialect, + duckdbDialect, + type SqlDialect, type SqlDocumentContext, type SqlDocumentSession, type SqlLanguageService, @@ -17,7 +18,8 @@ interface DateContext extends HostContext { readonly lastUsed: Date; } -const dialect = defineSqlDialect({ displayName: "DuckDB", id: "duckdb" }); +const dialect = duckdbDialect(); +const typedDialect: SqlDialect = dialect; const service = createSqlLanguageService({ dialects: [dialect] }); const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, @@ -79,9 +81,16 @@ change.from = 1; range.to = 1; // @ts-expect-error session revision is readonly session.revision = revision; +// @ts-expect-error statement indexes remain an internal session detail +session.getStatementIndexForTesting(); +// @ts-expect-error dialect IDs are readonly +dialect.id = "other"; +// @ts-expect-error structural dialect objects are not authentic handles +createSqlLanguageService({ dialects: [{ id: "duckdb", displayName: "DuckDB" }] }); void objectRevision; void numberRevision; void range; +void typedDialect; void widenedService; void widenedSession; From 9e9e17e77a1236de8306b4803c7d4dfc13e3c11e Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 00:59:26 +0800 Subject: [PATCH 07/42] feat(vnext): define normalized syntax contract (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - define the internal normalized syntax state and parser-analysis contracts - bind every artifact, diagnostic, analysis, conformance claim, and parser to exact source plus authenticated backend/configuration/dialect authority - add one mandatory parser runner that normalizes failures and returns runner-created analyzed state - add runtime and compile-time invariant tests plus ADR 0002 - keep the stable vNext export surface unchanged ## Safety and correctness - direct invalidity requires matching conformance authority - compatibility rejection cannot become authoritative invalid SQL - malformed locations fail closed instead of becoming unavailable - parser input is capped at 1 MiB; messages, diagnostics, and limitations are bounded - hostile custom iterators cannot bypass collection limits - full AbortSignal runtime validation matches the declared callback surface - raw ASTs and backend payloads remain private ## Validation - 875 unit tests passed, with 1 governed expected failure - changed syntax.ts coverage: 97.68% statements, 95.42% branches, 100% functions, 97.66% lines - full typecheck and oxlint passed - browser tests passed - test integrity passed - packed-consumer smoke passed - demo build passed - two independent adversarial reviewers approved exact commit db43409 after four fix/review loops ## Scope Internal contract only. This PR does not add a parser adapter, cache/session wiring, catalog types, or stable public exports. --- ## Summary by cubic Defines an internal normalized SQL syntax contract with a single parser runner that authenticates outputs and separates lexical eligibility from parser analysis. Strengthens cancellation: preserves the exact `AbortSignal` reason, rejects pre-aborted requests without invoking the backend, and races cancellation vs. backend completion correctly; public vNext exports stay the same and ADR 0002 documents the boundary. - New Features - Normalized statement states and parser analyses. - Branded statement-relative ranges; parser receives only exact statement text and an AbortSignal. - Parser, authority, and conformance identities; artifacts/diagnostics bound to exact source and authority; backend data stays private. - Runner validates identities, normalizes sync/async failures, handles cancellation races, and returns authenticated analyzed state. Adds ADR 0002 and tests; no change to stable exports. - Safety - 1 MiB input cap; bounded messages and collections; hostile iterators contained. - `AbortSignal` surface validated (cross-realm supported); preserves exact abort reason; pre-aborted requests skip backend; late aborts don’t override completed backend outcomes. - Malformed locations and fabricated results fail closed as malformed-output. - Compatibility rejection is never treated as invalid; invalidity requires matching conformance and authority identities. Written for commit ed58bac9c2ef9ca7551c042a208fc1b49c11c47f. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 3 +- docs/adr/0002-normalized-syntax-contract.md | 195 +++ src/vnext/__tests__/syntax.test.ts | 1382 +++++++++++++++++ src/vnext/syntax.ts | 1255 +++++++++++++++ test/vnext-types/syntax.test-d.ts | 182 +++ 5 files changed, 3016 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-normalized-syntax-contract.md create mode 100644 src/vnext/__tests__/syntax.test.ts create mode 100644 src/vnext/syntax.ts create mode 100644 test/vnext-types/syntax.test-d.ts diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index 763a1d9..f838e87 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -91,7 +91,8 @@ service.dispose(); `syntax` is an opaque module accepted by the stable service factory. Its parser and semantic-model SPI is not stable in this ADR. Official adapters can use an experimental subpath until two materially different backends validate the -normalized artifacts. +normalized artifacts. The first internal boundary is specified by +[ADR 0002](./0002-normalized-syntax-contract.md). ## Document context diff --git a/docs/adr/0002-normalized-syntax-contract.md b/docs/adr/0002-normalized-syntax-contract.md new file mode 100644 index 0000000..d165717 --- /dev/null +++ b/docs/adr/0002-normalized-syntax-contract.md @@ -0,0 +1,195 @@ +# ADR 0002: Internal Normalized Syntax Contract + +Status: accepted +Date: 2026-07-24 + +## Context + +The legacy parser API accepts a complete CodeMirror `EditorState`, mutates +offset bookkeeping, exposes backend-specific AST shapes, and can report success +without a usable tree. Dialect support and location quality are also easy to +overstate: a compatibility grammar rejecting input is not evidence that the +target dialect is invalid. + +The vNext statement index already separates exact, incomplete, and opaque +lexical boundaries. The next layer needs a narrow parser boundary that can +support multiple backends without making the first backend's AST public or +mixing lexical eligibility, parser evidence, and request cancellation into one +ambiguous result. + +This contract is internal. It must be exercised by a real adapter and semantic +consumer before any part is considered for the stable `/vnext` API. + +## Decision + +Syntax processing has three separate state machines: + +1. Lexical eligibility decides whether a statement is empty, incomplete, + opaque, unavailable, or eligible for an analyzed result. +2. Parser analysis reports parsed, invalid, unsupported, or failed evidence. +3. The session request lifecycle owns caller cancellation, supersession, + disposal, revision applicability, and timeouts. + +Parser outcomes never contain `empty`, `incomplete`, `opaque`, `cancelled`, or +`superseded`. Keeping those layers separate prevents a parser failure from +being mistaken for invalid SQL and prevents an incomplete lexical construct +from being sent to a backend. + +The parser runner transports cancellation without classifying it: a +pre-aborted request does not invoke the backend, and an in-flight abort rejects +with the signal's exact reason if it wins the race with backend completion. +Late backend rejection remains handled. The session decides whether that +rejection represents cancellation, supersession, disposal, or another request +lifecycle event. + +### Eligibility + +| State | Meaning | Parser invoked | +| --- | --- | --- | +| `empty` | Exact slot has no code | No | +| `incomplete` | Exact slot ends inside a known lexical construct | No | +| `opaque` | Statement boundaries are not trustworthy | No | +| `unavailable` | No parser is configured for the resolved dialect | No | +| `analyzed` | The parser returned authenticated normalized evidence | Yes | + +Incomplete and opaque states preserve the scanner's closed construct/reason +unions and a statement-relative location. They do not accept arbitrary strings. + +### Parser analysis + +`parsed` has two mutually exclusive modes: + +- `direct` carries a conformance identity. The backend is configured for the + target grammar and may make authoritative syntax claims within that grammar. +- `compatibility` carries one or more explicit limitations. It may provide a + useful artifact, but it does not claim target-dialect conformance. + +`invalid` requires a direct conformance identity and at least one normalized +syntax diagnostic. Compatibility rejection is therefore `unsupported` with +reason `compatibility-rejected`, never `invalid`. + +`unsupported` is an expected capability boundary: backend capability, +compatibility rejection, uncovered construct, or resource limit. `failed` +means the backend failed or violated its output contract; it carries a bounded +safe message and explicit retryability. + +Parser-reported locations are either an exact statement-relative range or +explicitly `unavailable/not-reported`. An adapter that claims a malformed +location returns `failed/malformed-output`; it must not erase the defect by +turning the location into `unavailable`. + +### Coordinates and input + +The parser receives only: + +- The exact code-bearing normal slot's `source` slice +- An `AbortSignal` + +The slice excludes the statement terminator and is not trimmed. It contains no +editor state, document offsets, cursor, catalog, focus, or host context. +Parser requests are package-created and frozen. Input is bounded to 1 MiB; a +larger statement is an explicit coordinator resource limit and is not passed to +the backend. Abort signals are checked by their cross-realm-compatible +`AbortSignal` surface instead of `instanceof`; adapters may safely use every +member declared by that interface. + +All artifact and diagnostic ranges are branded half-open UTF-16 offsets +relative to that exact slice: + +```text +0 <= from <= to <= statementText.length +``` + +Absolute document ranges and statement-relative ranges are not assignable. +This lets an unchanged statement artifact move with an edited prefix without +rewriting its coordinates. + +### Artifacts and identity + +The normalized artifact initially exposes only a closed statement kind and its +full statement-relative range. It exposes no generic AST, facts bag, backend +node, source text, or mutable payload. + +Package-private metadata binds every artifact, diagnostic, and analysis to its +exact immutable statement text. This text is not exposed or copied into the +artifact, but exact equality provides collision-free reuse for identical +statements and prevents same-length text from sharing evidence. + +Artifacts, diagnostics, analyses, states, ranges, and identities are created by +package constructors, frozen, and authenticated through package-owned weak +identity sets. Structural copies and fabricated objects are rejected at +runtime. Future official adapters may key private `WeakMap` payloads by the +artifact so relation extraction can reuse a parse without exposing backend +data. + +Cache authority requires three distinct package-owned identities: + +- Backend/module identity +- Parser-configuration identity +- Dialect-syntax identity + +One frozen package-owned parser-authority handle captures that exact tuple. +Parsers, artifacts, diagnostics, analyses, and future private backend payloads +all retain the handle in private metadata. The runner rejects every outcome +whose handle differs from its parser, including compatibility, unsupported, and +failed outcomes. A conformance identity is scoped to the same handle, so direct +or invalid evidence cannot pair one backend's artifact with another backend's +authority. + +Direct validity also carries a conformance identity. String dialect IDs never +substitute for these authorities. A conformance identity is created for and +retains the exact backend, parser-configuration, and dialect-syntax identity +tuple; a parser cannot use another tuple's conformance to make an authoritative +claim. + +Parsers are also package-created frozen descriptors. Their callback stays in +private metadata and can be invoked only through the contract runner. The +runner: + +- Accepts only authentic parser and request objects +- Normalizes synchronous throws and rejected promises without retaining raw + errors +- Rejects fabricated results +- Verifies that every authentic artifact or diagnostic was constructed for the + exact request text +- Verifies direct/invalid conformance against the parser's exact authority + tuple + +The runner returns the authenticated `analyzed` lexical state directly; there +is no separately callable analyzed-state constructor. This prevents authentic +evidence created for one statement from being replayed against another. + +Compatibility limitations are a unique, non-empty subset of the three closed +values and are capped accordingly. Diagnostic collections, messages, parser +input, and retained strings are likewise bounded before backend-controlled work +can cause unbounded copying. + +## Consequences + +- Impossible result combinations are rejected by TypeScript and constructor + validation. +- Backends can be changed without exposing their AST as API. +- Unsupported dialect coverage remains honest. +- Parser artifacts can be cached independently of catalog generations. +- The contract has more explicit result variants, but consumers narrow on + stable discriminants instead of interpreting nullable fields or exceptions. + +The initial `node-sql-parser` adapter will lazy-load dialect-specific builds, +request locations, retain backend data privately, and normalize every boundary +before constructing these results. Cache/session wiring follows only after the +adapter passes the contract suite. + +## Non-goals + +This decision does not define: + +- A public parser plugin SPI +- A universal SQL AST +- Semantic relations, scopes, types, or catalog lookup +- Recovery-node semantics +- Document-level validation +- Session cancellation or stale-result publication +- Parser cache sizing and eviction + +Those layers depend on this contract but remain independently reviewable +decisions. diff --git a/src/vnext/__tests__/syntax.test.ts b/src/vnext/__tests__/syntax.test.ts new file mode 100644 index 0000000..97bcaf5 --- /dev/null +++ b/src/vnext/__tests__/syntax.test.ts @@ -0,0 +1,1382 @@ +import { describe, expect, it } from "vitest"; +import { + createCompatibilityParsedAnalysis, + createDirectParsedAnalysis, + createEmptySyntaxState, + createFailedParserAnalysis, + createIncompleteSyntaxState, + createInvalidParserAnalysis, + createOpaqueSyntaxState, + createSqlConformanceIdentity, + createSqlDialectSyntaxIdentity, + createSqlParserDiagnostic, + createSqlParserAuthority, + createSqlStatementParseRequest, + createSqlStatementParser, + createSqlStatementRelativeRange, + createSqlSyntaxArtifact, + createSqlSyntaxBackendIdentity, + createSqlSyntaxConfigurationIdentity, + createUnavailableSyntaxState, + createUnsupportedParserAnalysis, + isSqlParserAnalysis, + isSqlStatementSyntaxState, + MAX_SQL_SYNTAX_DIAGNOSTICS, + MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH, + MAX_SQL_SYNTAX_MESSAGE_LENGTH, + MAX_SQL_STATEMENT_PARSE_LENGTH, + runSqlStatementParser, + SqlSyntaxContractError, + type SqlStatementKind, +} from "../syntax.js"; + +function invokeWithUnknown( + callback: (...values: never[]) => unknown, + ...values: unknown[] +): unknown { + return Reflect.apply(callback, undefined, values); +} + +function expectContractError( + callback: () => unknown, + code: SqlSyntaxContractError["code"], +): void { + try { + callback(); + } catch (error: unknown) { + expect(error).toBeInstanceOf(SqlSyntaxContractError); + if (error instanceof SqlSyntaxContractError) { + expect(error.code).toBe(code); + expect(error.name).toBe("SqlSyntaxContractError"); + } + return; + } + throw new Error(`Expected ${code}`); +} + +function createParserAuthority() { + const backendIdentity = createSqlSyntaxBackendIdentity(); + const configurationIdentity = + createSqlSyntaxConfigurationIdentity(); + const dialectIdentity = createSqlDialectSyntaxIdentity(); + const authority = createSqlParserAuthority( + backendIdentity, + configurationIdentity, + dialectIdentity, + ); + const conformance = createSqlConformanceIdentity(authority); + return { + authority, + backendIdentity, + configurationIdentity, + conformance, + dialectIdentity, + }; +} + +describe("statement-relative ranges", () => { + it("creates frozen half-open UTF-16 ranges, including empty ranges", () => { + const astralText = "a🦆\ud800"; + const full = createSqlStatementRelativeRange( + 0, + astralText.length, + astralText.length, + ); + const atEnd = createSqlStatementRelativeRange( + astralText.length, + astralText.length, + astralText.length, + ); + + expect(full).toMatchObject({ from: 0, to: 4 }); + expect(atEnd).toMatchObject({ from: 4, to: 4 }); + expect(Object.isFrozen(full)).toBe(true); + }); + + it.each([ + [-1, 0, 0], + [1, 0, 1], + [0, 2, 1], + [0.5, 1, 1], + [0, Number.NaN, 1], + [0, 1, Number.POSITIVE_INFINITY], + [0, 0, -1], + [0, 0, 0.5], + ])("rejects invalid range (%j, %j, %j)", (from, to, length) => { + expectContractError( + () => createSqlStatementRelativeRange(from, to, length), + "invalid-range", + ); + }); +}); + +describe("opaque identities", () => { + it("creates frozen, distinct identities for each authority boundary", () => { + const backend = createSqlSyntaxBackendIdentity(); + const configuration = createSqlSyntaxConfigurationIdentity(); + const dialect = createSqlDialectSyntaxIdentity(); + const authority = createSqlParserAuthority( + backend, + configuration, + dialect, + ); + const conformance = createSqlConformanceIdentity(authority); + + expect(Object.isFrozen(backend)).toBe(true); + expect(Object.isFrozen(configuration)).toBe(true); + expect(Object.isFrozen(dialect)).toBe(true); + expect(Object.isFrozen(authority)).toBe(true); + expect(authority).toMatchObject({ + backendIdentity: backend, + configurationIdentity: configuration, + dialectIdentity: dialect, + }); + expect(Object.isFrozen(conformance)).toBe(true); + expect(createSqlSyntaxBackendIdentity()).not.toBe(backend); + expect(createSqlSyntaxConfigurationIdentity()).not.toBe(configuration); + expect(createSqlDialectSyntaxIdentity()).not.toBe(dialect); + expect(createSqlConformanceIdentity(authority)).not.toBe( + conformance, + ); + }); + + it("rejects fabricated authority inputs", () => { + const backend = createSqlSyntaxBackendIdentity(); + const configuration = createSqlSyntaxConfigurationIdentity(); + const dialect = createSqlDialectSyntaxIdentity(); + expectContractError( + () => + invokeWithUnknown( + createSqlParserAuthority, + {}, + configuration, + dialect, + ), + "invalid-identity", + ); + expectContractError( + () => + invokeWithUnknown( + createSqlParserAuthority, + backend, + {}, + dialect, + ), + "invalid-identity", + ); + expectContractError( + () => + invokeWithUnknown( + createSqlParserAuthority, + backend, + configuration, + {}, + ), + "invalid-identity", + ); + expectContractError( + () => invokeWithUnknown(createSqlConformanceIdentity, {}), + "invalid-identity", + ); + }); +}); + +describe("syntax artifacts and diagnostics", () => { + const { authority } = createParserAuthority(); + const kinds: readonly SqlStatementKind[] = [ + "alter", + "create", + "delete", + "drop", + "insert", + "merge", + "other", + "query", + "transaction", + "update", + ]; + + it.each(kinds)("creates an authentic frozen %s artifact", (kind) => { + const artifact = createSqlSyntaxArtifact( + kind, + "x".repeat(12), + authority, + ); + expect(artifact.kind).toBe(kind); + expect(artifact.range).toMatchObject({ from: 0, to: 12 }); + expect(Object.isFrozen(artifact)).toBe(true); + expect(Object.isFrozen(artifact.range)).toBe(true); + }); + + it("rejects unknown kinds and invalid lengths", () => { + expectContractError( + () => createSqlSyntaxArtifact("select", "abc", authority), + "invalid-artifact", + ); + expectContractError( + () => createSqlSyntaxArtifact("query", -1, authority), + "invalid-artifact", + ); + }); + + it("normalizes exact and unavailable diagnostic locations", () => { + const range = createSqlStatementRelativeRange(2, 5, 8); + const exact = createSqlParserDiagnostic( + " bad token ", + range, + "12345678", + authority, + ); + const unavailable = createSqlParserDiagnostic( + "bad", + null, + "12345678", + authority, + ); + + expect(exact).toMatchObject({ + code: "syntax-error", + location: { availability: "exact", range }, + message: "bad token", + severity: "error", + }); + expect(unavailable.location).toEqual({ + availability: "unavailable", + reason: "not-reported", + }); + expect(Object.isFrozen(exact)).toBe(true); + expect(Object.isFrozen(exact.location)).toBe(true); + expect(Object.isFrozen(unavailable.location)).toBe(true); + }); + + it("bounds messages and rejects malformed diagnostics", () => { + const longMessage = "x".repeat(MAX_SQL_SYNTAX_MESSAGE_LENGTH + 10); + expect( + createSqlParserDiagnostic( + longMessage, + null, + "", + authority, + ).message, + ).toHaveLength(MAX_SQL_SYNTAX_MESSAGE_LENGTH); + + for (const message of ["", " \n "]) { + expectContractError( + () => + createSqlParserDiagnostic(message, null, "", authority), + "invalid-diagnostic", + ); + } + for (const location of [false, 0, "", undefined, Number.NaN]) { + expectContractError( + () => + createSqlParserDiagnostic( + "bad", + location, + "x", + authority, + ), + "invalid-diagnostic", + ); + } + expectContractError( + () => + invokeWithUnknown( + createSqlParserDiagnostic, + 12, + null, + "", + authority, + ), + "invalid-diagnostic", + ); + expectContractError( + () => + createSqlParserDiagnostic( + "x".repeat(MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH + 1), + null, + "", + authority, + ), + "invalid-diagnostic", + ); + const outside = createSqlStatementRelativeRange(0, 4, 4); + expectContractError( + () => + createSqlParserDiagnostic( + "bad", + outside, + "abc", + authority, + ), + "invalid-diagnostic", + ); + expectContractError( + () => + invokeWithUnknown( + createSqlParserDiagnostic, + "bad", + { from: 0, to: 1 }, + "x", + authority, + ), + "invalid-diagnostic", + ); + }); +}); + +describe("parser analyses", () => { + const { authority, conformance } = createParserAuthority(); + const artifact = createSqlSyntaxArtifact( + "query", + "SELECT 1", + authority, + ); + const diagnostic = createSqlParserDiagnostic( + "bad", + null, + "SELECT 1", + authority, + ); + + it("constructs direct and compatibility success without raw AST data", () => { + const direct = createDirectParsedAnalysis(conformance, artifact); + const input = ["dialect-compatibility", "recovered"] as const; + const compatibility = createCompatibilityParsedAnalysis(artifact, input); + + expect(direct).toMatchObject({ + artifact, + conformance, + mode: "direct", + status: "parsed", + }); + expect(compatibility).toMatchObject({ + artifact, + limitations: input, + mode: "compatibility", + status: "parsed", + }); + expect(compatibility.limitations).not.toBe(input); + expect(Object.isFrozen(compatibility.limitations)).toBe(true); + expect(isSqlParserAnalysis(direct)).toBe(true); + expect(isSqlParserAnalysis(compatibility)).toBe(true); + }); + + it("constructs authoritative invalid analyses with bounded diagnostics", () => { + const input = [diagnostic] as const; + const invalid = createInvalidParserAnalysis(conformance, input); + + expect(invalid).toMatchObject({ + conformance, + diagnostics: input, + status: "invalid", + }); + expect(invalid.diagnostics).not.toBe(input); + expect(Object.isFrozen(invalid.diagnostics)).toBe(true); + }); + + it("constructs every closed unsupported and failure reason", () => { + for (const reason of [ + "backend-capability", + "compatibility-rejected", + "resource-limit", + "uncovered-construct", + ] as const) { + expect( + createUnsupportedParserAnalysis( + reason, + "SELECT 1", + authority, + ), + ).toMatchObject({ + reason, + status: "unsupported", + }); + } + for (const reason of [ + "backend-failure", + "malformed-output", + ] as const) { + expect( + createFailedParserAnalysis( + reason, + " unavailable ", + true, + "SELECT 1", + authority, + ), + ).toMatchObject({ + message: "unavailable", + reason, + retryable: true, + status: "failed", + }); + } + }); + + it("rejects fabricated identities, artifacts, and diagnostics", () => { + expectContractError( + () => + invokeWithUnknown( + createDirectParsedAnalysis, + {}, + artifact, + ), + "invalid-identity", + ); + expectContractError( + () => + invokeWithUnknown( + createDirectParsedAnalysis, + conformance, + {}, + ), + "invalid-artifact", + ); + expectContractError( + () => + invokeWithUnknown( + createInvalidParserAnalysis, + conformance, + [{}], + ), + "invalid-analysis", + ); + expectContractError( + () => + createCompatibilityParsedAnalysis(artifact, [ + "recovered", + "recovered", + ]), + "invalid-analysis", + ); + expectContractError( + () => + createCompatibilityParsedAnalysis(artifact, [ + "dialect-compatibility", + "partial-artifact", + "recovered", + "recovered", + ]), + "invalid-analysis", + ); + const hostileDiagnostics = [diagnostic]; + Object.defineProperty(hostileDiagnostics, Symbol.iterator, { + value: function* hostileIterator() { + for ( + let index = 0; + index <= MAX_SQL_SYNTAX_DIAGNOSTICS; + index += 1 + ) { + yield diagnostic; + } + }, + }); + expect( + createInvalidParserAnalysis( + conformance, + hostileDiagnostics, + ).diagnostics, + ).toHaveLength(1); + + const hostileLimitations: Array<"recovered"> = ["recovered"]; + Object.defineProperty(hostileLimitations, Symbol.iterator, { + value: function* hostileIterator() { + while (true) { + yield "recovered"; + } + }, + }); + expect( + createCompatibilityParsedAnalysis( + artifact, + hostileLimitations, + ).limitations, + ).toEqual(["recovered"]); + }); + + it("rejects empty, invalid, and oversized collections", () => { + expectContractError( + () => createCompatibilityParsedAnalysis(artifact, []), + "invalid-analysis", + ); + expectContractError( + () => + invokeWithUnknown( + createCompatibilityParsedAnalysis, + artifact, + ["unknown"], + ), + "invalid-analysis", + ); + expectContractError( + () => createInvalidParserAnalysis(conformance, []), + "invalid-analysis", + ); + expectContractError( + () => + createInvalidParserAnalysis( + conformance, + Array.from( + { length: MAX_SQL_SYNTAX_DIAGNOSTICS + 1 }, + () => diagnostic, + ), + ), + "invalid-analysis", + ); + const wrongSource = createSqlParserDiagnostic( + "bad", + null, + "SELECT 2", + authority, + ); + expectContractError( + () => + createInvalidParserAnalysis(conformance, [ + diagnostic, + wrongSource, + ]), + "invalid-analysis", + ); + const other = createParserAuthority(); + const otherDiagnostic = createSqlParserDiagnostic( + "bad", + null, + "SELECT 1", + other.authority, + ); + expectContractError( + () => + createInvalidParserAnalysis(conformance, [ + diagnostic, + otherDiagnostic, + ]), + "invalid-analysis", + ); + expectContractError( + () => + createInvalidParserAnalysis(conformance, [ + otherDiagnostic, + ]), + "invalid-analysis", + ); + expectContractError( + () => + invokeWithUnknown( + createInvalidParserAnalysis, + conformance, + "not-an-array", + ), + "invalid-analysis", + ); + }); + + it("rejects invalid reason, message, and retryability values", () => { + expectContractError( + () => + invokeWithUnknown( + createUnsupportedParserAnalysis, + "unknown", + "x", + authority, + ), + "invalid-analysis", + ); + expectContractError( + () => + invokeWithUnknown( + createFailedParserAnalysis, + "unknown", + "bad", + false, + "x", + authority, + ), + "invalid-analysis", + ); + expectContractError( + () => + invokeWithUnknown( + createFailedParserAnalysis, + "backend-failure", + "bad", + "yes", + "x", + authority, + ), + "invalid-analysis", + ); + expectContractError( + () => + createFailedParserAnalysis( + "backend-failure", + " ", + false, + "x", + authority, + ), + "invalid-analysis", + ); + }); + + it("recognizes only package-created analyses", () => { + expect(isSqlParserAnalysis(null)).toBe(false); + expect(isSqlParserAnalysis("parsed")).toBe(false); + expect( + isSqlParserAnalysis( + Object.freeze({ mode: "direct", status: "parsed" }), + ), + ).toBe(false); + }); +}); + +describe("lexical eligibility states", () => { + it("keeps lexical eligibility distinct from parser analysis", async () => { + const empty = createEmptySyntaxState(); + const opening = createSqlStatementRelativeRange(7, 7, 8); + const incomplete = createIncompleteSyntaxState( + "single-quoted-string", + opening, + 8, + ); + const opaque = createOpaqueSyntaxState( + "procedural-block", + opening, + 8, + ); + const unavailable = createUnavailableSyntaxState( + "parser-not-configured", + ); + const authority = createParserAuthority(); + const unvalidatedAnalysis = createUnsupportedParserAnalysis( + "backend-capability", + "SELECT 1", + authority.authority, + ); + const parser = createSqlStatementParser( + authority.authority, + async () => unvalidatedAnalysis, + ); + const request = createSqlStatementParseRequest( + "SELECT 1", + new AbortController().signal, + ); + const analyzed = await runSqlStatementParser(parser, request); + const analysis = analyzed.analysis; + + expect(empty).toMatchObject({ state: "empty" }); + expect(incomplete).toMatchObject({ + construct: "single-quoted-string", + opening, + state: "incomplete", + }); + expect(opaque).toMatchObject({ + at: opening, + reason: "procedural-block", + state: "opaque", + }); + expect(unavailable).toMatchObject({ + reason: "parser-not-configured", + state: "unavailable", + }); + expect(analyzed).toMatchObject({ analysis, state: "analyzed" }); + for (const state of [ + empty, + incomplete, + opaque, + unavailable, + analyzed, + ]) { + expect(Object.isFrozen(state)).toBe(true); + expect(isSqlStatementSyntaxState(state)).toBe(true); + } + }); + + it("accepts every closed lexical and unavailability reason", () => { + const point = createSqlStatementRelativeRange(0, 0, 0); + for (const construct of [ + "backtick-quoted-identifier", + "block-comment", + "dollar-quoted-string", + "double-quoted-identifier", + "double-quoted-string", + "single-quoted-string", + "triple-double-quoted-string", + "triple-single-quoted-string", + ] as const) { + expect( + createIncompleteSyntaxState(construct, point, 0).construct, + ).toBe(construct); + } + for (const reason of [ + "custom-delimiter", + "procedural-block", + "resource-limit", + ] as const) { + expect(createOpaqueSyntaxState(reason, point, 0).reason).toBe( + reason, + ); + } + for (const reason of [ + "dialect-not-supported", + "parser-not-configured", + ] as const) { + expect(createUnavailableSyntaxState(reason).reason).toBe(reason); + } + }); + + it("rejects invalid and fabricated lexical state inputs", () => { + const point = createSqlStatementRelativeRange(0, 0, 0); + expectContractError( + () => createIncompleteSyntaxState("quote", point, 0), + "invalid-analysis", + ); + expectContractError( + () => createOpaqueSyntaxState("guess", point, 0), + "invalid-analysis", + ); + expectContractError( + () => createUnavailableSyntaxState("offline"), + "invalid-analysis", + ); + expect(isSqlStatementSyntaxState({ state: "empty" })).toBe(false); + expect(isSqlStatementSyntaxState(null)).toBe(false); + expect(isSqlStatementSyntaxState("empty")).toBe(false); + }); +}); + +describe("parser request boundary", () => { + it("runs an authentic parser with exact text and signal", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const expected = createUnsupportedParserAnalysis( + "backend-capability", + " SELECT 1 ", + authority.authority, + ); + const parser = createSqlStatementParser( + authority.authority, + async (request) => { + request.signal.throwIfAborted(); + expect(request).toMatchObject({ + signal: controller.signal, + text: " SELECT 1 ", + }); + expect(Object.isFrozen(request)).toBe(true); + return expected; + }, + ); + + const request = createSqlStatementParseRequest( + " SELECT 1 ", + controller.signal, + ); + expect(Object.isFrozen(parser)).toBe(true); + const result = await runSqlStatementParser(parser, request); + expect(result.state).toBe("analyzed"); + expect(result.analysis).toBe(expected); + }); + + it("rejects pre-aborted requests without invoking the callback", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const reason = new Error("superseded"); + controller.abort(reason); + let invoked = false; + const parser = createSqlStatementParser( + authority.authority, + async () => { + invoked = true; + return createUnsupportedParserAnalysis( + "backend-capability", + "SELECT 1", + authority.authority, + ); + }, + ); + + await expect( + runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ), + ).rejects.toBe(reason); + expect(invoked).toBe(false); + }); + + it("rejects in-flight requests with the exact abort reason", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const reason = { kind: "superseded" }; + let rejectCallback: ((reason: unknown) => void) | undefined; + const parser = createSqlStatementParser( + authority.authority, + () => + new Promise((_, reject) => { + rejectCallback = reject; + }), + ); + const result = runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ); + + controller.abort(reason); + await expect(result).rejects.toBe(reason); + rejectCallback?.(new Error("late backend rejection")); + await Promise.resolve(); + }); + + it("keeps backend success that settles before a later abort", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const expected = createUnsupportedParserAnalysis( + "backend-capability", + "SELECT 1", + authority.authority, + ); + let resolveCallback: + | ((analysis: typeof expected) => void) + | undefined; + const parser = createSqlStatementParser( + authority.authority, + () => + new Promise((resolve) => { + resolveCallback = resolve; + }), + ); + const result = runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ); + + resolveCallback?.(expected); + controller.abort(new Error("late cancellation")); + expect((await result).analysis).toBe(expected); + }); + + it("keeps backend failure that settles before a later abort", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const backendError = new Error("backend failure"); + let rejectCallback: ((reason: unknown) => void) | undefined; + const parser = createSqlStatementParser( + authority.authority, + () => + new Promise((_, reject) => { + rejectCallback = reject; + }), + ); + const result = runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ); + + rejectCallback?.(backendError); + controller.abort(backendError); + expect((await result).analysis).toMatchObject({ + reason: "backend-failure", + status: "failed", + }); + }); + + it("lets cancellation win when the callback also throws", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + const reason = new SqlSyntaxContractError( + "invalid-analysis", + "superseded", + ); + const parser = createSqlStatementParser( + authority.authority, + () => { + controller.abort(reason); + throw new Error("backend failed after cancellation"); + }, + ); + + await expect( + runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ), + ).rejects.toBe(reason); + }); + + it("preserves falsy abort reasons", async () => { + const authority = createParserAuthority(); + const controller = new AbortController(); + controller.abort(0); + const parser = createSqlStatementParser( + authority.authority, + async () => + createUnsupportedParserAnalysis( + "backend-capability", + "SELECT 1", + authority.authority, + ), + ); + let caught: unknown = Symbol("not rejected"); + + try { + await runSqlStatementParser( + parser, + createSqlStatementParseRequest("SELECT 1", controller.signal), + ); + } catch (error: unknown) { + caught = error; + } + expect(Object.is(caught, 0)).toBe(true); + }); + + it("rejects malformed requests", () => { + const signal = new AbortController().signal; + expectContractError( + () => createSqlStatementParseRequest(1, signal), + "invalid-request", + ); + expectContractError( + () => + createSqlStatementParseRequest( + "x".repeat(MAX_SQL_STATEMENT_PARSE_LENGTH + 1), + signal, + ), + "invalid-request", + ); + for (const malformedSignal of [ + null, + {}, + [], + new Date(), + { aborted: false }, + { + aborted: false, + addEventListener() {}, + }, + { + aborted: false, + addEventListener() {}, + removeEventListener() {}, + }, + { + aborted: false, + addEventListener() {}, + dispatchEvent() { + return true; + }, + onabort: null, + reason: undefined, + removeEventListener() {}, + throwIfAborted() {}, + }, + ]) { + expectContractError( + () => + createSqlStatementParseRequest( + "SELECT 1", + malformedSignal, + ), + "invalid-request", + ); + } + const hostileSignal = Object.defineProperty({}, "aborted", { + get() { + throw new Error("hostile getter"); + }, + }); + expectContractError( + () => createSqlStatementParseRequest("SELECT 1", hostileSignal), + "invalid-request", + ); + const hostileTag = Object.defineProperty( + {}, + Symbol.toStringTag, + { + get() { + throw new Error("hostile identity"); + }, + }, + ); + expectContractError( + () => createSqlStatementParseRequest("SELECT 1", hostileTag), + "invalid-request", + ); + }); + + it("accepts genuine cross-realm abort signals", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + try { + const foreignWindow = iframe.contentWindow; + expect(foreignWindow).not.toBeNull(); + if (foreignWindow === null) { + throw new Error("iframe window unavailable"); + } + const ForeignAbortController = Reflect.get( + foreignWindow, + "AbortController", + ); + expect(typeof ForeignAbortController).toBe("function"); + if (typeof ForeignAbortController !== "function") { + throw new Error("foreign AbortController unavailable"); + } + const controller: unknown = Reflect.construct( + ForeignAbortController, + [], + ); + if (controller === null || typeof controller !== "object") { + throw new Error("foreign AbortController is invalid"); + } + const signal = Reflect.get(controller, "signal"); + const request = createSqlStatementParseRequest( + "SELECT 1", + signal, + ); + const authority = createParserAuthority(); + const expected = createUnsupportedParserAnalysis( + "backend-capability", + "SELECT 1", + authority.authority, + ); + const parser = createSqlStatementParser( + authority.authority, + async () => expected, + ); + expect(request.signal).toBe(signal); + expect((await runSqlStatementParser(parser, request)).analysis).toBe( + expected, + ); + } finally { + iframe.remove(); + } + }); + + it("rejects fabricated parser identities and callbacks", () => { + const authority = createParserAuthority(); + const parseStatement = async () => + createUnsupportedParserAnalysis( + "backend-capability", + "x", + authority.authority, + ); + + expectContractError( + () => + invokeWithUnknown( + createSqlStatementParser, + {}, + parseStatement, + ), + "invalid-identity", + ); + expectContractError( + () => + invokeWithUnknown( + createSqlStatementParser, + authority.authority, + "parse", + ), + "invalid-request", + ); + }); + + it("rejects fabricated parsers, requests, and parser output", async () => { + const authority = createParserAuthority(); + const request = createSqlStatementParseRequest( + "x", + new AbortController().signal, + ); + const malformedParser = invokeWithUnknown( + createSqlStatementParser, + authority.authority, + async () => ({ status: "parsed" }), + ); + const malformedResult = await Promise.resolve( + invokeWithUnknown( + runSqlStatementParser, + malformedParser, + request, + ), + ); + expect(malformedResult).toMatchObject({ + analysis: { + reason: "malformed-output", + retryable: false, + status: "failed", + }, + state: "analyzed", + }); + + const validParser = createSqlStatementParser( + authority.authority, + async () => + createUnsupportedParserAnalysis( + "backend-capability", + "x", + authority.authority, + ), + ); + await expect( + Promise.resolve( + invokeWithUnknown( + runSqlStatementParser, + {}, + request, + ), + ), + ).rejects.toMatchObject({ code: "invalid-request" }); + await expect( + Promise.resolve( + invokeWithUnknown( + runSqlStatementParser, + validParser, + { signal: new AbortController().signal, text: "x" }, + ), + ), + ).rejects.toMatchObject({ code: "invalid-request" }); + }); + + it("rejects authentic evidence for different statement text", async () => { + const authority = createParserAuthority(); + const request = createSqlStatementParseRequest( + "x", + new AbortController().signal, + ); + const analyses = [ + createDirectParsedAnalysis( + authority.conformance, + createSqlSyntaxArtifact( + "query", + "xx", + authority.authority, + ), + ), + createCompatibilityParsedAnalysis( + createSqlSyntaxArtifact( + "query", + "xx", + authority.authority, + ), + ["dialect-compatibility"], + ), + createInvalidParserAnalysis(authority.conformance, [ + createSqlParserDiagnostic( + "bad", + null, + "xx", + authority.authority, + ), + ]), + createDirectParsedAnalysis( + authority.conformance, + createSqlSyntaxArtifact( + "query", + "y", + authority.authority, + ), + ), + createCompatibilityParsedAnalysis( + createSqlSyntaxArtifact( + "query", + "y", + authority.authority, + ), + ["dialect-compatibility"], + ), + createInvalidParserAnalysis(authority.conformance, [ + createSqlParserDiagnostic( + "bad", + null, + "y", + authority.authority, + ), + ]), + ]; + + for (const analysis of analyses) { + const parser = createSqlStatementParser( + authority.authority, + async () => analysis, + ); + expect(await runSqlStatementParser(parser, request)).toMatchObject({ + analysis: { + reason: "malformed-output", + retryable: false, + status: "failed", + }, + state: "analyzed", + }); + } + }); + + it("rejects conformance from another parser authority", async () => { + const parserAuthority = createParserAuthority(); + const otherAuthority = createParserAuthority(); + const request = createSqlStatementParseRequest( + "x", + new AbortController().signal, + ); + const analyses = [ + createDirectParsedAnalysis( + otherAuthority.conformance, + createSqlSyntaxArtifact( + "query", + "x", + otherAuthority.authority, + ), + ), + createCompatibilityParsedAnalysis( + createSqlSyntaxArtifact( + "query", + "x", + otherAuthority.authority, + ), + ["dialect-compatibility"], + ), + createInvalidParserAnalysis(otherAuthority.conformance, [ + createSqlParserDiagnostic( + "bad", + null, + "x", + otherAuthority.authority, + ), + ]), + createUnsupportedParserAnalysis( + "compatibility-rejected", + "x", + otherAuthority.authority, + ), + createFailedParserAnalysis( + "backend-failure", + "failed", + true, + "x", + otherAuthority.authority, + ), + ]; + + expectContractError( + () => + createDirectParsedAnalysis( + parserAuthority.conformance, + createSqlSyntaxArtifact( + "query", + "x", + otherAuthority.authority, + ), + ), + "invalid-analysis", + ); + + for (const analysis of analyses) { + const parser = createSqlStatementParser( + parserAuthority.authority, + async () => analysis, + ); + expect(await runSqlStatementParser(parser, request)).toMatchObject({ + analysis: { + reason: "malformed-output", + status: "failed", + }, + state: "analyzed", + }); + } + }); + + it("accepts matching direct, compatibility, and invalid evidence", async () => { + const authority = createParserAuthority(); + const request = createSqlStatementParseRequest( + "x", + new AbortController().signal, + ); + const analyses = [ + createDirectParsedAnalysis( + authority.conformance, + createSqlSyntaxArtifact( + "query", + "x", + authority.authority, + ), + ), + createCompatibilityParsedAnalysis( + createSqlSyntaxArtifact( + "query", + "x", + authority.authority, + ), + ["dialect-compatibility"], + ), + createInvalidParserAnalysis(authority.conformance, [ + createSqlParserDiagnostic( + "bad", + null, + "x", + authority.authority, + ), + ]), + ]; + + for (const analysis of analyses) { + const parser = createSqlStatementParser( + authority.authority, + async () => analysis, + ); + expect( + (await runSqlStatementParser(parser, request)).analysis, + ).toBe(analysis); + } + }); + + it("normalizes synchronous throws and rejections without raw errors", async () => { + const authority = createParserAuthority(); + const request = createSqlStatementParseRequest( + "x", + new AbortController().signal, + ); + const callbacks = [ + () => { + throw new Error("secret sync error"); + }, + async () => { + throw new Error("secret async error"); + }, + ]; + + for (const callback of callbacks) { + const parser = createSqlStatementParser( + authority.authority, + callback, + ); + const state = await runSqlStatementParser(parser, request); + expect(state.analysis).toMatchObject({ + message: "SQL parser backend failed", + reason: "backend-failure", + retryable: true, + status: "failed", + }); + if (state.analysis.status === "failed") { + expect(state.analysis.message).not.toContain("secret"); + } + } + + const malformedParser = createSqlStatementParser( + authority.authority, + async () => { + throw new SqlSyntaxContractError( + "invalid-analysis", + "secret malformed output", + ); + }, + ); + const malformed = await runSqlStatementParser( + malformedParser, + request, + ); + expect(malformed.analysis).toMatchObject({ + message: "SQL parser returned malformed normalized output", + reason: "malformed-output", + retryable: false, + status: "failed", + }); + }); +}); diff --git a/src/vnext/syntax.ts b/src/vnext/syntax.ts new file mode 100644 index 0000000..10c2c89 --- /dev/null +++ b/src/vnext/syntax.ts @@ -0,0 +1,1255 @@ +import type { + SqlOpaqueBoundaryReason, + SqlUnterminatedConstruct, +} from "./statement-index.js"; + +const statementRangeBrand: unique symbol = Symbol( + "SqlStatementRelativeRange", +); +const syntaxArtifactBrand: unique symbol = Symbol("SqlSyntaxArtifact"); +const parserDiagnosticBrand: unique symbol = Symbol( + "SqlParserDiagnostic", +); +const parserAnalysisBrand: unique symbol = Symbol("SqlParserAnalysis"); +const syntaxStateBrand: unique symbol = Symbol( + "SqlStatementSyntaxState", +); +const parserRequestBrand: unique symbol = Symbol( + "SqlStatementParseRequest", +); +const statementParserBrand: unique symbol = Symbol( + "SqlStatementParser", +); +const backendIdentityBrand: unique symbol = Symbol( + "SqlSyntaxBackendIdentity", +); +const configurationIdentityBrand: unique symbol = Symbol( + "SqlSyntaxConfigurationIdentity", +); +const dialectIdentityBrand: unique symbol = Symbol( + "SqlDialectSyntaxIdentity", +); +const conformanceIdentityBrand: unique symbol = Symbol( + "SqlConformanceIdentity", +); +const parserAuthorityBrand: unique symbol = Symbol( + "SqlParserAuthority", +); + +export const MAX_SQL_SYNTAX_DIAGNOSTICS = 64; +export const MAX_SQL_SYNTAX_MESSAGE_LENGTH = 2_048; +export const MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH = 8_192; +export const MAX_SQL_STATEMENT_PARSE_LENGTH = 1024 * 1024; +export const MAX_SQL_COMPATIBILITY_LIMITATIONS = 3; + +const statementRanges = new WeakSet(); +const syntaxArtifacts = new WeakSet(); +const parserDiagnostics = new WeakSet(); +const parserAnalyses = new WeakSet(); +const statementSyntaxStates = new WeakSet(); +const parserRequests = new WeakSet(); +const statementParsers = new WeakSet(); +const backendIdentities = new WeakSet(); +const configurationIdentities = new WeakSet(); +const dialectIdentities = new WeakSet(); +const conformanceIdentities = new WeakSet(); +const parserAuthorities = new WeakSet(); +const conformanceAuthorities = new WeakMap< + SqlConformanceIdentity, + SqlParserAuthority +>(); +const artifactSources = new WeakMap(); +const artifactAuthorities = new WeakMap< + SqlSyntaxArtifact, + SqlParserAuthority +>(); +const diagnosticSources = new WeakMap< + SqlParserDiagnostic, + string +>(); +const diagnosticAuthorities = new WeakMap< + SqlParserDiagnostic, + SqlParserAuthority +>(); +const analysisSources = new WeakMap< + SqlParserAnalysis, + string +>(); +const analysisAuthorities = new WeakMap< + SqlParserAnalysis, + SqlParserAuthority +>(); +const parserCallbacks = new WeakMap< + SqlStatementParser, + SqlStatementParserCallback +>(); + +export type SqlSyntaxContractErrorCode = + | "invalid-analysis" + | "invalid-artifact" + | "invalid-diagnostic" + | "invalid-identity" + | "invalid-range" + | "invalid-request"; + +export class SqlSyntaxContractError extends Error { + readonly code: SqlSyntaxContractErrorCode; + + constructor(code: SqlSyntaxContractErrorCode, message: string) { + super(message); + this.name = "SqlSyntaxContractError"; + this.code = code; + } +} + +export interface SqlStatementRelativeRange { + readonly [statementRangeBrand]: "SqlStatementRelativeRange"; + readonly from: number; + readonly to: number; +} + +function requireStatementLength( + value: unknown, + code: SqlSyntaxContractErrorCode, +): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new SqlSyntaxContractError( + code, + "SQL statement length must be a non-negative safe integer", + ); + } + return Number(value); +} + +function requireStatementText( + value: unknown, + code: SqlSyntaxContractErrorCode, +): string { + if (typeof value !== "string") { + throw new SqlSyntaxContractError( + code, + "SQL statement text must be a string", + ); + } + if (value.length > MAX_SQL_STATEMENT_PARSE_LENGTH) { + throw new SqlSyntaxContractError( + code, + "SQL statement text exceeds the parser input limit", + ); + } + return value; +} + +export function createSqlStatementRelativeRange( + from: unknown, + to: unknown, + statementLength: unknown, +): SqlStatementRelativeRange { + const length = requireStatementLength(statementLength, "invalid-range"); + if ( + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + Number(from) < 0 || + Number(from) > Number(to) || + Number(to) > length + ) { + throw new SqlSyntaxContractError( + "invalid-range", + "SQL statement range must be an in-bounds half-open UTF-16 range", + ); + } + const range: SqlStatementRelativeRange = { + [statementRangeBrand]: "SqlStatementRelativeRange", + from: Number(from), + to: Number(to), + }; + Object.freeze(range); + statementRanges.add(range); + return range; +} + +function isAuthenticStatementRange( + value: unknown, +): value is SqlStatementRelativeRange { + return ( + value !== null && + typeof value === "object" && + statementRanges.has(value) + ); +} + +function requireStatementRange( + range: unknown, + statementLength: number, + code: SqlSyntaxContractErrorCode, +): SqlStatementRelativeRange { + if (!isAuthenticStatementRange(range)) { + throw new SqlSyntaxContractError( + code, + "SQL statement range must be created by this syntax contract", + ); + } + if (range.to > statementLength) { + throw new SqlSyntaxContractError( + code, + "SQL statement range exceeds the current statement", + ); + } + return range; +} + +export interface SqlSyntaxBackendIdentity { + readonly [backendIdentityBrand]: "SqlSyntaxBackendIdentity"; +} + +export interface SqlSyntaxConfigurationIdentity { + readonly [configurationIdentityBrand]: "SqlSyntaxConfigurationIdentity"; +} + +export interface SqlDialectSyntaxIdentity { + readonly [dialectIdentityBrand]: "SqlDialectSyntaxIdentity"; +} + +export interface SqlConformanceIdentity { + readonly [conformanceIdentityBrand]: "SqlConformanceIdentity"; +} + +export interface SqlParserAuthority { + readonly [parserAuthorityBrand]: "SqlParserAuthority"; + readonly backendIdentity: SqlSyntaxBackendIdentity; + readonly configurationIdentity: SqlSyntaxConfigurationIdentity; + readonly dialectIdentity: SqlDialectSyntaxIdentity; +} + +export function createSqlSyntaxBackendIdentity(): SqlSyntaxBackendIdentity { + const identity: SqlSyntaxBackendIdentity = { + [backendIdentityBrand]: "SqlSyntaxBackendIdentity", + }; + Object.freeze(identity); + backendIdentities.add(identity); + return identity; +} + +export function createSqlSyntaxConfigurationIdentity(): SqlSyntaxConfigurationIdentity { + const identity: SqlSyntaxConfigurationIdentity = { + [configurationIdentityBrand]: "SqlSyntaxConfigurationIdentity", + }; + Object.freeze(identity); + configurationIdentities.add(identity); + return identity; +} + +export function createSqlDialectSyntaxIdentity(): SqlDialectSyntaxIdentity { + const identity: SqlDialectSyntaxIdentity = { + [dialectIdentityBrand]: "SqlDialectSyntaxIdentity", + }; + Object.freeze(identity); + dialectIdentities.add(identity); + return identity; +} + +export function createSqlParserAuthority( + backendIdentity: SqlSyntaxBackendIdentity, + configurationIdentity: SqlSyntaxConfigurationIdentity, + dialectIdentity: SqlDialectSyntaxIdentity, +): SqlParserAuthority { + requireIdentity(backendIdentity, backendIdentities); + requireIdentity(configurationIdentity, configurationIdentities); + requireIdentity(dialectIdentity, dialectIdentities); + const authority: SqlParserAuthority = { + [parserAuthorityBrand]: "SqlParserAuthority", + backendIdentity, + configurationIdentity, + dialectIdentity, + }; + Object.freeze(authority); + parserAuthorities.add(authority); + return authority; +} + +export function createSqlConformanceIdentity( + authority: SqlParserAuthority, +): SqlConformanceIdentity { + requireIdentity(authority, parserAuthorities); + const identity: SqlConformanceIdentity = { + [conformanceIdentityBrand]: "SqlConformanceIdentity", + }; + Object.freeze(identity); + conformanceIdentities.add(identity); + conformanceAuthorities.set(identity, authority); + return identity; +} + +function requireIdentity( + identity: object, + identities: WeakSet, +): void { + if (!identities.has(identity)) { + throw new SqlSyntaxContractError( + "invalid-identity", + "SQL syntax identity must be created by this syntax contract", + ); + } +} + +export type SqlStatementKind = + | "alter" + | "create" + | "delete" + | "drop" + | "insert" + | "merge" + | "other" + | "query" + | "transaction" + | "update"; + +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; + } +} + +export interface SqlSyntaxArtifact { + readonly [syntaxArtifactBrand]: "SqlSyntaxArtifact"; + readonly kind: SqlStatementKind; + readonly range: SqlStatementRelativeRange; +} + +export function createSqlSyntaxArtifact( + kind: unknown, + statementText: unknown, + authority: SqlParserAuthority, +): SqlSyntaxArtifact { + requireIdentity(authority, parserAuthorities); + const text = requireStatementText( + statementText, + "invalid-artifact", + ); + if (!isStatementKind(kind)) { + throw new SqlSyntaxContractError( + "invalid-artifact", + "SQL statement kind is not supported by the syntax contract", + ); + } + const artifact: SqlSyntaxArtifact = { + [syntaxArtifactBrand]: "SqlSyntaxArtifact", + kind, + range: createSqlStatementRelativeRange( + 0, + text.length, + text.length, + ), + }; + Object.freeze(artifact); + syntaxArtifacts.add(artifact); + artifactSources.set(artifact, text); + artifactAuthorities.set(artifact, authority); + return artifact; +} + +interface SqlArtifactMetadata { + readonly authority: SqlParserAuthority; + readonly source: string; +} + +function requireSyntaxArtifact( + artifact: SqlSyntaxArtifact, +): SqlArtifactMetadata { + const source = artifactSources.get(artifact); + const authority = artifactAuthorities.get(artifact); + if ( + !syntaxArtifacts.has(artifact) || + source === undefined || + authority === undefined + ) { + throw new SqlSyntaxContractError( + "invalid-artifact", + "SQL syntax artifact must be created by this syntax contract", + ); + } + return { authority, source }; +} + +export type SqlParserLocation = + | { + readonly availability: "exact"; + readonly range: SqlStatementRelativeRange; + } + | { + readonly availability: "unavailable"; + readonly reason: "not-reported"; + }; + +const LOCATION_NOT_REPORTED: Extract< + SqlParserLocation, + { readonly availability: "unavailable" } +> = Object.freeze({ + availability: "unavailable", + reason: "not-reported", +}); + +export interface SqlParserDiagnostic { + readonly [parserDiagnosticBrand]: "SqlParserDiagnostic"; + readonly code: "syntax-error"; + readonly location: SqlParserLocation; + readonly message: string; + readonly severity: "error"; +} + +function normalizeMessage( + value: unknown, + code: SqlSyntaxContractErrorCode, +): string { + if (typeof value !== "string") { + throw new SqlSyntaxContractError( + code, + "SQL syntax message must be a string", + ); + } + if (value.length > MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH) { + throw new SqlSyntaxContractError( + code, + "SQL syntax message exceeds the contract input limit", + ); + } + const message = value.trim(); + if (message.length === 0) { + throw new SqlSyntaxContractError( + code, + "SQL syntax message cannot be empty", + ); + } + return message.slice(0, MAX_SQL_SYNTAX_MESSAGE_LENGTH); +} + +export function createSqlParserDiagnostic( + message: unknown, + location: unknown, + statementText: unknown, + authority: SqlParserAuthority, +): SqlParserDiagnostic { + requireIdentity(authority, parserAuthorities); + const text = requireStatementText( + statementText, + "invalid-diagnostic", + ); + const normalizedLocation: SqlParserLocation = + location === null + ? LOCATION_NOT_REPORTED + : Object.freeze({ + availability: "exact", + range: requireStatementRange( + location, + text.length, + "invalid-diagnostic", + ), + }); + const diagnostic: SqlParserDiagnostic = { + [parserDiagnosticBrand]: "SqlParserDiagnostic", + code: "syntax-error", + location: normalizedLocation, + message: normalizeMessage(message, "invalid-diagnostic"), + severity: "error", + }; + Object.freeze(diagnostic); + parserDiagnostics.add(diagnostic); + diagnosticSources.set(diagnostic, text); + diagnosticAuthorities.set(diagnostic, authority); + return diagnostic; +} + +function copyDiagnostics( + diagnostics: readonly SqlParserDiagnostic[], + requireNonEmpty: boolean, +): readonly SqlParserDiagnostic[] { + const count = Array.isArray(diagnostics) + ? diagnostics.length + : -1; + if ( + count < 0 || + count > MAX_SQL_SYNTAX_DIAGNOSTICS || + (requireNonEmpty && count === 0) + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + requireNonEmpty + ? "SQL invalid analysis requires bounded non-empty diagnostics" + : "SQL syntax diagnostics exceed the contract limit", + ); + } + const copy: SqlParserDiagnostic[] = []; + for (let index = 0; index < count; index += 1) { + const diagnostic = diagnostics[index]; + if ( + diagnostic === undefined || + !parserDiagnostics.has(diagnostic) + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser diagnostic must be created by this syntax contract", + ); + } + copy.push(diagnostic); + } + return Object.freeze(copy); +} + +export type SqlCompatibilityLimitation = + | "dialect-compatibility" + | "partial-artifact" + | "recovered"; + +export type SqlParserUnsupportedReason = + | "backend-capability" + | "compatibility-rejected" + | "resource-limit" + | "uncovered-construct"; + +export type SqlParserFailureReason = + | "backend-failure" + | "malformed-output"; + +interface SqlParserAnalysisIdentity { + readonly [parserAnalysisBrand]: "SqlParserAnalysis"; +} + +export type SqlParserAnalysis = SqlParserAnalysisIdentity & + ( + | { + readonly status: "parsed"; + readonly mode: "direct"; + readonly conformance: SqlConformanceIdentity; + readonly artifact: SqlSyntaxArtifact; + } + | { + readonly status: "parsed"; + readonly mode: "compatibility"; + readonly artifact: SqlSyntaxArtifact; + readonly limitations: readonly [ + SqlCompatibilityLimitation, + ...SqlCompatibilityLimitation[], + ]; + } + | { + readonly status: "invalid"; + readonly conformance: SqlConformanceIdentity; + readonly diagnostics: readonly [ + SqlParserDiagnostic, + ...SqlParserDiagnostic[], + ]; + } + | { + readonly status: "unsupported"; + readonly reason: SqlParserUnsupportedReason; + } + | { + readonly status: "failed"; + readonly reason: SqlParserFailureReason; + readonly message: string; + readonly retryable: boolean; + } + ); + +function recordAnalysis( + analysis: Analysis, + source: string, + authority: SqlParserAuthority, +): Analysis { + Object.freeze(analysis); + parserAnalyses.add(analysis); + analysisSources.set(analysis, source); + analysisAuthorities.set(analysis, authority); + return analysis; +} + +export function createDirectParsedAnalysis( + conformance: SqlConformanceIdentity, + artifact: SqlSyntaxArtifact, +): Extract< + SqlParserAnalysis, + { readonly mode: "direct"; readonly status: "parsed" } +> { + requireIdentity(conformance, conformanceIdentities); + const metadata = requireSyntaxArtifact(artifact); + if (conformanceAuthorities.get(conformance) !== metadata.authority) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL conformance and artifact authority must match", + ); + } + return recordAnalysis( + { + [parserAnalysisBrand]: "SqlParserAnalysis", + artifact, + conformance, + mode: "direct", + status: "parsed", + }, + metadata.source, + metadata.authority, + ); +} + +export function createCompatibilityParsedAnalysis( + artifact: SqlSyntaxArtifact, + limitations: readonly SqlCompatibilityLimitation[], +): Extract< + SqlParserAnalysis, + { readonly mode: "compatibility"; readonly status: "parsed" } +> { + const metadata = requireSyntaxArtifact(artifact); + if ( + !Array.isArray(limitations) || + limitations.length === 0 || + limitations.length > MAX_SQL_COMPATIBILITY_LIMITATIONS + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL compatibility analysis requires bounded non-empty limitations", + ); + } + const copy: SqlCompatibilityLimitation[] = []; + const seen = new Set(); + const limitationCount = limitations.length; + for (let index = 0; index < limitationCount; index += 1) { + const limitation = limitations[index]; + if ( + limitation !== "dialect-compatibility" && + limitation !== "partial-artifact" && + limitation !== "recovered" + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL compatibility limitation is invalid", + ); + } + if (seen.has(limitation)) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL compatibility limitations cannot contain duplicates", + ); + } + seen.add(limitation); + copy.push(limitation); + } + const first = copy[0]; + if (first === undefined) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL compatibility analysis requires at least one limitation", + ); + } + const nonEmpty: [ + SqlCompatibilityLimitation, + ...SqlCompatibilityLimitation[], + ] = [first, ...copy.slice(1)]; + return recordAnalysis( + { + [parserAnalysisBrand]: "SqlParserAnalysis", + artifact, + limitations: Object.freeze(nonEmpty), + mode: "compatibility", + status: "parsed", + }, + metadata.source, + metadata.authority, + ); +} + +export function createInvalidParserAnalysis( + conformance: SqlConformanceIdentity, + diagnostics: readonly SqlParserDiagnostic[], +): Extract { + requireIdentity(conformance, conformanceIdentities); + const copy = copyDiagnostics(diagnostics, true); + const first = copy[0]; + if (first === undefined) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL invalid analysis requires at least one diagnostic", + ); + } + const source = diagnosticSources.get(first); + const authority = diagnosticAuthorities.get(first); + if (source === undefined || authority === undefined) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser diagnostic is missing statement metadata", + ); + } + for (const diagnostic of copy) { + if (diagnosticSources.get(diagnostic) !== source) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser diagnostics must describe the same statement text", + ); + } + if (diagnosticAuthorities.get(diagnostic) !== authority) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser diagnostics must share parser authority", + ); + } + } + if (conformanceAuthorities.get(conformance) !== authority) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL conformance and diagnostic authority must match", + ); + } + const nonEmpty: [ + SqlParserDiagnostic, + ...SqlParserDiagnostic[], + ] = [first, ...copy.slice(1)]; + return recordAnalysis( + { + [parserAnalysisBrand]: "SqlParserAnalysis", + conformance, + diagnostics: Object.freeze(nonEmpty), + status: "invalid", + }, + source, + authority, + ); +} + +export function createUnsupportedParserAnalysis( + reason: SqlParserUnsupportedReason, + statementText: unknown, + authority: SqlParserAuthority, +): Extract { + requireIdentity(authority, parserAuthorities); + const source = requireStatementText( + statementText, + "invalid-analysis", + ); + if ( + reason !== "backend-capability" && + reason !== "compatibility-rejected" && + reason !== "resource-limit" && + reason !== "uncovered-construct" + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser unsupported reason is invalid", + ); + } + return recordAnalysis( + { + [parserAnalysisBrand]: "SqlParserAnalysis", + reason, + status: "unsupported", + }, + source, + authority, + ); +} + +export function createFailedParserAnalysis( + reason: SqlParserFailureReason, + message: unknown, + retryable: unknown, + statementText: unknown, + authority: SqlParserAuthority, +): Extract { + requireIdentity(authority, parserAuthorities); + const source = requireStatementText( + statementText, + "invalid-analysis", + ); + if ( + reason !== "backend-failure" && + reason !== "malformed-output" + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser failure reason is invalid", + ); + } + if (typeof retryable !== "boolean") { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser failure retryability must be a boolean", + ); + } + return recordAnalysis( + { + [parserAnalysisBrand]: "SqlParserAnalysis", + message: normalizeMessage(message, "invalid-analysis"), + reason, + retryable, + status: "failed", + }, + source, + authority, + ); +} + +export type SqlSyntaxUnavailableReason = + | "dialect-not-supported" + | "parser-not-configured"; + +interface SqlStatementSyntaxStateIdentity { + readonly [syntaxStateBrand]: "SqlStatementSyntaxState"; +} + +export type SqlStatementSyntaxState = SqlStatementSyntaxStateIdentity & + ( + | { + readonly state: "empty"; + } + | { + readonly state: "incomplete"; + readonly construct: SqlUnterminatedConstruct; + readonly opening: SqlStatementRelativeRange; + } + | { + readonly state: "opaque"; + readonly at: SqlStatementRelativeRange; + readonly reason: SqlOpaqueBoundaryReason; + } + | { + readonly state: "unavailable"; + readonly reason: SqlSyntaxUnavailableReason; + } + | { + readonly state: "analyzed"; + readonly analysis: SqlParserAnalysis; + } + ); + +function recordSyntaxState( + state: State, +): State { + Object.freeze(state); + statementSyntaxStates.add(state); + return state; +} + +function isUnterminatedConstruct( + value: unknown, +): value is SqlUnterminatedConstruct { + switch (value) { + case "backtick-quoted-identifier": + case "block-comment": + case "dollar-quoted-string": + case "double-quoted-identifier": + case "double-quoted-string": + case "single-quoted-string": + case "triple-double-quoted-string": + case "triple-single-quoted-string": + return true; + default: + return false; + } +} + +function isOpaqueBoundaryReason( + value: unknown, +): value is SqlOpaqueBoundaryReason { + return ( + value === "custom-delimiter" || + value === "procedural-block" || + value === "resource-limit" + ); +} + +export function createEmptySyntaxState(): Extract< + SqlStatementSyntaxState, + { readonly state: "empty" } +> { + return recordSyntaxState({ + [syntaxStateBrand]: "SqlStatementSyntaxState", + state: "empty", + }); +} + +export function createIncompleteSyntaxState( + construct: unknown, + opening: SqlStatementRelativeRange, + statementLength: unknown, +): Extract< + SqlStatementSyntaxState, + { readonly state: "incomplete" } +> { + const length = requireStatementLength( + statementLength, + "invalid-analysis", + ); + if (!isUnterminatedConstruct(construct)) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL incomplete construct is invalid", + ); + } + return recordSyntaxState({ + [syntaxStateBrand]: "SqlStatementSyntaxState", + construct, + opening: requireStatementRange( + opening, + length, + "invalid-analysis", + ), + state: "incomplete", + }); +} + +export function createOpaqueSyntaxState( + reason: unknown, + at: SqlStatementRelativeRange, + statementLength: unknown, +): Extract< + SqlStatementSyntaxState, + { readonly state: "opaque" } +> { + const length = requireStatementLength( + statementLength, + "invalid-analysis", + ); + if (!isOpaqueBoundaryReason(reason)) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL opaque boundary reason is invalid", + ); + } + return recordSyntaxState({ + [syntaxStateBrand]: "SqlStatementSyntaxState", + at: requireStatementRange(at, length, "invalid-analysis"), + reason, + state: "opaque", + }); +} + +export function createUnavailableSyntaxState( + reason: unknown, +): Extract< + SqlStatementSyntaxState, + { readonly state: "unavailable" } +> { + if ( + reason !== "dialect-not-supported" && + reason !== "parser-not-configured" + ) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL syntax unavailability reason is invalid", + ); + } + return recordSyntaxState({ + [syntaxStateBrand]: "SqlStatementSyntaxState", + reason, + state: "unavailable", + }); +} + +function createAnalyzedSyntaxState( + analysis: SqlParserAnalysis, +): Extract< + SqlStatementSyntaxState, + { readonly state: "analyzed" } +> { + if (!parserAnalyses.has(analysis)) { + throw new SqlSyntaxContractError( + "invalid-analysis", + "SQL parser analysis must be created by this syntax contract", + ); + } + return recordSyntaxState({ + [syntaxStateBrand]: "SqlStatementSyntaxState", + analysis, + state: "analyzed", + }); +} + +export function isSqlStatementSyntaxState( + value: unknown, +): value is SqlStatementSyntaxState { + return ( + value !== null && + typeof value === "object" && + statementSyntaxStates.has(value) + ); +} + +export interface SqlStatementParseRequest { + readonly [parserRequestBrand]: "SqlStatementParseRequest"; + readonly text: string; + readonly signal: AbortSignal; +} + +export interface SqlStatementParser { + readonly [statementParserBrand]: "SqlStatementParser"; + readonly authority: SqlParserAuthority; +} + +export type SqlStatementParserCallback = ( + request: SqlStatementParseRequest, +) => Promise; + +function isAbortSignal(value: unknown): value is AbortSignal { + if (value === null || typeof value !== "object") { + return false; + } + try { + return ( + Object.prototype.toString.call(value) === + "[object AbortSignal]" && + "aborted" in value && + typeof value.aborted === "boolean" && + "reason" in value && + "onabort" in value && + (value.onabort === null || + typeof value.onabort === "function") && + "addEventListener" in value && + typeof value.addEventListener === "function" && + "removeEventListener" in value && + typeof value.removeEventListener === "function" && + "dispatchEvent" in value && + typeof value.dispatchEvent === "function" && + "throwIfAborted" in value && + typeof value.throwIfAborted === "function" + ); + } catch { + return false; + } +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + +function getAbortReason(signal: AbortSignal): unknown { + return signal.reason; +} + +export function createSqlStatementParseRequest( + text: unknown, + signal: unknown, +): SqlStatementParseRequest { + const statementText = requireStatementText(text, "invalid-request"); + if (!isAbortSignal(signal)) { + throw new SqlSyntaxContractError( + "invalid-request", + "SQL statement parser signal must satisfy the AbortSignal contract", + ); + } + const request: SqlStatementParseRequest = { + [parserRequestBrand]: "SqlStatementParseRequest", + signal, + text: statementText, + }; + Object.freeze(request); + parserRequests.add(request); + return request; +} + +export function createSqlStatementParser( + authority: SqlParserAuthority, + parseStatement: SqlStatementParserCallback, +): SqlStatementParser { + requireIdentity(authority, parserAuthorities); + if (typeof parseStatement !== "function") { + throw new SqlSyntaxContractError( + "invalid-request", + "SQL statement parser requires a parse function", + ); + } + const parser: SqlStatementParser = { + [statementParserBrand]: "SqlStatementParser", + authority, + }; + Object.freeze(parser); + statementParsers.add(parser); + parserCallbacks.set(parser, parseStatement); + return parser; +} + +function conformanceMatchesParser( + conformance: SqlConformanceIdentity, + parser: SqlStatementParser, +): boolean { + return conformanceAuthorities.get(conformance) === parser.authority; +} + +function malformedParserOutput( + statementText: string, + authority: SqlParserAuthority, +): Extract< + SqlParserAnalysis, + { readonly status: "failed" } +> { + return createFailedParserAnalysis( + "malformed-output", + "SQL parser returned malformed normalized output", + false, + statementText, + authority, + ); +} + +type SqlParserCallbackOutcome = + | { + readonly kind: "returned"; + readonly analysis: unknown; + } + | { + readonly kind: "threw"; + readonly error: unknown; + }; + +const parserAbortMarker = Object.freeze({ + kind: "SqlParserAbortMarker", +}); + +async function invokeParserCallback( + callback: SqlStatementParserCallback, + request: SqlStatementParseRequest, +): Promise { + const { signal } = request; + if (isAborted(signal)) { + throw getAbortReason(signal); + } + + let abortObserved = false; + let abortReason: unknown; + let onAbort: (() => void) | undefined; + const abortOutcome = new Promise((resolve) => { + onAbort = () => { + abortReason = getAbortReason(signal); + abortObserved = true; + resolve(parserAbortMarker); + }; + signal.addEventListener("abort", onAbort, { + once: true, + }); + }); + + try { + let callbackOutcome: Promise; + try { + callbackOutcome = callback(request); + } catch (error: unknown) { + if (abortObserved) { + throw abortReason; + } + return { + error, + kind: "threw", + }; + } + + let analysis: SqlParserAnalysis | typeof parserAbortMarker; + try { + analysis = await Promise.race([ + abortOutcome, + callbackOutcome, + ]); + } catch (error: unknown) { + return { + error, + kind: "threw", + }; + } + if (analysis === parserAbortMarker) { + throw abortReason; + } + return { + analysis, + kind: "returned", + }; + } finally { + if (onAbort !== undefined) { + signal.removeEventListener("abort", onAbort); + } + } +} + +export async function runSqlStatementParser( + parser: SqlStatementParser, + request: SqlStatementParseRequest, +): Promise< + Extract +> { + if (!statementParsers.has(parser) || !parserRequests.has(request)) { + throw new SqlSyntaxContractError( + "invalid-request", + "SQL parser and request must be created by this syntax contract", + ); + } + const callback = parserCallbacks.get(parser); + if (callback === undefined) { + throw new SqlSyntaxContractError( + "invalid-request", + "SQL statement parser callback is unavailable", + ); + } + + const outcome = await invokeParserCallback(callback, request); + if (outcome.kind === "threw") { + const { error } = outcome; + if (error instanceof SqlSyntaxContractError) { + return createAnalyzedSyntaxState( + malformedParserOutput(request.text, parser.authority), + ); + } + return createAnalyzedSyntaxState( + createFailedParserAnalysis( + "backend-failure", + "SQL parser backend failed", + true, + request.text, + parser.authority, + ), + ); + } + const { analysis } = outcome; + if (!isSqlParserAnalysis(analysis)) { + return createAnalyzedSyntaxState( + malformedParserOutput(request.text, parser.authority), + ); + } + + const source = analysisSources.get(analysis); + if ( + source === undefined || + source !== request.text || + analysisAuthorities.get(analysis) !== parser.authority + ) { + return createAnalyzedSyntaxState( + malformedParserOutput(request.text, parser.authority), + ); + } + if ( + (analysis.status === "invalid" || + (analysis.status === "parsed" && analysis.mode === "direct")) && + !conformanceMatchesParser(analysis.conformance, parser) + ) { + return createAnalyzedSyntaxState( + malformedParserOutput(request.text, parser.authority), + ); + } + return createAnalyzedSyntaxState(analysis); +} + +export function isSqlParserAnalysis( + value: unknown, +): value is SqlParserAnalysis { + return ( + value !== null && + typeof value === "object" && + parserAnalyses.has(value) + ); +} diff --git a/test/vnext-types/syntax.test-d.ts b/test/vnext-types/syntax.test-d.ts new file mode 100644 index 0000000..a5bbece --- /dev/null +++ b/test/vnext-types/syntax.test-d.ts @@ -0,0 +1,182 @@ +import type { SqlTextRange } from "../../src/vnext/index.js"; +import { + createCompatibilityParsedAnalysis, + createDirectParsedAnalysis, + createInvalidParserAnalysis, + createSqlConformanceIdentity, + createSqlParserAuthority, + createSqlParserDiagnostic, + createSqlStatementRelativeRange, + createSqlStatementParser, + createSqlStatementParseRequest, + createSqlSyntaxArtifact, + createSqlSyntaxBackendIdentity, + createSqlSyntaxConfigurationIdentity, + createSqlDialectSyntaxIdentity, + type SqlParserAnalysis, + runSqlStatementParser, + type SqlStatementParser, + type SqlStatementParseRequest, + type SqlStatementRelativeRange, + type SqlStatementSyntaxState, + type SqlSyntaxArtifact, +} from "../../src/vnext/syntax.js"; + +const range = createSqlStatementRelativeRange(0, 8, 8); +const backendIdentity = createSqlSyntaxBackendIdentity(); +const configurationIdentity = createSqlSyntaxConfigurationIdentity(); +const dialectIdentity = createSqlDialectSyntaxIdentity(); +const authority = createSqlParserAuthority( + backendIdentity, + configurationIdentity, + dialectIdentity, +); +const conformance = createSqlConformanceIdentity(authority); +const artifact = createSqlSyntaxArtifact( + "query", + "SELECT 1", + authority, +); +const diagnostic = createSqlParserDiagnostic( + "bad", + range, + "SELECT 1", + authority, +); +const direct = createDirectParsedAnalysis(conformance, artifact); +const compatibility = createCompatibilityParsedAnalysis(artifact, [ + "dialect-compatibility", +]); +const invalid = createInvalidParserAnalysis(conformance, [diagnostic]); + +function describeAnalysis(analysis: SqlParserAnalysis): string { + switch (analysis.status) { + case "parsed": + return analysis.mode === "direct" + ? analysis.conformance.toString() + : analysis.limitations.join(","); + case "invalid": + return analysis.diagnostics[0].message; + case "unsupported": + return analysis.reason; + case "failed": + return analysis.message; + default: { + const exhaustive: never = analysis; + return exhaustive; + } + } +} + +function describeSyntaxState(state: SqlStatementSyntaxState): string { + switch (state.state) { + case "empty": + return "empty"; + case "incomplete": + return state.construct; + case "opaque": + return state.reason; + case "unavailable": + return state.reason; + case "analyzed": + return describeAnalysis(state.analysis); + default: { + const exhaustive: never = state; + return exhaustive; + } + } +} + +declare const publicRange: SqlTextRange; +// @ts-expect-error absolute public ranges cannot become statement-relative +const relativeRange: SqlStatementRelativeRange = publicRange; +// @ts-expect-error statement-relative ranges require package construction +const fabricatedRange: SqlStatementRelativeRange = { from: 0, to: 8 }; +// @ts-expect-error syntax artifacts require package construction +const fabricatedArtifact: SqlSyntaxArtifact = { kind: "query", range }; +const impossibleDirect: SqlParserAnalysis = { + status: "parsed", + mode: "direct", + conformance, + artifact, + // @ts-expect-error direct parses cannot carry compatibility limitations + limitations: ["recovered"], +}; +const impossibleCompatibility: SqlParserAnalysis = { + status: "parsed", + mode: "compatibility", + artifact, + limitations: ["recovered"], + // @ts-expect-error compatibility parses cannot claim direct conformance + conformance, +}; +// @ts-expect-error invalid is authoritative and requires conformance +const invalidWithoutConformance: SqlParserAnalysis = { + status: "invalid", + diagnostics: [diagnostic], +}; +const invalidWithoutDiagnostics: SqlParserAnalysis = { + status: "invalid", + conformance, + // @ts-expect-error invalid requires at least one diagnostic + diagnostics: [], +}; +const cancelledAnalysis: SqlParserAnalysis = { + // @ts-expect-error cancellation is request lifecycle, not parser analysis + status: "cancelled", + // @ts-expect-error cancellation reasons do not leak into parser failures + reason: "caller", +}; +const incompleteAnalysis: SqlParserAnalysis = { + // @ts-expect-error lexical incompleteness is not a parser analysis + status: "incomplete", + construct: "single-quoted-string", +}; +// @ts-expect-error raw ASTs are not exposed through normalized artifacts +void artifact.ast; +// @ts-expect-error backend payloads are private implementation data +void artifact.payload; + +const parser = createSqlStatementParser( + authority, + async ({ signal, text }) => { + void signal; + void text; + return direct; + }, +); +const typedParser: SqlStatementParser = parser; +const request = createSqlStatementParseRequest( + "SELECT 1", + new AbortController().signal, +); +const typedRequest: SqlStatementParseRequest = request; +void runSqlStatementParser(parser, request); + +// @ts-expect-error parsers require package construction +const fabricatedParser: SqlStatementParser = { + authority, +}; +// @ts-expect-error parser requests require package construction +const fabricatedRequest: SqlStatementParseRequest = { + signal: new AbortController().signal, + text: "SELECT 1", +}; + +void cancelledAnalysis; +void compatibility; +void describeSyntaxState; +void fabricatedArtifact; +void fabricatedRange; +void fabricatedParser; +void fabricatedRequest; +void impossibleCompatibility; +void impossibleDirect; +void incompleteAnalysis; +void invalid; +void invalidWithoutConformance; +void invalidWithoutDiagnostics; +void parser; +void typedParser; +void typedRequest; +void relativeRange; From 79dc2717da95a9ec0e8c7fab998486b8e9786c3c Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:01:04 +0800 Subject: [PATCH 08/42] feat(vnext): add bounded node-sql-parser adapter (#177) ## Summary - add an internal normalized node-sql-parser adapter for PostgreSQL, BigQuery, and DuckDB compatibility evidence - pin node-sql-parser 5.4.0 and load only dialect-specific builds through a race-safe pure-Node loader - keep all successful evidence non-conformant, retain ASTs privately, and fail closed in browser/worker/DOM-shim realms pending dedicated-worker support - document the evidence, execution, resource, packaging, and global-cleanup boundaries in ADR 0003 - add adversarial unit/browser coverage and validated warm parser benchmarks ## Correctness policy - PostgreSQL/BigQuery acceptance: parsed/compatibility with partial-artifact - PostgreSQL/BigQuery rejection: unsupported/uncovered-construct, never invalid - DuckDB acceptance: parsed/compatibility with dialect-compatibility + partial-artifact - DuckDB rejection: unsupported/compatibility-rejected - Dremio: no adapter ## Validation - 971 tests passed, 1 expected failure - changed coverage: 99.13% statements, 99.35% branches, 100% functions, 99.12% lines - full strict typecheck and non-mutating lint - Chromium browser suite - isolated package smoke and demo build - test integrity check - preflight-validated parser benchmark - two independent adversarial review loops approved the final exact tree --- ## Summary by cubic Add an internal `node-sql-parser` adapter for PostgreSQL and BigQuery with a DuckDB compatibility path that returns small, private artifacts under strict bounds and pure-Node loading. Also fixes and documents a test spelling false positive, and keeps the CI report spelling-clean. - **New Features** - Evidence policy: PostgreSQL/BigQuery accept -> parsed/compatibility (partial); reject -> unsupported/uncovered-construct. DuckDB uses the PostgreSQL build: accept -> parsed/compatibility (dialect-compatibility, partial); reject -> unsupported/compatibility-rejected. - Bounded artifacts: only statement kind + full range; backend ASTs kept private. - Resource/placement: 16 KiB statement limit; synchronous parse; cancellation checked before/after load and after parse; not wired to sessions. - Execution realm safety: pure Node only with race-safe loader and global cleanup; fails closed in window/worker/DOM-shim realms. - Docs and tests: added ADR 0003, linked ADR 0002, and a vNext adapter doc; Node and browser tests plus a warm parser benchmark and `bench:parser-adapter` script; fixed spelling CI false positive and kept `state/artifacts/reports/pr-177-spelling-ci.md` spelling-clean. - **Dependencies** - Pin `node-sql-parser` to `5.4.0` and load only `build/postgresql.js` and `build/bigquery.js`. Written for commit 254362469573211dac7783331b67b156c299be6c. Summary will update on new commits. Review in cubic --- docs/adr/0002-normalized-syntax-contract.md | 6 + docs/adr/0003-node-sql-parser-adapter.md | 202 +++ docs/vnext/node-sql-parser-adapter.md | 116 ++ package.json | 3 +- pnpm-lock.yaml | 2 +- src/node-module.d.ts | 5 + .../node-sql-parser-adapter.bench.ts | 126 ++ .../__tests__/node-sql-parser-adapter.test.ts | 1174 +++++++++++++++++ .../node-sql-parser-adapter.test.ts | 102 ++ src/vnext/node-sql-parser-adapter.ts | 724 ++++++++++ state/artifacts/reports/pr-177-spelling-ci.md | 25 + 11 files changed, 2483 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0003-node-sql-parser-adapter.md create mode 100644 docs/vnext/node-sql-parser-adapter.md create mode 100644 src/node-module.d.ts create mode 100644 src/vnext/__tests__/node-sql-parser-adapter.bench.ts create mode 100644 src/vnext/__tests__/node-sql-parser-adapter.test.ts create mode 100644 src/vnext/browser_tests/node-sql-parser-adapter.test.ts create mode 100644 src/vnext/node-sql-parser-adapter.ts create mode 100644 state/artifacts/reports/pr-177-spelling-ci.md diff --git a/docs/adr/0002-normalized-syntax-contract.md b/docs/adr/0002-normalized-syntax-contract.md index d165717..4791b0b 100644 --- a/docs/adr/0002-normalized-syntax-contract.md +++ b/docs/adr/0002-normalized-syntax-contract.md @@ -179,6 +179,12 @@ request locations, retain backend data privately, and normalize every boundary before constructing these results. Cache/session wiring follows only after the adapter passes the contract suite. +[ADR 0003](./0003-node-sql-parser-adapter.md) applies this contract +conservatively: PostgreSQL, BigQuery, and DuckDB acceptance is compatibility +evidence with explicit limitations; rejection is unsupported rather than +invalid; and session wiring waits for an explicit +synchronous-execution/worker decision. + ## Non-goals This decision does not define: diff --git a/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md new file mode 100644 index 0000000..246b85f --- /dev/null +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -0,0 +1,202 @@ +# ADR 0003: Internal node-sql-parser Adapter + +Status: accepted +Date: 2026-07-25 + +## Context + +ADR 0002 defines an internal normalized syntax contract, but it deliberately +does not decide how a concrete parser earns authority. The first adapter uses +`node-sql-parser` because the dependency is already present and can provide a +useful local AST in Node or an isolated worker. It must not turn the +dependency's incomplete grammar, +partial locations, synchronous execution, or packaging behavior into broader +vNext guarantees. + +The installed `node-sql-parser` 5.4.0 package provides separate CommonJS +bundles for PostgreSQL and BigQuery. The complete bundle is approximately +2.5 MB uncompressed and 428 KiB compressed, while the PostgreSQL and BigQuery +builds are approximately 59 KiB and 42 KiB compressed respectively. Loading +the complete bundle would make consumers pay for unrelated grammars. + +The target-named grammars are not authoritative validators for their database +engines. In local probes, the PostgreSQL build rejected valid `MERGE`, +`FETCH FIRST`, `INSERT ... DEFAULT VALUES`, identity-column, and +`FOR UPDATE SKIP LOCKED` statements. The BigQuery build rejected valid +`QUALIFY`, `MERGE`, and `DECLARE` statements. A parse rejection therefore +cannot establish that target-dialect SQL is invalid. + +The parser also runs synchronously. An `AbortSignal` can prevent work before +the parse begins and suppress publication after it finishes, but it cannot +interrupt `astify` while JavaScript is blocked. + +## Decision + +The adapter remains internal and is not exported from `/vnext`. This change +does not connect it to document sessions, caches, diagnostics, completion, or +any other feature. Session wiring requires a separate decision about worker +isolation and cancellation. + +### Dependency and loading + +Pin `node-sql-parser` exactly to version `5.4.0`. The adapter depends on deep +paths, CommonJS interop, AST shapes, and grammar behavior that are not stable +enough for an unconstrained dependency range. + +Load only these extension-qualified paths: + +```text +node-sql-parser/build/postgresql.js +node-sql-parser/build/bigquery.js +``` + +After dynamically acquiring `createRequire` from `node:module`, the adapter +revalidates the realm and synchronously requires the selected build. Module +evaluation and global restoration therefore run in one JavaScript stack with +no alias-mutation gap. The adapter does not load the all-dialect entry point +and does not pass a `database` option to a dialect-specific build. + +Node ESM exposes these CommonJS builds through `default`/`module.exports` even +though the declarations suggest a named `Parser` export. The module boundary +is decoded from `unknown` and accepts only a runtime-validated constructor and +`astify` method. No unchecked assertion compensates for the declaration/runtime +mismatch. + +The parser receives fixed options: + +```ts +{ + trimQuery: false, + parseOptions: { + includeLocations: true, + }, +} +``` + +Disabling trimming is required to keep every backend offset relative to the +exact untrimmed statement slice. Location inclusion is requested for future +private semantic decoding, but location availability remains per node and is +not promoted to a dialect-wide capability. + +The distributed UMD-style builds can assign `NodeSQLParser` and, in some +environments, `global` on the global object while loading. The adapter refuses +to load them whenever `window` or `self` exists, or `global` does not resolve +exactly to `globalThis`. Only pure Node is currently eligible. This prevents +global collisions, including DOM shims in Node, and accidental synchronous +main-thread parsing. The Node loader captures the existing own-property +descriptors for the affected names and restores them synchronously after module +evaluation. A module that cannot be loaded and cleaned up safely permanently +poisons loading and returns a non-retryable backend failure. + +### Evidence policy + +PostgreSQL and BigQuery acceptance is positive-only compatibility evidence. A +successful target-specific parse produces `parsed/compatibility` with the +`partial-artifact` limitation. It means only that the target-named backend +produced a bounded normalized artifact. The adapter does not create a +conformance identity because the grammar also accepts constructs that the +target engines reject. + +Every PostgreSQL or BigQuery parse rejection produces: + +```text +unsupported / uncovered-construct +``` + +It never produces `invalid`, even for input that appears obviously malformed. +The same parser error is indistinguishable from rejection of valid syntax that +the dependency does not implement. This adapter therefore creates no +authoritative syntax diagnostics. + +DuckDB uses the PostgreSQL build only as a compatibility parser: + +- Acceptance produces `parsed/compatibility` with the + `dialect-compatibility` and `partial-artifact` limitations. +- Rejection produces `unsupported/compatibility-rejected`. +- The adapter performs no rewrites, success-without-AST shortcuts, bracket + quoting, comment stripping, or offset repair. + +Dremio has no node-sql-parser adapter. When a coordinator is added later, it +will classify Dremio as `unavailable/dialect-not-supported` rather than silently +selecting another grammar. + +Unexpected backend exceptions are failures, not syntax evidence. Invalid +module shapes or malformed AST roots are `failed/malformed-output`; other +unexpected parser exceptions are `failed/backend-failure`. Raw exceptions and +backend messages do not cross the normalized boundary. + +### Input and artifacts + +The adapter imposes a 16 KiB statement limit, below the syntax contract's +general 1 MiB ceiling. A larger statement returns +`unsupported/resource-limit` before importing or invoking the backend. +This conservative limit bounds work and temporary parser memory, but it does +not satisfy the provisional active-statement latency envelope: warm local +16 KiB parses already exceed that target. Placement measurements must set a +lower interactive limit or move parsing off the main thread. + +The backend receives only the exact, untrimmed, code-bearing statement source. +It receives no terminator, document coordinates, editor state, catalog, cursor, +or host context. + +A successful result must contain exactly one object root with a string `type`. +A direct object or one-element array is accepted. Zero or multiple roots are +`unsupported/uncovered-construct`; malformed roots are +`failed/malformed-output`. + +The public normalized artifact exposes only its closed statement kind and full +statement-relative range. The backend AST is retained in a module-private +`WeakMap` keyed by the authenticated artifact. It is neither enumerable nor +returned through the syntax contract. Future relation extraction must decode +and validate backend nodes inside the adapter boundary rather than exposing +the raw AST to core features. + +### Cancellation and execution placement + +The callback checks cancellation before loading, after loading, and after +parsing. These checks preserve the syntax runner's request-lifecycle behavior, +but they cannot preempt synchronous parser execution. + +The adapter must not be wired into interactive session requests until a +follow-up decision records one of: + +- Worker/process isolation with enforceable wall-clock and memory limits, or +- Measured main-thread execution with an accepted adversarial-input risk and + explicit scheduling policy. + +That decision must include browser measurements, cancellation behavior, +worker/module failure recovery, and the effect of many mounted editors. + +The current production loader supports pure Node only. A future browser +integration must invoke parsing from a dedicated worker whose global object is +not shared with application code, the legacy parser, or another installed copy. +The unsupported-realm rejection remains until that isolated execution path +exists. + +## Consequences + +- Local parsing can be evaluated without exposing a backend AST or + committing the language service to one parser. +- PostgreSQL and BigQuery false negatives degrade to unsupported instead of + false invalid diagnostics. +- PostgreSQL and BigQuery acceptance remains useful without claiming target + conformance. +- DuckDB compatibility remains useful without pretending to be native + conformance. +- Consumers do not load every node-sql-parser grammar. +- The 16 KiB limit and lack of session wiring intentionally restrict initial + capability. +- Authoritative invalidity requires a separately justified validator and an + owned dialect conformance corpus. + +## Non-goals + +This decision does not define: + +- Stable parser or provider APIs +- Session cache keys or eviction +- Document-level syntax diagnostics +- Semantic relation, scope, column, or type models +- A worker protocol +- Native DuckDB or remote validation providers +- Dremio parsing diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md new file mode 100644 index 0000000..31a8693 --- /dev/null +++ b/docs/vnext/node-sql-parser-adapter.md @@ -0,0 +1,116 @@ +# vNext node-sql-parser Adapter + +Status: internal and not wired to sessions + +The first concrete syntax adapter exercises the normalized contract from +[ADR 0002](../adr/0002-normalized-syntax-contract.md) under the evidence and +execution policy in +[ADR 0003](../adr/0003-node-sql-parser-adapter.md). It is not exported from +`/vnext` and does not currently power completion, diagnostics, hover, +navigation, or any other editor feature. + +## Capability matrix + +| Dialect | Backend | Acceptance | Rejection | +| --- | --- | --- | --- | +| PostgreSQL | PostgreSQL-specific build | `parsed/compatibility` with `partial-artifact` | `unsupported/uncovered-construct` | +| BigQuery | BigQuery-specific build | `parsed/compatibility` with `partial-artifact` | `unsupported/uncovered-construct` | +| DuckDB | PostgreSQL-specific build | `parsed/compatibility` with `dialect-compatibility`, `partial-artifact` | `unsupported/compatibility-rejected` | +| Dremio | None | Unavailable when coordinator wiring exists | `unavailable/dialect-not-supported` | + +This adapter never returns `direct` or `invalid`. The PostgreSQL and BigQuery +grammars both reject valid target-dialect constructs and accept constructs the +target engines reject. Acceptance therefore records only a partial +compatibility artifact; rejection is not an authoritative syntax diagnostic. + +## Loading and packaging + +The implementation is tied to an exact `node-sql-parser` 5.4.0 pin and loads +only: + +```text +node-sql-parser/build/postgresql.js +node-sql-parser/build/bigquery.js +``` + +The complete all-dialect entry point is not used. In pure Node, the adapter +dynamically acquires `createRequire`, revalidates the realm, then synchronously +requires the selected build so evaluation and cleanup cannot be interleaved +with another task. Runtime module decoding handles the package's CommonJS shape +rather than trusting its inaccurate named-export declarations. + +The adapter invokes `astify` with location collection enabled and query +trimming disabled. Disabling trimming preserves offsets relative to the exact +statement slice. Locations remain partial: PostgreSQL commonly omits root and +identifier locations, while BigQuery provides broader but still incomplete +coverage. + +Loading the distributed bundles may write `NodeSQLParser` or `global` on a +global object. The adapter rejects parser loads whenever `window` or `self` +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. + +Approximate local Node 24 arm64 measurements for the installed package were: + +| Build | Raw size | gzip size | Cold import | +| --- | ---: | ---: | ---: | +| PostgreSQL-specific | 308,145 B | 58,648 B | 19 ms | +| BigQuery-specific | 208,264 B | 41,828 B | 14 ms | +| All dialects | 2,505,457 B | 428,097 B | Not selected | + +These figures are investigation evidence, not cross-machine performance +guarantees. Reproducible browser and bundle baselines are required before +session integration. + +## Input and output rules + +The adapter accepts at most 16 KiB of statement text. Larger statements return +`unsupported/resource-limit` before a backend import or parse. + +The input is the exact code-bearing normal statement source: + +- Leading and trailing trivia are retained. +- A statement terminator is excluded. +- The source is not trimmed or rewritten. +- The backend receives no editor state, document offset, cursor, catalog, or + host context. + +A usable backend result has exactly one object root with a string `type`. The +adapter accepts either that object directly or a one-element array containing +it. Empty or multi-root output is uncovered; structurally malformed output is a +backend contract failure. + +Normalized statement kinds are intentionally small: + +| Backend type | Normalized kind | +| --- | --- | +| `select`, `union` | `query` | +| `insert`, `replace` | `insert` | +| `update`, `delete`, `create`, `alter`, `drop`, `merge`, `transaction` | Same closed kind | +| Any other string | `other` | + +Only the normalized kind and full statement-relative range appear in the +syntax artifact. The raw AST stays in private weakly keyed storage for a future +adapter-owned semantic decoder. Flat `tableList` and `columnList` output is not +used as the semantic model. + +## Failure and cancellation behavior + +Expected PostgreSQL and BigQuery PEG rejections are uncovered capability, not +failures. DuckDB rejection is compatibility rejection. Unexpected native +exceptions, module-load failures, and malformed module or AST values remain +explicit backend failures; raw exceptions are not retained in results. + +Cancellation is checked before and after asynchronous loading and after the +parse. The parse itself is synchronous and cannot be interrupted by an +`AbortSignal` while it blocks JavaScript. A late result can be discarded, but +the CPU work has already occurred. Unsupported execution realms fail closed +without importing a backend. + +For that reason, this adapter remains unwired. A worker-versus-main-thread ADR, +with browser latency, memory, hostile-input, timeout, and recovery evidence, is +required before interactive sessions may call it. diff --git a/package.json b/package.json index 0417463..90031e7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:coverage:changed": "node ./scripts/changed-coverage.mjs", "test:browser": "vitest run --config vitest.browser.config.ts", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", @@ -95,6 +96,6 @@ }, "module": "./dist/index.js", "dependencies": { - "node-sql-parser": "^5.3.13" + "node-sql-parser": "5.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fc8e25..56ec913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: specifier: ^6 version: 6.7.1 node-sql-parser: - specifier: ^5.3.13 + specifier: 5.4.0 version: 5.4.0 devDependencies: '@codemirror/lang-sql': diff --git a/src/node-module.d.ts b/src/node-module.d.ts new file mode 100644 index 0000000..faa807f --- /dev/null +++ b/src/node-module.d.ts @@ -0,0 +1,5 @@ +declare module "node:module" { + export function createRequire( + filename: string, + ): (specifier: string) => unknown; +} diff --git a/src/vnext/__tests__/node-sql-parser-adapter.bench.ts b/src/vnext/__tests__/node-sql-parser-adapter.bench.ts new file mode 100644 index 0000000..fada3a2 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-adapter.bench.ts @@ -0,0 +1,126 @@ +// @vitest-environment node + +import { beforeAll, bench, describe, expect } from "vitest"; +import { + getBigQueryNodeSqlStatementParser, + getDuckDbCompatibilityNodeSqlStatementParser, + getPostgresqlNodeSqlStatementParser, +} from "../node-sql-parser-adapter.js"; +import { + createSqlStatementParseRequest, + runSqlStatementParser, + type SqlStatementParser, +} from "../syntax.js"; + +function statementNear(targetLength: number): string { + const prefix = "SELECT "; + const suffix = " FROM benchmark_table"; + const columns: string[] = []; + let columnLength = 0; + for (let index = 0; ; index += 1) { + const column = `column_${index}`; + const separatorLength = columns.length === 0 ? 0 : 2; + if ( + prefix.length + + columnLength + + separatorLength + + column.length + + suffix.length > + targetLength + ) { + break; + } + columns.push(column); + columnLength += separatorLength + column.length; + } + return `${prefix}${columns.join(", ")}${suffix}`; +} + +const controller = new AbortController(); +const statements = [ + ["1 KiB", statementNear(1024)], + ["8 KiB", statementNear(8 * 1024)], + ["16 KiB", statementNear(16 * 1024)], +] as const; + +function requestFor(statement: string) { + return createSqlStatementParseRequest( + statement, + controller.signal, + ); +} + +const requests = statements.map( + ([label, statement]) => + [label, requestFor(statement)] as const, +); + +const postgresqlParser = getPostgresqlNodeSqlStatementParser(); +const bigQueryParser = getBigQueryNodeSqlStatementParser(); +const duckDbParser = getDuckDbCompatibilityNodeSqlStatementParser(); + +async function parse( + parser: SqlStatementParser, + request: ReturnType, +) { + return await runSqlStatementParser(parser, request); +} + +beforeAll(async () => { + const warmup = requestFor("SELECT 1"); + const warmups = await Promise.all([ + parse(postgresqlParser, warmup), + parse(bigQueryParser, warmup), + parse(duckDbParser, warmup), + ]); + for (const state of warmups) { + expect(state.analysis).toMatchObject({ + mode: "compatibility", + status: "parsed", + }); + } + for (const [, request] of requests) { + const states = await Promise.all([ + parse(postgresqlParser, request), + parse(bigQueryParser, request), + ]); + for (const state of states) { + expect(state.analysis).toMatchObject({ + limitations: ["partial-artifact"], + mode: "compatibility", + status: "parsed", + }); + } + } + const duckDbRequest = requests[1]?.[1]; + if (duckDbRequest === undefined) { + throw new Error("DuckDB benchmark request is unavailable"); + } + expect( + (await parse(duckDbParser, duckDbRequest)).analysis, + ).toMatchObject({ + limitations: ["dialect-compatibility", "partial-artifact"], + mode: "compatibility", + status: "parsed", + }); +}); + +describe("node-sql-parser adapter", () => { + for (const [label, request] of requests) { + bench(`PostgreSQL ${label}`, async () => { + await parse(postgresqlParser, request); + }); + + bench(`BigQuery ${label}`, async () => { + await parse(bigQueryParser, request); + }); + } + + bench("DuckDB compatibility 8 KiB", async () => { + const request = requests[1]?.[1]; + if (request === undefined) { + throw new Error("DuckDB benchmark request is unavailable"); + } + await parse(duckDbParser, request); + }); +}); diff --git a/src/vnext/__tests__/node-sql-parser-adapter.test.ts b/src/vnext/__tests__/node-sql-parser-adapter.test.ts new file mode 100644 index 0000000..b9d61fe --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-adapter.test.ts @@ -0,0 +1,1174 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + exportedForTesting, + getBigQueryNodeSqlStatementParser, + getDuckDbCompatibilityNodeSqlStatementParser, + getPostgresqlNodeSqlStatementParser, + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, +} from "../node-sql-parser-adapter.js"; +import { + createSqlStatementParseRequest, + runSqlStatementParser, + type SqlStatementParser, +} from "../syntax.js"; + +type ModuleShape = + | "constructor" + | "default-constructor" + | "default-named" + | "module-exports" + | "named"; + +function invokeWithUnknown( + callback: (...values: never[]) => unknown, + ...values: unknown[] +): unknown { + return Reflect.apply(callback, undefined, values); +} + +function createBackendModule( + astify: (statementText: string, options: unknown) => unknown, + shape: ModuleShape = "named", +): unknown { + class Parser { + astify(statementText: string, options: unknown): unknown { + return astify(statementText, options); + } + } + + switch (shape) { + case "constructor": + return Parser; + case "default-constructor": + return { default: Parser }; + case "default-named": + return { default: { Parser } }; + case "module-exports": + return { "module.exports": { Parser } }; + case "named": + return { Parser }; + } +} + +function createFakeParser( + astify: (statementText: string, options: unknown) => unknown, + policy: "dialect-compatibility" | "target-grammar" = "target-grammar", + shape: ModuleShape = "named", +): SqlStatementParser { + return exportedForTesting.createParser( + policy, + async () => createBackendModule(astify, shape), + ); +} + +async function parse( + parser: SqlStatementParser, + text = "SELECT 1", + signal: AbortSignal = new AbortController().signal, +) { + return await runSqlStatementParser( + parser, + createSqlStatementParseRequest(text, signal), + ); +} + +function syntaxError( + message: string, + from: number, + to: number, +): object { + return { + location: { + end: { offset: to }, + start: { offset: from }, + }, + message, + name: "SyntaxError", + }; +} + +function expectFailed( + state: Awaited>, + reason: "backend-failure" | "malformed-output", +): void { + expect(state.analysis).toMatchObject({ + reason, + status: "failed", + }); +} + +describe("node-sql-parser module boundary", () => { + it("returns stable frozen parsers with scoped authority identities", () => { + const postgresql = getPostgresqlNodeSqlStatementParser(); + const bigQuery = getBigQueryNodeSqlStatementParser(); + const duckDb = getDuckDbCompatibilityNodeSqlStatementParser(); + + expect(getPostgresqlNodeSqlStatementParser()).toBe(postgresql); + expect(getBigQueryNodeSqlStatementParser()).toBe(bigQuery); + expect(getDuckDbCompatibilityNodeSqlStatementParser()).toBe(duckDb); + expect(Object.isFrozen(postgresql)).toBe(true); + expect(Object.isFrozen(bigQuery)).toBe(true); + expect(Object.isFrozen(duckDb)).toBe(true); + expect(duckDb.authority.backendIdentity).toBe( + postgresql.authority.backendIdentity, + ); + expect(duckDb.authority.configurationIdentity).not.toBe( + postgresql.authority.configurationIdentity, + ); + expect(duckDb.authority.dialectIdentity).not.toBe( + postgresql.authority.dialectIdentity, + ); + expect(bigQuery.authority.backendIdentity).not.toBe( + postgresql.authority.backendIdentity, + ); + }); + + it.each([ + "constructor", + "default-constructor", + "default-named", + "module-exports", + "named", + ])("accepts the supported %s module shape", async (shape) => { + const parser = createFakeParser( + () => ({ type: "select" }), + "target-grammar", + shape, + ); + + const state = await parse(parser); + + expect(state.analysis).toMatchObject({ + artifact: { kind: "query", range: { from: 0, to: 8 } }, + mode: "compatibility", + status: "parsed", + }); + }); + + it("passes immutable location-preserving options and exact source", async () => { + const source = " SELECT 1\n"; + let receivedText: string | undefined; + let receivedOptions: unknown; + const parser = createFakeParser((text, options) => { + receivedText = text; + receivedOptions = options; + return { type: "select" }; + }); + + await parse(parser, source); + + expect(receivedText).toBe(source); + expect(receivedOptions).toEqual({ + parseOptions: { includeLocations: true }, + trimQuery: false, + }); + expect(Object.isFrozen(receivedOptions)).toBe(true); + if (receivedOptions === null || typeof receivedOptions !== "object") { + throw new Error("Expected parser options"); + } + const parseOptions = Reflect.get(receivedOptions, "parseOptions"); + expect(Object.isFrozen(parseOptions)).toBe(true); + }); + + it("deduplicates concurrent module loads", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let loads = 0; + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + loads += 1; + await gate; + return createBackendModule(() => ({ type: "select" })); + }, + ); + + const first = parse(parser, "SELECT 1"); + const second = parse(parser, "SELECT 2"); + await Promise.resolve(); + await Promise.resolve(); + expect(loads).toBe(1); + if (release === undefined) { + throw new Error("Module load gate was not initialized"); + } + release(); + + await expect(first).resolves.toMatchObject({ + analysis: { status: "parsed" }, + }); + await expect(second).resolves.toMatchObject({ + analysis: { status: "parsed" }, + }); + expect(loads).toBe(1); + }); + + it("clears a rejected load so the next request can retry", async () => { + let loads = 0; + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + loads += 1; + if (loads === 1) { + throw new Error("private loader details"); + } + return createBackendModule(() => ({ type: "select" })); + }, + ); + + const first = await parse(parser); + expect(first.analysis).toEqual( + expect.objectContaining({ + message: "node-sql-parser failed to load", + reason: "backend-failure", + retryable: true, + status: "failed", + }), + ); + expect(JSON.stringify(first)).not.toContain("private loader details"); + + const second = await parse(parser); + expect(second.analysis).toMatchObject({ + mode: "compatibility", + status: "parsed", + }); + expect(loads).toBe(2); + }); + + it("classifies and caches a constructor exception as a backend failure", async () => { + let constructions = 0; + class Parser { + constructor() { + constructions += 1; + throw new Error("private constructor details"); + } + } + const parser = exportedForTesting.createParser( + "target-grammar", + async () => ({ Parser }), + ); + + const state = await parse(parser); + + expectFailed(state, "backend-failure"); + expect(state.analysis).toMatchObject({ + message: "node-sql-parser backend failed", + retryable: false, + }); + expect(JSON.stringify(state)).not.toContain("private"); + expectFailed(await parse(parser), "backend-failure"); + expect(constructions).toBe(1); + }); + + it.each([ + null, + {}, + { Parser: null }, + { Parser: class MissingAstify {} }, + { + Parser: class InvalidAstify { + readonly astify = "not callable"; + }, + }, + ])("normalizes malformed module shape %#", async (moduleValue) => { + const parser = exportedForTesting.createParser( + "target-grammar", + async () => moduleValue, + ); + + const state = await parse(parser); + + expectFailed(state, "malformed-output"); + expect(state.analysis).toMatchObject({ + message: "node-sql-parser returned malformed output", + retryable: false, + }); + }); + + it("caches a non-retryable malformed module shape", async () => { + let loads = 0; + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + loads += 1; + return {}; + }, + ); + + expectFailed(await parse(parser), "malformed-output"); + expectFailed(await parse(parser), "malformed-output"); + expect(loads).toBe(1); + }); + + it("does not invoke an accessor-backed astify method", async () => { + let invoked = false; + class Parser { + get astify(): never { + invoked = true; + throw new Error("private accessor details"); + } + } + const parser = exportedForTesting.createParser( + "target-grammar", + async () => ({ Parser }), + ); + + const state = await parse(parser); + + expectFailed(state, "malformed-output"); + expect(invoked).toBe(false); + }); + + it("normalizes a prototype-trapping parser instance", async () => { + const parserInstance = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("private prototype details"); + }, + }, + ); + function Parser(): object { + return parserInstance; + } + const parser = exportedForTesting.createParser( + "target-grammar", + async () => ({ Parser }), + ); + + const state = await parse(parser); + + expectFailed(state, "malformed-output"); + expect(JSON.stringify(state)).not.toContain("private"); + }); + + it("restores globals and recovers from a synchronous load failure", () => { + const sentinel = {}; + const descriptor: PropertyDescriptor = { + configurable: true, + enumerable: false, + value: sentinel, + writable: true, + }; + const syntheticGlobal = {}; + Object.defineProperty( + syntheticGlobal, + "NodeSQLParser", + descriptor, + ); + const loadSynchronously = + exportedForTesting.createSynchronousModuleLoader( + syntheticGlobal, + ); + + expect(() => + loadSynchronously(() => { + Object.defineProperty( + syntheticGlobal, + "NodeSQLParser", + { + configurable: true, + value: "temporary", + }, + ); + throw new Error("private load details"); + }), + ).toThrow("private load details"); + expect( + Object.getOwnPropertyDescriptor( + syntheticGlobal, + "NodeSQLParser", + ), + ).toStrictEqual(descriptor); + expect(loadSynchronously(() => "loaded")).toBe("loaded"); + }); + + it("permanently poisons synchronous loading after cleanup failure", async () => { + const syntheticGlobal = {}; + const loadSynchronously = + exportedForTesting.createSynchronousModuleLoader( + syntheticGlobal, + ); + let firstLoads = 0; + const firstParser = exportedForTesting.createParser( + "target-grammar", + async () => + loadSynchronously(() => { + firstLoads += 1; + Object.defineProperty( + syntheticGlobal, + "NodeSQLParser", + { + configurable: false, + value: "pollution", + }, + ); + return createBackendModule(() => ({ type: "select" })); + }), + ); + + const first = await parse(firstParser); + + expectFailed(first, "backend-failure"); + expect(first.analysis).toMatchObject({ retryable: false }); + expect(firstLoads).toBe(1); + + let laterLoads = 0; + const laterParser = exportedForTesting.createParser( + "target-grammar", + async () => + loadSynchronously(() => { + laterLoads += 1; + return createBackendModule(() => ({ type: "select" })); + }), + ); + + const later = await parse(laterParser); + + expectFailed(later, "backend-failure"); + expect(later.analysis).toMatchObject({ retryable: false }); + expect(laterLoads).toBe(0); + expect( + Reflect.get(syntheticGlobal, "NodeSQLParser"), + ).toBe("pollution"); + }); + + it("permanently poisons synchronous loading after snapshot failure", () => { + let inspections = 0; + const syntheticGlobal = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + inspections += 1; + throw new Error("private snapshot details"); + }, + }, + ); + const loadSynchronously = + exportedForTesting.createSynchronousModuleLoader( + syntheticGlobal, + ); + let loads = 0; + + expect(() => + loadSynchronously(() => { + loads += 1; + }), + ).toThrow(); + const inspectionsAfterFirst = inspections; + expect(() => + loadSynchronously(() => { + loads += 1; + }), + ).toThrow(); + expect(inspections).toBe(inspectionsAfterFirst); + expect(loads).toBe(0); + }); +}); + +describe("node-sql-parser result decoding", () => { + it.each([ + ["select", "query"], + ["UNION", "query"], + ["insert", "insert"], + ["replace", "insert"], + ["update", "update"], + ["delete", "delete"], + ["create", "create"], + ["alter", "alter"], + ["drop", "drop"], + ["merge", "merge"], + ["transaction", "transaction"], + ["truncate", "other"], + ] as const)("maps backend kind %s to %s", async (backend, expected) => { + const state = await parse( + createFakeParser(() => ({ type: backend })), + ); + + expect(state.analysis).toMatchObject({ + artifact: { kind: expected }, + mode: "compatibility", + status: "parsed", + }); + }); + + it("accepts a one-element AST array", async () => { + const state = await parse( + createFakeParser(() => [{ type: "select" }]), + ); + + expect(state.analysis).toMatchObject({ + artifact: { kind: "query" }, + status: "parsed", + }); + }); + + it.each([ + [[]], + [[{ type: "select" }, { type: "select" }]], + ] as const)("classifies non-singleton AST arrays as unsupported", async (root) => { + const state = await parse(createFakeParser(() => root)); + + expect(state.analysis).toEqual( + expect.objectContaining({ + reason: "uncovered-construct", + status: "unsupported", + }), + ); + }); + + it.each([ + null, + undefined, + 1, + "select", + {}, + { type: "" }, + { type: " select" }, + { type: "select " }, + { type: "x".repeat(129) }, + [null], + Array(1), + ])("rejects malformed root %#", async (root) => { + const state = await parse(createFakeParser(() => root)); + + expectFailed(state, "malformed-output"); + }); + + it("rejects an accessor-backed type without invoking it", async () => { + let invoked = false; + const root = {}; + Object.defineProperty(root, "type", { + get() { + invoked = true; + throw new Error("private accessor details"); + }, + }); + + const state = await parse(createFakeParser(() => root)); + + expectFailed(state, "malformed-output"); + expect(invoked).toBe(false); + expect(JSON.stringify(state)).not.toContain("private accessor details"); + }); + + it("normalizes a descriptor-trapping root proxy as malformed", async () => { + const root = new Proxy( + { type: "select" }, + { + getOwnPropertyDescriptor() { + throw new Error("private proxy details"); + }, + }, + ); + + const state = await parse(createFakeParser(() => root)); + + expectFailed(state, "malformed-output"); + expect(JSON.stringify(state)).not.toContain("private proxy details"); + }); + + it("normalizes revoked and array-length-trapping proxies as malformed", async () => { + const revoked = Proxy.revocable({ type: "select" }, {}); + revoked.revoke(); + const arrayProxy = new Proxy( + [{ type: "select" }], + { + getOwnPropertyDescriptor(target, key) { + if (key === "length") { + throw new Error("private array proxy details"); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + + for (const root of [revoked.proxy, arrayProxy]) { + const state = await parse(createFakeParser(() => root)); + expectFailed(state, "malformed-output"); + expect(JSON.stringify(state)).not.toContain("private"); + } + }); + + it("rejects callable AST roots", async () => { + function root(): void { + // A parser AST is data, never executable. + } + Object.defineProperty(root, "type", { + value: "select", + }); + + const state = await parse(createFakeParser(() => root)); + + expectFailed(state, "malformed-output"); + }); +}); + +describe("node-sql-parser error boundary", () => { + it.each([ + ["target-grammar", "uncovered-construct"], + ["dialect-compatibility", "compatibility-rejected"], + ] as const)( + "keeps a %s grammar rejection non-authoritative", + async (policy, expected) => { + const parser = createFakeParser(() => { + throw syntaxError("Unexpected token", 7, 8); + }, policy); + + const state = await parse(parser); + + expect(state.analysis).toEqual( + expect.objectContaining({ + reason: expected, + status: "unsupported", + }), + ); + expect(JSON.stringify(state)).not.toContain("Unexpected token"); + }, + ); + + it.each([ + [ + "PostgreSQL", + getPostgresqlNodeSqlStatementParser, + "SELECT `value` FROM `items`", + ], + [ + "BigQuery", + getBigQueryNodeSqlStatementParser, + "SELECT value FROM dataset.items LIMIT 1, 2", + ], + ] as const)( + "does not claim direct %s conformance for an over-accepted construct", + async (_dialect, getParser, source) => { + const state = await parse(getParser(), source); + + expect(state.analysis).toMatchObject({ + limitations: ["partial-artifact"], + mode: "compatibility", + status: "parsed", + }); + }, + ); + + it.each([ + { location: null, message: "bad", name: "SyntaxError" }, + { location: {}, message: "bad", name: "SyntaxError" }, + { + location: { end: { offset: 1 }, start: { offset: -1 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 1 }, start: { offset: 2 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 9 }, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 1.5 }, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: {}, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + syntaxError("", 0, 1), + syntaxError("x".repeat(8_193), 0, 1), + ])("rejects malformed SyntaxError %#", async (error) => { + const state = await parse( + createFakeParser(() => { + throw error; + }), + ); + + expectFailed(state, "malformed-output"); + }); + + it("does not invoke accessor-backed SyntaxError fields", async () => { + let invoked = false; + const error = { + message: "bad", + name: "SyntaxError", + }; + Object.defineProperty(error, "location", { + get() { + invoked = true; + throw new Error("private accessor details"); + }, + }); + + const state = await parse( + createFakeParser(() => { + throw error; + }), + ); + + expectFailed(state, "malformed-output"); + expect(invoked).toBe(false); + }); + + it.each([ + new Error("private backend stack and message"), + "private primitive rejection", + null, + ])("normalizes backend failure %# without raw leakage", async (error) => { + const state = await parse( + createFakeParser(() => { + throw error; + }), + ); + + expectFailed(state, "backend-failure"); + expect(state.analysis).toMatchObject({ + message: "node-sql-parser backend failed", + retryable: false, + }); + expect(JSON.stringify(state)).not.toContain("private"); + }); +}); + +describe("node-sql-parser resource and cancellation boundary", () => { + it("rejects oversized input without loading the backend", async () => { + let loads = 0; + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + loads += 1; + return createBackendModule(() => ({ type: "select" })); + }, + ); + + const state = await parse( + parser, + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + 1), + ); + + expect(state.analysis).toEqual( + expect.objectContaining({ + reason: "resource-limit", + status: "unsupported", + }), + ); + expect(loads).toBe(0); + }); + + it("accepts the exact adapter input boundary", async () => { + let calls = 0; + const parser = createFakeParser(() => { + calls += 1; + return { type: "select" }; + }); + + const state = await parse( + parser, + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH), + ); + + expect(state.analysis).toMatchObject({ + mode: "compatibility", + status: "parsed", + }); + expect(calls).toBe(1); + }); + + it("does not load for a pre-aborted request", async () => { + let loads = 0; + const reason = Object.freeze({ reason: "pre-aborted" }); + const controller = new AbortController(); + controller.abort(reason); + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + loads += 1; + return createBackendModule(() => ({ type: "select" })); + }, + ); + + await expect( + parse(parser, "SELECT 1", controller.signal), + ).rejects.toBe(reason); + expect(loads).toBe(0); + }); + + it("propagates exact abort during module load and never parses", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let astifyCalls = 0; + const parser = exportedForTesting.createParser( + "target-grammar", + async () => { + await gate; + return createBackendModule(() => { + astifyCalls += 1; + return { type: "select" }; + }); + }, + ); + const controller = new AbortController(); + const reason = Object.freeze({ reason: "during-load" }); + + const result = parse(parser, "SELECT 1", controller.signal); + await Promise.resolve(); + controller.abort(reason); + if (release === undefined) { + throw new Error("Module load gate was not initialized"); + } + release(); + + await expect(result).rejects.toBe(reason); + expect(astifyCalls).toBe(0); + }); + + it("does not publish when the backend aborts synchronously", async () => { + const controller = new AbortController(); + const reason = Object.freeze({ reason: "during-parse" }); + const parser = createFakeParser(() => { + controller.abort(reason); + return { type: "select" }; + }); + + await expect( + parse(parser, "SELECT 1", controller.signal), + ).rejects.toBe(reason); + }); +}); + +describe("real node-sql-parser builds", () => { + it.each([ + [ + "PostgreSQL", + getPostgresqlNodeSqlStatementParser, + " SELECT 1\nFROM users", + "query", + ], + [ + "PostgreSQL", + getPostgresqlNodeSqlStatementParser, + "BEGIN", + "transaction", + ], + [ + "BigQuery", + getBigQueryNodeSqlStatementParser, + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + "query", + ], + [ + "BigQuery", + getBigQueryNodeSqlStatementParser, + "CREATE TABLE dataset.result (id INT64)", + "create", + ], + ] as const)( + "parses a valid %s statement", + async (_dialect, getParser, source, expectedKind) => { + const state = await parse(getParser(), source); + + expect(state.analysis).toMatchObject({ + artifact: { + kind: expectedKind, + range: { from: 0, to: source.length }, + }, + limitations: ["partial-artifact"], + mode: "compatibility", + status: "parsed", + }); + }, + ); + + it.each([ + [ + "PostgreSQL", + getPostgresqlNodeSqlStatementParser, + "MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET value = source.value", + ], + [ + "BigQuery", + getBigQueryNodeSqlStatementParser, + "SELECT value FROM dataset.table QUALIFY ROW_NUMBER() OVER () = 1", + ], + [ + "PostgreSQL malformed query", + getPostgresqlNodeSqlStatementParser, + "SELECT FROM", + ], + [ + "BigQuery malformed query", + getBigQueryNodeSqlStatementParser, + "SELECT FROM", + ], + ] as const)( + "classifies %s syntax rejection as unsupported", + async (_dialect, getParser, source) => { + const state = await parse(getParser(), source); + + expect(state.analysis).toEqual( + expect.objectContaining({ + reason: "uncovered-construct", + status: "unsupported", + }), + ); + }, + ); + + it("marks DuckDB's PostgreSQL subset as compatibility evidence", async () => { + const state = await parse( + getDuckDbCompatibilityNodeSqlStatementParser(), + "SELECT 1", + ); + + expect(state.analysis).toMatchObject({ + artifact: { kind: "query" }, + limitations: ["dialect-compatibility", "partial-artifact"], + mode: "compatibility", + status: "parsed", + }); + }); + + it("never turns a DuckDB compatibility rejection into invalid SQL", async () => { + const state = await parse( + getDuckDbCompatibilityNodeSqlStatementParser(), + "FROM 'data.parquet'", + ); + + expect(state.analysis).toEqual( + expect.objectContaining({ + reason: "compatibility-rejected", + status: "unsupported", + }), + ); + }); +}); + +describe("backend artifact privacy", () => { + it("retains backend data only behind the authentic artifact", async () => { + const backendRoot = { + privateBackendField: "must-not-leak", + type: "select", + }; + const state = await parse(createFakeParser(() => backendRoot)); + if ( + state.analysis.status !== "parsed" + ) { + throw new Error("Expected a parsed analysis"); + } + const { artifact } = state.analysis; + + expect(exportedForTesting.hasBackendPayload(artifact)).toBe(true); + expect(Object.keys(artifact)).toEqual(["kind", "range"]); + expect(JSON.stringify(state)).not.toContain("must-not-leak"); + expect(Reflect.ownKeys(artifact)).not.toContain("privateBackendField"); + + const structuralCopy = { + kind: artifact.kind, + range: artifact.range, + }; + expect( + invokeWithUnknown( + exportedForTesting.hasBackendPayload, + structuralCopy, + ), + ).toBe(false); + }); + + it("does not retain malformed or unsupported backend values", async () => { + const malformedState = await parse(createFakeParser(() => null)); + const unsupportedState = await parse(createFakeParser(() => [])); + + for (const state of [malformedState, unsupportedState]) { + expect(state.analysis.status).not.toBe("parsed"); + const fabricated = { + kind: "other", + range: { from: 0, to: 0 }, + }; + expect( + invokeWithUnknown( + exportedForTesting.hasBackendPayload, + fabricated, + ), + ).toBe(false); + } + }); +}); + +describe("execution realm boundary", () => { + it.each(["self", "window"] as const)( + "rejects and caches a distinct %s alias before import", + async (alias) => { + const original = Object.getOwnPropertyDescriptor( + globalThis, + alias, + ); + const sentinel = {}; + Object.defineProperty(globalThis, alias, { + configurable: true, + value: sentinel, + }); + + try { + vi.resetModules(); + const isolatedAdapter = await import( + "../node-sql-parser-adapter.js" + ); + const isolatedSyntax = await import("../syntax.js"); + const parser = + isolatedAdapter.getPostgresqlNodeSqlStatementParser(); + const run = async () => + await isolatedSyntax.runSqlStatementParser( + parser, + isolatedSyntax.createSqlStatementParseRequest( + "SELECT 1", + new AbortController().signal, + ), + ); + + const first = await run(); + expect(first.analysis).toMatchObject({ + reason: "backend-failure", + retryable: false, + status: "failed", + }); + expect(Reflect.deleteProperty(globalThis, alias)).toBe(true); + + const second = await run(); + expect(second.analysis).toMatchObject({ + reason: "backend-failure", + retryable: false, + status: "failed", + }); + expect( + Object.getOwnPropertyDescriptor( + sentinel, + "NodeSQLParser", + ), + ).toBeUndefined(); + } finally { + if (original === undefined) { + Reflect.deleteProperty(globalThis, alias); + } else { + Object.defineProperty(globalThis, alias, original); + } + vi.resetModules(); + } + }, + ); + + it("rejects and caches an inherited self alias", async () => { + const prototype = Object.getPrototypeOf(globalThis); + if (prototype === null) { + throw new Error("Expected a global prototype"); + } + const original = Object.getOwnPropertyDescriptor( + prototype, + "self", + ); + const sentinel = {}; + Object.defineProperty(prototype, "self", { + configurable: true, + value: sentinel, + }); + + try { + vi.resetModules(); + const isolatedAdapter = await import( + "../node-sql-parser-adapter.js" + ); + const isolatedSyntax = await import("../syntax.js"); + const parser = + isolatedAdapter.getPostgresqlNodeSqlStatementParser(); + const run = async () => + await isolatedSyntax.runSqlStatementParser( + parser, + isolatedSyntax.createSqlStatementParseRequest( + "SELECT 1", + new AbortController().signal, + ), + ); + + expect((await run()).analysis).toMatchObject({ + reason: "backend-failure", + retryable: false, + status: "failed", + }); + expect(Reflect.deleteProperty(prototype, "self")).toBe(true); + expect((await run()).analysis).toMatchObject({ + reason: "backend-failure", + retryable: false, + status: "failed", + }); + expect( + Object.getOwnPropertyDescriptor( + sentinel, + "NodeSQLParser", + ), + ).toBeUndefined(); + } finally { + if (original === undefined) { + Reflect.deleteProperty(prototype, "self"); + } else { + Object.defineProperty(prototype, "self", original); + } + vi.resetModules(); + } + }); + + it("rejects an inherited window accessor without invoking it", async () => { + const prototype = Object.getPrototypeOf(globalThis); + if (prototype === null) { + throw new Error("Expected a global prototype"); + } + const original = Object.getOwnPropertyDescriptor( + prototype, + "window", + ); + let invoked = false; + Object.defineProperty(prototype, "window", { + configurable: true, + get() { + invoked = true; + return {}; + }, + }); + + try { + vi.resetModules(); + const isolatedAdapter = await import( + "../node-sql-parser-adapter.js" + ); + const isolatedSyntax = await import("../syntax.js"); + const state = await isolatedSyntax.runSqlStatementParser( + isolatedAdapter.getPostgresqlNodeSqlStatementParser(), + isolatedSyntax.createSqlStatementParseRequest( + "SELECT 1", + new AbortController().signal, + ), + ); + + expect(state.analysis).toMatchObject({ + reason: "backend-failure", + retryable: false, + status: "failed", + }); + expect(invoked).toBe(false); + } finally { + if (original === undefined) { + Reflect.deleteProperty(prototype, "window"); + } else { + Object.defineProperty(prototype, "window", original); + } + vi.resetModules(); + } + }); +}); diff --git a/src/vnext/browser_tests/node-sql-parser-adapter.test.ts b/src/vnext/browser_tests/node-sql-parser-adapter.test.ts new file mode 100644 index 0000000..f691f76 --- /dev/null +++ b/src/vnext/browser_tests/node-sql-parser-adapter.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "vitest"; +import { + getBigQueryNodeSqlStatementParser, + getPostgresqlNodeSqlStatementParser, +} from "../node-sql-parser-adapter.js"; +import { + createSqlStatementParseRequest, + runSqlStatementParser, + type SqlStatementParser, +} from "../syntax.js"; + +const parserGlobalKeys = ["NodeSQLParser", "global"] as const; + +function snapshotGlobals() { + return parserGlobalKeys.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(globalThis, key), + key, + })); +} + +function restoreGlobals( + snapshots: ReturnType, +): void { + for (const { descriptor, key } of snapshots) { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.defineProperty(globalThis, key, descriptor); + } + } +} + +async function expectWindowRealmBlocked( + parser: SqlStatementParser, +): Promise { + const state = await runSqlStatementParser( + parser, + createSqlStatementParseRequest( + "SELECT 1 AS value", + new AbortController().signal, + ), + ); + expect(state).toMatchObject({ + analysis: { + reason: "backend-failure", + retryable: false, + status: "failed", + }, + state: "analyzed", + }); +} + +test( + "window-realm dialect requests fail without mutating globals", + { timeout: 10_000 }, + async () => { + const original = snapshotGlobals(); + try { + const nodeSqlParserSentinel = Object.freeze({ + owner: "consumer", + }); + const nodeSqlParserDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: false, + value: nodeSqlParserSentinel, + writable: true, + }; + const globalDescriptor: PropertyDescriptor = { + configurable: true, + enumerable: true, + value: undefined, + writable: true, + }; + Object.defineProperty( + globalThis, + "NodeSQLParser", + nodeSqlParserDescriptor, + ); + Object.defineProperty(globalThis, "global", globalDescriptor); + + await Promise.all([ + expectWindowRealmBlocked( + getPostgresqlNodeSqlStatementParser(), + ), + expectWindowRealmBlocked( + getBigQueryNodeSqlStatementParser(), + ), + ]); + expect( + Object.getOwnPropertyDescriptor( + globalThis, + "NodeSQLParser", + ), + ).toStrictEqual(nodeSqlParserDescriptor); + expect( + Object.getOwnPropertyDescriptor(globalThis, "global"), + ).toStrictEqual(globalDescriptor); + } finally { + restoreGlobals(original); + } + }, +); diff --git a/src/vnext/node-sql-parser-adapter.ts b/src/vnext/node-sql-parser-adapter.ts new file mode 100644 index 0000000..c0b7e59 --- /dev/null +++ b/src/vnext/node-sql-parser-adapter.ts @@ -0,0 +1,724 @@ +import { + createCompatibilityParsedAnalysis, + createFailedParserAnalysis, + createSqlDialectSyntaxIdentity, + createSqlParserAuthority, + createSqlStatementParser, + createSqlSyntaxArtifact, + createSqlSyntaxBackendIdentity, + createSqlSyntaxConfigurationIdentity, + createUnsupportedParserAnalysis, + MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH, + type SqlCompatibilityLimitation, + type SqlParserAuthority, + type SqlStatementKind, + type SqlStatementParser, + type SqlSyntaxArtifact, + type SqlSyntaxBackendIdentity, +} from "./syntax.js"; + +export const MAX_NODE_SQL_PARSER_STATEMENT_LENGTH = 16 * 1024; + +const MAX_BACKEND_TYPE_LENGTH = 128; +const NODE_SQL_PARSER_OPTIONS = Object.freeze({ + parseOptions: Object.freeze({ + includeLocations: true, + }), + trimQuery: false, +}); +const TARGET_GRAMMAR_LIMITATIONS = Object.freeze([ + "partial-artifact", +] as const); +const DIALECT_COMPATIBILITY_LIMITATIONS = Object.freeze([ + "dialect-compatibility", + "partial-artifact", +] as const); + +type NodeSqlParserPolicy = + | "dialect-compatibility" + | "target-grammar"; + +interface NodeSqlParserBackend { + readonly astify: (statementText: string) => unknown; +} + +type NodeSqlParserBackendLoader = () => Promise; +type NodeSqlParserModuleLoader = () => Promise; + +interface NodeSqlParserRuntime { + readonly authority: SqlParserAuthority; + readonly limitations: readonly [ + SqlCompatibilityLimitation, + ...SqlCompatibilityLimitation[], + ]; + readonly loadBackend: NodeSqlParserBackendLoader; + readonly policy: NodeSqlParserPolicy; +} + +type OwnDataProperty = + | { + readonly kind: "missing"; + } + | { + readonly kind: "invalid"; + } + | { + readonly kind: "value"; + readonly value: unknown; + }; + +type DecodedRoot = + | { + readonly kind: "malformed"; + } + | { + readonly kind: "root"; + readonly root: object; + readonly statementKind: SqlStatementKind; + } + | { + readonly kind: "unsupported"; + }; + +type DecodedParserError = + | "backend-failure" + | "malformed-output" + | "syntax-rejection"; + +class NodeSqlParserModuleShapeError extends Error {} +class NodeSqlParserInitializationError extends Error {} +class NodeSqlParserGlobalCleanupError extends Error {} +class NodeSqlParserExecutionRealmError extends Error {} + +const backendPayloads = new WeakMap(); + +function isObjectLike(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || + typeof value === "function" + ); +} + +function isRecordObject(value: unknown): value is object { + return typeof value === "object" && value !== null; +} + +function readOwnDataProperty( + value: object, + key: PropertyKey, +): OwnDataProperty { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return { kind: "invalid" }; + } + if (descriptor === undefined) { + return { kind: "missing" }; + } + if (!("value" in descriptor)) { + return { kind: "invalid" }; + } + return { + kind: "value", + value: descriptor.value, + }; +} + +function readDataProperty( + value: object, + key: PropertyKey, +): OwnDataProperty { + let candidate: object | null = value; + for (let depth = 0; candidate !== null && depth < 16; depth += 1) { + const property = readOwnDataProperty(candidate, key); + if (property.kind !== "missing") { + return property; + } + try { + candidate = Object.getPrototypeOf(candidate); + } catch { + return { kind: "invalid" }; + } + } + return candidate === null + ? { kind: "missing" } + : { kind: "invalid" }; +} + +function readDataMethod( + value: object, + key: PropertyKey, +): ((...arguments_: unknown[]) => unknown) | null { + let candidate: object | null = value; + for (let depth = 0; candidate !== null && depth < 16; depth += 1) { + const property = readOwnDataProperty(candidate, key); + if (property.kind === "invalid") { + return null; + } + if (property.kind === "value") { + if (typeof property.value !== "function") { + return null; + } + const method = property.value; + return (...arguments_: unknown[]) => + Reflect.apply(method, value, arguments_); + } + try { + candidate = Object.getPrototypeOf(candidate); + } catch { + return null; + } + } + return null; +} + +function moduleCandidates(moduleValue: unknown): readonly unknown[] { + if (!isObjectLike(moduleValue)) { + return [moduleValue]; + } + const candidates: unknown[] = [moduleValue]; + for (const key of ["default", "module.exports"] as const) { + const property = readOwnDataProperty(moduleValue, key); + if (property.kind === "value") { + candidates.push(property.value); + } + } + return candidates; +} + +function findParserConstructor(moduleValue: unknown): unknown { + for (const candidate of moduleCandidates(moduleValue)) { + if (typeof candidate === "function") { + return candidate; + } + if (!isObjectLike(candidate)) { + continue; + } + const parser = readOwnDataProperty(candidate, "Parser"); + if (parser.kind === "value" && typeof parser.value === "function") { + return parser.value; + } + } + return null; +} + +function decodeBackendModule(moduleValue: unknown): NodeSqlParserBackend { + const Parser = findParserConstructor(moduleValue); + if (typeof Parser !== "function") { + throw new NodeSqlParserModuleShapeError(); + } + let parser: object; + try { + parser = Reflect.construct(Parser, []); + } catch { + throw new NodeSqlParserInitializationError(); + } + const astify = readDataMethod(parser, "astify"); + if (astify === null) { + throw new NodeSqlParserModuleShapeError(); + } + return Object.freeze({ + astify(statementText: string): unknown { + return astify(statementText, NODE_SQL_PARSER_OPTIONS); + }, + }); +} + +function createRetryingBackendLoader( + loadModule: NodeSqlParserModuleLoader, +): NodeSqlParserBackendLoader { + let pending: Promise | undefined; + return async () => { + if (pending === undefined) { + const current = Promise.resolve() + .then(loadModule) + .then(decodeBackendModule); + pending = current; + try { + return await current; + } catch (error: unknown) { + if ( + !(error instanceof NodeSqlParserModuleShapeError) && + !(error instanceof NodeSqlParserInitializationError) && + !(error instanceof NodeSqlParserGlobalCleanupError) && + !(error instanceof NodeSqlParserExecutionRealmError) + ) { + pending = undefined; + } + throw error; + } + } + return await pending; + }; +} + +const PARSER_GLOBAL_KEYS = ["NodeSQLParser", "global"] as const; + +type ParserGlobalSnapshots = readonly { + readonly descriptor: PropertyDescriptor | undefined; + readonly key: (typeof PARSER_GLOBAL_KEYS)[number]; +}[]; + +function snapshotParserGlobals(target: object): ParserGlobalSnapshots { + try { + return PARSER_GLOBAL_KEYS.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(target, key), + key, + })); + } catch { + throw new NodeSqlParserGlobalCleanupError(); + } +} + +function restoreParserGlobals( + target: object, + snapshots: ParserGlobalSnapshots, +): boolean { + let cleanupFailed = false; + for (const snapshot of snapshots) { + try { + if (snapshot.descriptor === undefined) { + if (!Reflect.deleteProperty(target, snapshot.key)) { + cleanupFailed = true; + } + } else { + Object.defineProperty( + target, + snapshot.key, + snapshot.descriptor, + ); + } + } catch { + cleanupFailed = true; + } + } + return !cleanupFailed; +} + +function createSynchronousModuleLoader(target: object) { + let poisoned = false; + return (loadModule: () => unknown): unknown => { + if (poisoned) { + throw new NodeSqlParserGlobalCleanupError(); + } + let snapshots: ParserGlobalSnapshots; + try { + snapshots = snapshotParserGlobals(target); + } catch { + poisoned = true; + throw new NodeSqlParserGlobalCleanupError(); + } + const outcome: + | { readonly kind: "failed"; readonly error: unknown } + | { readonly kind: "loaded"; readonly value: unknown } = (() => { + try { + return { + kind: "loaded", + value: loadModule(), + }; + } catch (error: unknown) { + return { + error, + kind: "failed", + }; + } + })(); + if (!restoreParserGlobals(target, snapshots)) { + poisoned = true; + throw new NodeSqlParserGlobalCleanupError(); + } + if (outcome.kind === "failed") { + throw outcome.error; + } + return outcome.value; + }; +} + +const loadNodeModuleSynchronously = + createSynchronousModuleLoader(globalThis); + +function rejectUnsupportedExecutionRealm(): void { + const windowAlias = readDataProperty(globalThis, "window"); + const selfAlias = readDataProperty(globalThis, "self"); + const globalAlias = readDataProperty(globalThis, "global"); + if ( + windowAlias.kind === "invalid" || + (windowAlias.kind === "value" && + windowAlias.value !== undefined) || + selfAlias.kind === "invalid" || + (selfAlias.kind === "value" && + selfAlias.value !== undefined) || + globalAlias.kind === "invalid" || + globalAlias.kind !== "value" || + globalAlias.value !== globalThis + ) { + throw new NodeSqlParserExecutionRealmError(); + } +} + +async function loadNodeSqlParserBuild( + specifier: string, +): Promise { + rejectUnsupportedExecutionRealm(); + const { createRequire } = await import("node:module"); + rejectUnsupportedExecutionRealm(); + const require = createRequire(import.meta.url); + return loadNodeModuleSynchronously( + () => require(specifier), + ); +} + +function importPostgresqlBuild(): Promise { + return loadNodeSqlParserBuild( + "node-sql-parser/build/postgresql.js", + ); +} + +function importBigQueryBuild(): Promise { + return loadNodeSqlParserBuild( + "node-sql-parser/build/bigquery.js", + ); +} + +function mapStatementKind(backendType: string): SqlStatementKind { + switch (backendType.toLowerCase()) { + case "select": + case "union": + return "query"; + case "insert": + case "replace": + return "insert"; + case "update": + return "update"; + case "delete": + return "delete"; + case "create": + return "create"; + case "alter": + return "alter"; + case "drop": + return "drop"; + case "merge": + return "merge"; + case "transaction": + return "transaction"; + default: + return "other"; + } +} + +function decodeRoot(value: unknown): DecodedRoot { + let root = value; + let rootIsArray: boolean; + try { + rootIsArray = Array.isArray(root); + } catch { + return { kind: "malformed" }; + } + if (rootIsArray && isRecordObject(root)) { + const length = readOwnDataProperty(root, "length"); + if (length.kind !== "value") { + return { kind: "malformed" }; + } + if (length.value !== 1) { + return { kind: "unsupported" }; + } + const first = readOwnDataProperty(root, 0); + if (first.kind !== "value") { + return { kind: "malformed" }; + } + root = first.value; + } + if (!isRecordObject(root)) { + return { kind: "malformed" }; + } + const type = readOwnDataProperty(root, "type"); + if ( + type.kind !== "value" || + typeof type.value !== "string" || + type.value.length === 0 || + type.value.length > MAX_BACKEND_TYPE_LENGTH || + type.value.trim() !== type.value + ) { + return { kind: "malformed" }; + } + return { + kind: "root", + root, + statementKind: mapStatementKind(type.value), + }; +} + +function decodeSafeOffset( + value: unknown, + statementLength: number, +): number | null { + if ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 && + value <= statementLength + ) { + return value; + } + return null; +} + +function decodeParserError( + error: unknown, + statementLength: number, +): DecodedParserError { + if (!isObjectLike(error)) { + return "backend-failure"; + } + const name = readOwnDataProperty(error, "name"); + if ( + name.kind !== "value" || + name.value !== "SyntaxError" + ) { + return "backend-failure"; + } + const message = readOwnDataProperty(error, "message"); + const location = readOwnDataProperty(error, "location"); + if ( + message.kind !== "value" || + typeof message.value !== "string" || + message.value.trim().length === 0 || + message.value.length > MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH || + location.kind !== "value" || + !isRecordObject(location.value) + ) { + return "malformed-output"; + } + const start = readOwnDataProperty(location.value, "start"); + const end = readOwnDataProperty(location.value, "end"); + if ( + start.kind !== "value" || + end.kind !== "value" || + !isRecordObject(start.value) || + !isRecordObject(end.value) + ) { + return "malformed-output"; + } + const startOffset = readOwnDataProperty(start.value, "offset"); + const endOffset = readOwnDataProperty(end.value, "offset"); + if ( + startOffset.kind !== "value" || + endOffset.kind !== "value" + ) { + return "malformed-output"; + } + const from = decodeSafeOffset(startOffset.value, statementLength); + const to = decodeSafeOffset(endOffset.value, statementLength); + if (from === null || to === null || from > to) { + return "malformed-output"; + } + return "syntax-rejection"; +} + +function backendFailure( + authority: SqlParserAuthority, + statementText: string, + message: string, + retryable: boolean, +) { + return createFailedParserAnalysis( + "backend-failure", + message, + retryable, + statementText, + authority, + ); +} + +function malformedOutput( + authority: SqlParserAuthority, + statementText: string, +) { + return createFailedParserAnalysis( + "malformed-output", + "node-sql-parser returned malformed output", + false, + statementText, + authority, + ); +} + +function createAdapter(runtime: NodeSqlParserRuntime): SqlStatementParser { + return createSqlStatementParser( + runtime.authority, + async (request) => { + const { signal, text } = request; + if (text.length > MAX_NODE_SQL_PARSER_STATEMENT_LENGTH) { + return createUnsupportedParserAnalysis( + "resource-limit", + text, + runtime.authority, + ); + } + + signal.throwIfAborted(); + let backend: NodeSqlParserBackend; + try { + backend = await runtime.loadBackend(); + } catch (error: unknown) { + signal.throwIfAborted(); + if (error instanceof NodeSqlParserModuleShapeError) { + return malformedOutput(runtime.authority, text); + } + if ( + error instanceof NodeSqlParserInitializationError || + error instanceof NodeSqlParserGlobalCleanupError || + error instanceof NodeSqlParserExecutionRealmError + ) { + return backendFailure( + runtime.authority, + text, + "node-sql-parser backend failed", + false, + ); + } + return backendFailure( + runtime.authority, + text, + "node-sql-parser failed to load", + true, + ); + } + signal.throwIfAborted(); + + let output: unknown; + try { + output = backend.astify(text); + } catch (error: unknown) { + signal.throwIfAborted(); + const decoded = decodeParserError(error, text.length); + if (decoded === "malformed-output") { + return malformedOutput(runtime.authority, text); + } + if (decoded === "syntax-rejection") { + return createUnsupportedParserAnalysis( + runtime.policy === "dialect-compatibility" + ? "compatibility-rejected" + : "uncovered-construct", + text, + runtime.authority, + ); + } + return backendFailure( + runtime.authority, + text, + "node-sql-parser backend failed", + false, + ); + } + signal.throwIfAborted(); + + const decoded = decodeRoot(output); + if (decoded.kind === "unsupported") { + return createUnsupportedParserAnalysis( + "uncovered-construct", + text, + runtime.authority, + ); + } + if (decoded.kind === "malformed") { + return malformedOutput(runtime.authority, text); + } + + const artifact = createSqlSyntaxArtifact( + decoded.statementKind, + text, + runtime.authority, + ); + backendPayloads.set(artifact, decoded.root); + return createCompatibilityParsedAnalysis( + artifact, + runtime.limitations, + ); + }, + ); +} + +function createRuntime( + backendIdentity: SqlSyntaxBackendIdentity, + loadBackend: NodeSqlParserBackendLoader, + policy: NodeSqlParserPolicy, +): NodeSqlParserRuntime { + const authority = createSqlParserAuthority( + backendIdentity, + createSqlSyntaxConfigurationIdentity(), + createSqlDialectSyntaxIdentity(), + ); + return Object.freeze({ + authority, + limitations: + policy === "dialect-compatibility" + ? DIALECT_COMPATIBILITY_LIMITATIONS + : TARGET_GRAMMAR_LIMITATIONS, + loadBackend, + policy, + }); +} + +const postgresqlBackendIdentity = createSqlSyntaxBackendIdentity(); +const bigQueryBackendIdentity = createSqlSyntaxBackendIdentity(); +const postgresqlBackend = createRetryingBackendLoader( + importPostgresqlBuild, +); +const bigQueryBackend = createRetryingBackendLoader(importBigQueryBuild); + +const postgresqlParser = createAdapter( + createRuntime( + postgresqlBackendIdentity, + postgresqlBackend, + "target-grammar", + ), +); +const bigQueryParser = createAdapter( + createRuntime( + bigQueryBackendIdentity, + bigQueryBackend, + "target-grammar", + ), +); +const duckDbParser = createAdapter( + createRuntime( + postgresqlBackendIdentity, + postgresqlBackend, + "dialect-compatibility", + ), +); + +export function getPostgresqlNodeSqlStatementParser(): SqlStatementParser { + return postgresqlParser; +} + +export function getBigQueryNodeSqlStatementParser(): SqlStatementParser { + return bigQueryParser; +} + +export function getDuckDbCompatibilityNodeSqlStatementParser(): SqlStatementParser { + return duckDbParser; +} + +export const exportedForTesting = Object.freeze({ + createParser( + policy: NodeSqlParserPolicy, + loadModule: NodeSqlParserModuleLoader, + ): SqlStatementParser { + const backendIdentity = createSqlSyntaxBackendIdentity(); + return createAdapter( + createRuntime( + backendIdentity, + createRetryingBackendLoader(loadModule), + policy, + ), + ); + }, + hasBackendPayload(artifact: SqlSyntaxArtifact): boolean { + return backendPayloads.has(artifact); + }, + createSynchronousModuleLoader, +}); diff --git a/state/artifacts/reports/pr-177-spelling-ci.md b/state/artifacts/reports/pr-177-spelling-ci.md new file mode 100644 index 0000000..a2b197f --- /dev/null +++ b/state/artifacts/reports/pr-177-spelling-ci.md @@ -0,0 +1,25 @@ +# PR #177 spelling CI regression + +## Root cause + +The adapter rejection tests used a misspelled SQL keyword as deliberately +invalid SQL. The repository spelling check correctly flagged it even though it +was test data. + +## Resolution + +The fixtures now use the correctly spelled but syntactically invalid +`SELECT FROM`. This preserves the rejection-path coverage without broadening +the spelling allowlist. + +## Verification + +- Focused node-sql-parser adapter tests +- vNext test typecheck +- Lint and repository integrity checks +- `git diff --check` + +## Follow-up + +Prefer malformed grammar over misspelled keywords for parser rejection +fixtures unless a spelling error is itself the behavior under test. From a775bf455540cb956dda87d69178c78b120ae151 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:50:47 +0800 Subject: [PATCH 09/42] test(vnext): prove isolated parser worker placement (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - accept ADR 0004 for browser-first isolated parser execution - add a production-shaped Vite 8 / Chromium placement harness for one lazy, service-owned parser worker - prove exact PostgreSQL and BigQuery deep builds remain separate lazy chunks while core and `/vnext` remain parser/worker-free - record cold/warm phase timings, bundle ceilings, strict-CSP execution, exact global restoration, cleanup poisoning, and frozen offline fixture installation ## Scope This is an evidence gate, not production session wiring. The worker implementation remains fixture-owned in this PR. The following protocol PR must move the worker behind a packed optional integration, remove the fixture's direct parser dependency, and preserve the accepted packaging/realm boundaries. ## Evidence - exact `pnpm pack` archive imported under hostile SSR globals - root and `/vnext` core graph contains no parser modules or orphan JavaScript - one static module worker handles PostgreSQL then BigQuery - page graph contains no parser grammar - worker graph contains only `node-sql-parser/build/postgresql.js` and `node-sql-parser/build/bigquery.js` - grammar chunks are separate, lazy, and transitively budgeted - real Chromium execution under `worker-src 'self'` - worker-local `NodeSQLParser` and `global` descriptors restored exactly - reversible cleanup keeps the loader reusable; failed cleanup poisons it - nested fixture install is frozen and offline - clean temporary root install plus nested offline harness passed against a brand-new pnpm store ## Validation - `pnpm run test:worker-placement` - `pnpm run typecheck` - `pnpm exec oxlint` - `pnpm run test:integrity` - `pnpm test` — 971 passed, 1 expected failure - `pnpm run test:coverage` — vNext 98.67% statements / 97.13% branches / 100% functions / 98.66% lines - `pnpm run test:browser` - `pnpm run test:package` - `pnpm run demo` - two independent exact-head adversarial reviewers approved `bd5a08f` with zero findings Part of #169. --- ## Summary by cubic Accepts ADR 0004 for isolated browser parser execution and adds a Vite 8/Chromium harness that proves a single, lazy, service-owned module worker with PostgreSQL/BigQuery loaded as separate chunks under a strict CSP; also hardens the fixture to clean up worker listeners/timers to avoid leaks. CI now fails closed on size, graph, SSR-safety, and readiness regressions. Part of #169. - **New Features** - Added ADR 0004; updated ADR 0003 and vNext docs to reference it. - Added `scripts/worker-placement.mjs` to `pnpm pack`, build a nested fixture, serve with `worker-src 'self'`, run Chromium via `playwright`, record timings/sizes, and verify: lazy worker creation, no parser in core/page graphs, separate lazy `node-sql-parser/build/postgresql.js` and `.../bigquery.js` chunks, exact global restoration, and cleanup poisoning. - Enforces ceilings: PostgreSQL 68 KiB gzip, BigQuery 50 KiB gzip, worker total 120 KiB gzip / 570 KiB raw. - CI: added `pnpm run test:worker-placement` step. - **Dependencies** - Fixture pins `vite@8.0.13`, `playwright@1.61.1`, and `node-sql-parser@5.4.0` to match the packed transitive; runs offline with a frozen lockfile. - No runtime changes for consumers; adds the test harness and script only. Written for commit 8b1ea202c38fda9659d3073afd5d625f170a2254. Summary will update on new commits. Review in cubic --- .github/workflows/test.yml | 3 + docs/adr/0003-node-sql-parser-adapter.md | 5 + docs/adr/0004-isolated-parser-execution.md | 335 ++++++++ docs/vnext/node-sql-parser-adapter.md | 4 + package.json | 1 + scripts/worker-placement.mjs | 807 ++++++++++++++++++ test/worker-placement/README.md | 27 + test/worker-placement/core.html | 12 + test/worker-placement/package.json | 15 + test/worker-placement/pnpm-lock.yaml | 552 ++++++++++++ test/worker-placement/pnpm-workspace.yaml | 7 + test/worker-placement/src/core.js | 20 + .../src/parser-worker-entry.js | 8 + test/worker-placement/src/parser-worker.js | 360 ++++++++ test/worker-placement/src/workers.js | 284 ++++++ test/worker-placement/vite.core.config.mjs | 51 ++ test/worker-placement/vite.workers.config.mjs | 62 ++ test/worker-placement/workers.html | 12 + 18 files changed, 2565 insertions(+) create mode 100644 docs/adr/0004-isolated-parser-execution.md create mode 100644 scripts/worker-placement.mjs create mode 100644 test/worker-placement/README.md create mode 100644 test/worker-placement/core.html create mode 100644 test/worker-placement/package.json create mode 100644 test/worker-placement/pnpm-lock.yaml create mode 100644 test/worker-placement/pnpm-workspace.yaml create mode 100644 test/worker-placement/src/core.js create mode 100644 test/worker-placement/src/parser-worker-entry.js create mode 100644 test/worker-placement/src/parser-worker.js create mode 100644 test/worker-placement/src/workers.js create mode 100644 test/worker-placement/vite.core.config.mjs create mode 100644 test/worker-placement/vite.workers.config.mjs create mode 100644 test/worker-placement/workers.html diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5afba4..68f46db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,9 @@ jobs: - name: 🧪 Browser Tests run: pnpm run test:browser + - name: 🧵 Worker Placement Evidence + run: pnpm run test:worker-placement + package: env: EXPECTED_PNPM_VERSION: ${{ matrix.pnpm-version }} diff --git a/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md index 246b85f..3e60f1f 100644 --- a/docs/adr/0003-node-sql-parser-adapter.md +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -167,6 +167,11 @@ follow-up decision records one of: That decision must include browser measurements, cancellation behavior, worker/module failure recovery, and the effect of many mounted editors. +[ADR 0004](./0004-isolated-parser-execution.md) selects a dedicated, +service-owned browser worker and defines the remaining evidence gates. It does +not authorize session wiring until those gates and in-worker semantic +normalization pass. + The current production loader supports pure Node only. A future browser integration must invoke parsing from a dedicated worker whose global object is not shared with application code, the legacy parser, or another installed copy. diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md new file mode 100644 index 0000000..e501ef5 --- /dev/null +++ b/docs/adr/0004-isolated-parser-execution.md @@ -0,0 +1,335 @@ +# ADR 0004: Isolated Browser Parser Execution + +Status: accepted for implementation, session wiring gated by evidence + +Date: 2026-07-25 + +## Context + +ADR 0003 keeps the `node-sql-parser` adapter internal and unwired. Its parser +is synchronous, so an `AbortSignal` cannot interrupt it while it occupies the +JavaScript thread. Even a late result that is correctly discarded can make an +editor unresponsive. + +Moving the current adapter object into a worker is not valid. Parser requests, +authorities, ranges, artifacts, and analyses are authenticated by +package-owned, realm-local `WeakSet` and `WeakMap` state. Structured cloning +would produce unauthenticated copies. The backend AST is also retained in a +realm-local weak map and cannot become a cross-realm semantic API. + +The distributed dialect builds introduce separate constraints: + +- The Node loader uses `node:module` and intentionally rejects any realm with + `self` or `window`. +- The browser builds are CommonJS/UMD files which a consumer bundler must + transform. +- Loading a build may write `NodeSQLParser` or `global` on its realm. +- A browser worker can be terminated for a wall-clock deadline, but browsers + do not expose an enforceable per-worker heap limit. +- One worker per editor would multiply parser memory across marimo's many + mounted editors. + +This decision concerns local browser placement. Node `worker_threads`, native +providers, remote providers, and public packaging are separate decisions. + +## Decision + +### Browser-first placement + +Interactive browser parsing will use a dedicated module worker. The existing +pure-Node inline adapter remains internal evidence and batch-test +infrastructure. It is not a fallback when browser worker construction, +loading, or execution fails. + +Browser placement is accepted with an explicit residual risk: input, queue, +response, cache, and lifetime can be bounded, but transient parser allocation +cannot be capped before the browser itself terminates an over-consuming +worker. The current 16 KiB input ceiling remains an upper safety bound, not an +interactive performance claim. Production session wiring remains blocked +until adversarial memory, latency, failure-recovery, and many-editor gates +pass. + +### Ownership and scheduling + +Each `SqlLanguageService` will lazily own at most one dedicated parser worker. +All sessions opened by that service share it. The worker is neither a +`SharedWorker` nor a module-global singleton. + +The first executor is single-lane: + +- At most one request is posted at a time. +- The host queue is bounded independently by request count and retained UTF-16 + text units. +- A service owns construction, listeners, timers, termination, and disposal. +- No worker pool or idle shutdown is introduced without profile evidence. +- Service disposal terminates the worker and settles every pending consumer. + +Ordinary caller cancellation and supersession settle the consumer promptly +without relying on a worker message that cannot run during synchronous +parsing. The executor may drain and discard that active result. A hard +wall-clock deadline, worker crash, malformed protocol, or service disposal +terminates the generation. The placement benchmark must compare drain versus +restart under rapid edits before the executor policy is frozen. + +The execution deadline belongs to the posted worker job, not to any attached +consumer. Consumer cancellation never clears it. A draining operation retains +the active lane until it returns or its generation is terminated; queued work +has a separate wait deadline. Service disposal always terminates immediately. +These rules prevent a cancelled hostile parse from occupying the only worker +indefinitely. + +The safety deadline is separate from product latency targets. A deadline +failure never upgrades parser authority and an active request is not +automatically replayed after a crash or timeout. + +### Realm and loading boundary + +The worker is created with a same-origin URL: + +```ts +new Worker( + new URL("./node-sql-parser-browser-worker.js", import.meta.url), + { name: "codemirror-sql-parser", type: "module" }, +); +``` + +Blob, data, and evaluated workers are not used. Hosts must allow the emitted +worker URL in their Content Security Policy, normally through +`worker-src 'self'`. The worker asset response also receives a restrictive +policy because a worker has its own execution context. + +The constructor shape and its literal options stay static. Current +[Vite worker handling](https://vite.dev/guide/features#web-workers) recognizes +the URL only when `new URL(..., import.meta.url)` appears directly inside the +worker constructor. The +[platform worker contract](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) +also requires a same-origin entry URL and JavaScript response media type. + +The browser worker has a browser-specific loader. It does not weaken or reuse +the pure-Node realm gate. It dynamically imports literal, dialect-specific +paths only: + +```text +node-sql-parser/build/postgresql.js +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. + +### Private wire protocol + +The worker protocol is package-private, versioned, closed, and decoded from +`unknown` on both sides. It transports plain evidence, never authenticated +syntax objects. + +The initial request contains only: + +- Protocol version +- Host correlation ID +- Grammar ID: PostgreSQL or BigQuery +- Exact untrimmed statement text + +DuckDB uses the PostgreSQL grammar. Target-dialect policy stays in the main +realm. + +The initial response contains only one closed outcome: + +- Parsed normalized statement kind +- Syntax rejection +- Bounded unsupported reason +- Bounded failure code plus retryability + +Messages do not contain: + +- Public document revisions or session identities +- Parser authorities or dialect handles +- `AbortSignal`, `Error`, stack, or raw backend message values +- Source text echoed in a response +- Absolute document ranges +- Raw ASTs or generic payload bags + +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 +request text and the host-owned authority. PostgreSQL and BigQuery rejection +remain uncovered constructs; DuckDB rejection remains compatibility rejection. +Worker isolation does not strengthen the compatibility-only evidence recorded +by ADR 0003. + +Old-generation events are ignored by generation-owned listeners. A malformed, +duplicate, unsolicited, or mismatched response kills the generation and +settles the active operation exactly once without exposing raw event data. + +### Semantic reuse + +Raw backend ASTs will not cross the worker boundary and the first protocol will +not introduce remote AST handles or worker-local AST leases. + +Before production session wiring, the worker request will parse once and run +adapter-owned semantic decoders in the same realm. It will return only the +bounded, validated relation facts required by the first completion slice. +This keeps backend shapes private, avoids reparsing once for syntax and again +for relations, and makes cached main-realm evidence measurable. + +Worker-local AST caching is deferred until profiling demonstrates that +reparsing is material enough to justify leases, byte accounting, generation +invalidation, and release semantics. + +### Packaging boundary + +Core and `/vnext` imports must remain SSR-safe and contain no parser grammar or +worker asset. A future optional integration entry may create the worker lazily, +but it will expose an opaque language-service module factory rather than the +protocol, worker URL, transport, pool, or backend AST. + +The initial supported bundler claim is limited to packed-consumer fixtures that +run in CI. Source-workspace success is not packaging evidence. + +## Evidence required before session wiring + +A production-shaped fixture built alongside the exact `npm pack` archive must +prove: + +- Core-only import emits no parser or worker bytes. +- PostgreSQL and BigQuery emit separate worker chunks. +- The all-dialect build is absent. +- Worker creation is lazy. +- Both grammars execute in a real browser. +- Main-window parser globals remain unchanged. +- Core import remains SSR-safe. +- A same-origin module worker runs under a strict CSP. +- Worker startup, cold import, warm parse, and message round-trip samples are + recorded. +- Raw and gzip worker sizes are recorded. + +The executor and semantic slices additionally require: + +- Main-thread long-task and event-loop responsiveness evidence. +- Malformed message, crash, timeout, late-event, and restart tests. +- Rapid-edit drain-versus-restart measurements. +- One, ten, and fifty editor scenarios. +- Retained worker, listener, timer, and memory checks after disposal. +- Adversarial statements at the accepted input ceiling. + +The current product envelopes remain: + +- No routine main-thread task over 50 ms. +- Warm active-statement analysis p95 under 16 ms. +- Local completion p95 under 50 ms. + +Safety timeouts are not evidence that these product targets are met. + +### Initial packed-consumer baseline + +The placement harness introduced with this decision builds the exact packed +archive, verifies its core import in an isolated fixture, and separately uses +fixture-owned worker code with the pinned `node-sql-parser` dependency to prove +consumer-side Vite 8 placement feasibility. It serves the production output +with a same-origin CSP and runs it in Chromium. + +The worker portion does not yet prove a packed optional parser integration; +that entry does not exist. The protocol PR must move the worker implementation +behind the packed package boundary and remove the fixture's direct parser +dependency before making a public packaging claim. + +A representative local Node 24 / Chromium 149 / arm64 macOS sample recorded: + +| Output | Raw | gzip | +| --- | ---: | ---: | +| Core-only fixture | 24,056 B | 7,497 B | +| PostgreSQL transitive worker graph | 321,156 B | 67,396 B | +| BigQuery transitive worker graph | 225,309 B | 50,389 B | +| Complete worker fixture | 549,893 B | 118,170 B | + +The core module trace contained no `node-sql-parser` module. No dialect +resource loaded before explicit construction. A single static module worker +loaded PostgreSQL and then BigQuery through separate literal lazy imports; +both parsed successfully without changing the main-window parser sentinel. +The per-dialect figures conservatively include their complete static +transitive closures, with shared chunks also reported separately. + +One sequential cold/warm run on the shared worker measured: + +| Dialect | Grammar load and initialization | First parse | First round trip | Warm parse | Warm round trip | +| --- | ---: | ---: | ---: | ---: | ---: | +| PostgreSQL | 8.0 ms | 2.4 ms | 10.6 ms | 0.1 ms | 0.3 ms | +| BigQuery | 4.2 ms | 2.5 ms | 6.7 ms | 0.2 ms | 0.4 ms | + +The worker ready handshake took 10.3 ms in that run. + +These numbers establish packaging feasibility and initial size guards. They +are not percentile claims. Stable latency decisions require repeated, +cross-platform samples over the representative and adversarial corpus. + +The checked-in harness fails above 68 KiB gzip for the PostgreSQL transitive +graph, 50 KiB for the BigQuery transitive graph, or 120 KiB / 570 KiB for the +complete worker fixture in gzip/raw form. These ceilings include small +measurement headroom and are placement-spike guards, not the final +optional-integration bundle budget. + +## Implementation sequence + +1. Add this ADR and the packed-consumer browser placement harness. +2. Extract a realm-neutral backend engine and add strict protocol codecs. +3. Add the minimal browser worker and single-lane executor. +4. Add in-worker normalized relation extraction. +5. Add the pure statement coordinator, bounded cache, in-flight sharing, and + atomic session ownership. +6. Ship relation completion as the first public consuming vertical slice. + +Every production step is a medium change and receives two independent, +commit-bound adversarial reviews. + +## Consequences + +- Synchronous parser CPU work cannot block the editor main thread. +- Fifty editors on one service do not imply fifty parser workers. +- Realm-local authenticity remains an internal safety boundary. +- Worker failure is explicit and never falls back to unsafe inline parsing. +- The raw AST remains replaceable and private. +- A serial worker may create head-of-line blocking; measurement, queue bounds, + and hard deadlines make that tradeoff visible before considering a pool. +- Browser heap exhaustion cannot be fully contained and remains a documented + residual risk. +- Browser and Node interactive execution can evolve independently. + +## Rejected alternatives + +### Run the parser on the browser main thread + +Late-result rejection preserves correctness but cannot restore responsiveness +while synchronous parsing runs. + +### Clone normalized syntax objects from the worker + +Structured cloning loses the package-owned realm authentication required by +the syntax contract. + +### Send raw ASTs or AST handles + +Raw ASTs expose backend coupling and can be very large. Remote handles add +leases, eviction, crash invalidation, and release semantics before a semantic +consumer exists. + +### One worker per editor + +This multiplies grammar and runtime memory and conflicts with the many-editor +release target. + +### `SharedWorker` or a module-global singleton + +Both weaken service ownership and disposal isolation. `SharedWorker` also +narrows runtime and CSP compatibility. + +### A generic worker or provider transport + +The first need is one parser with a small closed protocol. A general framework +would stabilize abstractions before there is evidence from a second workload. + +### A worker pool + +A pool increases grammar duplication, memory, scheduling, and cancellation +complexity. It can be reconsidered only if a measured serial bottleneck +outweighs those costs. diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index 31a8693..be67067 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -114,3 +114,7 @@ without importing a backend. For that reason, this adapter remains unwired. A worker-versus-main-thread ADR, with browser latency, memory, hostile-input, timeout, and recovery evidence, is required before interactive sessions may call it. + +[ADR 0004](../adr/0004-isolated-parser-execution.md) chooses isolated +browser-worker execution and records the packaging, performance, memory, and +semantic-reuse gates that still block session wiring. diff --git a/package.json b/package.json index 90031e7..d28281b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "vitest run --config vitest.config.ts --coverage", "test:coverage:changed": "node ./scripts/changed-coverage.mjs", "test:browser": "vitest run --config vitest.browser.config.ts", + "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs new file mode 100644 index 0000000..f5ac3df --- /dev/null +++ b/scripts/worker-placement.mjs @@ -0,0 +1,807 @@ +import { execFileSync } from "node:child_process"; +import { + cpSync, + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, join, resolve, sep } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; + +const CONTENT_SECURITY_POLICY = [ + "base-uri 'none'", + "connect-src 'self'", + "default-src 'none'", + "object-src 'none'", + "script-src 'self'", + "worker-src 'self'", +].join("; "); +const PARSER_MARKERS = [ + "NodeSQLParser", + "whiteListCheck", + "trimQuery", + "columnList", + "tableList", +]; +const BIGQUERY_GZIP_LIMIT = 50 * 1024; +const POSTGRESQL_GZIP_LIMIT = 68 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 120 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 570 * 1024; +const MIME_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], +]); +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureSource = join(repository, "test", "worker-placement"); +const packageManagerExecutable = process.env.npm_execpath; + +function parseArguments(arguments_) { + let reportPath = process.env.WORKER_PLACEMENT_REPORT; + let index = arguments_[0] === "--" ? 1 : 0; + for (; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument !== "--report" || index + 1 >= arguments_.length) { + throw new Error( + "Usage: pnpm run test:worker-placement -- [--report ]", + ); + } + reportPath = arguments_[index + 1]; + index += 1; + } + return reportPath === undefined + ? undefined + : resolve(repository, reportPath); +} + +function run(command, arguments_, cwd, capture = false) { + return execFileSync(command, arguments_, { + cwd, + encoding: "utf8", + env: { + ...process.env, + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + npm_config_manage_package_manager_versions: "false", + npm_config_package_manager_strict_version: "false", + }, + stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit", + }); +} + +function runPackageManager(arguments_, cwd, capture = false) { + if (!packageManagerExecutable) { + throw new Error( + "Worker placement must run through a package-manager script", + ); + } + return run( + process.execPath, + [packageManagerExecutable, ...arguments_], + cwd, + capture, + ); +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listFiles(directory) { + const files = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(path); + } else if (entry.isFile()) { + files.push(path); + } + } + } + return files.sort(); +} + +function bundleReport(directory) { + const files = listFiles(directory).map((path) => { + const contents = readFileSync(path); + return { + file: path.slice(directory.length + 1).split(sep).join("/"), + gzipBytes: gzipSync(contents).length, + rawBytes: contents.length, + }; + }); + return { + files, + gzipBytes: files.reduce((total, file) => total + file.gzipBytes, 0), + rawBytes: files.reduce((total, file) => total + file.rawBytes, 0), + }; +} + +function requireModuleTrace(path) { + const trace = JSON.parse(readFileSync(path, "utf8")); + if ( + typeof trace !== "object" || + trace === null || + !Array.isArray(trace.chunks) + ) { + throw new Error(`${basename(path)} did not contain a chunk trace`); + } + return trace; +} + +function chunkMap(trace, workersDirectory) { + const chunks = new Map(); + for (const chunk of trace.chunks) { + if ( + typeof chunk !== "object" || + chunk === null || + typeof chunk.fileName !== "string" || + !Array.isArray(chunk.imports) || + !Array.isArray(chunk.dynamicImports) || + !Array.isArray(chunk.moduleIds) || + chunks.has(chunk.fileName) || + !existsSync(join(workersDirectory, chunk.fileName)) + ) { + throw new Error("Worker module trace contained an invalid chunk"); + } + chunks.set(chunk.fileName, chunk); + } + return chunks; +} + +function reachableChunks(chunks, roots, includeDynamicImports) { + const reachable = new Set(); + const pending = [...roots]; + while (pending.length > 0) { + const fileName = pending.pop(); + if (fileName === undefined || reachable.has(fileName)) { + continue; + } + const chunk = chunks.get(fileName); + if (chunk === undefined) { + throw new Error(`Chunk trace referenced missing chunk ${fileName}`); + } + reachable.add(fileName); + pending.push(...chunk.imports); + if (includeDynamicImports) { + pending.push(...chunk.dynamicImports); + } + } + return reachable; +} + +function metricsForFiles(report, fileNames) { + const filesByName = new Map( + report.files.map((file) => [file.file, file]), + ); + const files = [...fileNames].sort().map((fileName) => { + const file = filesByName.get(fileName); + if (file === undefined) { + throw new Error(`Bundle report omitted traced file ${fileName}`); + } + return file; + }); + return { + files: files.map((file) => file.file), + gzipBytes: files.reduce( + (total, file) => total + file.gzipBytes, + 0, + ), + rawBytes: files.reduce( + (total, file) => total + file.rawBytes, + 0, + ), + }; +} + +function verifyWorkerAssets(workersDirectory) { + const report = bundleReport(workersDirectory); + const pageTrace = requireModuleTrace( + join(workersDirectory, "page-module-trace.json"), + ); + const workerTrace = requireModuleTrace( + join(workersDirectory, "worker-module-trace.json"), + ); + const pageChunks = chunkMap(pageTrace, workersDirectory); + const workerChunks = chunkMap(workerTrace, workersDirectory); + const pageEntry = pageTrace.chunks.find( + (chunk) => chunk.isEntry === true, + ); + const workerEntry = workerTrace.chunks.find( + (chunk) => + chunk.isEntry === true && + chunk.facadeModuleId?.endsWith( + "src/parser-worker-entry.js", + ), + ); + if (pageEntry === undefined || workerEntry === undefined) { + throw new Error("Build traces omitted a page or parser worker entry"); + } + + const manifest = JSON.parse( + readFileSync( + join(workersDirectory, ".vite", "manifest.json"), + "utf8", + ), + ); + const manifestEntry = Object.values(manifest).find( + (entry) => entry?.isEntry === true, + ); + if ( + manifestEntry === undefined || + manifestEntry.file !== pageEntry.fileName + ) { + throw new Error("Vite manifest did not identify the traced page entry"); + } + const pageReachable = reachableChunks( + pageChunks, + [pageEntry.fileName], + true, + ); + const pageModuleIds = [...pageReachable].flatMap( + (fileName) => pageChunks.get(fileName)?.moduleIds ?? [], + ); + if ( + pageModuleIds.some((moduleId) => + moduleId.includes("/node-sql-parser/"), + ) + ) { + throw new Error("Page entry graph included a parser grammar"); + } + const pageSource = readFileSync( + join(workersDirectory, pageEntry.fileName), + "utf8", + ); + if (!pageSource.includes(basename(workerEntry.fileName))) { + throw new Error("Page entry did not reference the traced parser worker"); + } + + const allWorkerModuleIds = workerTrace.chunks.flatMap( + (chunk) => chunk.moduleIds, + ); + const nodeSqlParserModuleIds = allWorkerModuleIds.filter( + (moduleId) => moduleId.includes("/node-sql-parser/"), + ); + const postgresqlModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ), + ); + const bigqueryModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + moduleId.endsWith("/node-sql-parser/build/bigquery.js"), + ); + const unexpectedParserModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + !moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ) && + !moduleId.endsWith( + "/node-sql-parser/build/bigquery.js", + ), + ); + if ( + postgresqlModuleIds.length === 0 || + bigqueryModuleIds.length === 0 || + unexpectedParserModuleIds.length > 0 + ) { + throw new Error( + `Worker graph did not contain only the two exact deep builds: ${unexpectedParserModuleIds.join(", ")}`, + ); + } + + const postgresqlChunk = workerTrace.chunks.find((chunk) => + chunk.moduleIds.some((moduleId) => + moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ), + ), + ); + const bigqueryChunk = workerTrace.chunks.find((chunk) => + chunk.moduleIds.some((moduleId) => + moduleId.endsWith("/node-sql-parser/build/bigquery.js"), + ), + ); + if ( + postgresqlChunk === undefined || + bigqueryChunk === undefined || + postgresqlChunk.fileName === bigqueryChunk.fileName + ) { + throw new Error("Dialect builds did not emit separate lazy chunks"); + } + const staticWorkerEntry = reachableChunks( + workerChunks, + [workerEntry.fileName], + false, + ); + if ( + staticWorkerEntry.has(postgresqlChunk.fileName) || + staticWorkerEntry.has(bigqueryChunk.fileName) + ) { + throw new Error("Parser worker statically included a grammar chunk"); + } + const completeWorkerGraph = reachableChunks( + workerChunks, + [workerEntry.fileName], + true, + ); + if ( + !completeWorkerGraph.has(postgresqlChunk.fileName) || + !completeWorkerGraph.has(bigqueryChunk.fileName) + ) { + throw new Error("Parser worker could not reach both grammar chunks"); + } + + const postgresqlClosure = reachableChunks( + workerChunks, + [postgresqlChunk.fileName], + false, + ); + const bigqueryClosure = reachableChunks( + workerChunks, + [bigqueryChunk.fileName], + false, + ); + const sharedGrammarChunks = new Set( + [...postgresqlClosure].filter((fileName) => + bigqueryClosure.has(fileName), + ), + ); + const postgresqlMetrics = metricsForFiles( + report, + postgresqlClosure, + ); + const bigqueryMetrics = metricsForFiles(report, bigqueryClosure); + const sharedMetrics = metricsForFiles( + report, + sharedGrammarChunks, + ); + const workerEntryMetrics = metricsForFiles( + report, + staticWorkerEntry, + ); + if (postgresqlMetrics.gzipBytes > POSTGRESQL_GZIP_LIMIT) { + throw new Error( + `PostgreSQL graph exceeded ${POSTGRESQL_GZIP_LIMIT} gzip bytes: ${postgresqlMetrics.gzipBytes}`, + ); + } + if (bigqueryMetrics.gzipBytes > BIGQUERY_GZIP_LIMIT) { + throw new Error( + `BigQuery graph exceeded ${BIGQUERY_GZIP_LIMIT} gzip bytes: ${bigqueryMetrics.gzipBytes}`, + ); + } + if (report.gzipBytes > WORKER_TOTAL_GZIP_LIMIT) { + throw new Error( + `Worker output exceeded ${WORKER_TOTAL_GZIP_LIMIT} gzip bytes: ${report.gzipBytes}`, + ); + } + if (report.rawBytes > WORKER_TOTAL_RAW_LIMIT) { + throw new Error( + `Worker output exceeded ${WORKER_TOTAL_RAW_LIMIT} raw bytes: ${report.rawBytes}`, + ); + } + return { + ...report, + dialects: { + bigquery: { + ...bigqueryMetrics, + gzipLimit: BIGQUERY_GZIP_LIMIT, + }, + postgresql: { + ...postgresqlMetrics, + gzipLimit: POSTGRESQL_GZIP_LIMIT, + }, + }, + graph: { + allowedParserModuleIds: [ + "node-sql-parser/build/bigquery.js", + "node-sql-parser/build/postgresql.js", + ], + pageEntry: pageEntry.fileName, + parserWorkerEntry: workerEntry.fileName, + sharedGrammarChunks: sharedMetrics, + workerEntry: workerEntryMetrics, + }, + limits: { + gzipBytes: WORKER_TOTAL_GZIP_LIMIT, + rawBytes: WORKER_TOTAL_RAW_LIMIT, + }, + }; +} + +function verifyCoreExcludesParser(coreDirectory) { + const trace = requireModuleTrace( + join(coreDirectory, "core-module-trace.json"), + ); + const chunks = chunkMap(trace, coreDirectory); + const entries = trace.chunks.filter( + (chunk) => chunk.isEntry === true, + ); + if (entries.length !== 1) { + throw new Error("Core module trace did not contain exactly one entry"); + } + const reachable = reachableChunks( + chunks, + [entries[0].fileName], + true, + ); + const javascriptFiles = listFiles(coreDirectory) + .filter((path) => extname(path) === ".js") + .map((path) => + path.slice(coreDirectory.length + 1).split(sep).join("/"), + ); + const orphanJavascript = javascriptFiles.filter( + (fileName) => !reachable.has(fileName), + ); + if (orphanJavascript.length > 0) { + throw new Error( + `Core-only build emitted unreachable JavaScript: ${orphanJavascript.join(", ")}`, + ); + } + const moduleIds = trace.chunks.flatMap((chunk) => chunk.moduleIds); + const parserModules = moduleIds.filter( + (moduleId) => + typeof moduleId === "string" && + moduleId.includes("/node-sql-parser/"), + ); + if (parserModules.length > 0) { + throw new Error( + `Core-only build included parser modules: ${parserModules.join(", ")}`, + ); + } + for (const path of listFiles(coreDirectory)) { + const extension = extname(path); + if (extension !== ".js" && extension !== ".json") { + continue; + } + const contents = readFileSync(path, "utf8"); + const marker = PARSER_MARKERS.find((candidate) => + contents.includes(candidate), + ); + if (marker !== undefined) { + throw new Error( + `Core-only output ${basename(path)} contained parser marker ${marker}`, + ); + } + } + return new Set(moduleIds).size; +} + +function verifySsrImport(fixtureDirectory) { + const source = ` +Object.defineProperty(globalThis, "window", { + configurable: true, + get() { + throw new Error("SSR import read window"); + }, +}); +Object.defineProperty(globalThis, "Worker", { + configurable: true, + get() { + throw new Error("SSR import read Worker"); + }, +}); +const api = await import("@marimo-team/codemirror-sql/vnext"); +if ( + typeof api.createSqlLanguageService !== "function" || + typeof api.duckdbDialect !== "function" +) { + throw new Error("Packed SSR import was incomplete"); +} +`; + run( + process.execPath, + ["--input-type=module", "--eval", source], + fixtureDirectory, + ); +} + +function startStaticServer(directory) { + const root = resolve(directory); + const server = createServer((request, response) => { + const requestUrl = new URL( + request.url ?? "/", + "http://worker-placement.invalid", + ); + const relativePath = + requestUrl.pathname === "/" + ? "workers.html" + : decodeURIComponent(requestUrl.pathname.slice(1)); + const path = resolve(root, relativePath); + if (path !== root && !path.startsWith(`${root}${sep}`)) { + response.writeHead(403); + response.end("forbidden"); + return; + } + if (!existsSync(path) || !statSync(path).isFile()) { + response.writeHead(404); + response.end("not found"); + return; + } + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Security-Policy": CONTENT_SECURITY_POLICY, + "Content-Type": + MIME_TYPES.get(extname(path)) ?? + "application/octet-stream", + "Cross-Origin-Resource-Policy": "same-origin", + "X-Content-Type-Options": "nosniff", + }); + createReadStream(path).pipe(response); + }); + return new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Static server did not acquire a TCP port")); + return; + } + resolvePromise({ + close: async () => + await new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + } else { + resolveClose(); + } + }); + }), + url: `http://127.0.0.1:${address.port}/workers.html`, + }); + }); + }); +} + +async function runChromium(fixtureDirectory, workersDirectory) { + const fixtureRequire = createRequire( + join(fixtureDirectory, "package.json"), + ); + const { chromium } = fixtureRequire("playwright"); + const staticServer = await startStaticServer(workersDirectory); + let browser; + let result; + let operationError; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const browserErrors = []; + page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + browserErrors.push(`page: ${error.message}`); + }); + const response = await page.goto(staticServer.url, { + waitUntil: "load", + }); + if ( + response === null || + response.headers()["content-security-policy"] !== + CONTENT_SECURITY_POLICY + ) { + throw new Error("Fixture did not receive the strict CSP header"); + } + await page.waitForFunction( + () => + document.body.dataset.status === "passed" || + document.body.dataset.status === "failed", + undefined, + { timeout: 20_000 }, + ); + const status = await page.locator("body").getAttribute("data-status"); + if (status !== "passed") { + throw new Error( + `Worker fixture failed: ${await page.locator("#result").textContent()}`, + ); + } + if (browserErrors.length > 0) { + throw new Error(browserErrors.join("\n")); + } + const timings = await page.evaluate( + () => globalThis.__CODEMIRROR_SQL_WORKER_PLACEMENT__, + ); + if ( + typeof timings !== "object" || + timings === null || + typeof timings.workerReadyMs !== "number" || + typeof timings.postgresql?.firstRequestRoundTripMs !== + "number" || + typeof timings.bigquery?.firstRequestRoundTripMs !== "number" + ) { + throw new Error("Browser fixture returned malformed timing data"); + } + result = { + browserVersion: browser.version(), + csp: CONTENT_SECURITY_POLICY, + timings, + }; + } catch (error) { + operationError = error; + } + + const cleanupErrors = []; + if (browser !== undefined) { + try { + await browser.close(); + } catch (error) { + cleanupErrors.push(error); + } + } + try { + await staticServer.close(); + } catch (error) { + cleanupErrors.push(error); + } + if (operationError !== undefined) { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [operationError, ...cleanupErrors], + "Worker browser verification and cleanup failed", + ); + } + throw operationError; + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + cleanupErrors, + "Worker browser verification cleanup failed", + ); + } + if (result === undefined) { + throw new Error("Worker browser verification produced no result"); + } + return result; +} + +const reportPath = parseArguments(process.argv.slice(2)); +const temporaryDirectory = mkdtempSync( + join(tmpdir(), "codemirror-sql-worker-placement-"), +); + +try { + runPackageManager(["run", "build"], repository); + const packOutput = JSON.parse( + runPackageManager( + [ + "pack", + "--json", + "--pack-destination", + temporaryDirectory, + ], + repository, + true, + ), + ); + const manifest = Array.isArray(packOutput) ? packOutput[0] : packOutput; + if (!manifest || typeof manifest.filename !== "string") { + throw new Error("pnpm pack did not report an archive"); + } + const archive = join(temporaryDirectory, basename(manifest.filename)); + const fixtureDirectory = join(temporaryDirectory, "fixture"); + cpSync(fixtureSource, fixtureDirectory, { recursive: true }); + runPackageManager( + [ + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--offline", + ], + fixtureDirectory, + ); + + const packageDirectory = join( + fixtureDirectory, + "node_modules", + "@marimo-team", + "codemirror-sql", + ); + mkdirSync(packageDirectory, { recursive: true }); + run( + "tar", + ["-xzf", archive, "-C", packageDirectory, "--strip-components=1"], + fixtureDirectory, + ); + const packedPackage = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), + ); + const fixturePackage = JSON.parse( + readFileSync(join(fixtureDirectory, "package.json"), "utf8"), + ); + if ( + packedPackage.name !== "@marimo-team/codemirror-sql" || + packedPackage.version !== manifest.version || + packedPackage.dependencies?.["node-sql-parser"] !== "5.4.0" + ) { + throw new Error("Extracted package did not match the pnpm pack manifest"); + } + if ( + fixturePackage.dependencies?.["node-sql-parser"] !== + packedPackage.dependencies["node-sql-parser"] + ) { + throw new Error( + "Fixture direct parser dependency did not match the packed transitive dependency", + ); + } + verifySsrImport(fixtureDirectory); + + runPackageManager(["run", "build:core"], fixtureDirectory); + runPackageManager(["run", "build:workers"], fixtureDirectory); + const coreDirectory = join(fixtureDirectory, "core-dist"); + const workersDirectory = join(fixtureDirectory, "workers-dist"); + const coreModuleCount = verifyCoreExcludesParser(coreDirectory); + const chromiumResult = await runChromium( + fixtureDirectory, + workersDirectory, + ); + const workerBundles = verifyWorkerAssets(workersDirectory); + if ( + chromiumResult.timings.resources.mainBeforeCreation.length !== 0 + ) { + throw new Error("Browser reported eager parser worker loading"); + } + const report = { + bundles: { + core: bundleReport(coreDirectory), + workers: workerBundles, + }, + evidence: { + coreModuleCount, + coreParserModules: 0, + exactTarballSsrImport: true, + exactNodeSqlParserDependency: "5.4.0", + fixtureDirectDependencyReason: + "The fixture installs dependencies before extracting the exact tarball", + parserMarkerCount: PARSER_MARKERS.length, + strictSameOriginCsp: true, + }, + package: { + archive: basename(archive), + archiveSha256: sha256(archive), + name: packedPackage.name, + version: packedPackage.version, + }, + runtime: { + chromium: chromiumResult.browserVersion, + node: process.version, + platform: `${process.platform}-${process.arch}`, + }, + schemaVersion: 1, + timingsMs: chromiumResult.timings, + }; + const serializedReport = `${JSON.stringify(report, null, 2)}\n`; + process.stdout.write(serializedReport); + if (reportPath !== undefined) { + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, serializedReport); + } +} finally { + if ( + basename(temporaryDirectory).startsWith( + "codemirror-sql-worker-placement-", + ) + ) { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md new file mode 100644 index 0000000..3916cb0 --- /dev/null +++ b/test/worker-placement/README.md @@ -0,0 +1,27 @@ +# Worker placement evidence fixture + +This fixture is copied into an isolated temporary directory and consumes the +exact tarball created by `pnpm pack`. It is intentionally not a workspace +package. Its direct `node-sql-parser` dependency is intentional: the frozen +fixture dependencies are installed before the exact tarball is extracted, and +the harness verifies that its exact `5.4.0` version matches the packed +package's dependency. The fixture workspace pins Vite's floating transitive +versions to the exact versions in the root lock, so the nested frozen install +can run offline after a clean root CI install. + +The minified Vite 8 single-worker baseline is 67,396 gzip bytes for the +PostgreSQL transitive graph, 50,389 gzip bytes for the BigQuery transitive +graph, and 118,170 gzip/549,893 raw bytes for the complete worker build output. +The PostgreSQL and BigQuery figures each include their transitive shared +chunks; the report also identifies those shared chunks explicitly. The +fail-closed ceilings include small explicit headroom over that measured +packed-consumer baseline: + +- PostgreSQL transitive graph: 68 KiB gzip +- BigQuery transitive graph: 50 KiB gzip +- Complete worker build output: 120 KiB gzip and 570 KiB raw + +These are provisional placement limits, not product bundle promises. The +orchestration script fails closed when they are exceeded, when the dialects no +longer have separate lazy chunks, when their transitive reachability changes, +or when the page/core graphs import parser modules. diff --git a/test/worker-placement/core.html b/test/worker-placement/core.html new file mode 100644 index 0000000..09c7af8 --- /dev/null +++ b/test/worker-placement/core.html @@ -0,0 +1,12 @@ + + + + + + codemirror-sql core-only placement fixture + + +
pending
+ + + diff --git a/test/worker-placement/package.json b/test/worker-placement/package.json new file mode 100644 index 0000000..ac7fe1c --- /dev/null +++ b/test/worker-placement/package.json @@ -0,0 +1,15 @@ +{ + "name": "codemirror-sql-worker-placement-fixture", + "private": true, + "type": "module", + "packageManager": "pnpm@11.4.0", + "scripts": { + "build:core": "vite build --config vite.core.config.mjs", + "build:workers": "vite build --config vite.workers.config.mjs" + }, + "dependencies": { + "node-sql-parser": "5.4.0", + "playwright": "1.61.1", + "vite": "8.0.13" + } +} diff --git a/test/worker-placement/pnpm-lock.yaml b/test/worker-placement/pnpm-lock.yaml new file mode 100644 index 0000000..73abec9 --- /dev/null +++ b/test/worker-placement/pnpm-lock.yaml @@ -0,0 +1,552 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + lightningcss: 1.32.0 + nanoid: 3.3.15 + postcss: 8.5.16 + +importers: + + .: + dependencies: + node-sql-parser: + specifier: 5.4.0 + version: 5.4.0 + playwright: + specifier: 1.61.1 + version: 1.61.1 + vite: + specifier: 8.0.13 + version: 8.0.13 + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/pegjs@0.10.6': + resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-sql-parser@5.4.0: + resolution: {integrity: sha512-jVe6Z61gPcPjCElPZ6j8llB3wnqGcuQzefim1ERsqIakxnEy5JlzV7XKdO1KmacRG5TKwPc4vJTgSRQ0LfkbFw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.130.0': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/pegjs@0.10.6': {} + + big-integer@1.6.52: {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.15: {} + + node-sql-parser@5.4.0: + dependencies: + '@types/pegjs': 0.10.6 + big-integer: 1.6.52 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: + optional: true + + vite@8.0.13: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.0.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/test/worker-placement/pnpm-workspace.yaml b/test/worker-placement/pnpm-workspace.yaml new file mode 100644 index 0000000..43ce887 --- /dev/null +++ b/test/worker-placement/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - . + +overrides: + lightningcss: 1.32.0 + nanoid: 3.3.15 + postcss: 8.5.16 diff --git a/test/worker-placement/src/core.js b/test/worker-placement/src/core.js new file mode 100644 index 0000000..1e77877 --- /dev/null +++ b/test/worker-placement/src/core.js @@ -0,0 +1,20 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql/vnext"; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "SELECT 1", +}); + +if (!session.isCurrent(session.revision)) { + throw new Error("The packed core failed its revision identity check"); +} + +service.dispose(); +document.body.dataset.status = "passed"; +document.querySelector("#result").textContent = "core-only import passed"; diff --git a/test/worker-placement/src/parser-worker-entry.js b/test/worker-placement/src/parser-worker-entry.js new file mode 100644 index 0000000..495b606 --- /dev/null +++ b/test/worker-placement/src/parser-worker-entry.js @@ -0,0 +1,8 @@ +import { installParserWorker } from "./parser-worker.js"; + +installParserWorker({ + bigquery: async () => + await import("node-sql-parser/build/bigquery.js"), + postgresql: async () => + await import("node-sql-parser/build/postgresql.js"), +}); diff --git a/test/worker-placement/src/parser-worker.js b/test/worker-placement/src/parser-worker.js new file mode 100644 index 0000000..89cc653 --- /dev/null +++ b/test/worker-placement/src/parser-worker.js @@ -0,0 +1,360 @@ +const GLOBAL_KEYS = ["NodeSQLParser", "global"]; +const MAX_STATEMENT_LENGTH = 16 * 1024; +const PARSER_OPTIONS = Object.freeze({ + parseOptions: Object.freeze({ + includeLocations: true, + }), + trimQuery: false, +}); + +function readOwnDataProperty(value, key) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { kind: "missing" }; + } + if (!("value" in descriptor)) { + return { kind: "invalid" }; + } + return { kind: "value", value: descriptor.value }; +} + +function moduleCandidates(moduleValue) { + if ( + (typeof moduleValue !== "object" || moduleValue === null) && + typeof moduleValue !== "function" + ) { + return [moduleValue]; + } + const candidates = [moduleValue]; + for (const key of ["default", "module.exports"]) { + const property = readOwnDataProperty(moduleValue, key); + if (property.kind === "value") { + candidates.push(property.value); + } + } + return candidates; +} + +function findParserConstructor(moduleValue) { + for (const candidate of moduleCandidates(moduleValue)) { + if (typeof candidate === "function") { + return candidate; + } + if (typeof candidate !== "object" || candidate === null) { + continue; + } + const parser = readOwnDataProperty(candidate, "Parser"); + if (parser.kind === "value" && typeof parser.value === "function") { + return parser.value; + } + } + throw new Error("The dialect bundle did not expose a Parser constructor"); +} + +function snapshotGlobals(target) { + return GLOBAL_KEYS.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(target, key), + key, + })); +} + +function descriptorsEqual(left, right) { + 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 restoreGlobals(target, snapshots) { + for (const { descriptor, key } of snapshots) { + if (descriptor === undefined) { + if (!Reflect.deleteProperty(target, key)) { + throw new Error(`Could not remove worker global ${key}`); + } + } else { + Object.defineProperty(target, key, descriptor); + } + } + return Object.fromEntries( + snapshots.map(({ descriptor, key }) => [ + key, + descriptorsEqual( + descriptor, + Object.getOwnPropertyDescriptor(target, key), + ), + ]), + ); +} + +function createGuardedModuleLoader(target) { + let poisoned = false; + return async (loadModule) => { + if (poisoned) { + throw new Error("The guarded module loader is poisoned"); + } + const snapshots = snapshotGlobals(target); + let moduleValue; + let loadError; + try { + moduleValue = await loadModule(); + } catch (error) { + loadError = error; + } + let descriptorEquality; + try { + descriptorEquality = restoreGlobals(target, snapshots); + } catch (error) { + poisoned = true; + throw error; + } + if (loadError !== undefined) { + throw loadError; + } + return { descriptorEquality, moduleValue }; + }; +} + +function assertDedicatedWorkerRealm() { + if ( + globalThis.self !== globalThis || + "window" in globalThis || + "document" in globalThis + ) { + throw new Error("Parser fixture did not start in a dedicated worker"); + } +} + +function resourceEntries() { + return performance.getEntriesByType("resource").map((entry) => ({ + decodedBodySize: entry.decodedBodySize, + encodedBodySize: entry.encodedBodySize, + initiatorType: entry.initiatorType, + name: entry.name, + transferSize: entry.transferSize, + })); +} + +async function syntheticCleanupEvidence() { + const successfulTarget = {}; + const originalNodeSqlParser = Object.freeze({ + owner: "original-node-sql-parser", + }); + const originalGlobal = () => "original-global"; + Object.defineProperties(successfulTarget, { + NodeSQLParser: { + configurable: true, + enumerable: true, + value: originalNodeSqlParser, + writable: false, + }, + global: { + configurable: true, + enumerable: false, + get: originalGlobal, + }, + }); + const successfulLoad = createGuardedModuleLoader(successfulTarget); + let successfulEvaluations = 0; + const successful = await successfulLoad(async () => { + successfulEvaluations += 1; + Object.defineProperties(successfulTarget, { + NodeSQLParser: { + configurable: true, + enumerable: false, + value: "temporary-node-sql-parser", + writable: true, + }, + global: { + configurable: true, + enumerable: true, + value: "temporary-global", + writable: true, + }, + }); + return "first-load"; + }); + const reusable = await successfulLoad(async () => { + successfulEvaluations += 1; + return "second-load"; + }); + + const target = {}; + const load = createGuardedModuleLoader(target); + let poisonedEvaluations = 0; + let cleanupFailed = false; + try { + await load(async () => { + poisonedEvaluations += 1; + Object.defineProperty(target, "NodeSQLParser", { + configurable: false, + value: "synthetic-pollution", + }); + return {}; + }); + } catch { + cleanupFailed = true; + } + let poisonedRetryFailed = false; + try { + await load(async () => { + poisonedEvaluations += 1; + return {}; + }); + } catch { + poisonedRetryFailed = true; + } + return { + cleanupFailed, + poisonedEvaluations, + poisonedRetryFailed, + successfulDescriptorEquality: successful.descriptorEquality, + successfulEvaluations, + successfulModuleValues: + successful.moduleValue === "first-load" && + reusable.moduleValue === "second-load", + }; +} + +export function installParserWorker(moduleLoaders) { + assertDedicatedWorkerRealm(); + const guardedLoad = createGuardedModuleLoader(globalThis); + const parsers = new Map(); + + async function getParser(grammar) { + const cached = parsers.get(grammar); + if (cached !== undefined) { + return { + cached: true, + grammarLoadAndInitMs: 0, + ...cached, + }; + } + const startedAt = performance.now(); + const loadModule = moduleLoaders[grammar]; + const { descriptorEquality, moduleValue } = + await guardedLoad(loadModule); + const Parser = findParserConstructor(moduleValue); + const parser = Reflect.construct(Parser, []); + if ( + typeof parser !== "object" || + parser === null || + typeof parser.astify !== "function" + ) { + throw new Error("The dialect Parser did not expose astify"); + } + const initialized = { + descriptorEquality, + parser, + }; + parsers.set(grammar, initialized); + return { + cached: false, + grammarLoadAndInitMs: performance.now() - startedAt, + ...initialized, + }; + } + + globalThis.addEventListener("message", async (event) => { + const request = event.data; + if ( + typeof request !== "object" || + request === null || + !Number.isSafeInteger(request.id) || + request.id < 0 + ) { + globalThis.postMessage({ + error: "invalid-request", + id: -1, + kind: "result", + resources: resourceEntries(), + status: "failed", + }); + return; + } + if (request.kind === "test-cleanup") { + globalThis.postMessage({ + evidence: await syntheticCleanupEvidence(), + id: request.id, + kind: "cleanup-result", + resources: resourceEntries(), + }); + return; + } + if ( + request.kind !== "parse" || + (request.grammar !== "postgresql" && + request.grammar !== "bigquery") || + typeof request.text !== "string" || + request.text.length > MAX_STATEMENT_LENGTH + ) { + globalThis.postMessage({ + error: "invalid-request", + id: request.id, + kind: "result", + resources: resourceEntries(), + status: "failed", + }); + return; + } + + try { + const loaded = await getParser(request.grammar); + const astifyStartedAt = performance.now(); + const output = loaded.parser.astify( + request.text, + PARSER_OPTIONS, + ); + const astifyMs = performance.now() - astifyStartedAt; + const root = Array.isArray(output) ? output[0] : output; + if ( + typeof root !== "object" || + root === null || + typeof root.type !== "string" + ) { + throw new Error("The dialect parser returned no typed AST root"); + } + globalThis.postMessage({ + astType: root.type, + astifyMs, + descriptorEquality: loaded.descriptorEquality, + grammar: request.grammar, + grammarLoadAndInitMs: loaded.grammarLoadAndInitMs, + id: request.id, + kind: "result", + moduleCached: loaded.cached, + resources: resourceEntries(), + status: "parsed", + }); + } catch { + globalThis.postMessage({ + error: "parse-failed", + id: request.id, + kind: "result", + resources: resourceEntries(), + status: "failed", + }); + } + }); + + globalThis.postMessage({ + kind: "ready", + resources: resourceEntries(), + }); +} diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js new file mode 100644 index 0000000..f7f4e59 --- /dev/null +++ b/test/worker-placement/src/workers.js @@ -0,0 +1,284 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql/vnext"; + +const REQUEST_TIMEOUT_MS = 10_000; + +function request(worker, requestValue) { + return new Promise((resolve, reject) => { + const startedAt = performance.now(); + const cleanup = () => { + clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + }; + const onError = (event) => { + cleanup(); + reject( + new Error( + event.message || + `Worker request ${requestValue.id} failed`, + ), + ); + }; + const onMessage = (event) => { + if (event.data?.id !== requestValue.id) { + return; + } + cleanup(); + resolve({ + response: event.data, + roundTripMs: performance.now() - startedAt, + }); + }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Worker request ${requestValue.id} timed out`)); + }, REQUEST_TIMEOUT_MS); + worker.addEventListener("error", onError, { once: true }); + worker.addEventListener("message", onMessage); + worker.postMessage(requestValue); + }); +} + +function waitForReady(worker) { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + }; + const onError = (event) => { + cleanup(); + reject( + new Error(event.message || "Parser worker startup failed"), + ); + }; + const onMessage = (event) => { + if (event.data?.kind !== "ready") { + return; + } + cleanup(); + resolve(event.data); + }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Parser worker ready handshake timed out")); + }, REQUEST_TIMEOUT_MS); + worker.addEventListener("error", onError, { once: true }); + worker.addEventListener("message", onMessage); + }); +} + +function createParserWorker() { + return new Worker( + new URL("./parser-worker-entry.js", import.meta.url), + { + name: "codemirror-sql-parser-placement", + type: "module", + }, + ); +} + +function parserResourceNames() { + return performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .filter((name) => /parser-worker/i.test(name)); +} + +function requireParsed(result, expectedGrammar) { + if ( + result.response.status !== "parsed" || + result.response.kind !== "result" || + result.response.grammar !== expectedGrammar || + result.response.astType !== "select" + ) { + throw new Error(`${expectedGrammar} worker request did not parse`); + } + if ( + result.response.descriptorEquality.NodeSQLParser !== true || + result.response.descriptorEquality.global !== true + ) { + throw new Error( + `${expectedGrammar} worker globals were not restored exactly`, + ); + } +} + +async function measureGrammar(worker, grammar, text, firstId) { + const first = await request(worker, { + grammar, + id: firstId, + kind: "parse", + text, + }); + requireParsed(first, grammar); + if ( + first.response.moduleCached !== false || + first.response.grammarLoadAndInitMs < 0 + ) { + throw new Error(`${grammar} first request did not load its grammar`); + } + const warm = await request(worker, { + grammar, + id: firstId + 1, + kind: "parse", + text, + }); + requireParsed(warm, grammar); + if ( + warm.response.moduleCached !== true || + warm.response.grammarLoadAndInitMs !== 0 + ) { + throw new Error(`${grammar} warm request did not reuse its parser`); + } + return { + astifyMs: first.response.astifyMs, + firstRequestRoundTripMs: first.roundTripMs, + grammarLoadAndInitMs: + first.response.grammarLoadAndInitMs, + resourcesAfterFirstRequest: first.response.resources, + warmAstifyMs: warm.response.astifyMs, + warmRequestRoundTripMs: warm.roundTripMs, + warmResources: warm.response.resources, + }; +} + +async function run() { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + service.dispose(); + + const sentinel = Object.freeze({ owner: "browser-main-thread" }); + const original = Object.getOwnPropertyDescriptor( + globalThis, + "NodeSQLParser", + ); + Object.defineProperty(globalThis, "NodeSQLParser", { + configurable: true, + value: sentinel, + }); + + let worker; + try { + const mainResourcesBeforeCreation = parserResourceNames(); + if (mainResourcesBeforeCreation.length !== 0) { + throw new Error( + `Parser worker loaded before creation: ${mainResourcesBeforeCreation.join(", ")}`, + ); + } + const workerStartedAt = performance.now(); + worker = createParserWorker(); + const ready = await waitForReady(worker); + const workerReadyMs = performance.now() - workerStartedAt; + const mainResourcesAfterReady = parserResourceNames(); + if (mainResourcesAfterReady.length !== 1) { + throw new Error("Parser worker entry did not load exactly once"); + } + + const postgresql = await measureGrammar( + worker, + "postgresql", + "SELECT 1 AS value", + 1, + ); + const postgresqlResourceNames = + postgresql.resourcesAfterFirstRequest.map( + (resource) => resource.name, + ); + if ( + !postgresqlResourceNames.some((name) => + /postgresql/i.test(name), + ) || + postgresqlResourceNames.some((name) => + /bigquery/i.test(name), + ) + ) { + throw new Error( + "PostgreSQL request did not load only PostgreSQL resources", + ); + } + + const bigquery = await measureGrammar( + worker, + "bigquery", + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + 3, + ); + const bigQueryResourceNames = + bigquery.resourcesAfterFirstRequest.map( + (resource) => resource.name, + ); + if ( + !bigQueryResourceNames.some((name) => + /bigquery/i.test(name), + ) || + !bigQueryResourceNames.some((name) => + /postgresql/i.test(name), + ) + ) { + throw new Error( + "BigQuery request did not retain both lazy grammar resources", + ); + } + + const cleanup = await request(worker, { + id: 5, + kind: "test-cleanup", + }); + if ( + cleanup.response.kind !== "cleanup-result" || + cleanup.response.evidence.cleanupFailed !== true || + cleanup.response.evidence.poisonedRetryFailed !== true || + cleanup.response.evidence.poisonedEvaluations !== 1 || + cleanup.response.evidence.successfulEvaluations !== 2 || + cleanup.response.evidence.successfulModuleValues !== true || + cleanup.response.evidence.successfulDescriptorEquality + .NodeSQLParser !== true || + cleanup.response.evidence.successfulDescriptorEquality.global !== + true + ) { + throw new Error( + "Synthetic cleanup did not prove restoration and poisoning", + ); + } + if (globalThis.NodeSQLParser !== sentinel) { + throw new Error("A parser bundle changed the browser main global"); + } + const report = Object.freeze({ + bigquery, + cleanup: cleanup.response.evidence, + postgresql, + resources: { + mainAfterReady: mainResourcesAfterReady, + mainBeforeCreation: mainResourcesBeforeCreation, + workerAtReady: ready.resources, + }, + workerReadyMs, + }); + globalThis.__CODEMIRROR_SQL_WORKER_PLACEMENT__ = report; + document.body.dataset.status = "passed"; + document.querySelector("#result").textContent = + JSON.stringify(report); + } finally { + if (worker !== undefined) { + worker.terminate(); + } + if (original === undefined) { + Reflect.deleteProperty(globalThis, "NodeSQLParser"); + } else { + Object.defineProperty(globalThis, "NodeSQLParser", original); + } + } +} + +run().catch((error) => { + document.body.dataset.status = "failed"; + document.querySelector("#result").textContent = + error instanceof Error + ? error.message + : "unknown worker fixture failure"; +}); diff --git a/test/worker-placement/vite.core.config.mjs b/test/worker-placement/vite.core.config.mjs new file mode 100644 index 0000000..7d6cb5d --- /dev/null +++ b/test/worker-placement/vite.core.config.mjs @@ -0,0 +1,51 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +const fixtureRoot = `${import.meta.dirname.replaceAll("\\", "/")}/`; + +function normalizeModuleId(moduleId) { + const normalized = moduleId.replaceAll("\\", "/"); + return normalized.startsWith(fixtureRoot) + ? normalized.slice(fixtureRoot.length) + : normalized; +} + +function moduleTrace() { + return { + name: "worker-placement-core-module-trace", + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .filter((output) => output.type === "chunk") + .map((chunk) => ({ + dynamicImports: [...chunk.dynamicImports].sort(), + fileName: chunk.fileName, + imports: [...chunk.imports].sort(), + isEntry: chunk.isEntry, + moduleIds: Object.keys(chunk.modules) + .map(normalizeModuleId) + .sort(), + })) + .sort((left, right) => + left.fileName.localeCompare(right.fileName), + ); + this.emitFile({ + fileName: "core-module-trace.json", + source: `${JSON.stringify({ chunks }, null, 2)}\n`, + type: "asset", + }); + }, + }; +} + +export default defineConfig({ + base: "/", + build: { + emptyOutDir: true, + manifest: true, + outDir: "core-dist", + rollupOptions: { + input: resolve(import.meta.dirname, "core.html"), + }, + }, + plugins: [moduleTrace()], +}); diff --git a/test/worker-placement/vite.workers.config.mjs b/test/worker-placement/vite.workers.config.mjs new file mode 100644 index 0000000..6883503 --- /dev/null +++ b/test/worker-placement/vite.workers.config.mjs @@ -0,0 +1,62 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +const fixtureRoot = `${import.meta.dirname.replaceAll("\\", "/")}/`; + +function normalizeModuleId(moduleId) { + const normalized = moduleId.replaceAll("\\", "/"); + return normalized.startsWith(fixtureRoot) + ? normalized.slice(fixtureRoot.length) + : normalized; +} + +function moduleTrace(fileName) { + return { + name: `worker-placement-${fileName}`, + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .filter((output) => output.type === "chunk") + .map((chunk) => ({ + dynamicImports: [...chunk.dynamicImports].sort(), + facadeModuleId: + chunk.facadeModuleId === null + ? null + : normalizeModuleId(chunk.facadeModuleId), + fileName: chunk.fileName, + imports: [...chunk.imports].sort(), + isDynamicEntry: chunk.isDynamicEntry, + isEntry: chunk.isEntry, + moduleIds: Object.keys(chunk.modules) + .map(normalizeModuleId) + .sort(), + })) + .sort((left, right) => + left.fileName.localeCompare(right.fileName), + ); + this.emitFile({ + fileName, + source: `${JSON.stringify({ chunks }, null, 2)}\n`, + type: "asset", + }); + }, + }; +} + +export default defineConfig({ + base: "/", + build: { + emptyOutDir: true, + manifest: true, + outDir: "workers-dist", + rollupOptions: { + input: resolve(import.meta.dirname, "workers.html"), + }, + }, + plugins: [moduleTrace("page-module-trace.json")], + worker: { + format: "es", + plugins: () => [ + moduleTrace("worker-module-trace.json"), + ], + }, +}); diff --git a/test/worker-placement/workers.html b/test/worker-placement/workers.html new file mode 100644 index 0000000..7200e23 --- /dev/null +++ b/test/worker-placement/workers.html @@ -0,0 +1,12 @@ + + + + + + codemirror-sql worker placement fixture + + +
pending
+ + + From 6e0e234abaceca21bf568da9c2e3efc19b921af9 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 03:13:47 +0800 Subject: [PATCH 10/42] refactor(vnext): extract realm-neutral parser backend (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - extract module decoding, parser invocation, result validation, and error normalization into an internal realm-neutral backend - preserve Node-specific realm validation, module loading, cleanup, parser authority, and private AST ownership in the existing adapter - add exhaustive backend contract tests covering bounds, cancellation checkpoints, retry caching, hostile values, and ambient neutrality - document the host/engine responsibility boundary No public export, session wiring, worker protocol, or user-visible API is added. ## Validation - 1,041 unit tests passed; 1 intentional expected failure - vNext coverage: 98.54% statements, 97.06% branches, 100% functions, 98.53% lines - new backend coverage: 100% statements, branches, functions, and lines - source and test TypeScript checks - oxlint and test-integrity checks - Chromium browser tests - exact packed-consumer smoke test - isolated worker placement and bundle-budget harness - demo production build - parser adapter benchmark - git diff check ## Independent review Two independent adversarial reviewers approved exact commit 96bd2ce with zero actionable findings. --- ## Summary by cubic Extracted a new internal backend for `node-sql-parser` and rewired the Node adapter to call it. No public API changes. - **Refactors** - New `node-sql-parser-backend`: decodes module, runs `astify`, validates output, and normalizes errors; no Node/window/worker refs. - Node adapter now only handles realm checks, `require` loading, cleanup, parser authority, and private AST ownership; uses backend outcomes. - Unified backend outcomes; concurrent load de‑dup and retry cache; enforces `MAX_NODE_SQL_PARSER_STATEMENT_LENGTH`. - Accepts constructor/`default`/`module.exports`/named `Parser`; ignores accessors/proxies and redacts private data. - Added backend tests (bounds, cancellation, retry, hostile values, ambient neutrality) and documented the host/engine boundary. - **Bug Fixes** - Validate AST array lengths, rejecting spoofed or non‑integer length descriptors. Written for commit 08a9d67d1957bcadc18871e1eb8dbeedc9ae6f53. Summary will update on new commits. Review in cubic --- docs/vnext/node-sql-parser-adapter.md | 8 + .../__tests__/node-sql-parser-backend.test.ts | 801 ++++++++++++++++++ src/vnext/node-sql-parser-adapter.ts | 516 +++-------- src/vnext/node-sql-parser-backend.ts | 431 ++++++++++ 4 files changed, 1366 insertions(+), 390 deletions(-) create mode 100644 src/vnext/__tests__/node-sql-parser-backend.test.ts create mode 100644 src/vnext/node-sql-parser-backend.ts diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index be67067..9178526 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -45,6 +45,14 @@ statement slice. Locations remain partial: PostgreSQL commonly omits root and identifier locations, while BigQuery provides broader but still incomplete coverage. +Module-shape decoding, parser invocation, result validation, and error +normalization live in a realm-neutral internal backend engine. That engine has +no Node, browser-window, or worker dependency. The Node adapter remains +responsible for realm validation, module loading, cleanup, parser authority, +and private AST ownership. A browser worker can therefore reuse the same +backend semantics without importing Node globals or weakening main-realm +artifact authenticity. + Loading the distributed bundles may write `NodeSQLParser` or `global` on a global object. The adapter rejects parser loads whenever `window` or `self` exists, or `global` does not resolve exactly to `globalThis`, before loading a diff --git a/src/vnext/__tests__/node-sql-parser-backend.test.ts b/src/vnext/__tests__/node-sql-parser-backend.test.ts new file mode 100644 index 0000000..42db5ba --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-backend.test.ts @@ -0,0 +1,801 @@ +// @vitest-environment node + +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + createNodeSqlParserBackend, + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, + type NodeSqlParserModuleLoadOutcome, +} from "../node-sql-parser-backend.js"; + +type ModuleShape = + | "constructor" + | "default-constructor" + | "default-named" + | "module-exports" + | "named"; + +function loaded(moduleValue: unknown): NodeSqlParserModuleLoadOutcome { + return { + kind: "loaded", + moduleValue, + }; +} + +function createBackendModule( + astify: (statementText: string, options: unknown) => unknown, + shape: ModuleShape = "named", +): unknown { + class Parser { + astify(statementText: string, options: unknown): unknown { + return astify(statementText, options); + } + } + + switch (shape) { + case "constructor": + return Parser; + case "default-constructor": + return { default: Parser }; + case "default-named": + return { default: { Parser } }; + case "module-exports": + return { "module.exports": { Parser } }; + case "named": + return { Parser }; + } +} + +function syntaxError( + message: string, + from: number, + to: number, +): object { + return { + location: { + end: { offset: to }, + start: { offset: from }, + }, + message, + name: "SyntaxError", + }; +} + +function backendFor( + astify: (statementText: string, options: unknown) => unknown, + shape: ModuleShape = "named", +) { + return createNodeSqlParserBackend(async () => + loaded(createBackendModule(astify, shape)), + ); +} + +describe("realm-neutral node-sql-parser backend", () => { + it.each([ + ["select", "query"], + ["UNION", "query"], + ["insert", "insert"], + ["replace", "insert"], + ["update", "update"], + ["delete", "delete"], + ["create", "create"], + ["alter", "alter"], + ["drop", "drop"], + ["merge", "merge"], + ["transaction", "transaction"], + ["truncate", "other"], + ] as const)("maps backend type %s to %s", async (type, expected) => { + const root = { type }; + const backend = backendFor(() => root); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toStrictEqual({ + kind: "parsed", + root, + statementKind: expected, + }); + expect(Object.isFrozen(outcome)).toBe(true); + expect(Object.isFrozen(backend)).toBe(true); + }); + + it("passes exact source and immutable location-preserving options", async () => { + const source = " SELECT 1\nFROM users"; + let receivedSource: string | undefined; + let receivedOptions: unknown; + const backend = backendFor((statementText, options) => { + receivedSource = statementText; + receivedOptions = options; + return { type: "select" }; + }); + + await backend.parse(source); + + expect(receivedSource).toBe(source); + expect(receivedOptions).toStrictEqual({ + parseOptions: { includeLocations: true }, + trimQuery: false, + }); + expect(Object.isFrozen(receivedOptions)).toBe(true); + expect( + Object.isFrozen( + (receivedOptions as { parseOptions: object }).parseOptions, + ), + ).toBe(true); + }); + + it.each([ + "constructor", + "default-constructor", + "default-named", + "module-exports", + "named", + ] as const)("accepts the %s module shape", async (shape) => { + const backend = backendFor( + () => ({ type: "select" }), + shape, + ); + + await expect(backend.parse("SELECT 1")).resolves.toMatchObject({ + kind: "parsed", + statementKind: "query", + }); + }); + + it("does not load or checkpoint oversized input", async () => { + let loads = 0; + let checkpoints = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + return loaded( + createBackendModule(() => ({ type: "select" })), + ); + }); + + const outcome = await backend.parse( + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + 1), + () => { + checkpoints += 1; + }, + ); + + expect(outcome).toStrictEqual({ + kind: "unsupported", + reason: "resource-limit", + }); + expect(loads).toBe(0); + expect(checkpoints).toBe(0); + }); + + it("accepts the exact input boundary", async () => { + let calls = 0; + const backend = backendFor(() => { + calls += 1; + return { type: "select" }; + }); + + const outcome = await backend.parse( + "x".repeat(MAX_NODE_SQL_PARSER_STATEMENT_LENGTH), + ); + + expect(outcome).toMatchObject({ kind: "parsed" }); + expect(calls).toBe(1); + }); + + it("runs checkpoints before load, after load, and after astify", async () => { + const events: string[] = []; + let checkpoints = 0; + const backend = createNodeSqlParserBackend(async () => { + events.push("load"); + return loaded( + createBackendModule(() => { + events.push("astify"); + return { type: "select" }; + }), + ); + }); + + await backend.parse("SELECT 1", () => { + checkpoints += 1; + events.push(`checkpoint-${checkpoints}`); + }); + + expect(events).toStrictEqual([ + "checkpoint-1", + "load", + "checkpoint-2", + "astify", + "checkpoint-3", + ]); + }); + + it("propagates the exact checkpoint error before loading", async () => { + const reason = Object.freeze({ checkpoint: "before-load" }); + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + return loaded({}); + }); + + await expect( + backend.parse("SELECT 1", () => { + throw reason; + }), + ).rejects.toBe(reason); + expect(loads).toBe(0); + }); + + it("propagates the exact checkpoint error after loading", async () => { + const reason = Object.freeze({ checkpoint: "after-load" }); + let checkpoints = 0; + let astifyCalls = 0; + const backend = backendFor(() => { + astifyCalls += 1; + return { type: "select" }; + }); + + await expect( + backend.parse("SELECT 1", () => { + checkpoints += 1; + if (checkpoints === 2) { + throw reason; + } + }), + ).rejects.toBe(reason); + expect(astifyCalls).toBe(0); + + await expect(backend.parse("SELECT 1")).resolves.toMatchObject({ + kind: "parsed", + }); + expect(astifyCalls).toBe(1); + }); + + it("propagates the exact checkpoint error after astify", async () => { + const reason = Object.freeze({ checkpoint: "after-astify" }); + let checkpoints = 0; + let astifyCalls = 0; + const backend = backendFor(() => { + astifyCalls += 1; + return { type: "select" }; + }); + + await expect( + backend.parse("SELECT 1", () => { + checkpoints += 1; + if (checkpoints === 3) { + throw reason; + } + }), + ).rejects.toBe(reason); + expect(astifyCalls).toBe(1); + }); + + it("checks cancellation after a throwing astify before classifying it", async () => { + const backendError = new Error("private backend failure"); + const checkpointError = Object.freeze({ + checkpoint: "after-throw", + }); + let checkpoints = 0; + const backend = backendFor(() => { + throw backendError; + }); + + await expect( + backend.parse("SELECT 1", () => { + checkpoints += 1; + if (checkpoints === 3) { + throw checkpointError; + } + }), + ).rejects.toBe(checkpointError); + }); +}); + +describe("node-sql-parser backend loading", () => { + it("deduplicates concurrent module loads", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + await gate; + return loaded( + createBackendModule(() => ({ type: "select" })), + ); + }); + + const first = backend.parse("SELECT 1"); + const second = backend.parse("SELECT 2"); + await Promise.resolve(); + await Promise.resolve(); + expect(loads).toBe(1); + if (release === undefined) { + throw new Error("Load gate was not initialized"); + } + release(); + + await expect(first).resolves.toMatchObject({ kind: "parsed" }); + await expect(second).resolves.toMatchObject({ kind: "parsed" }); + expect(loads).toBe(1); + }); + + it("clears a retryable failed load", async () => { + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + if (loads === 1) { + return { + code: "module-load", + kind: "failed", + retryable: true, + }; + } + return loaded( + createBackendModule(() => ({ type: "select" })), + ); + }); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + code: "module-load", + kind: "failed", + retryable: true, + }); + await expect(backend.parse("SELECT 1")).resolves.toMatchObject({ + kind: "parsed", + }); + expect(loads).toBe(2); + }); + + it("caches a non-retryable failed load", async () => { + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + return { + code: "backend", + kind: "failed", + retryable: false, + }; + }); + + const expected = { + code: "backend", + kind: "failed", + retryable: false, + }; + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual( + expected, + ); + await expect(backend.parse("SELECT 2")).resolves.toStrictEqual( + expected, + ); + expect(loads).toBe(1); + }); + + it("normalizes a rejected loader and permits retry", async () => { + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + if (loads === 1) { + throw new Error("private load rejection"); + } + return loaded( + createBackendModule(() => ({ type: "select" })), + ); + }); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + code: "module-load", + kind: "failed", + retryable: true, + }); + await expect(backend.parse("SELECT 1")).resolves.toMatchObject({ + kind: "parsed", + }); + expect(loads).toBe(2); + }); + + it.each([ + null, + {}, + { Parser: null }, + { Parser: class MissingAstify {} }, + { + Parser: class InvalidAstify { + readonly astify = "not callable"; + }, + }, + ])("caches malformed module shape %#", async (moduleValue) => { + let loads = 0; + const backend = createNodeSqlParserBackend(async () => { + loads += 1; + return loaded(moduleValue); + }); + + const expected = { + code: "malformed-output", + kind: "failed", + retryable: false, + }; + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual( + expected, + ); + await expect(backend.parse("SELECT 2")).resolves.toStrictEqual( + expected, + ); + expect(loads).toBe(1); + }); + + it("caches a constructor failure without leaking it", async () => { + let constructions = 0; + class Parser { + constructor() { + constructions += 1; + throw new Error("private constructor detail"); + } + } + const backend = createNodeSqlParserBackend(async () => + loaded({ Parser }), + ); + + const first = await backend.parse("SELECT 1"); + const second = await backend.parse("SELECT 2"); + + expect(first).toStrictEqual({ + code: "backend", + kind: "failed", + retryable: false, + }); + expect(second).toStrictEqual(first); + expect(JSON.stringify(first)).not.toContain("private"); + expect(constructions).toBe(1); + }); + + it("does not invoke accessor-backed module or astify properties", async () => { + let moduleAccessorInvoked = false; + const moduleValue = {}; + Object.defineProperty(moduleValue, "Parser", { + get() { + moduleAccessorInvoked = true; + throw new Error("private module accessor"); + }, + }); + const moduleBackend = createNodeSqlParserBackend(async () => + loaded(moduleValue), + ); + + await expect( + moduleBackend.parse("SELECT 1"), + ).resolves.toMatchObject({ + code: "malformed-output", + kind: "failed", + }); + expect(moduleAccessorInvoked).toBe(false); + + let astifyAccessorInvoked = false; + class Parser { + get astify(): never { + astifyAccessorInvoked = true; + throw new Error("private astify accessor"); + } + } + const astifyBackend = createNodeSqlParserBackend(async () => + loaded({ Parser }), + ); + + await expect( + astifyBackend.parse("SELECT 1"), + ).resolves.toMatchObject({ + code: "malformed-output", + kind: "failed", + }); + expect(astifyAccessorInvoked).toBe(false); + }); + + it("normalizes a prototype-trapping parser", async () => { + const parser = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("private prototype trap"); + }, + }, + ); + function Parser(): object { + return parser; + } + const backend = createNodeSqlParserBackend(async () => + loaded({ Parser }), + ); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + expect(JSON.stringify(outcome)).not.toContain("private"); + }); +}); + +describe("node-sql-parser backend output decoding", () => { + it("accepts a one-element AST array", async () => { + const root = { type: "select" }; + const backend = backendFor(() => [root]); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + kind: "parsed", + root, + statementKind: "query", + }); + }); + + it.each([ + [[]], + [[{ type: "select" }, { type: "select" }]], + ] as const)( + "classifies AST array %# as multiple statements", + async (root) => { + const backend = backendFor(() => root); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + kind: "unsupported", + reason: "multiple-statements", + }); + }, + ); + + it.each([ + null, + undefined, + 1, + "select", + {}, + { type: "" }, + { type: " select" }, + { type: "select " }, + { type: "x".repeat(129) }, + [null], + Array(1), + ])("rejects malformed root %#", async (root) => { + const backend = backendFor(() => root); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + }); + + it.each(["1", Number.NaN, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "rejects an array with a spoofed length descriptor %#", + async (length) => { + const root = new Proxy([{ type: "select" }], { + getOwnPropertyDescriptor(target, key) { + if (key === "length") { + return { + configurable: false, + enumerable: false, + value: length, + writable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + const backend = backendFor(() => root); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + }, + ); + + it("does not invoke an accessor-backed root type", async () => { + let invoked = false; + const root = {}; + Object.defineProperty(root, "type", { + get() { + invoked = true; + throw new Error("private type accessor"); + }, + }); + const backend = backendFor(() => root); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toMatchObject({ + code: "malformed-output", + kind: "failed", + }); + expect(invoked).toBe(false); + }); + + it("normalizes descriptor-trapping and revoked roots", async () => { + const descriptorTrap = new Proxy( + { type: "select" }, + { + getOwnPropertyDescriptor() { + throw new Error("private descriptor trap"); + }, + }, + ); + const revoked = Proxy.revocable({ type: "select" }, {}); + revoked.revoke(); + + for (const root of [descriptorTrap, revoked.proxy]) { + const outcome = await backendFor(() => root).parse("SELECT 1"); + expect(outcome).toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + expect(JSON.stringify(outcome)).not.toContain("private"); + } + }); + + it("rejects a callable AST root", async () => { + function root(): void { + // Backend AST roots are data. + } + Object.defineProperty(root, "type", { + value: "select", + }); + + await expect( + backendFor(() => root).parse("SELECT 1"), + ).resolves.toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + }); +}); + +describe("node-sql-parser backend error decoding", () => { + it("classifies a bounded SyntaxError without exposing its message", async () => { + const backend = backendFor(() => { + throw syntaxError("private syntax detail", 0, 1); + }); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toStrictEqual({ kind: "syntax-rejected" }); + expect(JSON.stringify(outcome)).not.toContain("private"); + }); + + it.each([ + { location: null, message: "bad", name: "SyntaxError" }, + { location: {}, message: "bad", name: "SyntaxError" }, + { + location: { end: { offset: 1 }, start: { offset: -1 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 1 }, start: { offset: 2 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 9 }, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: { offset: 1.5 }, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + { + location: { end: {}, start: { offset: 0 } }, + message: "bad", + name: "SyntaxError", + }, + syntaxError("", 0, 1), + syntaxError("x".repeat(8_193), 0, 1), + ])("rejects malformed SyntaxError %#", async (error) => { + const backend = backendFor(() => { + throw error; + }); + + await expect(backend.parse("SELECT 1")).resolves.toStrictEqual({ + code: "malformed-output", + kind: "failed", + retryable: false, + }); + }); + + it("does not invoke accessor-backed SyntaxError fields", async () => { + let invoked = false; + const error = { + message: "bad", + name: "SyntaxError", + }; + Object.defineProperty(error, "location", { + get() { + invoked = true; + throw new Error("private location accessor"); + }, + }); + const backend = backendFor(() => { + throw error; + }); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toMatchObject({ + code: "malformed-output", + kind: "failed", + }); + expect(invoked).toBe(false); + }); + + it.each([ + new Error("private backend stack and message"), + "private primitive rejection", + null, + ])("normalizes backend failure %#", async (error) => { + const backend = backendFor(() => { + throw error; + }); + + const outcome = await backend.parse("SELECT 1"); + + expect(outcome).toStrictEqual({ + code: "backend", + kind: "failed", + retryable: false, + }); + expect(JSON.stringify(outcome)).not.toContain("private"); + }); +}); + +describe("node-sql-parser backend ambient boundary", () => { + it("does not reference environment-specific globals or modules", () => { + const source = readFileSync( + new URL("../node-sql-parser-backend.ts", import.meta.url), + "utf8", + ); + + expect(source).not.toMatch( + /\b(?:globalThis|window|self|Worker)\b|node:module/, + ); + }); + + it("parses while ambient realm getters throw", async () => { + const keys = ["window", "self", "Worker"] as const; + const snapshots = keys.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(globalThis, key), + key, + })); + try { + for (const key of keys) { + Object.defineProperty(globalThis, key, { + configurable: true, + get() { + throw new Error(`Backend read ambient ${key}`); + }, + }); + } + const backend = backendFor(() => ({ type: "select" })); + + await expect(backend.parse("SELECT 1")).resolves.toMatchObject({ + kind: "parsed", + statementKind: "query", + }); + } finally { + for (const { descriptor, key } of snapshots) { + if (descriptor === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.defineProperty(globalThis, key, descriptor); + } + } + } + }); +}); diff --git a/src/vnext/node-sql-parser-adapter.ts b/src/vnext/node-sql-parser-adapter.ts index c0b7e59..86d80d6 100644 --- a/src/vnext/node-sql-parser-adapter.ts +++ b/src/vnext/node-sql-parser-adapter.ts @@ -1,3 +1,10 @@ +import { + createNodeSqlParserBackend, + type NodeSqlParserBackend, + type NodeSqlParserBackendOutcome, + type NodeSqlParserModuleLoadOutcome, + type NodeSqlParserModuleLoader, +} from "./node-sql-parser-backend.js"; import { createCompatibilityParsedAnalysis, createFailedParserAnalysis, @@ -8,24 +15,15 @@ import { createSqlSyntaxBackendIdentity, createSqlSyntaxConfigurationIdentity, createUnsupportedParserAnalysis, - MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH, type SqlCompatibilityLimitation, type SqlParserAuthority, - type SqlStatementKind, type SqlStatementParser, type SqlSyntaxArtifact, type SqlSyntaxBackendIdentity, } from "./syntax.js"; -export const MAX_NODE_SQL_PARSER_STATEMENT_LENGTH = 16 * 1024; +export { MAX_NODE_SQL_PARSER_STATEMENT_LENGTH } from "./node-sql-parser-backend.js"; -const MAX_BACKEND_TYPE_LENGTH = 128; -const NODE_SQL_PARSER_OPTIONS = Object.freeze({ - parseOptions: Object.freeze({ - includeLocations: true, - }), - trimQuery: false, -}); const TARGET_GRAMMAR_LIMITATIONS = Object.freeze([ "partial-artifact", ] as const); @@ -38,20 +36,13 @@ type NodeSqlParserPolicy = | "dialect-compatibility" | "target-grammar"; -interface NodeSqlParserBackend { - readonly astify: (statementText: string) => unknown; -} - -type NodeSqlParserBackendLoader = () => Promise; -type NodeSqlParserModuleLoader = () => Promise; - interface NodeSqlParserRuntime { readonly authority: SqlParserAuthority; + readonly backend: NodeSqlParserBackend; readonly limitations: readonly [ SqlCompatibilityLimitation, ...SqlCompatibilityLimitation[], ]; - readonly loadBackend: NodeSqlParserBackendLoader; readonly policy: NodeSqlParserPolicy; } @@ -67,42 +58,11 @@ type OwnDataProperty = readonly value: unknown; }; -type DecodedRoot = - | { - readonly kind: "malformed"; - } - | { - readonly kind: "root"; - readonly root: object; - readonly statementKind: SqlStatementKind; - } - | { - readonly kind: "unsupported"; - }; - -type DecodedParserError = - | "backend-failure" - | "malformed-output" - | "syntax-rejection"; - -class NodeSqlParserModuleShapeError extends Error {} -class NodeSqlParserInitializationError extends Error {} class NodeSqlParserGlobalCleanupError extends Error {} class NodeSqlParserExecutionRealmError extends Error {} const backendPayloads = new WeakMap(); -function isObjectLike(value: unknown): value is object { - return ( - (typeof value === "object" && value !== null) || - typeof value === "function" - ); -} - -function isRecordObject(value: unknown): value is object { - return typeof value === "object" && value !== null; -} - function readOwnDataProperty( value: object, key: PropertyKey, @@ -146,113 +106,6 @@ function readDataProperty( : { kind: "invalid" }; } -function readDataMethod( - value: object, - key: PropertyKey, -): ((...arguments_: unknown[]) => unknown) | null { - let candidate: object | null = value; - for (let depth = 0; candidate !== null && depth < 16; depth += 1) { - const property = readOwnDataProperty(candidate, key); - if (property.kind === "invalid") { - return null; - } - if (property.kind === "value") { - if (typeof property.value !== "function") { - return null; - } - const method = property.value; - return (...arguments_: unknown[]) => - Reflect.apply(method, value, arguments_); - } - try { - candidate = Object.getPrototypeOf(candidate); - } catch { - return null; - } - } - return null; -} - -function moduleCandidates(moduleValue: unknown): readonly unknown[] { - if (!isObjectLike(moduleValue)) { - return [moduleValue]; - } - const candidates: unknown[] = [moduleValue]; - for (const key of ["default", "module.exports"] as const) { - const property = readOwnDataProperty(moduleValue, key); - if (property.kind === "value") { - candidates.push(property.value); - } - } - return candidates; -} - -function findParserConstructor(moduleValue: unknown): unknown { - for (const candidate of moduleCandidates(moduleValue)) { - if (typeof candidate === "function") { - return candidate; - } - if (!isObjectLike(candidate)) { - continue; - } - const parser = readOwnDataProperty(candidate, "Parser"); - if (parser.kind === "value" && typeof parser.value === "function") { - return parser.value; - } - } - return null; -} - -function decodeBackendModule(moduleValue: unknown): NodeSqlParserBackend { - const Parser = findParserConstructor(moduleValue); - if (typeof Parser !== "function") { - throw new NodeSqlParserModuleShapeError(); - } - let parser: object; - try { - parser = Reflect.construct(Parser, []); - } catch { - throw new NodeSqlParserInitializationError(); - } - const astify = readDataMethod(parser, "astify"); - if (astify === null) { - throw new NodeSqlParserModuleShapeError(); - } - return Object.freeze({ - astify(statementText: string): unknown { - return astify(statementText, NODE_SQL_PARSER_OPTIONS); - }, - }); -} - -function createRetryingBackendLoader( - loadModule: NodeSqlParserModuleLoader, -): NodeSqlParserBackendLoader { - let pending: Promise | undefined; - return async () => { - if (pending === undefined) { - const current = Promise.resolve() - .then(loadModule) - .then(decodeBackendModule); - pending = current; - try { - return await current; - } catch (error: unknown) { - if ( - !(error instanceof NodeSqlParserModuleShapeError) && - !(error instanceof NodeSqlParserInitializationError) && - !(error instanceof NodeSqlParserGlobalCleanupError) && - !(error instanceof NodeSqlParserExecutionRealmError) - ) { - pending = undefined; - } - throw error; - } - } - return await pending; - }; -} - const PARSER_GLOBAL_KEYS = ["NodeSQLParser", "global"] as const; type ParserGlobalSnapshots = readonly { @@ -359,164 +212,49 @@ function rejectUnsupportedExecutionRealm(): void { async function loadNodeSqlParserBuild( specifier: string, -): Promise { - rejectUnsupportedExecutionRealm(); - const { createRequire } = await import("node:module"); - rejectUnsupportedExecutionRealm(); - const require = createRequire(import.meta.url); - return loadNodeModuleSynchronously( - () => require(specifier), - ); +): Promise { + try { + rejectUnsupportedExecutionRealm(); + const { createRequire } = await import("node:module"); + rejectUnsupportedExecutionRealm(); + const require = createRequire(import.meta.url); + return { + kind: "loaded", + moduleValue: loadNodeModuleSynchronously( + () => require(specifier), + ), + }; + } catch (error: unknown) { + if ( + error instanceof NodeSqlParserGlobalCleanupError || + error instanceof NodeSqlParserExecutionRealmError + ) { + return { + code: "backend", + kind: "failed", + retryable: false, + }; + } + return { + code: "module-load", + kind: "failed", + retryable: true, + }; + } } -function importPostgresqlBuild(): Promise { +function importPostgresqlBuild(): Promise { return loadNodeSqlParserBuild( "node-sql-parser/build/postgresql.js", ); } -function importBigQueryBuild(): Promise { +function importBigQueryBuild(): Promise { return loadNodeSqlParserBuild( "node-sql-parser/build/bigquery.js", ); } -function mapStatementKind(backendType: string): SqlStatementKind { - switch (backendType.toLowerCase()) { - case "select": - case "union": - return "query"; - case "insert": - case "replace": - return "insert"; - case "update": - return "update"; - case "delete": - return "delete"; - case "create": - return "create"; - case "alter": - return "alter"; - case "drop": - return "drop"; - case "merge": - return "merge"; - case "transaction": - return "transaction"; - default: - return "other"; - } -} - -function decodeRoot(value: unknown): DecodedRoot { - let root = value; - let rootIsArray: boolean; - try { - rootIsArray = Array.isArray(root); - } catch { - return { kind: "malformed" }; - } - if (rootIsArray && isRecordObject(root)) { - const length = readOwnDataProperty(root, "length"); - if (length.kind !== "value") { - return { kind: "malformed" }; - } - if (length.value !== 1) { - return { kind: "unsupported" }; - } - const first = readOwnDataProperty(root, 0); - if (first.kind !== "value") { - return { kind: "malformed" }; - } - root = first.value; - } - if (!isRecordObject(root)) { - return { kind: "malformed" }; - } - const type = readOwnDataProperty(root, "type"); - if ( - type.kind !== "value" || - typeof type.value !== "string" || - type.value.length === 0 || - type.value.length > MAX_BACKEND_TYPE_LENGTH || - type.value.trim() !== type.value - ) { - return { kind: "malformed" }; - } - return { - kind: "root", - root, - statementKind: mapStatementKind(type.value), - }; -} - -function decodeSafeOffset( - value: unknown, - statementLength: number, -): number | null { - if ( - typeof value === "number" && - Number.isSafeInteger(value) && - value >= 0 && - value <= statementLength - ) { - return value; - } - return null; -} - -function decodeParserError( - error: unknown, - statementLength: number, -): DecodedParserError { - if (!isObjectLike(error)) { - return "backend-failure"; - } - const name = readOwnDataProperty(error, "name"); - if ( - name.kind !== "value" || - name.value !== "SyntaxError" - ) { - return "backend-failure"; - } - const message = readOwnDataProperty(error, "message"); - const location = readOwnDataProperty(error, "location"); - if ( - message.kind !== "value" || - typeof message.value !== "string" || - message.value.trim().length === 0 || - message.value.length > MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH || - location.kind !== "value" || - !isRecordObject(location.value) - ) { - return "malformed-output"; - } - const start = readOwnDataProperty(location.value, "start"); - const end = readOwnDataProperty(location.value, "end"); - if ( - start.kind !== "value" || - end.kind !== "value" || - !isRecordObject(start.value) || - !isRecordObject(end.value) - ) { - return "malformed-output"; - } - const startOffset = readOwnDataProperty(start.value, "offset"); - const endOffset = readOwnDataProperty(end.value, "offset"); - if ( - startOffset.kind !== "value" || - endOffset.kind !== "value" - ) { - return "malformed-output"; - } - const from = decodeSafeOffset(startOffset.value, statementLength); - const to = decodeSafeOffset(endOffset.value, statementLength); - if (from === null || to === null || from > to) { - return "malformed-output"; - } - return "syntax-rejection"; -} - function backendFailure( authority: SqlParserAuthority, statementText: string, @@ -545,105 +283,72 @@ function malformedOutput( ); } -function createAdapter(runtime: NodeSqlParserRuntime): SqlStatementParser { - return createSqlStatementParser( - runtime.authority, - async (request) => { - const { signal, text } = request; - if (text.length > MAX_NODE_SQL_PARSER_STATEMENT_LENGTH) { - return createUnsupportedParserAnalysis( - "resource-limit", - text, - runtime.authority, - ); - } - - signal.throwIfAborted(); - let backend: NodeSqlParserBackend; - try { - backend = await runtime.loadBackend(); - } catch (error: unknown) { - signal.throwIfAborted(); - if (error instanceof NodeSqlParserModuleShapeError) { - return malformedOutput(runtime.authority, text); - } - if ( - error instanceof NodeSqlParserInitializationError || - error instanceof NodeSqlParserGlobalCleanupError || - error instanceof NodeSqlParserExecutionRealmError - ) { - return backendFailure( - runtime.authority, - text, - "node-sql-parser backend failed", - false, - ); - } - return backendFailure( - runtime.authority, - text, - "node-sql-parser failed to load", - true, - ); - } - signal.throwIfAborted(); - - let output: unknown; - try { - output = backend.astify(text); - } catch (error: unknown) { - signal.throwIfAborted(); - const decoded = decodeParserError(error, text.length); - if (decoded === "malformed-output") { - return malformedOutput(runtime.authority, text); - } - if (decoded === "syntax-rejection") { - return createUnsupportedParserAnalysis( - runtime.policy === "dialect-compatibility" - ? "compatibility-rejected" - : "uncovered-construct", - text, - runtime.authority, - ); - } - return backendFailure( - runtime.authority, - text, - "node-sql-parser backend failed", - false, - ); +function materializeBackendOutcome( + runtime: NodeSqlParserRuntime, + statementText: string, + outcome: NodeSqlParserBackendOutcome, +) { + switch (outcome.kind) { + case "failed": + if (outcome.code === "malformed-output") { + return malformedOutput(runtime.authority, statementText); } - signal.throwIfAborted(); - - const decoded = decodeRoot(output); - if (decoded.kind === "unsupported") { - return createUnsupportedParserAnalysis( - "uncovered-construct", - text, + return backendFailure( runtime.authority, + statementText, + outcome.code === "module-load" + ? "node-sql-parser failed to load" + : "node-sql-parser backend failed", + outcome.retryable, ); - } - if (decoded.kind === "malformed") { - return malformedOutput(runtime.authority, text); - } - + case "parsed": { const artifact = createSqlSyntaxArtifact( - decoded.statementKind, - text, + outcome.statementKind, + statementText, runtime.authority, ); - backendPayloads.set(artifact, decoded.root); + backendPayloads.set(artifact, outcome.root); return createCompatibilityParsedAnalysis( artifact, runtime.limitations, ); + } + case "syntax-rejected": + return createUnsupportedParserAnalysis( + runtime.policy === "dialect-compatibility" + ? "compatibility-rejected" + : "uncovered-construct", + statementText, + runtime.authority, + ); + case "unsupported": + return createUnsupportedParserAnalysis( + outcome.reason === "resource-limit" + ? "resource-limit" + : "uncovered-construct", + statementText, + runtime.authority, + ); + } +} + +function createAdapter(runtime: NodeSqlParserRuntime): SqlStatementParser { + return createSqlStatementParser( + runtime.authority, + async (request) => { + const { signal, text } = request; + const outcome = await runtime.backend.parse( + text, + () => signal.throwIfAborted(), + ); + return materializeBackendOutcome(runtime, text, outcome); }, ); } function createRuntime( backendIdentity: SqlSyntaxBackendIdentity, - loadBackend: NodeSqlParserBackendLoader, + backend: NodeSqlParserBackend, policy: NodeSqlParserPolicy, ): NodeSqlParserRuntime { const authority = createSqlParserAuthority( @@ -653,21 +358,21 @@ function createRuntime( ); return Object.freeze({ authority, + backend, limitations: policy === "dialect-compatibility" ? DIALECT_COMPATIBILITY_LIMITATIONS : TARGET_GRAMMAR_LIMITATIONS, - loadBackend, policy, }); } const postgresqlBackendIdentity = createSqlSyntaxBackendIdentity(); const bigQueryBackendIdentity = createSqlSyntaxBackendIdentity(); -const postgresqlBackend = createRetryingBackendLoader( +const postgresqlBackend = createNodeSqlParserBackend( importPostgresqlBuild, ); -const bigQueryBackend = createRetryingBackendLoader(importBigQueryBuild); +const bigQueryBackend = createNodeSqlParserBackend(importBigQueryBuild); const postgresqlParser = createAdapter( createRuntime( @@ -691,6 +396,35 @@ const duckDbParser = createAdapter( ), ); +function adaptModuleLoaderForTesting( + loadModule: () => Promise, +): NodeSqlParserModuleLoader { + return async () => { + try { + return { + kind: "loaded", + moduleValue: await loadModule(), + }; + } catch (error: unknown) { + if ( + error instanceof NodeSqlParserGlobalCleanupError || + error instanceof NodeSqlParserExecutionRealmError + ) { + return { + code: "backend", + kind: "failed", + retryable: false, + }; + } + return { + code: "module-load", + kind: "failed", + retryable: true, + }; + } + }; +} + export function getPostgresqlNodeSqlStatementParser(): SqlStatementParser { return postgresqlParser; } @@ -706,13 +440,15 @@ export function getDuckDbCompatibilityNodeSqlStatementParser(): SqlStatementPars export const exportedForTesting = Object.freeze({ createParser( policy: NodeSqlParserPolicy, - loadModule: NodeSqlParserModuleLoader, + loadModule: () => Promise, ): SqlStatementParser { const backendIdentity = createSqlSyntaxBackendIdentity(); return createAdapter( createRuntime( backendIdentity, - createRetryingBackendLoader(loadModule), + createNodeSqlParserBackend( + adaptModuleLoaderForTesting(loadModule), + ), policy, ), ); diff --git a/src/vnext/node-sql-parser-backend.ts b/src/vnext/node-sql-parser-backend.ts new file mode 100644 index 0000000..87f24eb --- /dev/null +++ b/src/vnext/node-sql-parser-backend.ts @@ -0,0 +1,431 @@ +import { + MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH, + type SqlStatementKind, +} from "./syntax.js"; + +export const MAX_NODE_SQL_PARSER_STATEMENT_LENGTH = 16 * 1024; + +const MAX_BACKEND_TYPE_LENGTH = 128; +const NODE_SQL_PARSER_OPTIONS = Object.freeze({ + parseOptions: Object.freeze({ + includeLocations: true, + }), + trimQuery: false, +}); + +export type NodeSqlParserModuleLoadOutcome = + | { + readonly kind: "loaded"; + readonly moduleValue: unknown; + } + | { + readonly kind: "failed"; + readonly code: "backend" | "module-load"; + readonly retryable: boolean; + }; + +export type NodeSqlParserModuleLoader = + () => Promise; + +export type NodeSqlParserBackendOutcome = + | { + readonly kind: "parsed"; + readonly root: object; + readonly statementKind: SqlStatementKind; + } + | { + readonly kind: "syntax-rejected"; + } + | { + readonly kind: "unsupported"; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly kind: "failed"; + readonly code: + | "backend" + | "malformed-output" + | "module-load"; + readonly retryable: boolean; + }; + +export interface NodeSqlParserBackend { + readonly parse: ( + statementText: string, + checkpoint?: () => void, + ) => Promise; +} + +interface DecodedBackend { + readonly astify: (statementText: string) => unknown; +} + +type BackendLoadOutcome = + | { + readonly kind: "loaded"; + readonly backend: DecodedBackend; + } + | Extract; + +type OwnDataProperty = + | { + readonly kind: "missing"; + } + | { + readonly kind: "invalid"; + } + | { + readonly kind: "value"; + readonly value: unknown; + }; + +function failed( + code: Extract< + NodeSqlParserBackendOutcome, + { readonly kind: "failed" } + >["code"], + retryable: boolean, +): Extract< + NodeSqlParserBackendOutcome, + { readonly kind: "failed" } +> { + return Object.freeze({ + code, + kind: "failed", + retryable, + }); +} + +function isObjectLike(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || + typeof value === "function" + ); +} + +function isRecordObject(value: unknown): value is object { + return typeof value === "object" && value !== null; +} + +function readOwnDataProperty( + value: object, + key: PropertyKey, +): OwnDataProperty { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return { kind: "invalid" }; + } + if (descriptor === undefined) { + return { kind: "missing" }; + } + if (!("value" in descriptor)) { + return { kind: "invalid" }; + } + return { + kind: "value", + value: descriptor.value, + }; +} + +function readDataMethod( + value: object, + key: PropertyKey, +): ((...arguments_: unknown[]) => unknown) | null { + let candidate: object | null = value; + for (let depth = 0; candidate !== null && depth < 16; depth += 1) { + const property = readOwnDataProperty(candidate, key); + if (property.kind === "invalid") { + return null; + } + if (property.kind === "value") { + if (typeof property.value !== "function") { + return null; + } + const method = property.value; + return (...arguments_: unknown[]) => + Reflect.apply(method, value, arguments_); + } + try { + candidate = Object.getPrototypeOf(candidate); + } catch { + return null; + } + } + return null; +} + +function moduleCandidates(moduleValue: unknown): readonly unknown[] { + if (!isObjectLike(moduleValue)) { + return [moduleValue]; + } + const candidates: unknown[] = [moduleValue]; + for (const key of ["default", "module.exports"] as const) { + const property = readOwnDataProperty(moduleValue, key); + if (property.kind === "value") { + candidates.push(property.value); + } + } + return candidates; +} + +function findParserConstructor(moduleValue: unknown): unknown { + for (const candidate of moduleCandidates(moduleValue)) { + if (typeof candidate === "function") { + return candidate; + } + if (!isObjectLike(candidate)) { + continue; + } + const parser = readOwnDataProperty(candidate, "Parser"); + if ( + parser.kind === "value" && + typeof parser.value === "function" + ) { + return parser.value; + } + } + return null; +} + +function decodeBackendModule(moduleValue: unknown): BackendLoadOutcome { + const Parser = findParserConstructor(moduleValue); + if (typeof Parser !== "function") { + return failed("malformed-output", false); + } + let parser: object; + try { + parser = Reflect.construct(Parser, []); + } catch { + return failed("backend", false); + } + const astify = readDataMethod(parser, "astify"); + if (astify === null) { + return failed("malformed-output", false); + } + return Object.freeze({ + backend: Object.freeze({ + astify(statementText: string): unknown { + return astify(statementText, NODE_SQL_PARSER_OPTIONS); + }, + }), + kind: "loaded", + }); +} + +function mapStatementKind(backendType: string): SqlStatementKind { + switch (backendType.toLowerCase()) { + case "select": + case "union": + return "query"; + case "insert": + case "replace": + return "insert"; + case "update": + return "update"; + case "delete": + return "delete"; + case "create": + return "create"; + case "alter": + return "alter"; + case "drop": + return "drop"; + case "merge": + return "merge"; + case "transaction": + return "transaction"; + default: + return "other"; + } +} + +function decodeRoot( + value: unknown, +): Exclude { + let root = value; + let rootIsArray: boolean; + try { + rootIsArray = Array.isArray(root); + } catch { + return failed("malformed-output", false); + } + if (rootIsArray && isRecordObject(root)) { + const length = readOwnDataProperty(root, "length"); + if ( + length.kind !== "value" || + !Number.isSafeInteger(length.value) || + Number(length.value) < 0 + ) { + return failed("malformed-output", false); + } + if (length.value !== 1) { + return Object.freeze({ + kind: "unsupported", + reason: "multiple-statements", + }); + } + const first = readOwnDataProperty(root, 0); + if (first.kind !== "value") { + return failed("malformed-output", false); + } + root = first.value; + } + if (!isRecordObject(root)) { + return failed("malformed-output", false); + } + const type = readOwnDataProperty(root, "type"); + if ( + type.kind !== "value" || + typeof type.value !== "string" || + type.value.length === 0 || + type.value.length > MAX_BACKEND_TYPE_LENGTH || + type.value.trim() !== type.value + ) { + return failed("malformed-output", false); + } + return Object.freeze({ + kind: "parsed", + root, + statementKind: mapStatementKind(type.value), + }); +} + +function decodeSafeOffset( + value: unknown, + statementLength: number, +): number | null { + if ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 && + value <= statementLength + ) { + return value; + } + return null; +} + +function decodeParserError( + error: unknown, + statementLength: number, +): Exclude { + if (!isObjectLike(error)) { + return failed("backend", false); + } + const name = readOwnDataProperty(error, "name"); + if ( + name.kind !== "value" || + name.value !== "SyntaxError" + ) { + return failed("backend", false); + } + const message = readOwnDataProperty(error, "message"); + const location = readOwnDataProperty(error, "location"); + if ( + message.kind !== "value" || + typeof message.value !== "string" || + message.value.trim().length === 0 || + message.value.length > MAX_SQL_SYNTAX_MESSAGE_INPUT_LENGTH || + location.kind !== "value" || + !isRecordObject(location.value) + ) { + return failed("malformed-output", false); + } + const start = readOwnDataProperty(location.value, "start"); + const end = readOwnDataProperty(location.value, "end"); + if ( + start.kind !== "value" || + end.kind !== "value" || + !isRecordObject(start.value) || + !isRecordObject(end.value) + ) { + return failed("malformed-output", false); + } + const startOffset = readOwnDataProperty(start.value, "offset"); + const endOffset = readOwnDataProperty(end.value, "offset"); + if ( + startOffset.kind !== "value" || + endOffset.kind !== "value" + ) { + return failed("malformed-output", false); + } + const from = decodeSafeOffset(startOffset.value, statementLength); + const to = decodeSafeOffset(endOffset.value, statementLength); + if (from === null || to === null || from > to) { + return failed("malformed-output", false); + } + return Object.freeze({ + kind: "syntax-rejected", + }); +} + +export function createNodeSqlParserBackend( + loadModule: NodeSqlParserModuleLoader, +): NodeSqlParserBackend { + let pending: Promise | undefined; + + async function loadBackend(): Promise { + if (pending !== undefined) { + return await pending; + } + const current = Promise.resolve() + .then(loadModule) + .then((outcome): BackendLoadOutcome => { + if (outcome.kind === "failed") { + return failed(outcome.code, outcome.retryable); + } + return decodeBackendModule(outcome.moduleValue); + }) + .catch(() => failed("module-load", true)); + pending = current; + const outcome = await current; + if ( + outcome.kind === "failed" && + outcome.retryable && + pending === current + ) { + pending = undefined; + } + return outcome; + } + + return Object.freeze({ + async parse( + statementText: string, + checkpoint?: () => void, + ): Promise { + if ( + statementText.length > + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + ) { + return Object.freeze({ + kind: "unsupported", + reason: "resource-limit", + }); + } + + checkpoint?.(); + const loaded = await loadBackend(); + checkpoint?.(); + if (loaded.kind === "failed") { + return loaded; + } + + let output: unknown; + try { + output = loaded.backend.astify(statementText); + } catch (error: unknown) { + checkpoint?.(); + return decodeParserError(error, statementText.length); + } + checkpoint?.(); + return decodeRoot(output); + }, + }); +} From a5c4e8673d28e290a84b52993b29c9008b1a000e Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 04:28:06 +0800 Subject: [PATCH 11/42] feat(vnext): add private parser worker protocol (#180) ## Summary - add a strict private parser worker wire protocol and browser worker endpoint - load only literal PostgreSQL and BigQuery parser modules inside the isolated worker - fail closed on malformed messages, unsafe realms, overlap, and poisoned parser descriptors - add Node and Chromium coverage plus package artifact assertions ## Validation - 1,159 Node tests passed with one intentional expected failure - 4 Chromium worker tests passed - typecheck, oxlint, test integrity, demo, package smoke, worker placement, and changed coverage passed - changed production coverage: 99.46% statements/lines and 100% branches/functions - exact-head adversarial review approved by architecture/security, lifecycle/session, and packaging/browser reviewers ## Scope Private infrastructure only: no public exports, session wiring, semantic extraction, cache, or API compatibility commitment. ## Review policy Copilot review is requested once for this PR. It will not be re-requested after later pushes. --- ## Summary by cubic Adds a private, versioned wire protocol and a dedicated module-worker endpoint for `node-sql-parser` to run parsing in an isolated browser worker. The endpoint now guards the entire backend operation and uses stricter, bounded wire decoding; artifacts are production-shaped but still private. - **New Features** - Closed protocol v1 for parse requests/responses; no AST, source text, or raw errors on the wire; retryability derived from failure code. - Dedicated worker endpoint that lazily loads `bigquery`/`postgresql`, reuses the backend normalizer, and restores exact `NodeSQLParser`/`global` descriptors around module load, decode, parser construction, parse, and normalization. - Single-flight only: one in-flight request; malformed or overlapping requests emit a protocol error and then close. - Outcomes include normalized statement kind, bounded unsupported reasons, or bounded failure codes. - **Bug Fixes** - Guarded the full worker backend parse and permanently poison on restoration failure; close the generation on `module-load` failures. - Bounded and hardened wire decoding: accept only closed plain records, enforce key limits, and avoid accessor traps. Written for commit e06a42fbca182b8d712e61015dfeec661f619a1c. Summary will update on new commits. Review in cubic --- docs/adr/0004-isolated-parser-execution.md | 11 +- docs/vnext/node-sql-parser-adapter.md | 41 +- scripts/changed-coverage.mjs | 3 +- scripts/package-smoke.mjs | 15 + ...sql-parser-browser-worker-endpoint.test.ts | 916 ++++++++++++++++++ .../__tests__/node-sql-parser-wire.test.ts | 738 ++++++++++++++ .../node-sql-parser-browser-worker.test.ts | 269 +++++ ...node-sql-parser-browser-worker-endpoint.ts | 343 +++++++ src/vnext/node-sql-parser-browser-worker.ts | 8 + src/vnext/node-sql-parser-wire.ts | 457 +++++++++ vitest.browser.config.ts | 6 + vitest.config.ts | 1 + 12 files changed, 2803 insertions(+), 5 deletions(-) create mode 100644 src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts create mode 100644 src/vnext/__tests__/node-sql-parser-wire.test.ts create mode 100644 src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts create mode 100644 src/vnext/node-sql-parser-browser-worker-endpoint.ts create mode 100644 src/vnext/node-sql-parser-browser-worker.ts create mode 100644 src/vnext/node-sql-parser-wire.ts 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"], From 873c926acd0030be9d01caba26ee7e5d449638b3 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 05:01:17 +0800 Subject: [PATCH 12/42] feat(vnext): add bounded browser parser executor (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the private main-realm executor for the isolated parser worker selected in ADR 0004. - Lazily creates at most one authoritative worker generation. - Serializes requests through a bounded FIFO queue with independent request-count and retained UTF-16 text limits. - Enforces startup, queue-wait, and posted-execution safety deadlines. - Settles caller cancellation promptly while draining already-posted work. - Retires crashed, timed-out, malformed, or poisoned generations without replaying posted work. - Preserves never-posted queued work across eligible generation replacement. - Rejects reused worker identities and serializes retirement across hostile cleanup/settlement reentrancy. - Keeps the worker factory, executor, protocol, and implementation types private; there is no root or `/vnext` public API change. ## Risk classification Medium: concurrency, cancellation, worker lifecycle, resource accounting, and packaging boundaries. The principal risks are double settlement, stale-generation authority, physical worker overlap, posted-work replay, unbounded retention, and browser bundler regressions. The implementation mutates ownership before every external cleanup or settlement boundary, uses an iterative pump trampoline, and tests hostile synchronous callbacks at each boundary. ## Evidence - Full Node suite: 1,246 passed, 1 expected failure. - Focused deterministic executor suite: 84 passed. - Chromium: 4 files / 7 tests, including real worker, crash, silent-worker deadline, recovery, and default static Worker URL behavior. - Changed executor coverage: - Statements: 95.51% - Branches: 95.37% - Functions: 100% - Lines: 95.49% - All TypeScript project checks, oxlint, test-integrity, and diff checks pass. - Packed-package smoke test passes and asserts the executor artifact. - The post-rebase executor patch ID exactly matches the twice-reviewed pre-rebase patch. ## Adversarial review ledger The review loop found and fixed: - cancellation and FIFO reentrancy during promotion; - `preventDefault` running before generation retirement; - listener installation continuing after synchronous retirement; - recursive synchronous worker responses overflowing the stack; - startup-failure queue transfer and ready-failure ownership errors; - postMessage accessor disposal/forged-response races; - a P1 physical-worker overlap during cleanup-triggered nested submission; and - same-worker identity reuse after termination. Two independent reviewers approved the corrected pre-rebase patch. Two fresh independent audits also approved the exact rebased head after rerunning Node, Chromium, package, coverage, type, lint, integrity, placement, and demo gates. ## API and rollout This is implementation infrastructure only. It is not wired to sessions, completion, diagnostics, or other public features. No compatibility or migration work is required in this slice. The next consuming coordinator must preserve the same bounded ownership and no-replay rules. --- ## Summary by cubic Adds a private, single-lane browser executor for the isolated SQL parser worker with bounded queueing, strict deadlines, and safe lifecycle handling. Internal only; no public or `/vnext` API changes. Hardened to fail closed on retirement errors with explicit terminal states. - **New Features** - Lazily creates at most one worker generation; posts one request at a time. - Bounded FIFO queue with request-count and UTF‑16 text limits; startup/queue/execution deadlines and prompt caller cancellation. - Retires crashed/timed-out/poisoned generations without replay; preserves never-posted queued work across replacement. - Rejects reused worker identities; retirement is serialized and atomic to avoid reentrancy races. - Executor, worker factory, protocol, and types remain private; docs updated and `scripts/package-smoke.mjs` asserts executor artifacts. - **Bug Fixes** - Fails closed if worker retirement throws to prevent accepting new work in an unsafe state. - Makes terminal states explicit to avoid resurrection and double settlement. Written for commit 1cb710529add26ec5152f8ed81890ffe8a03e299. Summary will update on new commits. Review in cubic --- docs/adr/0004-isolated-parser-execution.md | 37 +- docs/vnext/node-sql-parser-adapter.md | 29 +- scripts/package-smoke.mjs | 2 + .../node-sql-parser-browser-executor.test.ts | 2576 +++++++++++++++++ .../fixtures/node-sql-parser-crash-worker.js | 7 + .../fixtures/node-sql-parser-silent-worker.js | 5 + .../node-sql-parser-browser-executor.test.ts | 278 ++ src/vnext/node-sql-parser-browser-executor.ts | 1132 ++++++++ 8 files changed, 4043 insertions(+), 23 deletions(-) create mode 100644 src/vnext/__tests__/node-sql-parser-browser-executor.test.ts create mode 100644 src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js create mode 100644 src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js create mode 100644 src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts create mode 100644 src/vnext/node-sql-parser-browser-executor.ts diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index c9f0131..2853504 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -55,21 +55,26 @@ Each `SqlLanguageService` will lazily own at most one dedicated parser worker. All sessions opened by that service share it. The worker is neither a `SharedWorker` nor a module-global singleton. -The first executor is single-lane: +The private browser executor is single-lane: - At most one request is posted at a time. - The host queue is bounded independently by request count and retained UTF-16 text units. -- A service owns construction, listeners, timers, termination, and disposal. +- Worker construction is lazy and the executor owns listeners, timers, + termination, and disposal. - No worker pool or idle shutdown is introduced without profile evidence. -- Service disposal terminates the worker and settles every pending consumer. +- Executor disposal terminates the worker and settles every pending consumer. + +The executor and its worker factory remain package-private. No package export, +`/vnext` export, language-service module, session ownership, or public +configuration surface is introduced by this slice. Ordinary caller cancellation and supersession settle the consumer promptly without relying on a worker message that cannot run during synchronous parsing. The executor may drain and discard that active result. A hard wall-clock deadline, worker crash, malformed protocol, or service disposal -terminates the generation. The placement benchmark must compare drain versus -restart under rapid edits before the executor policy is frozen. +terminates the generation. Never-posted queued requests may continue on a +fresh generation, but a posted request is never replayed. The execution deadline belongs to the posted worker job, not to any attached consumer. Consumer cancellation never clears it. A draining operation retains @@ -154,16 +159,20 @@ Messages do not contain: 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. +`malformed-output` are terminal. Every worker-reported failure retires the +current generation. In particular, `backend` cannot distinguish an ordinary +parse failure from descriptor-cleanup poisoning that closes the endpoint, and +`module-load` must not reuse a rejected dynamic-import realm. Never-posted +queued requests may continue on a fresh generation; the failed posted request +is never replayed. 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 -request text and the host-owned authority. PostgreSQL and BigQuery rejection -remain uncovered constructs; DuckDB rejection remains compatibility rejection. -Worker isolation does not strengthen the compatibility-only evidence recorded -by ADR 0003. +A future statement coordinator will construct an authentic +`SqlParserAnalysis` with the exact pending request text and the host-owned +authority. PostgreSQL and BigQuery rejection remain uncovered constructs; +DuckDB rejection remains compatibility rejection. Worker isolation does not +strengthen the compatibility-only evidence recorded by ADR 0003. Old-generation events are ignored by generation-owned listeners. A malformed, duplicate, unsolicited, or mismatched response kills the generation and @@ -211,7 +220,7 @@ prove: recorded. - Raw and gzip worker sizes are recorded. -The executor and semantic slices additionally require: +The semantic and session-integration slices additionally require: - Main-thread long-task and event-loop responsiveness evidence. - Malformed message, crash, timeout, late-event, and restart tests. @@ -280,7 +289,7 @@ optional-integration bundle budget. 1. Add this ADR and the packed-consumer browser placement harness. 2. Extract a realm-neutral backend engine and add strict protocol codecs. -3. Add the minimal browser worker and single-lane executor. +3. Add the minimal browser worker and private bounded single-lane executor. 4. Add in-worker normalized relation extraction. 5. Add the pure statement coordinator, bounded cache, in-flight sharing, and atomic session ownership. diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index a65006f..17995ea 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -69,7 +69,7 @@ parsing, and output normalization. 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. +module, or session integration. The endpoint: @@ -88,16 +88,27 @@ The endpoint: 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. +fail closed instead of creating an implicit worker-side queue. A private +main-realm executor now provides bounded FIFO admission, serialization, +correlation, startup/queue/execution deadlines, prompt consumer cancellation, +generation replacement, and disposal. It creates the production module worker +lazily and keeps cancelled posted work in a draining lane until the worker +responds or its safety deadline retires that generation. Posted work is never +replayed. Every worker-reported failure retires the generation because a +`backend` failure may be indistinguishable from endpoint cleanup poisoning; +never-posted queued work retains its original deadline on the replacement. + +The executor remains implementation infrastructure only. It is not exported +from the root package or `/vnext`, is not owned by `SqlLanguageService`, and +does not yet create authenticated syntax analyses or relation facts for a +session. 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. +real grammar builds, including the private executor's production worker path. +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 a session API already exists. Approximate local Node 24 arm64 measurements for the installed package were: diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index f794f27..a10c94e 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -170,6 +170,8 @@ try { throw new Error("Packed manifest does not declare node-sql-parser"); } const privateWorkerArtifacts = [ + "dist/vnext/node-sql-parser-browser-executor.d.ts", + "dist/vnext/node-sql-parser-browser-executor.js", "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", diff --git a/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..9e86078 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,2576 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + createNodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutorDeadlineScheduler, + type NodeSqlParserBrowserExecutorEventType, + type NodeSqlParserBrowserExecutorOptions, + type NodeSqlParserBrowserExecutorOutcome, + type NodeSqlParserBrowserExecutorSubmission, + type NodeSqlParserBrowserExecutorWorker, +} from "../node-sql-parser-browser-executor.js"; +import { + decodeNodeSqlParserWireRequest, + encodeNodeSqlParserWireBackendOutcome, + encodeNodeSqlParserWireReady, + type NodeSqlParserWireMessage, + type NodeSqlParserWireRequest, +} from "../node-sql-parser-wire.js"; + +type WorkerListener = (event: unknown) => void; + +interface ScheduledDeadline { + readonly callback: () => void; + readonly deadline: number; + readonly handle: number; +} + +class ManualDeadlineScheduler + implements NodeSqlParserBrowserExecutorDeadlineScheduler +{ + readonly clearedHandles: number[] = []; + readonly scheduledDelays: number[] = []; + #deadlines = new Map(); + #nextHandle = 1; + #now = 0; + + clearTimeout(handle: unknown): void { + if (typeof handle !== "number") { + throw new TypeError("test scheduler received an invalid handle"); + } + this.clearedHandles.push(handle); + this.#deadlines.delete(handle); + } + + setTimeout(callback: () => void, delayMs: number): unknown { + const handle = this.#nextHandle; + this.#nextHandle += 1; + this.scheduledDelays.push(delayMs); + this.#deadlines.set(handle, { + callback, + deadline: this.#now + delayMs, + handle, + }); + return handle; + } + + advanceBy(milliseconds: number): void { + const target = this.#now + milliseconds; + for (;;) { + const next = [...this.#deadlines.values()] + .filter(({ deadline }) => deadline <= target) + .sort( + (left, right) => + left.deadline - right.deadline || + left.handle - right.handle, + )[0]; + if (next === undefined) { + break; + } + this.#now = next.deadline; + this.#deadlines.delete(next.handle); + next.callback(); + } + this.#now = target; + } + + pendingCount(): number { + return this.#deadlines.size; + } +} + +interface FakeWorkerFailures { + readonly add?: NodeSqlParserBrowserExecutorEventType; + readonly post?: boolean; + readonly retainRemovedListeners?: boolean; + readonly remove?: NodeSqlParserBrowserExecutorEventType; + readonly terminate?: boolean; +} + +class FakeWorker implements NodeSqlParserBrowserExecutorWorker { + readonly posted: unknown[] = []; + readonly removed: NodeSqlParserBrowserExecutorEventType[] = []; + readonly failures: FakeWorkerFailures; + #addHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #beforeAddHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #postHook: ((message: unknown) => void) | undefined; + #removeHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #terminateHook: (() => void) | undefined; + #listeners = new Map< + NodeSqlParserBrowserExecutorEventType, + Set + >(); + #terminateCalls = 0; + + constructor(failures: FakeWorkerFailures = {}) { + this.failures = failures; + } + + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: WorkerListener, + ): void { + this.#beforeAddHook?.(type); + if (this.failures.add === type) { + throw new Error("private addEventListener failure"); + } + const listeners = this.#listeners.get(type) ?? new Set(); + listeners.add(listener); + this.#listeners.set(type, listeners); + this.#addHook?.(type); + } + + dispatch(type: NodeSqlParserBrowserExecutorEventType, event: unknown): void { + for (const listener of this.#listeners.get(type) ?? []) { + listener(event); + } + } + + emit(message: unknown): void { + this.dispatch("message", { data: message }); + } + + listenerCount(type?: NodeSqlParserBrowserExecutorEventType): number { + if (type !== undefined) { + return this.#listeners.get(type)?.size ?? 0; + } + return [...this.#listeners.values()].reduce( + (count, listeners) => count + listeners.size, + 0, + ); + } + + postMessage(message: unknown): void { + if (this.failures.post) { + throw new Error("private postMessage failure"); + } + this.posted.push(message); + this.#postHook?.(message); + } + + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: WorkerListener, + ): void { + this.removed.push(type); + if (!this.failures.retainRemovedListeners) { + this.#listeners.get(type)?.delete(listener); + } + this.#removeHook?.(type); + if (this.failures.remove === type) { + throw new Error("private removeEventListener failure"); + } + } + + terminate(): void { + this.#terminateCalls += 1; + this.#terminateHook?.(); + if (this.failures.terminate) { + throw new Error("private terminate failure"); + } + } + + terminateCalls(): number { + return this.#terminateCalls; + } + + setPostHook(hook: ((message: unknown) => void) | undefined): void { + this.#postHook = hook; + } + + setRemoveHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#removeHook = hook; + } + + setTerminateHook(hook: (() => void) | undefined): void { + this.#terminateHook = hook; + } + + setAddHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#addHook = hook; + } + + setBeforeAddHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#beforeAddHook = hook; + } +} + +class FakeWorkerFactory { + readonly created: FakeWorker[] = []; + readonly queued: FakeWorker[] = []; + #failure: Error | undefined; + + create = (): FakeWorker => { + if (this.#failure !== undefined) { + throw this.#failure; + } + const worker = this.queued.shift() ?? new FakeWorker(); + this.created.push(worker); + return worker; + }; + + enqueue(worker: FakeWorker): void { + this.queued.push(worker); + } + + fail(error = new Error("private worker factory failure")): void { + this.#failure = error; + } + + recover(): void { + this.#failure = undefined; + } +} + +interface Harness { + readonly factory: FakeWorkerFactory; + readonly options: NodeSqlParserBrowserExecutorOptions; + readonly scheduler: ManualDeadlineScheduler; +} + +function harness( + overrides: Partial = {}, +): Harness { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + return { + factory, + options: { + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + ...overrides, + }, + scheduler, + }; +} + +function postedRequest(worker: FakeWorker, index = 0): NodeSqlParserWireRequest { + const request = decodeNodeSqlParserWireRequest(worker.posted[index]); + if (request === null) { + throw new Error("test worker did not receive a valid request"); + } + return request; +} + +function createdWorker( + factory: FakeWorkerFactory, + index = 0, +): FakeWorker { + const worker = factory.created[index]; + if (worker === undefined) { + throw new Error(`test worker generation ${index} was not created`); + } + return worker; +} + +function ready(worker: FakeWorker): void { + worker.emit(encodeNodeSqlParserWireReady()); +} + +function respond( + worker: FakeWorker, + outcome: + | { readonly kind: "parsed"; readonly statementKind: "query" } + | { readonly kind: "syntax-rejected" } + | { + readonly kind: "unsupported"; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly kind: "failed"; + readonly code: "backend" | "malformed-output" | "module-load"; + }, + index = 0, +): void { + const backendOutcome = + outcome.kind === "parsed" + ? { + ...outcome, + root: Object.freeze({}), + } + : outcome.kind === "failed" + ? { + ...outcome, + retryable: outcome.code === "module-load", + } + : outcome; + worker.emit( + encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker, index).requestId, + backendOutcome, + ), + ); +} + +async function outcome( + submission: NodeSqlParserBrowserExecutorSubmission, +): Promise { + return submission.result; +} + +async function expectPending( + submission: NodeSqlParserBrowserExecutorSubmission, +): Promise { + const sentinel = Symbol("pending"); + expect( + await Promise.race([ + submission.result, + Promise.resolve(sentinel), + ]), + ).toBe(sentinel); +} + +describe("node-sql-parser browser executor admission", () => { + it("starts lazily, waits for ready, and sends a frozen closed request", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + + expect(factory.created).toHaveLength(0); + expect(scheduler.pendingCount()).toBe(0); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + expect(worker.posted).toHaveLength(0); + expect(worker.listenerCount()).toBe(3); + expect(scheduler.scheduledDelays).toStrictEqual([20, 10]); + + ready(worker); + const request = postedRequest(worker); + expect(request).toStrictEqual({ + grammar: "postgresql", + kind: "parse", + protocolVersion: 1, + requestId: 1, + text: "SELECT 1", + }); + expect(Object.isFrozen(request)).toBe(true); + + respond(worker, { + kind: "parsed", + statementKind: "query", + }); + const result = await outcome(submission); + expect(result).toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(Object.isFrozen(result)).toBe(true); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("runs one request at a time in FIFO order across grammars", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 3, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const second = executor.submit({ + grammar: "bigquery", + text: "SELECT 2", + }); + const third = executor.submit({ + grammar: "postgresql", + text: "SELECT 3", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(worker.posted).toHaveLength(1); + expect(postedRequest(worker).text).toBe("SELECT 1"); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1)).toMatchObject({ + grammar: "bigquery", + text: "SELECT 2", + }); + + respond(worker, { + kind: "unsupported", + reason: "multiple-statements", + }, 1); + expect(await outcome(second)).toStrictEqual({ + kind: "unsupported", + reason: "multiple-statements", + }); + expect(postedRequest(worker, 2).text).toBe("SELECT 3"); + + respond(worker, { + kind: "parsed", + statementKind: "query", + }, 2); + expect(await outcome(third)).toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + }); + + it("becomes terminal when the request ID space is exhausted", async () => { + const { factory, options, scheduler } = harness({ + maxQueuedRequests: 3, + requestIdStart: Number.MAX_SAFE_INTEGER, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const lastIdentified = executor.submit({ + grammar: "postgresql", + text: "SELECT last", + }); + const exhausted = executor.submit({ + grammar: "postgresql", + text: "SELECT exhausted", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(postedRequest(worker).requestId).toBe( + Number.MAX_SAFE_INTEGER, + ); + respond(worker, { kind: "syntax-rejected" }); + + expect(await outcome(lastIdentified)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(await outcome(exhausted)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + executor.dispose(); + const disposed = executor.submit({ + grammar: "postgresql", + text: "SELECT disposed", + }); + expect(await outcome(disposed)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + }); + + it("handles a response dispatched synchronously from postMessage", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker(); + factory.enqueue(worker); + worker.setPostHook((value) => { + const request = decodeNodeSqlParserWireRequest(value); + if (request === null) { + throw new Error("expected a valid request"); + } + worker.emit( + encodeNodeSqlParserWireBackendOutcome(request.requestId, { + kind: "syntax-rejected", + }), + ); + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("drains a large bounded FIFO under synchronous terminal responses without recursion", async () => { + const requestCount = 20_000; + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + factory.enqueue(worker); + worker.setPostHook((value) => { + const request = decodeNodeSqlParserWireRequest(value); + if (request === null) { + throw new Error("expected a valid request"); + } + worker.emit( + encodeNodeSqlParserWireBackendOutcome(request.requestId, { + kind: "syntax-rejected", + }), + ); + }); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 100, + maxQueuedRequests: requestCount, + maxQueuedTextUnits: 1_000_000, + queueDeadlineMs: 1_000_000, + startupDeadlineMs: 100, + workerFactory: factory.create, + }); + const submissions = Array.from( + { length: requestCount }, + (_, index) => + executor.submit({ + grammar: index % 2 === 0 ? "postgresql" : "bigquery", + text: `request-${index}`, + }), + ); + + ready(worker); + const results = await Promise.all( + submissions.map((submission) => submission.result), + ); + expect(results).toHaveLength(requestCount); + expect( + results.every( + (result) => result.kind === "syntax-rejected", + ), + ).toBe(true); + expect(worker.posted).toHaveLength(requestCount); + for (let index = 0; index < requestCount; index += 1) { + const request = decodeNodeSqlParserWireRequest( + worker.posted[index], + ); + if (request === null) { + throw new Error(`invalid request at FIFO index ${index}`); + } + expect(request.requestId).toBe(index + 1); + expect(request.text).toBe(`request-${index}`); + } + expect(scheduler.pendingCount()).toBe(0); + }, 10_000); + + it("bounds queued count without disturbing admitted work", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 1, + maxQueuedTextUnits: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const rejected = executor.submit({ + grammar: "postgresql", + text: "rejected", + }); + + expect(await outcome(rejected)).toStrictEqual({ + code: "queue-limit", + kind: "failed", + }); + expect(postedRequest(worker).text).toBe("active"); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(active)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(worker, 1).text).toBe("queued"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("bounds retained queued UTF-16 units independently of count", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 4, + maxQueuedTextUnits: 4, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "a", + }); + const worker = createdWorker(factory); + ready(worker); + const twoAstralUnits = executor.submit({ + grammar: "postgresql", + text: "😀", + }); + const twoMore = executor.submit({ + grammar: "postgresql", + text: "xy", + }); + const overLimit = executor.submit({ + grammar: "postgresql", + text: "z", + }); + + expect(await outcome(overLimit)).toStrictEqual({ + code: "queue-limit", + kind: "failed", + }); + respond(worker, { kind: "syntax-rejected" }); + await outcome(active); + expect(postedRequest(worker, 1).text).toBe("😀"); + respond(worker, { kind: "syntax-rejected" }, 1); + await outcome(twoAstralUnits); + expect(postedRequest(worker, 2).text).toBe("xy"); + respond(worker, { kind: "syntax-rejected" }, 2); + await outcome(twoMore); + }); + + it("rejects an oversized input without creating a worker", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "x".repeat(16 * 1024 + 1), + }); + + expect(await outcome(submission)).toStrictEqual({ + kind: "unsupported", + reason: "resource-limit", + }); + expect(factory.created).toHaveLength(0); + }); +}); + +describe("node-sql-parser browser executor deadlines and cancellation", () => { + it("contains disposal reentrancy while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + executor?.dispose(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.posted).toHaveLength(0); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(manual.pendingCount()).toBe(0); + }); + + it("drains cancellation reentrancy while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let first: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + first?.cancel(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(first)).toStrictEqual({ + kind: "cancelled", + }); + expect(worker.posted).toHaveLength(1); + expect(postedRequest(worker).text).toBe("first"); + await expectPending(second); + + respond(worker, { kind: "syntax-rejected" }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1).text).toBe("second"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(second)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }); + + it("preserves FIFO for nested submission while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + nested = executor?.submit({ + grammar: "bigquery", + text: "nested", + }); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const worker = createdWorker(factory); + + ready(worker); + if (nested === undefined) { + throw new Error("nested submission was not created"); + } + expect(postedRequest(worker)).toMatchObject({ + requestId: 1, + text: "first", + }); + expect(worker.posted).toHaveLength(1); + + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(worker, 1)).toMatchObject({ + requestId: 2, + text: "nested", + }); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(nested)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }); + + it("applies a startup deadline and retires a silent worker", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + scheduler.advanceBy(9); + await expectPending(submission); + scheduler.advanceBy(1); + expect(await outcome(submission)).toStrictEqual({ + code: "startup-timeout", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("measures queue time from admission without resetting on ready", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 20, + startupDeadlineMs: 50, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "SELECT 2", + }); + const worker = createdWorker(factory); + + scheduler.advanceBy(15); + ready(worker); + expect(worker.posted).toHaveLength(1); + scheduler.advanceBy(5); + expect(await outcome(second)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + await expectPending(first); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("starts the execution deadline before posting and retires on timeout", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + ready(worker); + + scheduler.advanceBy(29); + await expectPending(submission); + scheduler.advanceBy(1); + expect(await outcome(submission)).toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + }); + + it("cancels queued work promptly and releases its queue capacity", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 1, + maxQueuedTextUnits: 6, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + const cancelled = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + + cancelled.cancel(); + cancelled.cancel(); + expect(await outcome(cancelled)).toStrictEqual({ + kind: "cancelled", + }); + const replacement = executor.submit({ + grammar: "postgresql", + text: "next", + }); + + respond(worker, { kind: "syntax-rejected" }); + await outcome(active); + expect(postedRequest(worker, 1).text).toBe("next"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(replacement)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("cancels active work promptly but drains its response before advancing", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const worker = createdWorker(factory); + ready(worker); + + active.cancel(); + active.cancel(); + expect(await outcome(active)).toStrictEqual({ + kind: "cancelled", + }); + expect(worker.posted).toHaveLength(1); + expect(scheduler.pendingCount()).toBeGreaterThan(0); + + respond(worker, { kind: "syntax-rejected" }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1).text).toBe("queued"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("keeps the execution safety deadline after active cancellation", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + active.cancel(); + await outcome(active); + + scheduler.advanceBy(30); + expect(firstWorker.terminateCalls()).toBe(1); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("does not reset a queued deadline across generation replacement", async () => { + const { factory, options, scheduler } = harness({ + executionDeadlineMs: 200, + queueDeadlineMs: 100, + startupDeadlineMs: 200, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + + scheduler.advanceBy(60); + firstWorker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + createdWorker(factory, 1); + scheduler.advanceBy(39); + await expectPending(queued); + scheduler.advanceBy(1); + expect(await outcome(queued)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + executor.dispose(); + }); + + it("cancels during startup without ever posting the request", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + submission.cancel(); + expect(await outcome(submission)).toStrictEqual({ + kind: "cancelled", + }); + ready(worker); + expect(worker.posted).toHaveLength(0); + executor.dispose(); + }); +}); + +describe("node-sql-parser browser executor hostile worker handling", () => { + it.each(["clear", "remove", "terminate"] as const)( + "isolates original startup waiters from %s cleanup reentrancy", + async (boundary) => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const firstWorker = new FakeWorker(); + const replacementWorker = new FakeWorker(); + factory.enqueue(firstWorker); + factory.enqueue(replacementWorker); + replacementWorker.setAddHook((type) => { + if (type === "message") { + ready(replacementWorker); + } + }); + let executor: + | ReturnType + | undefined; + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + let cleanupDepth = 0; + let replacementCreatedDuringCleanup = false; + const submitNested = () => { + if (nested === undefined) { + nested = executor?.submit({ + grammar: "bigquery", + text: "nested", + }); + } + }; + const submitNestedDuringCleanup = () => { + cleanupDepth += 1; + try { + submitNested(); + } finally { + cleanupDepth -= 1; + } + }; + if (boundary === "remove") { + firstWorker.setRemoveHook((type) => { + if (type === "message") { + submitNestedDuringCleanup(); + } + }); + } + if (boundary === "terminate") { + firstWorker.setTerminateHook( + submitNestedDuringCleanup, + ); + } + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (boundary === "clear" && handle === 2) { + submitNestedDuringCleanup(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 3, + maxQueuedTextUnits: 100, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => { + if ( + cleanupDepth > 0 && + factory.created.length > 0 + ) { + replacementCreatedDuringCleanup = true; + } + return factory.create(); + }, + }); + const originalFirst = executor.submit({ + grammar: "postgresql", + text: "original-first", + }); + const originalSecond = executor.submit({ + grammar: "postgresql", + text: "original-second", + }); + + firstWorker.dispatch("error", {}); + if (nested === undefined) { + throw new Error( + `${boundary} cleanup did not create nested work`, + ); + } + expect(await outcome(originalFirst)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(originalSecond)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(firstWorker.posted).toHaveLength(0); + expect(firstWorker.terminateCalls()).toBe(1); + expect(replacementCreatedDuringCleanup).toBe(false); + expect(factory.created).toHaveLength(2); + expect(replacementWorker.posted).toHaveLength(1); + expect(postedRequest(replacementWorker)).toMatchObject({ + grammar: "bigquery", + requestId: 1, + text: "nested", + }); + + respond(replacementWorker, { kind: "syntax-rejected" }); + expect(await outcome(nested)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }, + ); + + it("rejects a worker identity reused across generations", async () => { + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + let factoryCalls = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => { + factoryCalls += 1; + return worker; + }, + }); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + ready(worker); + + worker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(factoryCalls).toBe(2); + expect(worker.posted).toHaveLength(1); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it.each([ + undefined, + null, + {}, + { data: null }, + { data: { kind: "ready", protocolVersion: 2 } }, + { + data: { + extra: true, + kind: "ready", + protocolVersion: 1, + }, + }, + ])("fails closed for malformed message event %#", async (event) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + worker.dispatch("message", event); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("fails closed when a message data getter throws", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch("message", { + get data() { + throw new Error("private data getter failure"); + }, + }); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + }); + + it("retires on unsolicited and duplicate ready messages", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("retires when the worker reports a protocol error", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + worker.emit({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + } satisfies NodeSqlParserWireMessage); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("retires on a mismatched response and never replays active work", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + const activeId = postedRequest(firstWorker).requestId; + + firstWorker.emit({ + kind: "syntax-rejected", + protocolVersion: 1, + requestId: activeId + 1, + } satisfies NodeSqlParserWireMessage); + expect(await outcome(active)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(secondWorker.posted).toHaveLength(1); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect( + firstWorker.posted.filter( + (value) => + decodeNodeSqlParserWireRequest(value)?.text === "active", + ), + ).toHaveLength(1); + }); + + it("ignores late messages from a retired generation", async () => { + const { factory, options } = harness(); + const retainedWorker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + }); + factory.enqueue(retainedWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + firstWorker.dispatch("error", new Error("private worker error")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + + firstWorker.emit(encodeNodeSqlParserWireReady()); + firstWorker.dispatch( + "message", + new Proxy( + {}, + { + get() { + throw new Error("must not inspect retired event"); + }, + }, + ), + ); + let staleFailureInspections = 0; + firstWorker.dispatch("error", { + get preventDefault() { + staleFailureInspections += 1; + throw new Error("must not inspect retired failure event"); + }, + }); + expect(staleFailureInspections).toBe(0); + expect(secondWorker.posted).toHaveLength(0); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it.each(["error", "messageerror"] as const)( + "retires on a worker %s event without leaking event details", + async (eventType) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch( + eventType, + new Error("private event detail"), + ); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it.each(["remove", "terminate"] as const)( + "keeps worker-failure ownership when %s cleanup reentrantly cancels active work", + async (boundary) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + if (boundary === "remove") { + worker.setRemoveHook((type) => { + if (type === "message") { + active.cancel(); + } + }); + } else { + worker.setTerminateHook(active.cancel); + } + + worker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("safely prevents the default handling of worker failures", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + let prevented = 0; + + worker.dispatch("error", { + preventDefault() { + prevented += 1; + }, + }); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(prevented).toBe(1); + }); + + it("retires before preventDefault can reentrantly deliver an active response", async () => { + const { factory, options } = harness(); + const firstWorker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + }); + factory.enqueue(firstWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + ready(firstWorker); + const staleResponse = encodeNodeSqlParserWireBackendOutcome( + postedRequest(firstWorker).requestId, + { kind: "syntax-rejected" }, + ); + let prevented = 0; + let workerCountDuringPrevention = 0; + + firstWorker.dispatch("error", { + preventDefault() { + prevented += 1; + workerCountDuringPrevention = factory.created.length; + firstWorker.emit(staleResponse); + }, + }); + expect(prevented).toBe(1); + expect(workerCountDuringPrevention).toBe(1); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(firstWorker.terminateCalls()).toBe(1); + expect(firstWorker.posted).toHaveLength(1); + + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker)).toMatchObject({ + requestId: 2, + text: "queued", + }); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("accepts primitive worker failure events without inspection", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch("error", "private event detail"); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + }); + + it.each(["backend", "malformed-output", "module-load"] as const)( + "retires after a valid %s failure and preserves never-posted work", + async (code) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "bigquery", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + + respond(firstWorker, { code, kind: "failed" }); + expect(await outcome(active)).toStrictEqual({ + code, + kind: "failed", + }); + expect(firstWorker.terminateCalls()).toBe(1); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker)).toMatchObject({ + grammar: "bigquery", + text: "queued", + }); + expect(postedRequest(secondWorker).requestId).toBe( + postedRequest(firstWorker).requestId + 1, + ); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }, + ); + + it("retires on a duplicate terminal response", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + const duplicate = encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker).requestId, + { kind: "syntax-rejected" }, + ); + + worker.emit(duplicate); + await outcome(first); + expect(worker.posted).toHaveLength(2); + worker.emit(duplicate); + expect(await outcome(second)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); +}); + +describe("node-sql-parser browser executor host failure containment", () => { + it("contains worker factory failure and settles the startup waiter", async () => { + const { factory, options, scheduler } = harness(); + factory.fail(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("rejects a non-object worker factory result", async () => { + const { factory, options, scheduler } = harness(); + const workerFactory = new Proxy(factory.create, { + apply() { + return () => undefined; + }, + }); + const executor = createNodeSqlParserBrowserExecutor({ + ...options, + workerFactory, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("can create a fresh generation on a later submission after factory failure", async () => { + const { factory, options } = harness(); + factory.fail(); + const executor = createNodeSqlParserBrowserExecutor(options); + const failed = executor.submit({ + grammar: "postgresql", + text: "first", + }); + expect(await outcome(failed)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + + factory.recover(); + const recovered = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(recovered)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("contains disposal reentrancy from the worker factory", async () => { + const worker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory() { + executor?.dispose(); + return worker; + }, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("keeps one authoritative generation during factory submission reentrancy", async () => { + const outerWorker = new FakeWorker(); + const nestedWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let calls = 0; + let executor: + | ReturnType + | undefined; + let nestedSubmission: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 3, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory() { + calls += 1; + if (calls === 1) { + nestedSubmission = executor?.submit({ + grammar: "postgresql", + text: "nested", + }); + return outerWorker; + } + return nestedWorker; + }, + }); + + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + if (nestedSubmission === undefined) { + throw new Error("nested submission was not created"); + } + expect(calls).toBe(1); + expect(outerWorker.listenerCount()).toBe(3); + expect(outerWorker.terminateCalls()).toBe(0); + expect(nestedWorker.listenerCount()).toBe(0); + + ready(outerWorker); + expect(postedRequest(outerWorker).text).toBe("first"); + respond(outerWorker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(outerWorker, 1).text).toBe("nested"); + respond(outerWorker, { kind: "syntax-rejected" }, 1); + expect(await outcome(nestedSubmission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + expect(outerWorker.terminateCalls()).toBe(1); + }); + + it.each(["error", "message", "messageerror"] as const)( + "contains %s listener installation failure", + async (eventType) => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ add: eventType }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }, + ); + + it("contains both listener installation and explicit cleanup failures", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ + add: "message", + remove: "message", + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("removes a listener registered after addEventListener reentrantly disposes", async () => { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + factory.enqueue(worker); + let disposedDuringAdd = false; + let executor: + | ReturnType + | undefined; + worker.setBeforeAddHook(() => { + if (!disposedDuringAdd) { + disposedDuringAdd = true; + executor?.dispose(); + } + }); + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(disposedDuringAdd).toBe(true); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(worker.removed).toContain("error"); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("contains an add failure after addEventListener reentrantly disposes", async () => { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker({ add: "error" }); + factory.enqueue(worker); + let executor: + | ReturnType + | undefined; + worker.setBeforeAddHook(() => { + executor?.dispose(); + }); + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("contains postMessage failure and never replays the active request", async () => { + const { factory, options } = harness(); + const firstWorker = new FakeWorker({ post: true }); + factory.enqueue(firstWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + + ready(firstWorker); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("does not call a postMessage accessor result after the accessor disposes", async () => { + const baseWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + let callableInvocations = 0; + const hostileWorker: NodeSqlParserBrowserExecutorWorker = { + addEventListener(type, listener): void { + baseWorker.addEventListener(type, listener); + }, + get postMessage() { + executor?.dispose(); + return (_message: unknown): void => { + callableInvocations += 1; + }; + }, + removeEventListener(type, listener): void { + baseWorker.removeEventListener(type, listener); + }, + terminate(): void { + baseWorker.terminate(); + }, + }; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => hostileWorker, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + ready(baseWorker); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(callableInvocations).toBe(0); + expect(baseWorker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("rejects a correlated response emitted by a postMessage accessor", async () => { + const baseWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let callableInvocations = 0; + const hostileWorker: NodeSqlParserBrowserExecutorWorker = { + addEventListener(type, listener): void { + baseWorker.addEventListener(type, listener); + }, + get postMessage() { + baseWorker.emit( + encodeNodeSqlParserWireBackendOutcome(1, { + kind: "syntax-rejected", + }), + ); + return (_message: unknown): void => { + callableInvocations += 1; + }; + }, + removeEventListener(type, listener): void { + baseWorker.removeEventListener(type, listener); + }, + terminate(): void { + baseWorker.terminate(); + }, + }; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => hostileWorker, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + ready(baseWorker); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(callableInvocations).toBe(0); + expect(baseWorker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it.each(["error", "messageerror"] as const)( + "stops listener installation after synchronous %s failure", + async (eventType) => { + const { factory, options } = harness(); + const worker = new FakeWorker(); + worker.setAddHook((type) => { + if (type === eventType) { + worker.dispatch("error", {}); + } + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("becomes terminal when retirement cannot terminate the worker", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + terminate: true, + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + worker.setTerminateHook(() => { + nested = executor.submit({ + grammar: "postgresql", + text: "SELECT nested", + }); + nested.cancel(); + }); + const active = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "SELECT queued", + }); + + ready(worker); + worker.dispatch("error", new Error("private")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.removed).toContain("messageerror"); + expect(factory.created).toHaveLength(1); + if (nested === undefined) { + throw new Error("terminate hook did not submit"); + } + expect(await outcome(nested)).toStrictEqual({ + kind: "cancelled", + }); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(factory.created).toHaveLength(1); + worker.emit(encodeNodeSqlParserWireReady()); + worker.dispatch("error", new Error("late private")); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("lets reentrant disposal win for queued and future work", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ terminate: true }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + worker.setTerminateHook(() => { + executor.dispose(); + }); + const active = executor.submit({ + grammar: "postgresql", + text: "SELECT active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "SELECT queued", + }); + + ready(worker); + worker.dispatch("error", new Error("private")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + }); + + it("contains a deadline scheduler set failure", async () => { + const factory = new FakeWorkerFactory(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(): unknown { + throw new Error("private scheduler set failure"); + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + expect(factory.created).toHaveLength(0); + }); + + it("contains a synchronously firing queue deadline", async () => { + const factory = new FakeWorkerFactory(); + let clearCalls = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void { + clearCalls += 1; + }, + setTimeout(callback): unknown { + callback(); + return 1; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + expect(clearCalls).toBe(1); + expect(factory.created).toHaveLength(0); + }); + + it("contains a synchronously firing startup deadline", async () => { + const factory = new FakeWorkerFactory(); + let schedules = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(callback): unknown { + schedules += 1; + if (schedules === 2) { + callback(); + } + return schedules; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "startup-timeout", + kind: "failed", + }); + expect(createdWorker(factory).terminateCalls()).toBe(1); + }); + + it("contains a synchronously firing execution deadline", async () => { + const factory = new FakeWorkerFactory(); + let schedules = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(callback): unknown { + schedules += 1; + if (schedules === 3) { + callback(); + } + return schedules; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + expect(worker.posted).toHaveLength(0); + }); + + it("contains deadline scheduler clear failures", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void { + throw new Error("private scheduler clear failure"); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + }); + + it("ignores cleared queue, startup, and execution callbacks that fire late", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 10, + startupDeadlineMs: 20, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + + manual.advanceBy(30); + expect(worker.terminateCalls()).toBe(0); + executor.dispose(); + }); + + it("constructs the private default module worker lazily", async () => { + const workers: FakeWorker[] = []; + const constructorCalls: { + readonly options: unknown; + readonly url: unknown; + }[] = []; + class DefaultWorker extends FakeWorker { + constructor(url: unknown, options: unknown) { + super(); + workers.push(this); + constructorCalls.push({ options, url }); + } + } + vi.stubGlobal("Worker", DefaultWorker); + try { + const executor = createNodeSqlParserBrowserExecutor({ + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + }); + expect(workers).toHaveLength(0); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = workers[0]; + if (worker === undefined) { + throw new Error("default worker was not constructed"); + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]?.options).toStrictEqual({ + name: "codemirror-sql-parser", + type: "module", + }); + expect(String(constructorCalls[0]?.url)).toContain( + "node-sql-parser-browser-worker.js", + ); + + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("node-sql-parser browser executor disposal and validation", () => { + it.each(["remove", "terminate"] as const)( + "keeps disposed ownership when %s cleanup reentrantly cancels active work", + async (boundary) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + if (boundary === "remove") { + worker.setRemoveHook((type) => { + if (type === "message") { + active.cancel(); + } + }); + } else { + worker.setTerminateHook(active.cancel); + } + + executor.dispose(); + expect(await outcome(active)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("disposes starting, active, and queued submissions exactly once", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + + executor.dispose(); + executor.dispose(); + first.cancel(); + second.cancel(); + expect(await outcome(first)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(await outcome(second)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("disposes while a cancelled active request is draining", async () => { + const { factory, options } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const worker = createdWorker(factory); + ready(worker); + const lateResponse = encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker).requestId, + { kind: "syntax-rejected" }, + ); + active.cancel(); + expect(await outcome(active)).toStrictEqual({ + kind: "cancelled", + }); + + executor.dispose(); + expect(await outcome(queued)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + worker.emit(lateResponse); + expect(worker.posted).toHaveLength(1); + expect(worker.terminateCalls()).toBe(1); + }); + + it("settles submissions made after disposal without creating a worker", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + executor.dispose(); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + submission.cancel(); + expect(Object.isFrozen(submission)).toBe(true); + expect(factory.created).toHaveLength(0); + }); + + it.each([ + ["executionDeadlineMs", 0], + ["executionDeadlineMs", 2_147_483_648], + ["executionDeadlineMs", Number.POSITIVE_INFINITY], + ["maxQueuedRequests", -1], + ["maxQueuedRequests", 1.5], + ["maxQueuedTextUnits", -1], + ["maxQueuedTextUnits", Number.NaN], + ["queueDeadlineMs", 0], + ["startupDeadlineMs", -1], + ] as const)("rejects invalid %s=%s", (name, value) => { + const { options } = harness(); + + expect(() => + createNodeSqlParserBrowserExecutor({ + ...options, + [name]: value, + }), + ).toThrow(TypeError); + }); + + it("rejects invalid runtime inputs synchronously", () => { + const { options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + + expect(() => + Reflect.apply(executor.submit, executor, [{ + grammar: "sqlite", + text: "SELECT 1", + }]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(executor.submit, executor, [{ + grammar: "postgresql", + text: 1, + }]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(executor.submit, executor, [ + new Proxy( + {}, + { + get() { + throw new Error("private input getter failure"); + }, + }, + ), + ]), + ).toThrow(TypeError); + }); + + it("rejects hostile and structurally invalid options", () => { + const hostile = new Proxy( + {}, + { + get() { + throw new Error("private options getter failure"); + }, + }, + ); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + hostile, + ]), + ).toThrow(TypeError); + + const { options } = harness(); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + { + ...options, + deadlineScheduler: { + clearTimeout: 1, + setTimeout(): unknown { + return 1; + }, + }, + }, + ]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + { + ...options, + workerFactory: 1, + }, + ]), + ).toThrow(TypeError); + }); +}); diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js new file mode 100644 index 0000000..530f6a4 --- /dev/null +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js @@ -0,0 +1,7 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 1, +}); +globalThis.addEventListener("message", () => { + throw new Error("Intentional parser executor crash fixture"); +}); diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js new file mode 100644 index 0000000..b2c2edd --- /dev/null +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js @@ -0,0 +1,5 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 1, +}); +globalThis.addEventListener("message", () => {}); diff --git a/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..08a6b9b --- /dev/null +++ b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,278 @@ +import { expect, test } from "vitest"; +import { + createNodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutorEventType, + type NodeSqlParserBrowserExecutorWorker, +} from "../node-sql-parser-browser-executor.js"; + +const REAL_WORKER_LIMITS = Object.freeze({ + executionDeadlineMs: 4_000, + maxQueuedRequests: 4, + maxQueuedTextUnits: 32_768, + queueDeadlineMs: 4_000, + startupDeadlineMs: 4_000, +}); +const FAILURE_WORKER_LIMITS = Object.freeze({ + ...REAL_WORKER_LIMITS, + executionDeadlineMs: 250, + queueDeadlineMs: 250, + startupDeadlineMs: 250, +}); + +function adaptWorker( + worker: Worker, +): NodeSqlParserBrowserExecutorWorker { + type Listener = (event: unknown) => void; + const errorListeners = new Map< + Listener, + (event: ErrorEvent) => void + >(); + const messageListeners = new Map< + Listener, + (event: MessageEvent) => void + >(); + const messageErrorListeners = new Map< + Listener, + (event: MessageEvent) => void + >(); + + return { + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: Listener, + ): void { + switch (type) { + case "error": { + const adapter = (event: ErrorEvent): void => { + listener(event); + }; + errorListeners.set(listener, adapter); + worker.addEventListener("error", adapter); + return; + } + case "message": { + const adapter = (event: MessageEvent): void => { + listener(event); + }; + messageListeners.set(listener, adapter); + worker.addEventListener("message", adapter); + return; + } + case "messageerror": { + const adapter = (event: MessageEvent): void => { + listener(event); + }; + messageErrorListeners.set(listener, adapter); + worker.addEventListener("messageerror", adapter); + } + } + }, + postMessage(message: unknown): void { + worker.postMessage(message); + }, + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: Listener, + ): void { + switch (type) { + case "error": { + const adapter = errorListeners.get(listener); + if (adapter !== undefined) { + errorListeners.delete(listener); + worker.removeEventListener("error", adapter); + } + return; + } + case "message": { + const adapter = messageListeners.get(listener); + if (adapter !== undefined) { + messageListeners.delete(listener); + worker.removeEventListener("message", adapter); + } + return; + } + case "messageerror": { + const adapter = messageErrorListeners.get(listener); + if (adapter !== undefined) { + messageErrorListeners.delete(listener); + worker.removeEventListener("messageerror", adapter); + } + } + } + }, + terminate(): void { + worker.terminate(); + errorListeners.clear(); + messageListeners.clear(); + messageErrorListeners.clear(); + }, + }; +} + +function createSilentWorker(): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "./fixtures/node-sql-parser-silent-worker.js", + import.meta.url, + ), + { + name: "codemirror-sql-parser-silent-test", + type: "module", + }, + ), + ); +} + +function createCrashWorker(): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "./fixtures/node-sql-parser-crash-worker.js", + import.meta.url, + ), + { + name: "codemirror-sql-parser-crash-test", + type: "module", + }, + ), + ); +} + +function createRecoveryWorker( + name: string, +): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "../node-sql-parser-browser-worker.ts", + import.meta.url, + ), + { name, type: "module" }, + ), + ); +} + +function submitQuery( + executor: NodeSqlParserBrowserExecutor, + grammar: "bigquery" | "postgresql", + text: string, +) { + return executor.submit({ grammar, text }).result; +} + +test( + "runs sequential cold, warm, and cross-grammar work through the production worker", + { timeout: 15_000 }, + async () => { + const executor = + createNodeSqlParserBrowserExecutor(REAL_WORKER_LIMITS); + try { + await expect( + submitQuery( + executor, + "postgresql", + "SELECT 1 AS cold_value", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "postgresql", + "SELECT 2 AS warm_value", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "bigquery", + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + } finally { + executor.dispose(); + } + }, +); + +test( + "retires a silent active parse and serves later work on a fresh generation", + { timeout: 10_000 }, + async () => { + let generation = 0; + const executor = createNodeSqlParserBrowserExecutor({ + ...FAILURE_WORKER_LIMITS, + workerFactory: () => { + generation += 1; + return generation === 1 + ? createSilentWorker() + : createRecoveryWorker( + "codemirror-sql-parser-recovery-test", + ); + }, + }); + try { + await expect( + submitQuery(executor, "postgresql", "SELECT 1"), + ).resolves.toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + await expect( + submitQuery(executor, "postgresql", "SELECT 2"), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(generation).toBe(2); + } finally { + executor.dispose(); + } + }, +); + +test( + "retires a crashed active parse and serves later work on a fresh generation", + { timeout: 10_000 }, + async () => { + let generation = 0; + const executor = createNodeSqlParserBrowserExecutor({ + ...FAILURE_WORKER_LIMITS, + workerFactory: () => { + generation += 1; + return generation === 1 + ? createCrashWorker() + : createRecoveryWorker( + "codemirror-sql-parser-crash-recovery-test", + ); + }, + }); + try { + await expect( + submitQuery(executor, "postgresql", "SELECT 1"), + ).resolves.toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + await expect( + submitQuery(executor, "bigquery", "SELECT 2"), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(generation).toBe(2); + } finally { + executor.dispose(); + } + }, +); diff --git a/src/vnext/node-sql-parser-browser-executor.ts b/src/vnext/node-sql-parser-browser-executor.ts new file mode 100644 index 0000000..f250ec6 --- /dev/null +++ b/src/vnext/node-sql-parser-browser-executor.ts @@ -0,0 +1,1132 @@ +import { + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, +} from "./node-sql-parser-backend.js"; +import { + decodeNodeSqlParserWireMessage, + encodeNodeSqlParserWireRequest, + type NodeSqlParserWireFailureCode, + type NodeSqlParserWireGrammar, +} from "./node-sql-parser-wire.js"; +import type { SqlStatementKind } from "./syntax.js"; + +export interface NodeSqlParserBrowserExecutorLimits { + readonly executionDeadlineMs: number; + readonly maxQueuedRequests: number; + readonly maxQueuedTextUnits: number; + readonly queueDeadlineMs: number; + readonly startupDeadlineMs: number; +} + +export type NodeSqlParserBrowserExecutorEventType = + | "error" + | "message" + | "messageerror"; + +export interface NodeSqlParserBrowserExecutorWorker { + readonly addEventListener: ( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ) => void; + readonly postMessage: (message: unknown) => void; + readonly removeEventListener: ( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ) => void; + readonly terminate: () => void; +} + +export interface NodeSqlParserBrowserExecutorDeadlineScheduler { + readonly clearTimeout: (handle: unknown) => void; + readonly setTimeout: ( + callback: () => void, + delayMs: number, + ) => unknown; +} + +export interface NodeSqlParserBrowserExecutorOptions + extends NodeSqlParserBrowserExecutorLimits { + readonly deadlineScheduler?: NodeSqlParserBrowserExecutorDeadlineScheduler; + readonly requestIdStart?: number; + readonly workerFactory?: () => NodeSqlParserBrowserExecutorWorker; +} + +export interface NodeSqlParserBrowserExecutorInput { + readonly grammar: NodeSqlParserWireGrammar; + readonly text: string; +} + +export type NodeSqlParserBrowserExecutorFailureCode = + | NodeSqlParserWireFailureCode + | "disposed" + | "execution-timeout" + | "protocol-error" + | "queue-limit" + | "queue-timeout" + | "startup-timeout" + | "worker-failure"; + +export type NodeSqlParserBrowserExecutorOutcome = + | { + readonly kind: "parsed"; + readonly statementKind: SqlStatementKind; + } + | { + readonly kind: "syntax-rejected"; + } + | { + readonly kind: "unsupported"; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly kind: "failed"; + readonly code: NodeSqlParserBrowserExecutorFailureCode; + } + | { + readonly kind: "cancelled"; + }; + +export interface NodeSqlParserBrowserExecutorSubmission { + readonly cancel: () => void; + readonly result: Promise; +} + +export interface NodeSqlParserBrowserExecutor { + readonly dispose: () => void; + readonly submit: ( + input: NodeSqlParserBrowserExecutorInput, + ) => NodeSqlParserBrowserExecutorSubmission; +} + +interface Deadline { + readonly handle: unknown; +} + +interface DetachedQueueEntry { + readonly deadline: Deadline | null; + readonly entry: QueueEntry; +} + +interface DetachedActiveRequest { + readonly deadline: Deadline | null; + readonly request: ActiveRequest; +} + +interface RevokedGeneration { + readonly startupDeadline: Deadline | null; + readonly target: WorkerGeneration; +} + +interface QueueEntry { + consumerSettled: boolean; + grammar: NodeSqlParserWireGrammar; + location: "active" | "done" | "queued"; + queueDeadline: Deadline | null; + resolve: + | ((outcome: NodeSqlParserBrowserExecutorOutcome) => void) + | null; + text: string; + textUnits: number; +} + +interface ActiveRequest { + draining: boolean; + readonly entry: QueueEntry; + executionDeadline: Deadline | null; + readonly generation: WorkerGeneration; + posted: boolean; + readonly requestId: number; +} + +interface WorkerGeneration { + readonly onFailure: (event: unknown) => void; + readonly onMessage: (event: unknown) => void; + startupDeadline: Deadline | null; + state: "ready" | "retired" | "starting"; + readonly worker: NodeSqlParserBrowserExecutorWorker; +} + +interface NormalizedOptions extends NodeSqlParserBrowserExecutorLimits { + readonly deadlineScheduler: NodeSqlParserBrowserExecutorDeadlineScheduler; + readonly requestIdStart: number; + readonly workerFactory: () => NodeSqlParserBrowserExecutorWorker; +} + +const DEFAULT_DEADLINE_SCHEDULER: NodeSqlParserBrowserExecutorDeadlineScheduler = + Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(globalThis.clearTimeout, globalThis, [handle]); + }, + setTimeout(callback: () => void, delayMs: number): unknown { + return globalThis.setTimeout(callback, delayMs); + }, + }); + +function createDefaultWorker(): NodeSqlParserBrowserExecutorWorker { + const worker = new Worker( + new URL("./node-sql-parser-browser-worker.js", import.meta.url), + { name: "codemirror-sql-parser", type: "module" }, + ); + return Object.freeze({ + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): void { + worker.addEventListener(type, listener); + }, + postMessage(message: unknown): void { + worker.postMessage(message); + }, + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): void { + worker.removeEventListener(type, listener); + }, + terminate(): void { + worker.terminate(); + }, + }); +} + +function requirePositiveSafeInteger( + value: number, + label: string, +): void { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 + ) { + throw new TypeError(`${label} must be a positive safe integer`); + } +} + +function requireDeadline(value: number, label: string): void { + requirePositiveSafeInteger(value, label); + if (value > 2_147_483_647) { + throw new TypeError(`${label} exceeds the platform timer limit`); + } +} + +function normalizeDeadlineScheduler( + scheduler: + | NodeSqlParserBrowserExecutorDeadlineScheduler + | undefined, +): NodeSqlParserBrowserExecutorDeadlineScheduler { + if (scheduler === undefined) { + return DEFAULT_DEADLINE_SCHEDULER; + } + let clearTimeoutMethod: (handle: unknown) => void; + let setTimeoutMethod: ( + callback: () => void, + delayMs: number, + ) => unknown; + try { + clearTimeoutMethod = scheduler.clearTimeout; + setTimeoutMethod = scheduler.setTimeout; + if ( + typeof clearTimeoutMethod !== "function" || + typeof setTimeoutMethod !== "function" + ) { + throw new TypeError(); + } + } catch { + throw new TypeError( + "deadlineScheduler must implement deadlines", + ); + } + return Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(clearTimeoutMethod, scheduler, [handle]); + }, + setTimeout(callback: () => void, delayMs: number): unknown { + return Reflect.apply(setTimeoutMethod, scheduler, [ + callback, + delayMs, + ]); + }, + }); +} + +function normalizeOptions( + options: NodeSqlParserBrowserExecutorOptions, +): NormalizedOptions { + let deadlineScheduler: + | NodeSqlParserBrowserExecutorDeadlineScheduler + | undefined; + let executionDeadlineMs: number; + let maxQueuedRequests: number; + let maxQueuedTextUnits: number; + let queueDeadlineMs: number; + let requestIdStart: number | undefined; + let startupDeadlineMs: number; + let workerFactory: + | (() => NodeSqlParserBrowserExecutorWorker) + | undefined; + try { + deadlineScheduler = options.deadlineScheduler; + executionDeadlineMs = options.executionDeadlineMs; + maxQueuedRequests = options.maxQueuedRequests; + maxQueuedTextUnits = options.maxQueuedTextUnits; + queueDeadlineMs = options.queueDeadlineMs; + requestIdStart = options.requestIdStart; + startupDeadlineMs = options.startupDeadlineMs; + workerFactory = options.workerFactory; + } catch { + throw new TypeError("invalid parser executor options"); + } + requireDeadline( + executionDeadlineMs, + "executionDeadlineMs", + ); + requirePositiveSafeInteger( + maxQueuedRequests, + "maxQueuedRequests", + ); + requirePositiveSafeInteger( + maxQueuedTextUnits, + "maxQueuedTextUnits", + ); + requireDeadline( + queueDeadlineMs, + "queueDeadlineMs", + ); + requireDeadline( + startupDeadlineMs, + "startupDeadlineMs", + ); + if (requestIdStart !== undefined) { + requirePositiveSafeInteger(requestIdStart, "requestIdStart"); + } + if ( + workerFactory !== undefined && + typeof workerFactory !== "function" + ) { + throw new TypeError("workerFactory must be a function"); + } + return Object.freeze({ + deadlineScheduler: normalizeDeadlineScheduler( + deadlineScheduler, + ), + executionDeadlineMs, + maxQueuedRequests, + maxQueuedTextUnits, + queueDeadlineMs, + requestIdStart: requestIdStart ?? 1, + startupDeadlineMs, + workerFactory: workerFactory ?? createDefaultWorker, + }); +} + +function requireInput( + input: NodeSqlParserBrowserExecutorInput, +): NodeSqlParserBrowserExecutorInput { + try { + const grammar = input.grammar; + const text = input.text; + if ( + (grammar !== "bigquery" && grammar !== "postgresql") || + typeof text !== "string" + ) { + throw new TypeError("invalid parser executor input"); + } + return Object.freeze({ + grammar, + text, + }); + } catch { + throw new TypeError("invalid parser executor input"); + } +} + +function failedOutcome( + code: NodeSqlParserBrowserExecutorFailureCode, +): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ code, kind: "failed" }); +} + +function cancelledOutcome(): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ kind: "cancelled" }); +} + +function resourceLimitOutcome(): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ + kind: "unsupported", + reason: "resource-limit", + }); +} + +function readMessageData(event: unknown): unknown { + if (typeof event !== "object" || event === null) { + return undefined; + } + try { + return Reflect.get(event, "data"); + } catch { + return undefined; + } +} + +function preventDefault(event: unknown): void { + if (typeof event !== "object" || event === null) { + return; + } + try { + const method = Reflect.get(event, "preventDefault"); + if (typeof method === "function") { + Reflect.apply(method, event, []); + } + } catch { + // Worker failures remain closed for hostile event objects. + } +} + +function isWorkerIdentity( + value: unknown, +): value is object { + return typeof value === "object" && value !== null; +} + +export function createNodeSqlParserBrowserExecutor( + options: NodeSqlParserBrowserExecutorOptions, +): NodeSqlParserBrowserExecutor { + const normalized = normalizeOptions(options); + + const limits = Object.freeze({ + executionDeadlineMs: normalized.executionDeadlineMs, + maxQueuedRequests: normalized.maxQueuedRequests, + maxQueuedTextUnits: normalized.maxQueuedTextUnits, + queueDeadlineMs: normalized.queueDeadlineMs, + startupDeadlineMs: normalized.startupDeadlineMs, + }); + const scheduler = normalized.deadlineScheduler; + const workerFactory = normalized.workerFactory; + + let active: ActiveRequest | null = null; + let creatingWorker = false; + let disposed = false; + let generation: WorkerGeneration | null = null; + let nextRequestId = normalized.requestIdStart; + let pumping = false; + let pumpRequested = false; + let queuedTextUnits = 0; + let retirementDepth = 0; + let terminalFailure: + | "protocol-error" + | "worker-failure" + | null = null; + const queue: QueueEntry[] = []; + const seenWorkers = new WeakSet(); + + function clearDeadline(deadline: Deadline | null): void { + if (deadline === null) { + return; + } + try { + scheduler.clearTimeout(deadline.handle); + } catch { + // Deadline cleanup cannot be allowed to expose host failures. + } + } + + function scheduleDeadline( + callback: () => void, + delayMs: number, + ): Deadline | null { + let fired = false; + let handle: unknown; + try { + handle = scheduler.setTimeout(() => { + fired = true; + callback(); + }, delayMs); + } catch { + callback(); + return null; + } + if (fired) { + clearDeadline({ handle }); + return null; + } + return { handle }; + } + + function settleConsumer( + entry: QueueEntry, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + if (entry.consumerSettled) { + return; + } + entry.consumerSettled = true; + const resolve = entry.resolve; + entry.resolve = null; + resolve?.(outcome); + } + + function removeQueuedEntry(entry: QueueEntry): boolean { + if (entry.location !== "queued") { + return false; + } + const index = queue.indexOf(entry); + if (index < 0) { + return false; + } + queue.splice(index, 1); + queuedTextUnits -= entry.textUnits; + entry.location = "done"; + const deadline = entry.queueDeadline; + entry.queueDeadline = null; + entry.text = ""; + entry.textUnits = 0; + clearDeadline(deadline); + return true; + } + + function detachAllQueued(): readonly DetachedQueueEntry[] { + const entries = queue.splice(0); + queuedTextUnits = 0; + return entries.map((entry) => { + const deadline = entry.queueDeadline; + entry.location = "done"; + entry.queueDeadline = null; + entry.text = ""; + entry.textUnits = 0; + return { deadline, entry }; + }); + } + + function cleanupDetachedQueue( + detached: readonly DetachedQueueEntry[], + ): void { + for (const { deadline } of detached) { + clearDeadline(deadline); + } + } + + function settleDetachedQueue( + detached: readonly DetachedQueueEntry[], + createOutcome: () => NodeSqlParserBrowserExecutorOutcome, + ): void { + cleanupDetachedQueue(detached); + for (const { entry } of detached) { + settleConsumer(entry, createOutcome()); + } + } + + function detachActiveRequest( + target?: WorkerGeneration, + ): DetachedActiveRequest | null { + const request = active; + if ( + request === null || + (target !== undefined && request.generation !== target) + ) { + return null; + } + active = null; + const deadline = request.executionDeadline; + request.executionDeadline = null; + request.entry.location = "done"; + request.entry.text = ""; + request.entry.textUnits = 0; + return { deadline, request }; + } + + function cleanupDetachedActive( + detached: DetachedActiveRequest | null, + ): void { + if (detached !== null) { + clearDeadline(detached.deadline); + } + } + + function settleDetachedActive( + detached: DetachedActiveRequest | null, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + cleanupDetachedActive(detached); + if (detached !== null && !detached.request.draining) { + settleConsumer(detached.request.entry, outcome); + } + } + + function revokeGeneration( + target: WorkerGeneration, + ): RevokedGeneration | null { + if (target.state === "retired" || generation !== target) { + return null; + } + target.state = "retired"; + generation = null; + const startupDeadline = target.startupDeadline; + target.startupDeadline = null; + return { startupDeadline, target }; + } + + function cleanupRevokedGeneration( + revoked: RevokedGeneration | null, + ): boolean { + if (revoked === null) { + return true; + } + clearDeadline(revoked.startupDeadline); + removeGenerationListeners(revoked.target); + try { + revoked.target.worker.terminate(); + return true; + } catch { + return false; + } + } + + function rejectAllQueued( + createOutcome: () => NodeSqlParserBrowserExecutorOutcome, + ): void { + const detached = detachAllQueued(); + settleDetachedQueue(detached, createOutcome); + } + + function enterTerminalFailure( + code: "protocol-error" | "worker-failure", + ): void { + if (terminalFailure !== null || disposed) { + return; + } + terminalFailure = code; + rejectAllQueued(() => failedOutcome(code)); + } + + function removeGenerationListeners( + target: WorkerGeneration, + ): void { + for (const type of [ + "message", + "error", + "messageerror", + ] as const) { + try { + target.worker.removeEventListener( + type, + type === "message" ? target.onMessage : target.onFailure, + ); + } catch { + // Termination below remains authoritative. + } + } + } + + function installGenerationListener( + target: WorkerGeneration, + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): boolean { + try { + target.worker.addEventListener(type, listener); + } catch { + failGeneration(target, "worker-failure"); + try { + target.worker.removeEventListener(type, listener); + } catch { + // The retired generation is never reused. + } + return false; + } + if (generation === target && target.state !== "retired") { + return true; + } + try { + target.worker.removeEventListener(type, listener); + } catch { + // The retired generation is never reused. + } + return false; + } + + function startGeneration(): void { + if ( + disposed || + terminalFailure !== null || + creatingWorker || + generation !== null || + queue.length === 0 || + retirementDepth > 0 + ) { + return; + } + + let worker: NodeSqlParserBrowserExecutorWorker; + creatingWorker = true; + try { + worker = workerFactory(); + } catch { + creatingWorker = false; + rejectAllQueued(() => failedOutcome("worker-failure")); + return; + } + if ( + !isWorkerIdentity(worker) || + seenWorkers.has(worker) + ) { + creatingWorker = false; + rejectAllQueued(() => failedOutcome("worker-failure")); + return; + } + seenWorkers.add(worker); + creatingWorker = false; + if (disposed || generation !== null || queue.length === 0) { + try { + worker.terminate(); + } catch { + enterTerminalFailure("worker-failure"); + } + return; + } + + const target: WorkerGeneration = { + onFailure: (event) => { + if (generation !== target || target.state === "retired") { + return; + } + failGeneration(target, "worker-failure", () => { + preventDefault(event); + }); + }, + onMessage: (event) => { + if (generation !== target || target.state === "retired") { + return; + } + receiveMessage(target, readMessageData(event)); + }, + startupDeadline: null, + state: "starting", + worker, + }; + generation = target; + + const deadline = scheduleDeadline(() => { + if (generation === target && target.state === "starting") { + failGeneration(target, "startup-timeout"); + } + }, limits.startupDeadlineMs); + if (generation !== target || target.state !== "starting") { + clearDeadline(deadline); + return; + } + target.startupDeadline = deadline; + + if ( + !installGenerationListener( + target, + "error", + target.onFailure, + ) || + !installGenerationListener( + target, + "messageerror", + target.onFailure, + ) || + !installGenerationListener( + target, + "message", + target.onMessage, + ) + ) { + return; + } + } + + function failGeneration( + target: WorkerGeneration, + code: NodeSqlParserBrowserExecutorFailureCode, + afterRevocation?: () => void, + ): void { + if (generation !== target || target.state === "retired") { + return; + } + retirementDepth += 1; + try { + const failedDuringStartup = target.state === "starting"; + const detachedActive = detachActiveRequest(target); + const detachedQueue = failedDuringStartup + ? detachAllQueued() + : []; + const revoked = revokeGeneration(target); + + cleanupDetachedActive(detachedActive); + cleanupDetachedQueue(detachedQueue); + const retired = cleanupRevokedGeneration(revoked); + afterRevocation?.(); + if (!retired) { + enterTerminalFailure("worker-failure"); + } + + for (const { entry } of detachedQueue) { + settleConsumer(entry, failedOutcome(code)); + } + if ( + detachedActive !== null && + !detachedActive.request.draining + ) { + settleConsumer( + detachedActive.request.entry, + failedOutcome(code), + ); + } + } finally { + retirementDepth -= 1; + pump(); + } + } + + function finishActive( + request: ActiveRequest, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + const detached = detachActiveRequest(request.generation); + settleDetachedActive(detached, outcome); + pump(); + } + + function receiveMessage( + target: WorkerGeneration, + data: unknown, + ): void { + const message = decodeNodeSqlParserWireMessage(data); + if (message === null) { + failGeneration(target, "protocol-error"); + return; + } + + if (message.kind === "ready") { + if (target.state !== "starting" || active !== null) { + failGeneration(target, "protocol-error"); + return; + } + target.state = "ready"; + const startupDeadline = target.startupDeadline; + target.startupDeadline = null; + clearDeadline(startupDeadline); + if (generation === target && target.state === "ready") { + pump(); + } + return; + } + + if (message.kind === "protocol-error") { + failGeneration(target, "protocol-error"); + return; + } + + const request = active; + if ( + target.state !== "ready" || + request === null || + request.generation !== target || + !request.posted || + message.requestId !== request.requestId + ) { + failGeneration(target, "protocol-error"); + return; + } + + switch (message.kind) { + case "parsed": + finishActive( + request, + Object.freeze({ + kind: "parsed", + statementKind: message.statementKind, + }), + ); + return; + case "syntax-rejected": + finishActive( + request, + Object.freeze({ kind: "syntax-rejected" }), + ); + return; + case "unsupported": + finishActive( + request, + Object.freeze({ + kind: "unsupported", + reason: message.reason, + }), + ); + return; + case "failed": { + failGeneration(target, message.code); + return; + } + } + } + + function allocateRequestId(): number | null { + if (nextRequestId > Number.MAX_SAFE_INTEGER) { + return null; + } + const requestId = nextRequestId; + nextRequestId += 1; + return requestId; + } + + function pumpOnce(): void { + if ( + disposed || + terminalFailure !== null || + active !== null || + queue.length === 0 + ) { + return; + } + if (generation === null) { + startGeneration(); + return; + } + if (generation.state !== "ready") { + return; + } + + const requestId = allocateRequestId(); + if (requestId === null) { + retirementDepth += 1; + try { + terminalFailure = "protocol-error"; + const detachedQueue = detachAllQueued(); + const revoked = revokeGeneration(generation); + cleanupDetachedQueue(detachedQueue); + cleanupRevokedGeneration(revoked); + for (const { entry } of detachedQueue) { + settleConsumer( + entry, + failedOutcome("protocol-error"), + ); + } + } finally { + retirementDepth -= 1; + } + return; + } + + const entry = queue.shift(); + if (entry === undefined) { + return; + } + const target = generation; + const queueDeadline = entry.queueDeadline; + queuedTextUnits -= entry.textUnits; + entry.queueDeadline = null; + entry.location = "active"; + const request: ActiveRequest = { + draining: false, + entry, + executionDeadline: null, + generation: target, + posted: false, + requestId, + }; + active = request; + clearDeadline(queueDeadline); + if ( + disposed || + generation !== target || + target.state !== "ready" || + active !== request + ) { + return; + } + + const executionDeadline = scheduleDeadline(() => { + if ( + generation === target && + active === request && + target.state === "ready" + ) { + failGeneration(target, "execution-timeout"); + } + }, limits.executionDeadlineMs); + if (generation === target && active === request) { + request.executionDeadline = executionDeadline; + } else { + clearDeadline(executionDeadline); + return; + } + + let postMessage: (message: unknown) => void; + try { + postMessage = target.worker.postMessage; + if (typeof postMessage !== "function") { + throw new TypeError("worker postMessage must be callable"); + } + } catch { + failGeneration(target, "worker-failure"); + return; + } + if ( + disposed || + generation !== target || + target.state !== "ready" || + active !== request + ) { + return; + } + + try { + const wireRequest = encodeNodeSqlParserWireRequest( + entry.grammar, + requestId, + entry.text, + ); + request.posted = true; + Reflect.apply(postMessage, target.worker, [wireRequest]); + entry.text = ""; + entry.textUnits = 0; + } catch { + entry.text = ""; + entry.textUnits = 0; + failGeneration(target, "worker-failure"); + } + } + + function pump(): void { + pumpRequested = true; + if (pumping || retirementDepth > 0) { + return; + } + pumping = true; + try { + while (pumpRequested) { + pumpRequested = false; + pumpOnce(); + } + } finally { + pumping = false; + } + } + + function cancel(entry: QueueEntry): void { + if (entry.location === "queued") { + if (removeQueuedEntry(entry)) { + settleConsumer(entry, cancelledOutcome()); + } + return; + } + if ( + entry.location === "active" && + active !== null && + active.entry === entry + ) { + active.draining = true; + if (active.posted) { + entry.text = ""; + entry.textUnits = 0; + } + settleConsumer(entry, cancelledOutcome()); + } + } + + function immediateSubmission( + outcome: NodeSqlParserBrowserExecutorOutcome, + ): NodeSqlParserBrowserExecutorSubmission { + return Object.freeze({ + cancel(): void {}, + result: Promise.resolve(outcome), + }); + } + + function submit( + rawInput: NodeSqlParserBrowserExecutorInput, + ): NodeSqlParserBrowserExecutorSubmission { + const input = requireInput(rawInput); + if (disposed) { + return immediateSubmission(failedOutcome("disposed")); + } + if (terminalFailure !== null) { + return immediateSubmission(failedOutcome(terminalFailure)); + } + if (input.text.length > MAX_NODE_SQL_PARSER_STATEMENT_LENGTH) { + return immediateSubmission(resourceLimitOutcome()); + } + if ( + queue.length >= limits.maxQueuedRequests || + input.text.length > + limits.maxQueuedTextUnits - queuedTextUnits + ) { + return immediateSubmission(failedOutcome("queue-limit")); + } + + let resolve: + | ((outcome: NodeSqlParserBrowserExecutorOutcome) => void) + | undefined; + const result = new Promise( + (settle) => { + resolve = settle; + }, + ); + if (resolve === undefined) { + throw new Error("parser executor promise was not initialized"); + } + const entry: QueueEntry = { + consumerSettled: false, + grammar: input.grammar, + location: "queued", + queueDeadline: null, + resolve, + text: input.text, + textUnits: input.text.length, + }; + queue.push(entry); + queuedTextUnits += entry.textUnits; + + const queueDeadline = scheduleDeadline(() => { + if (removeQueuedEntry(entry)) { + settleConsumer(entry, failedOutcome("queue-timeout")); + } + }, limits.queueDeadlineMs); + if (entry.location === "queued") { + entry.queueDeadline = queueDeadline; + } else { + clearDeadline(queueDeadline); + } + pump(); + + return Object.freeze({ + cancel: () => { + cancel(entry); + }, + result, + }); + } + + function dispose(): void { + if (disposed) { + return; + } + disposed = true; + const target = generation; + const detachedActive = detachActiveRequest(); + const detachedQueue = detachAllQueued(); + const revoked = + target === null ? null : revokeGeneration(target); + + cleanupDetachedActive(detachedActive); + cleanupDetachedQueue(detachedQueue); + cleanupRevokedGeneration(revoked); + + for (const { entry } of detachedQueue) { + settleConsumer(entry, failedOutcome("disposed")); + } + if ( + detachedActive !== null && + !detachedActive.request.draining + ) { + settleConsumer( + detachedActive.request.entry, + failedOutcome("disposed"), + ); + } + } + + return Object.freeze({ dispose, submit }); +} From 88f9c6fcc68bd97126de2a4ac6b84fae02934446 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 05:19:17 +0800 Subject: [PATCH 13/42] docs(vnext): accept parser-independent relation completion (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - accept ADR 0005 for parser-independent relation completion - keep the first completion slice independent of parser acceptance and parser-worker startup - define a bounded partial-`SELECT` query-site recognizer and narrow CTE visibility model - define one provider-owned catalog boundary, canonical suffix rendering, epochs, cache identity, deadlines, cancellation, and deterministic composition - define atomic text/context/embedded-region transactions and service-originated revision notifications - define CodeMirror ownership, rich-info decoration, loading refresh intent, and marimo 1/10/50-view acceptance evidence - keep provider/completion declarations provisional until the vertical slice, two provider shapes, packed marimo integration, and hostile lifecycle suites pass ## Key decisions - incomplete relation completion does not depend on `node-sql-parser` - catalog matching/addressability belongs to the provider and scope; dialect runtime owns SQL decoding and role-aware rendering - catalog edits replace the authenticated whole typed path with a provider-proven canonical suffix - arbitrary embedded regions are grammar barriers; recovered sites remain explicitly heuristic and incomplete - one atomic shared-work owner set covers request consumers and refresh observers - hard safety deadlines are separate from the 40 ms default interactive catalog response budget - terminal and soft loading results carry bounded one-shot completion-intent leases - higher epochs discovered by responses or invalidations preserve active/menu/no-menu completion intent exactly once - completion search misses never prove unknown-object diagnostics; authoritative resolution is separate ## Validation - patch rebased onto merged executor checkpoint `873c926` - all TypeScript configurations pass - oxlint passes with zero findings - test integrity passes - 1,248 Node tests pass plus 1 expected negative - three specialist adversarial review loops covered SQL/API correctness, marimo ergonomics, and concurrency/performance - final exact-head approvals are recorded for `d66ea25` ## Follow-up The first implementation change is the runtime-free marimo-shaped type fixture. No production completion slice starts until that fixture compiles. --- ## Summary by cubic Accepts ADR 0005 to provide parser‑independent relation completion with clear catalog/provider contracts, CTE visibility, and deterministic lifecycle. Completion now works for incomplete FROM/JOIN without waiting for `node-sql-parser` or worker startup, and keeps `CodeMirror` integration separate. - New Features - Decoupled relation completion from parser/worker; added bounded partial-SELECT site and CTE-visibility recognizers. - Defined catalog provider boundary: scope/search paths in; canonical paths with `completionPathStart`, epochs, and coverage out. - Added atomic text/context/embedded-region transactions and `onDidChange` session notifications for service-originated revisions. - Clarified dialect-owned identifier decoding/quoting and role-aware rendering; no generic folding/dedup in the service. - Specified deterministic ranking, latest-wins cancellation, deadlines (queue/exec), cache identity, and one-shot refresh intent. - Documented `CodeMirror` adapter boundary, safe coalesced refresh on `catalog-availability`, and no worker construction for this slice. - Migration - Pass a complete embedded-region set on open/update; regions, text, and context update atomically. - Implement the new catalog provider contract (or use `createInMemoryRelationCatalog`); do not emit SQL text—return decoded paths and roles. - Do not gate completion on parser readiness; keep the parser worker and scoped semantics for future slices. - Subscribe to `session.onDidChange` and coalesce refresh on `catalog-availability`; respect the provided completion-intent lease. Written for commit d66ea252a428dc01c706ca45580dda89a9beec79. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 102 ++- docs/adr/0004-isolated-parser-execution.md | 26 +- ...-parser-independent-relation-completion.md | 736 ++++++++++++++++++ docs/vnext/capability-charter.md | 18 +- implementation.md | 32 +- 5 files changed, 862 insertions(+), 52 deletions(-) create mode 100644 docs/adr/0005-parser-independent-relation-completion.md diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index f838e87..bca9c87 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -37,7 +37,11 @@ debounce, dispatch, and session disposal. Shared providers contain no request-local mutable state. -## Stable introductory shape +## Target introductory shape + +The service/session lifecycle in this section is stable direction. Catalog, +search-path, embedded-region, completion, and service-originated notification +spellings remain provisional until ADR 0005's stabilization gate. The target consumer shape is: @@ -64,6 +68,7 @@ const service = createSqlLanguageService({ const session = service.openDocument({ text: "SELECT * FROM users", + embeddedRegions: [], context: { dialect: duckdb.id, engine: "__marimo__", @@ -104,11 +109,19 @@ interface SqlDocumentContext { readonly dialect: SqlDialectId; readonly catalog?: { readonly scope: string; - readonly searchPath?: readonly string[]; + readonly searchPath?: readonly (readonly { + readonly value: string; + readonly quoted: boolean; + }[])[]; }; } ``` +Search paths are ordered component paths rather than dot-joined strings. +Consequently a quoted identifier containing a dot is never confused with two +path components. ADR 0005 keeps the exact public spelling provisional until +the catalog vertical slice and consumer fixtures pass. + The service resolves dialect IDs through its immutable registry and rejects unknown or duplicate IDs. Duplicate registration is rejected even when the definitions appear equivalent. The built-in dialect factories return frozen @@ -130,28 +143,38 @@ complete operation without mutation. The accepted snapshot is recursively frozen and is the only context observed by providers. Caller mutation therefore cannot change an in-flight request. Providers never read ambient editor state. -The session updates text and context atomically: +The session updates text, context, and embedded regions atomically: ```ts session.update({ - kind: "document", baseRevision: session.revision, document: { kind: "changes", changes: [{ from: 14, to: 19, insert: "customers" }], }, context: nextContext, + embeddedRegions: nextEmbeddedRegions, }); ``` +A transaction contains `baseRevision` and any non-empty subset of `document`, +`context`, and `embeddedRegions`. A document mutation requires the complete +region set for its resulting text. Without a document mutation, an explicit +complete region set can change alone or together with context; omission +preserves the current regions. An explicit empty set clears them. Opening a +document accepts the same optional complete set, with omission or an empty set +meaning identity source. + A full-text replacement is available for simple consumers. Incremental changes are ordered, non-overlapping, absolute UTF-16 ranges in the current document. -The service validates the base revision and every range before mutating state. +The service validates the base revision, every edit, context, and the complete +resulting region set before mutating state. The transaction either commits one +revision or changes nothing; callers never need a fake no-op document +replacement to change template metadata. -A context-only update is valid and also requires `baseRevision`. Accepted -updates always create a new revision, including an `A → B → A` text cycle, so a -previous result never becomes current again. A stale base rejects the complete -update without partial mutation. +Accepted updates always create a new revision, including an `A → B → A` text +cycle, so a previous result never becomes current again. A stale base rejects +the complete update without partial mutation. ## Revision and applicability @@ -311,11 +334,26 @@ interface SqlDocumentSession { request?: SqlFormatRequest, ) => Promise>; + readonly onDidChange: ( + listener: (event: { + readonly revision: SqlRevision; + readonly reason: + | "catalog" + | "catalog-availability" + | "provider-configuration"; + }) => void, + ) => { readonly dispose: () => void }; + readonly isCurrent: (revision: SqlRevision) => boolean; readonly dispose: () => void; } ``` +`onDidChange` reports service-originated revision advances that do not come +from the host's own document transaction. State changes before notification; +listener failures are isolated; and both subscription and session disposal are +idempotent. ADR 0005 specifies the first catalog use. + The walking skeleton exposes only implemented features. Adding a method is backward-compatible; returning placeholder success from an unimplemented method is not. @@ -325,13 +363,13 @@ is not. Providers are narrow and async-only. Pure range, change, identifier, and statement-index primitives remain synchronous. -Provider requests contain: - -- Immutable source snapshot with distinctly named `originalText`, - `analysisText`, and source mapping -- Explicit document context -- Provider configuration identity -- `AbortSignal` +Each provider receives only the minimum feature-specific immutable projection. +Document-oriented validators may receive a distinctly named `originalText` or +`analysisText`, source mapping, and their declared context projection. Catalog +search receives only the catalog/query projection in ADR 0005. No provider +automatically receives the full source snapshot or arbitrary host context. +Provider configuration identity remains service-owned, and `AbortSignal` is +passed separately. Each validator declares both `granularity: "document" | "statement"` and `input: "original" | "analysis"`. Regardless of input form, returned public @@ -353,7 +391,8 @@ validates ranges and result limits before arbitration. Authority and arbitration are feature-specific: -- Completion merges, deduplicates, and deterministically ranks. +- Completion composes and deterministically ranks under its declared identity + rules; it never applies a generic catalog fold or deduplication policy. - Diagnostics use coverage and authority; they are not blindly unioned. - Hover can select or attribute sections. - Definitions use precedence, confidence, and provenance. @@ -363,15 +402,12 @@ Provider completion order cannot affect final ordering. ## Parser-neutral artifacts -The walking skeleton normalizes only evidence needed by its first vertical -slice: - -- Statement ranges and state -- Tokens -- Diagnostics -- Normalized relation references -- Cursor-context facts -- Exact completion replacement range +The parser walking skeleton normalizes only bounded syntax evidence it can +prove, initially statement kind, syntax acceptance/rejection, authenticated +source locations, and explicit availability/limitations. Parser-independent +statement indexing and relation completion keep their own smaller artifacts. +The parser does not manufacture relation references, cursor-context facts, or +completion ranges for ADR 0005. It does not expose `ast?: unknown` or attempt a universal SQL AST. @@ -387,9 +423,10 @@ Catalog access is lazy and bounded. Providers search and resolve within an explicit scope, prefix, object-kind set, and service-clamped result limit. Responses carry stable entity IDs, provider epoch, pagination, and coverage. -Empty-complete and empty-loading are distinct. A miss supports a definite -unknown-object diagnostic only when the response proves complete coverage for -the searched scope. +Empty-complete and empty-loading are distinct. A complete-empty completion +search proves only that the exact search produced no candidates. Only a +separate authoritative resolution contract with explicit complete resolution +coverage may support a definite unknown-object diagnostic. Catalog subscriptions are scoped and disposable. An invalidation identifies provider ID, affected scope, and: @@ -421,9 +458,10 @@ coverage. The initial internal source primitive implements identity snapshots and this length-preserving masking only. It does not expose a transformer or source-map -SPI. Sessions create a new source snapshot for document updates and reuse the -existing snapshot for context-only updates. Non-identity generated/reordered -source remains deferred until a concrete consumer validates the segment model. +SPI. Sessions create a new source snapshot from the complete post-update region +set for document or region updates and reuse the existing snapshot only when +text and regions are unchanged. Non-identity generated/reordered source remains +deferred until a concrete consumer validates the segment model. Current session edits use identity sources, so validated original-document changes are also trusted analysis-coordinate changes. A future transformed diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index 2853504..c5556cb 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -183,11 +183,16 @@ settles the active operation exactly once without exposing raw event data. Raw backend ASTs will not cross the worker boundary and the first protocol will not introduce remote AST handles or worker-local AST leases. -Before production session wiring, the worker request will parse once and run -adapter-owned semantic decoders in the same realm. It will return only the -bounded, validated relation facts required by the first completion slice. -This keeps backend shapes private, avoids reparsing once for syntax and again -for relations, and makes cached main-realm evidence measurable. +The first relation-completion slice is parser-independent under +[ADR 0005](0005-parser-independent-relation-completion.md). Protocol v1 remains +syntax-only. Incomplete relation completion must not wait for worker startup, +parser acceptance, timeout, or recovery. + +A future scope-dependent semantic slice will parse once and run adapter-owned +semantic decoders in the worker realm. It will move the closed protocol +atomically to v2 and return a bounded scoped IR, with normalized syntax and +semantic availability represented independently. It will not return flat +relation names, raw ASTs, source text, or absolute document ranges. Worker-local AST caching is deferred until profiling demonstrates that reparsing is material enough to justify leases, byte accounting, generation @@ -220,7 +225,7 @@ prove: recorded. - Raw and gzip worker sizes are recorded. -The semantic and session-integration slices additionally require: +Parser session-integration and future semantic slices additionally require: - Main-thread long-task and event-loop responsiveness evidence. - Malformed message, crash, timeout, late-event, and restart tests. @@ -290,10 +295,11 @@ optional-integration bundle budget. 1. Add this ADR and the packed-consumer browser placement harness. 2. Extract a realm-neutral backend engine and add strict protocol codecs. 3. Add the minimal browser worker and private bounded single-lane executor. -4. Add in-worker normalized relation extraction. -5. Add the pure statement coordinator, bounded cache, in-flight sharing, and - atomic session ownership. -6. Ship relation completion as the first public consuming vertical slice. +4. Add the pure statement coordinator, bounded cache, in-flight sharing, and + authenticated syntax integration. +5. Ship parser-independent relation completion under ADR 0005. +6. Design in-worker scoped semantics and protocol v2 before a scope-dependent + feature consumes parser evidence. Every production step is a medium change and receives two independent, commit-bound adversarial reviews. diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md new file mode 100644 index 0000000..d17d0c6 --- /dev/null +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -0,0 +1,736 @@ +# ADR 0005: Parser-Independent Relation Completion + +Status: accepted + +Date: 2026-07-25 + +## Context + +ADR 0004 originally required the isolated parser worker to return normalized +relation facts before the first relation-completion slice. Implementation and +adversarial inspection of the pinned `node-sql-parser` 5.4.0 builds showed that +this is the wrong dependency. + +Relation completion is most valuable for incomplete input such as: + +```sql +SELECT * FROM | +SELECT * FROM schema.us| +SELECT * FROM users u JOIN | +``` + +The compatibility parser rejects many of these states. Waiting for it would +make completion least reliable at the exact moment it is requested. It would +also construct a roughly 500 KiB parser-worker graph for a feature that can +recognize a narrow set of partial `SELECT` query sites without a complete AST. + +The backend AST cannot provide a safe shortcut: + +- Flat `tableList` data mixes physical relations with CTE references. +- Nested wrapper lists can include relations inherited from an ancestor. +- PostgreSQL and BigQuery use different shapes for CTE bodies. +- BigQuery qualified paths have different shapes when backtick quoted. +- PostgreSQL commonly omits relation locations and loses quote provenance. +- DML target and source fields have different read/write roles. +- CTE declaration order, nesting, shadowing, recursion, correlation, and + `LATERAL` require scopes rather than a flat list. + +A flat value named `dependencies`, `sources`, or `relations` would therefore +encode stronger semantics than the adapter can prove. Parser acceptance is +already compatibility-only evidence under ADR 0003. + +The current vNext core has the required parser-independent foundations: + +- immutable original and masked analysis source; +- absolute UTF-16 half-open coordinate mapping; +- dialect-owned lexical profiles and identifier rendering; +- exact and explicitly opaque statement slots; +- cursor affinity; +- atomic session revisions and context changes; and +- service ownership shared across many document sessions. + +Marimo adds concrete requirements. A notebook can mount many SQL editors, +change connection and dialect dynamically, combine notebook-local and remote +relations, and embed Python expressions in `{...}` regions. Its Python +completion source must remain independently composable. + +## Decision + +The first relation-completion vertical slice will not depend on the parser, +parser worker, or parser coordinator. + +It will combine: + +1. a private bounded partial-`SELECT` query-site recognizer; +2. a private bounded CTE frame and visibility recognizer; +3. one asynchronous relation-catalog provider with explicit coverage and epoch + state; +4. revision-aware session arbitration and deterministic result composition; + and +5. a separate CodeMirror adapter. + +Parser execution remains the selected boundary for syntax evidence and future +general scope-dependent semantics. A later semantic protocol will use a scoped +IR and will not expose raw ASTs or flat relation-name lists. + +The single configured provider may itself be a host-owned composite. The first +slice does not define provider fan-out, cross-provider deduplication, or +arbitration. Those remain deferred as required by ADR 0001. + +### Narrow initial query sites + +The private recognizer is a bounded partial SQL recognizer, not a keyword +scanner. It enters a relation-name state only from a proven supported `SELECT` +query block and a proven clause state at the current query depth: + +- immediately after `FROM` or `JOIN`; +- a partial qualified or unqualified relation path following those keywords; + and +- a comma-separated relation entry in the same `FROM` clause. + +The initial recognizer does not claim support for `UPDATE`, `INSERT INTO`, +table functions, derived output relations, `LATERAL`, or column positions. +It fails closed when unsupported syntax could change the clause, expression, +or query-block state. Strings and comments never create sites. `FROM (` and a +cursor inside a string, comment, quoted token that cannot be decoded, or +embedded region are unavailable or inactive according to the closed result. +CTE visibility is the one narrow scope-semantics exception; general +parser-derived scope semantics remain deferred. + +The conformance corpus includes positive base `FROM`, qualified prefix, +aliased `JOIN`, same-depth comma, and nested supported-query cases. It includes +negative `IS DISTINCT FROM`, `substring(... FROM ...)`, `extract(... FROM +...)`, `DELETE FROM`, `COPY ... FROM`, set-operation, `QUALIFY`, `WINDOW`, join +constraint, DML, and expression cases. A keyword match alone never creates a +site. + +The result distinguishes: + +- inactive: the cursor is not at a supported relation site; +- unavailable: an opaque statement, resource limit, or ambiguous construct + prevents a safe answer; and +- ready: the replacement range, decoded qualifier and prefix, visible CTEs, + and exact or recovered quality are known. + +The recognizer authenticates both the final identifier-segment range and the +whole typed relation-path range as statement-relative UTF-16 ranges created +from package-owned source. The session maps them to absolute original-document +ranges before returning a completion item. In the first slice every catalog +completion replaces the authenticated whole typed relation path with the fully +rendered canonical suffix selected by provider-proven `completionPathStart`. +It never inserts a full path into a final-segment edit, so `schema.us` cannot +become `schema.schema.users`. +Incomplete quoted identifiers replace the complete authenticated token or path +rather than creating an unmatched quote suffix. No edit crosses a statement or +embedded-region boundary. + +### Dialect-owned identifier policy + +Dialect runtime data, not providers, owns: + +- bare-identifier syntax; +- quote delimiters and escaping; +- query-local quoted and unquoted CTE equality; +- reserved-word quoting; +- rendering by path-segment role; +- maximum identifier path depth; and +- supported CTE grammar. + +The provider and catalog scope own catalog-path matching, case policy, and +addressability. This matters for systems such as BigQuery, where dataset +configuration can change case sensitivity, and where rendering rules differ +by path segment. Providers receive decoded identifier queries and the public +dialect ID, and return decoded path components plus positive matching evidence. +They never produce SQL insertion text or choose quoting. + +The service does not generic-fold, deduplicate, reject, or infer absence for +catalog entities. A dialect-derived normalized string may be used only as a +locale-independent deterministic sort key; it never establishes catalog +eligibility, equality, addressability, or authority. + +DuckDB uses DuckDB lexical and identifier rules for completion. Its use of the +PostgreSQL compatibility parser remains separate positive-only syntax +evidence. + +### Bounded CTE visibility + +The CTE recognizer handles a bounded subset of query-block-leading CTE syntax: + +```text +WITH [RECURSIVE] + name [(declared columns)] AS [NOT] MATERIALIZED (body), + ... +main query +``` + +It records only proven declaration names, body boundaries, declaration order, +and visibility. It is not a miniature general SQL AST. + +For non-recursive CTEs: + +- a declaration is not visible in its own body; +- earlier siblings are visible in later sibling bodies; +- all completed declarations are visible in the main query; +- later declarations are never visible earlier; +- visible outer CTEs flow into nested query blocks; +- a nested declaration shadows an outer name only inside its query block; and +- nested declarations never leak outward. + +Identifier equality follows the dialect. Duplicate names and structurally +ambiguous headers make the affected frame partial. `WITH RECURSIVE` may expose +proven names in the main query, but self and mutual-recursive body visibility +remain explicitly incomplete until implemented. + +An empty positive-only local-relation list never proves that no CTE is visible. + +### Embedded regions + +The first public template input is a complete set of length-preserving embedded +regions attached atomically to a document revision. `openDocument` accepts an +optional complete set; omission or an empty set means identity source. +Arbitrary transformer callbacks and generated/reordered source maps remain +deferred. + +Every accepted full-text or incremental document update includes the complete +ordered, non-overlapping region set for the resulting text. A base-revision +transaction may instead change regions alone or together with context while +leaving text and document identity unchanged. Without a document mutation, +omission preserves the current set and an explicit empty set clears it. Text, +context, and regions pass one validation gate and either create one new +revision together or leave the session unchanged. The CodeMirror adapter +supplies the post-transaction set through a typed effect or a configured pure +extractor; it never opens or changes text and follows with a second region +update. + +An untyped embedded region is an unknown grammar barrier, not an exact +expression token. The recognizer: + +- returns inactive while the cursor is inside an embedded region; +- never creates an edit or identifier path crossing a region; and +- marks a CTE header containing an opaque region partial rather than inventing + a declaration; and +- returns unavailable when proof cannot cross the barrier, or ready with + recovered quality, `isIncomplete: true`, and the closed reason + `opaque-template-context` when source tokens after the barrier heuristically + resemble a supported site and the edit coordinates are independently safe. + +Therefore generic marimo interpolation such as `FROM {df} JOIN |` is never +exact: its SQL site semantics remain uncertain even when a recovered completion +is useful. Exact continuation across a region requires a future trusted, +feature-specific syntactic-role contract; a language label alone is +insufficient. + +Marimo keeps Python-expression completion as an external CodeMirror source for +positions inside `{...}`. Its region conformance fixtures cover `{df}`, nested +Python expressions and strings, `{{...}}`, unmatched braces, braces in SQL +strings/comments, cursor-inside-region behavior, and edits that create or +remove regions. + +### Catalog provider boundary + +The provisional catalog provider is feature-specific and asynchronous. A relation +search request contains only copied, recursively frozen plain data: + +- catalog scope and an ordered list of search paths, where each path is an + ordered list of decoded components with quoted state; +- public dialect ID; +- decoded qualifier and prefix components with quoted state; +- result limit; +- expected epoch when known; and +- an optional bounded continuation token. + +The `AbortSignal` is passed separately. Providers never receive a session, +revision, `EditorState`, DOM object, credential object, or arbitrary live host +context. Connection identity belongs in the catalog scope; live resources stay +inside the provider closure. + +A returned relation contains: + +- a stable provider-local entity ID; +- relation kind; +- a canonical absolute catalog path whose components contain decoded values + and closed semantic roles; +- a `completionPathStart` index selecting an exact suffix of that canonical + path, positively proven addressable for the request scope and search path; +- a closed provider-proven match quality, initially `exact` or `equivalent`; + and +- optional bounded plain-text detail. + +The initial closed component roles are `catalog`, `schema`, `project`, +`dataset`, and `relation`. Each dialect accepts only its documented role +sequences, and the final component is always `relation`. + +The service never invents an unqualified candidate from an absolute path. The +provider proves matching and addressability through `matchQuality` and +`completionPathStart`; the service validates that the selected suffix and role +sequence are legal for the registered dialect, then produces dialect-correct, +segment-role-aware insertion text. Alias paths that are not canonical suffixes +remain deferred. Each catalog item has one positive catalog provenance +containing provider and entity IDs. + +Search responses distinguish: + +- ready with complete, partial, or paginated coverage; +- loading; and +- failed with a closed code and retry policy. + +A terminal `loading` response is not cached. A provider that later becomes +ready must publish a strictly higher epoch through its subscription; same-epoch +duplicate invalidation is not a readiness signal. A still-pending search +promise can instead resolve ready under the service-owned response/refresh +rules below. Every completion result carrying `catalog-loading`, whether caused +by terminal loading or soft expiry, also carries a checked remaining +completion-intent lease. For terminal loading that lease is independent of +in-flight work ownership and is keyed to the exact session, query, and captured +observed or unobserved epoch. + +Partial positive entities may be offered. A partial or absent result never +proves that a relation does not exist. A complete empty search means only that +the exact search produced no matches at that provider epoch; it is not reused +as an unknown-object diagnostic. + +Provider responses are decoded from `unknown` using bounded own enumerable +data properties and copied into fresh frozen current-realm objects. Accessors, +throwing proxies, unexpected keys, oversized strings or arrays, and malformed +closed values fail without exposing raw errors. + +The core also provides a provisional `createInMemoryRelationCatalog` helper for +bounded readonly relation data. It indexes stable IDs, kinds, role-bearing +canonical component paths, optional details, scopes, and caller-proven +addressable suffix starts. Each scope explicitly selects a closed matching +policy such as exact code-unit or ASCII case-insensitive matching; the helper +never infers catalog policy from dialect ID. Package dialect utilities decode +and render SQL but do not establish catalog eligibility. The helper returns +complete, frozen generation-zero results and requires no no-op subscription for +a static catalog. It imports no CodeMirror, DOM, React, or host state. + +### Epochs, subscriptions, and invalidation + +Epochs are monotonic per provider ID and catalog scope and contain: + +- a non-negative safe generation; and +- an opaque bounded non-secret token. + +They identify observable catalog search state, including loading versus ready +availability, not only the underlying database schema snapshot. + +Every response and invalidation passes through one serialized gate for the +configured provider identity and scope. A subscription callback is always a +change event, never an initial snapshot. The first accepted invalidation +establishes its epoch and advances all sessions already subscribed to that +scope. A first successful search response may establish a baseline without a +revision advance only when no invalidation has been accepted since the request +captured its revision and no soft-settled refresh observer requires the +availability notification defined below. + +After a baseline exists, lower generations are discarded with closed stale +evidence. Equal generation and token is a duplicate. Equal generation with a +different token is malformed provider behavior and causes no state mutation. +A higher accepted invalidation or response atomically installs the new epoch, +clears older scope cache entries, supersedes affected work, and then advances +each subscribed session revision exactly once. + +A search that discovers a higher epoch supersedes itself instead of publishing +against its older captured revision. Pages and cache entries from different +epochs are never merged. + +The service reference-counts one provider subscription per provider +configuration and scope, then fans invalidation out to subscribed sessions. +Subscription membership is installed atomically before a provider can call +back synchronously. A newly joining session captures an already-observed epoch +without a synthetic revision bump. Disposal removes membership before +unsubscribing or running external cleanup. Fifty sessions sharing a scope do +not create fifty provider subscriptions. + +Each installed subscription has a service-owned incarnation identity captured +by its callback. The identity is revoked before external unsubscribe or +cleanup, and every callback checks it before entering the serialized epoch +gate. Cleanup from a retired incarnation is keyed to that identity and cannot +remove, mutate, or notify a reentrantly installed replacement. + +The session exposes a disposable revision-change subscription for +service-originated changes: + +```ts +const subscription = session.onDidChange(({ revision, reason }) => { + // reason is a closed value such as "catalog" +}); +subscription.dispose(); +``` + +State and the opaque revision change before listeners run. Listener failures +are isolated; disposal is idempotent; and callbacks never run after session or +service disposal. The event contains no provider payload. Text and context +updates remain observed through the caller's own transaction and do not need a +duplicate event. + +### Completion result contract + +The framework-independent session returns immutable completion items containing +plain strings, one exact absolute original-document text edit, relation kind, +and one positive provenance: either a visible CTE with declaration position or +a catalog entity with provider and entity IDs. + +The list separately records whether it is incomplete and closed reasons such +as catalog loading, partial or paginated coverage, provider failure or timeout, +query-site recovery, recursive CTE uncertainty, and result limiting. + +A recognized site with no candidates is a ready empty list. It remains marked +incomplete when catalog or recognition evidence is incomplete. An inactive or +unavailable site lets the CodeMirror adapter return `null` so other completion +sources can run. + +Malformed synchronous request input throws a stable session contract error. +Normal provider, cancellation, supersession, disposal, and unsupported-analysis +outcomes settle through a discriminated request result. + +### Cancellation and deterministic composition + +Completion is latest-wins per session: + +- a new completion request supersedes the previous request; +- same-key supersession atomically attaches the new request consumer, or + retags the existing observer as that consumer, before removing old request + ownership; different-key supersession revokes the old observer first; +- text, context, embedded-region, relevant catalog, or provider-configuration + changes supersede captured work; +- caller cancellation and session/service disposal settle promptly; +- late provider resolution or rejection is drained without publication or an + unhandled rejection; and +- the adapter checks revision currency immediately before returning or + applying a result. + +Shared in-flight catalog work has one atomic owner set containing request +consumers and refresh observers. Request cancellation detaches that consumer +independently. The service aborts the provider only after one combined mutation +observes no owners of either kind. A same-key latest-wins request attaches or +transfers its owner before the old owner detaches, then evaluates abort once, +so a soft-expiry observer cannot lose and restart identical work. Ownership is +removed before any abort or external callback. All cancellation, supersession, +disposal, deadline, and completion races pass one settle-once transition and +leave no timer or listener behind. + +Ranking is independent of asynchronous completion order: + +1. visible CTEs; +2. provider-proven `exact` matches, then `equivalent` matches; +3. shorter proven completion paths; +4. catalog kind in the closed order temporary table, table, view, materialized + view, then external relation; +5. rendered label and path under code-unit comparison using `<` and `>`, never + locale-sensitive comparison; and +6. CTE declaration offset for local ties or provider entity ID for catalog + ties. + +A visible CTE shadows an unqualified catalog insertion only when the +dialect-owned query-local CTE equality policy proves equivalence. Distinct +catalog entities are never merged or deduplicated by a generic fold. The first +slice has one provider and does not accept arbitrary floating scores. + +### Deadlines, cache identity, and retention + +Provider invocation is measured against an 8 ms synchronous observation +budget. JavaScript cannot preempt synchronous code; an over-budget return is +therefore discarded as `catalog-timeout`, and CPU-heavy or untrusted providers +must use a worker or process. + +Each shared work item owns one immutable absolute queue deadline from its first +enqueue and, once invoked, one immutable absolute execution deadline. Defaults +are 100 ms and 250 ms. Configuration is checked at construction and restricted +to 10–2,000 ms for queue wait and 10–5,000 ms for execution. Joiners inherit +the remaining work budget and never extend either deadline. Hard queue expiry +atomically removes the work without invoking the provider and settles every +attached request consumer. Hard execution expiry atomically removes the work, +settles every attached consumer, removes its refresh observers, aborts once, +and drains a late resolve or reject. Earlier caller cancellation detaches only +that consumer. + +At a recognized site, queue overload or hard expiry still settles each attached +completion request with its composed local/CTE result, `isIncomplete: true`, +and a closed `catalog-overloaded`, `catalog-queue-timeout`, or +`catalog-timeout` reason. Catalog lifecycle failure never converts proven +query-site evidence into unavailable analysis. + +Hard safety deadlines do not define interactive latency. Each completion +request has a checked catalog response budget, default 40 ms and configurable +from 0 through 50 ms, measured from the start of the complete request. Local +and CTE evidence is composed first and never waits past the remaining response +budget. On soft expiry the request settles ready, possibly empty, with +`isIncomplete: true`, `catalog-loading`, and bounded remaining +completion-intent lease metadata; its session may atomically retag the request +consumer as a service-owned refresh observer. The intent lease is no longer +than the remaining work lease. +That bounded refresh lease can retain the shared operation only until its +existing hard deadline and active/queued service limits. With no consumers or +live observers, the service removes and aborts the work. An observer is bound +to the captured session revision and exact work key; any session change, +supersession, or disposal removes it. + +If leased work resolves ready compatibly with the captured state—either +`UNOBSERVED` to its first baseline epoch or at the already-observed equal +epoch—the service decodes and caches it, re-keys first-baseline work, removes +the observers, and advances each still-current observing session once with the +closed reason `catalog-availability`. A higher epoch follows the epoch-change +path and clears the same observers without a second availability notification. +The adapter coalesces the resulting notification into a new completion request. +This is service-owned evidence readiness, not a duplicate provider +invalidation. Hard queue or execution expiry removes observers without a +refresh notification and leaves the already-returned incomplete result valid. +No optional catalog promise can keep `complete()` pending indefinitely or +block the local baseline past its product response budget. + +The exact structural cache and shared-work key contains: + +- service-owned provider configuration identity and unique provider ID; +- exact catalog scope; +- ordered search paths, component values, and quote states; +- dialect runtime configuration identity; +- decoded qualifier and prefix values and quote states; +- clamped result limit; +- continuation token; and +- the captured epoch generation and token, or an explicit `UNOBSERVED` + sentinel. + +Hashes may accelerate comparison but never replace structural equality. +Provider IDs are unique within a service and callers cannot choose +configuration identity. The first decoded response can re-key unobserved work +to its epoch. Only decoded ready responses are cached under their response +epoch. Loading, failure, malformed, timeout, cancellation, supersession, queue +overload, and disposal outcomes are not cached. Complete-empty results are +reused only for the exact key and never prove an unknown-object diagnostic. +Partial and paginated entries retain incomplete coverage; pages combine only +for the identical base key and epoch, and each continuation remains a distinct +request key. Higher-epoch results may be cached only after older scope entries +are cleared and never publish to the revision that observed the older epoch. + +Decoded entity IDs are unique for `(provider configuration, scope, epoch)` +across every page composed into one result. A repeated ID, even with otherwise +identical data, makes the page chain malformed before ranking or caching; array +and page order therefore cannot resolve conflicting identity records. + +### Initial resource budgets + +The initial checked limits are: + +| Resource | Limit | +| --- | ---: | +| Active statement scanned | 65,536 UTF-16 units | +| Lexical tokens | 16,384 | +| Parenthesis/query depth | 128 | +| CTE declarations | 256 | +| Identifier path segments | 4 | +| Identifier segment | 256 UTF-16 units | +| Catalog scope | 512 UTF-16 units | +| Search paths | 32 | +| Components per search path | 4 | +| Total catalog context | 16,384 UTF-16 units | +| Configured relation catalog providers | 1 | +| Catalog results per search | 100 | +| Completion results after composition | 100 | +| Provider ID | 256 UTF-16 units | +| Entity ID | 256 UTF-16 units | +| Epoch token | 256 UTF-16 units | +| Plain-text detail | 1,024 UTF-16 units | +| Continuation token | 2,048 UTF-16 units | +| Decoded response aggregate | 65,536 UTF-16 units | +| Decoded response own keys | 1,024 | +| Decoded response nesting depth | 8 | +| Service catalog cache | 256 entries and 2 MiB estimated retained bytes | +| Service-wide active catalog searches | 8 | +| Service-wide queued catalog searches | 64 | +| Active completion consumers | 1 per session | +| Catalog response budget | 40 ms default, 50 ms maximum | +| Catalog refresh observers | 1 per session | +| Completion-intent lease | 1,000 ms default, 5,000 ms maximum | + +Limit exhaustion produces an explicit unavailable or incomplete result. An +oversized provider response is rejected; it is never silently truncated while +retaining a false `complete` claim. + +The 65,536-unit scan limit is a safety ceiling, not a latency target. The +recognizer must still meet the active-statement product envelope on the +representative 10 KiB workload and must return explicit resource unavailability +instead of creating a routine main-thread task over 50 ms. + +### CodeMirror boundary + +CodeMirror integration ships from a separate entry point. It owns: + +- one document session per `EditorView`; +- conversion of transactions into atomic text, context, and region updates; +- focus, visibility, debounce, and request cancellation; +- conversion to CodeMirror completion results; +- one coalesced subscription to service-originated session revision changes; +- safe rendering and external completion-source composition; and +- session disposal from the view plugin. + +The core does not expose CodeMirror `Completion`, `EditorState`, `EditorView`, +facets, DOM nodes, or renderer callbacks. The first adapter omits reusable +`validFor` caching so CodeMirror cannot reuse a result across a session revision +without revalidation. It checks `session.isCurrent()` both before returning and +immediately before applying a result. + +For every recognized incomplete `catalog-loading` result, including an empty +list that opens no menu, the adapter retains at most one bounded completion +intent. The one-shot intent records the exact view, document, selection, +context, query, captured epoch, and work identity when work exists. It expires +no later than the service-supplied completion-intent lease. On +`catalog-availability` or a service higher-epoch catalog revision notification +caused by either an accepted response or invalidation, the adapter aborts stale +captured work and coalesces exactly one scheduled refresh when the matching +view state has any of: + +- an active completion invocation that has not yet installed a result; +- an active menu representing the captured loading request; or +- a valid no-menu loading intent. + +If notification arrives while the invocation is active, the adapter records a +one-shot pending-refresh latch before aborting or allowing the stale invocation +to settle. It clears the latch before the scheduled replacement starts. A +later loading settlement or intent installation observes the latch and cannot +schedule a duplicate refresh. + +A newer completion, selection/document/context change, explicit completion +cancel or Escape, configured blur policy, lease expiry, or view/session +disposal clears the active invocation, latch, and intent. Consuming either +one-shot refresh marker clears it before dispatch, preventing reopen loops. The +adapter never synchronously dispatches from a CodeMirror update or provider +callback. + +A supplied language service is caller-owned. Each view plugin owns exactly one +session, revision subscription, debounce set, active request, and any UI +resource. `destroy()` unsubscribes, aborts, disposes its session and UI, and is +idempotent; it never disposes the shared service or provider. Service disposal +settles all sessions even if views still exist, and later view destruction +remains harmless. + +The adapter accepts a custom completion-info resolver/decorator. It receives +only the immutable core item and its provider/entity provenance, and may use a +host closure to resolve live metadata and produce rich CodeMirror information. +Async detail is cancellation-aware, late results are drained, and any returned +UI resource has explicit disposal so marimo can unmount React roots. The +default path creates safe plain text/DOM and never assigns provider strings to +`innerHTML`. React, Jotai, `DataTable`, DOM nodes, and live metadata never enter +the core contract. + +The marimo acceptance fixture runs 1, 10, and 50 views against one shared +service. It proves one session/listener/request owner per view but one provider +subscription, cache, and catalog index per provider configuration and scope. +Destroying 49 views retains the subscription; destroying the last releases it +exactly once. A second scope adds one subscription, not one per view. + +The fixture also proves that relation completion constructs no parser worker; +epoch invalidation creates one service fan-out and bounded coalesced view +refreshes; engine, dialect, and scope switches are atomic; and rapid edits, +loading/partial/failing providers, ignored abort signals, late settlement, +open-menu invalidation, repeated destruction, and service-before-view disposal +leave no stale apply, late dispatch, unresolved promise, unhandled rejection, +listener, subscription, React root, or unbounded cache retention. GC-capable +runs record retained resources, while 10 KiB documents enforce the capability +charter's main-thread and completion-latency envelopes. + +Browser cases include empty soft result to first-baseline and same-epoch ready +with exactly one no-typing refresh; cursor movement, Escape, blur policy, hard +expiry, or destruction before readiness with no reopen; a nonempty CTE plus +loading catalog whose open menu refreshes once; and bounded 50-view fan-out. +The coordinator suite also covers soft-expiry observer to same-key consumer +handoff without abort/restart. + +They also cover immediate empty terminal loading followed by a higher ready +epoch before intent expiry with exactly one no-typing refresh, and the same +event after intent expiry, Escape, movement, blur, or destruction with no +reopen. Coordinator/adapter ordering cases include a higher-epoch response +before soft expiry and a revision notification delivered before loading-intent +installation; each preserves the original invocation with exactly one +replacement request. + +## Parser semantics remain separate + +Worker protocol v1 remains unchanged. + +When additional or general parser-derived scope semantics are required for +completion, hover, navigation, or diagnostics, the worker protocol will move +atomically to v2. A parsed +response will keep normalized syntax and semantic availability independent. +The semantic payload will be a bounded scoped IR covering query blocks, +bindings, visibility, and limitations. It will not contain raw ASTs, raw source, +absolute document ranges, flat `tableList` data, or generic fact bags. + +The host and worker will not negotiate or simultaneously support v1 and v2. +Mixed cached assets fail the exact version check and retire the generation. + +## Implementation sequence + +1. Accept this ADR as the provisional contract and add a checked-in, + runtime-free marimo-shaped type fixture. Its core portion compiles region, + search-path, catalog, notification, and completion sketches without DOM + imports; its adapter portion compiles the rich-info resolver signature. No + production slice starts until both pass. +2. Attach embedded regions to session open/update transactions atomically. +3. Add the bounded partial-`SELECT` query-site recognizer. +4. Add the bounded CTE frame and visibility recognizer. +5. Add the service-owned catalog coordinator, subscriptions, cache, + in-flight sharing, cancellation, and provider contract suite. +6. Add the session completion method and deterministic composition. +7. Add the separate CodeMirror adapter and packed/browser fixtures. +8. Add the pinned packed/browser/runtime marimo fixture and 1/10/50-editor + performance and leak evidence. +9. Validate both the in-memory/notebook and hierarchical remote provider + shapes, then stabilize the declarations and public export surface. +10. Design scoped parser semantics and protocol v2 against PostgreSQL and + BigQuery corpora before any additional parser-derived scope feature + consumes it. + +Breaking refinement remains allowed through step 8. The provider and +completion types become stable only after the working vertical slice, reusable +provider contract suite, two materially different provider shapes, marimo +packed/browser integration, hostile decoding, cancellation, epoch and +pagination tests, declaration/API snapshots, and the rich-detail/external +completion-source migration decision all pass. + +Every production step is a medium change and requires two independent +commit-bound adversarial reviews. + +## Consequences + +- Incomplete relation completion does not wait for parser startup or success. +- Relation-only completion does not construct the parser worker. +- Cursor movement and catalog invalidation do not cause reparsing. +- DuckDB completion is not defined by PostgreSQL parser compatibility. +- Catalog changes do not invalidate statement parsing. +- Parser crashes and safety timeouts do not suppress local/catalog relation + completion. +- The query-site recognizer is intentionally narrow and reports uncertainty + instead of growing into an implicit general SQL parser. +- CTE visibility has an explicit bounded model rather than a flat query-wide + list. +- The public core remains independent of CodeMirror and DOM types. +- Scoped semantic work is deferred, not represented by an incorrect flat + contract. + +## Rejected alternatives + +### Gate relation completion on worker parsing + +The parser rejects important incomplete editor states, adds cold-start and +message latency, and is unnecessary for the narrow first cursor sites. + +### Return flat relation names from protocol v1 + +Flat names cannot distinguish CTE references, physical relations, nested +scope, or DML roles and have unreliable path and quote provenance. + +### Expose parser tokens or AST ranges to completion + +Backend-specific data would leak through the stable boundary and is not +consistent across the target dialects. + +### Reuse `SQLNamespace` as the catalog API + +A fully materialized nested object has no asynchronous coverage, pagination, +epoch, stable entity identity, or positive addressability contract. + +### Put CodeMirror completion types in the core + +That would couple providers and sessions to editor state, DOM rendering, and +CodeMirror lifecycle instead of keeping the language service reusable. diff --git a/docs/vnext/capability-charter.md b/docs/vnext/capability-charter.md index bb66f06..d6353aa 100644 --- a/docs/vnext/capability-charter.md +++ b/docs/vnext/capability-charter.md @@ -78,9 +78,11 @@ The stable session API is intended to support: - Statement and structure indicators - Explicit formatting through a selected formatter provider -The walking skeleton implements relation completion first. Subsequent features -reuse the same session, revision, range, cancellation, provenance, and result -contracts. No feature gets a separate parser or schema configuration. +The walking skeleton implements parser-independent relation completion first, +so incomplete `FROM` and `JOIN` input does not wait for parser acceptance. +Subsequent features reuse the same session, revision, range, cancellation, +provenance, and result contracts. No feature gets a separate parser or schema +configuration. ## Correctness contract @@ -110,7 +112,9 @@ The service distinguishes: - Disposal An absent or partial catalog cannot justify a definite unknown-object -diagnostic. +diagnostic. A complete-empty completion search cannot either; only a distinct +authoritative resolution contract with complete resolution coverage may prove +absence. ## Provider boundary @@ -154,6 +158,12 @@ Responses distinguish loading, partial, complete, failed, and paginated coverage. Stable entity identity and provider epochs prevent evidence from different catalog states being combined as authoritative. +For the first vertical slice the service configures one catalog provider; a +host may make it a composite. The provider and scope own catalog matching and +addressability, while dialect runtime data owns SQL token decoding and +segment-role-aware rendering. The service does not infer catalog equality or +absence by applying a generic dialect fold. + Catalog invalidation identifies provider, affected scope, and an epoch containing a provider/scope-monotonic generation plus opaque snapshot token. Lower generations are discarded and equal generations are duplicates. Only diff --git a/implementation.md b/implementation.md index 445aaed..47493ab 100644 --- a/implementation.md +++ b/implementation.md @@ -758,13 +758,32 @@ depend on them. Exit when arbitrary edit properties pass, stale results cannot apply, and unchanged statements are reused. -### 4. Parser artifacts and semantic completion slice +### 4. Parser integration and completion slices Deliver the parser contract, measured `node-sql-parser` adapter, normalized -artifacts, query blocks, typed visibility, CTE/relation/alias/column bindings, -explicit partial states, and a production-quality completion vertical slice. +artifacts, isolated execution, bounded coordination, and authenticated syntax +evidence. + +Deliver relation completion independently through a bounded partial-`SELECT` +query-site recognizer and CTE-visibility recognizer plus one asynchronous, +coverage-aware catalog provider. The host may implement that provider as a +composite; multi-provider arbitration remains a later explicit design. +This path must remain useful for incomplete SQL and must not construct or wait +for the parser worker. + +Keep the public provider and completion types provisional until the vertical +slice, two provider shapes, packed marimo integration, hostile decoding and +lifecycle suites, and declaration snapshots pass. + +Then design query blocks, typed visibility, CTE/relation/alias/column bindings, +and explicit partial semantic states against materially different dialect +corpora before an additional parser-derived scope feature consumes them. The +bounded CTE recognizer remains the explicit narrow exception. Flat parser +relation lists are not a semantic model. -Exit when goldens pass, parser ASTs do not leak, and differences are classified. +Exit when relation completion works through the framework-independent session, +catalog and parser failures remain independent, semantic goldens pass, parser +ASTs do not leak, and dialect differences are classified. ### 5. Feature vertical slices @@ -785,8 +804,9 @@ Expand the minimal contracts into lazy catalogs, stable identities/invalidation, production feature-specific authority, native/remote providers, and the recorded DuckDB/LSP/`sqruff` decisions. -Exit when optional remote work never blocks the local baseline and all -race/failure/partial-catalog contracts pass. +Exit when optional remote work never delays the local baseline beyond the +checked product response budget, incomplete local results remain useful, and +all race/failure/partial-catalog contracts pass. ### 7. Marimo migration and release From 1ee9a0564dd43722866b0595ca87c30f5e6282e2 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 05:48:27 +0800 Subject: [PATCH 14/42] feat(vnext): sketch relation completion contracts (#183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add package-private provisional contracts for relation catalog search, epochs, path authority, dialect-owned decoding/rendering, completion results, and session lifecycle - migrate catalog search paths from dot-joined strings to decoded identifier-component paths with quote provenance - add a runtime-free marimo-shaped core fixture covering atomic document/context/region transactions, providers, subscriptions, completion results, and negative type guarantees - add a separate CodeMirror fixture requiring explicit disposal for custom rich-info resources These declarations intentionally remain outside the `./vnext` public export surface until the complete vertical slice, two provider shapes, hostile decoding, packed marimo integration, and lifecycle/performance gates pass. ## Design constraints - provider authoring is strongly typed, but runtime ingress will be erased to `unknown` and strictly decoded - providers return decoded role-bearing canonical paths and positive addressability evidence; only dialect runtime data may decode SQL identifiers or render insertion text - provider requests contain only bounded catalog/query data; provider cancellation is passed separately - consumer results do not expose epochs, scheduler work IDs, deadlines, configuration identity, or terminal-vs-soft loading causes - every `catalog-loading` issue carries a bounded remaining intent lease - custom CodeMirror rich info must return `{ dom, destroy }`, enabling late-result draining and React-root unmounting ## Validation - `pnpm run typecheck` - `pnpm exec oxlint` - `pnpm run test:integrity` - `pnpm test` — 1,248 passed, 1 expected failure - `pnpm run test:browser` — 7 passed - `pnpm run test:package` - changed coverage — 100% statements, branches, functions, and lines - exact-head adversarial approval from SQL/API and marimo ergonomics reviewers at `d5a38af2804c928bc8acf7f7a0ae91a759f3de24` Part of #169. --- ## Summary by cubic Adds provisional, strongly typed contracts for SQL relation completion, including catalog search, dialect decoding/rendering, completion items/results, and session lifecycle. Migrates identifier handling to decoded `SqlIdentifierPath` components and adds a disposable CodeMirror resolver for custom info. Part of #169. - New Features - Relation catalog contracts: search request/response, epochs, coverage (complete/partial/paginated), provider reports. - Dialect runtime for identifier decode/render and CTE identifier equality. - Completion model for CTEs and catalog relations, with issues and ready/unavailable/cancelled/failed results. - Session API with transactional updates, completion, change events, and disposal. - CodeMirror `SqlCompletionInfoResolver` returning `{ dom, destroy }`. - Export `SqlIdentifierComponent` and `SqlIdentifierPath` from `src/vnext/index.ts`. - Migration - Replace dot-joined search paths with `SqlIdentifierPath`; use `SqlIdentifierComponent` for all name parts. - Providers are async, take `AbortSignal` separately, and return decoded paths (no SQL insert text); rendering moves to the dialect runtime. - Embedded regions are readonly; document updates must pass the full `embeddedRegions` set. - Incomplete completion lists must include at least one issue; `catalog-loading` issues include `remainingIntentLeaseMs`. Written for commit 2d0bade612567ad19e0b9c41f485f74d2588df48. Summary will update on new commits. Review in cubic --- .../codemirror/relation-completion-types.ts | 19 + src/vnext/index.ts | 2 + src/vnext/relation-completion-types.ts | 385 ++++++++++++++++++ src/vnext/types.ts | 9 +- ...o-relation-completion-codemirror.test-d.ts | 69 ++++ .../marimo-relation-completion.test-d.ts | 320 +++++++++++++++ 6 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 src/vnext/codemirror/relation-completion-types.ts create mode 100644 src/vnext/relation-completion-types.ts create mode 100644 test/vnext-types/marimo-relation-completion-codemirror.test-d.ts create mode 100644 test/vnext-types/marimo-relation-completion.test-d.ts diff --git a/src/vnext/codemirror/relation-completion-types.ts b/src/vnext/codemirror/relation-completion-types.ts new file mode 100644 index 0000000..8c736d7 --- /dev/null +++ b/src/vnext/codemirror/relation-completion-types.ts @@ -0,0 +1,19 @@ +import type { SqlRelationCompletionItem } from "../relation-completion-types.js"; + +// Provisional CodeMirror-only boundary; the framework-independent core stays DOM-free. +export interface SqlCompletionInfoResolverContext { + readonly signal: AbortSignal; +} + +export interface SqlDisposableCompletionInfo { + readonly dom: Node; + readonly destroy: () => void; +} + +export type SqlCompletionInfoResolver = ( + item: SqlRelationCompletionItem, + context: SqlCompletionInfoResolverContext, +) => + | SqlDisposableCompletionInfo + | null + | Promise; diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 674e5e5..02aea16 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -16,6 +16,8 @@ export type { SqlDocumentReplacement, SqlDocumentSession, SqlDocumentUpdate, + SqlIdentifierComponent, + SqlIdentifierPath, SqlLanguageService, SqlLanguageServiceOptions, SqlPlainData, diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts new file mode 100644 index 0000000..d6262d3 --- /dev/null +++ b/src/vnext/relation-completion-types.ts @@ -0,0 +1,385 @@ +import type { SqlEmbeddedRegion } from "./source.js"; +import type { + SqlContextInput, + SqlDocumentContext, + SqlDocumentEdit, + SqlIdentifierComponent, + SqlIdentifierPath, + SqlRevision, + SqlTextChange, +} from "./types.js"; + +// Provisional package-private declarations until the vertical slice is proven. +export interface SqlDisposable { + readonly dispose: () => void; +} + +export type SqlCatalogContainerRole = + | "catalog" + | "schema" + | "project" + | "dataset"; + +export interface SqlCatalogContainerComponent + extends SqlIdentifierComponent { + readonly role: SqlCatalogContainerRole; +} + +export interface SqlCatalogRelationComponent + extends SqlIdentifierComponent { + readonly role: "relation"; +} + +export type SqlCanonicalRelationPath = readonly [ + ...containers: SqlCatalogContainerComponent[], + relation: SqlCatalogRelationComponent, +]; + +export interface SqlCatalogEpoch { + readonly generation: number; + readonly token: string; +} + +export type SqlCatalogRelationKind = + | "temporary-table" + | "table" + | "view" + | "materialized-view" + | "external-relation"; + +export type SqlCatalogMatchQuality = "exact" | "equivalent"; + +export interface SqlCatalogRelation { + readonly entityId: string; + readonly relationKind: SqlCatalogRelationKind; + readonly canonicalPath: SqlCanonicalRelationPath; + readonly completionPathStart: number; + readonly matchQuality: SqlCatalogMatchQuality; + readonly detail?: string; +} + +export interface SqlCatalogSearchRequest { + readonly scope: string; + readonly searchPaths: readonly SqlIdentifierPath[]; + readonly dialectId: string; + readonly qualifier: SqlIdentifierPath; + readonly prefix: SqlIdentifierComponent; + readonly limit: number; + readonly expectedEpoch: SqlCatalogEpoch | null; + readonly continuationToken: string | null; +} + +export type SqlCatalogReadyCoverage = + | { + readonly kind: "complete"; + } + | { + readonly kind: "partial"; + } + | { + readonly kind: "paginated"; + readonly continuationToken: string; + }; + +export type SqlCatalogFailureCode = + | "authentication" + | "authorization" + | "invalid-configuration" + | "rate-limited" + | "unavailable" + | "unknown"; + +export type SqlCatalogRetryPolicy = + | "never" + | "next-request" + | "after-invalidation"; + +export type SqlCatalogSearchResponse = + | { + readonly status: "ready"; + readonly epoch: SqlCatalogEpoch; + readonly coverage: SqlCatalogReadyCoverage; + readonly relations: readonly SqlCatalogRelation[]; + } + | { + readonly status: "loading"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly status: "failed"; + readonly epoch: SqlCatalogEpoch; + readonly code: SqlCatalogFailureCode; + readonly retry: SqlCatalogRetryPolicy; + }; + +export interface SqlCatalogInvalidation { + readonly epoch: SqlCatalogEpoch; +} + +export interface SqlRelationCatalogProvider { + readonly id: string; + readonly search: ( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) => Promise; + readonly subscribe?: ( + scope: string, + onInvalidation: (event: SqlCatalogInvalidation) => void, + ) => SqlDisposable; +} + +export type SqlIdentifierDecodeResult = + | { + readonly status: "decoded"; + readonly component: SqlIdentifierComponent; + readonly quality: "exact" | "recovered"; + } + | { + readonly status: "unavailable"; + readonly reason: + | "invalid-identifier" + | "unsupported-quote" + | "undecodable-identifier"; + }; + +export type SqlRenderedRelationPath = + | { + readonly status: "rendered"; + readonly text: string; + } + | { + readonly status: "unsupported"; + readonly reason: "illegal-role-sequence"; + }; + +export interface SqlRelationCompletionDialectRuntime { + readonly decodeIdentifier: ( + token: string, + mode: "complete" | "completion-prefix", + ) => SqlIdentifierDecodeResult; + readonly renderRelationPath: ( + path: SqlCanonicalRelationPath, + ) => SqlRenderedRelationPath; + readonly cteIdentifiersEqual: ( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, + ) => boolean; +} + +export interface SqlRelationCompletionOpenDocument< + Context extends SqlDocumentContext, +> { + readonly text: string; + readonly context: SqlContextInput; + readonly embeddedRegions?: readonly SqlEmbeddedRegion[]; +} + +interface SqlRelationCompletionUpdateBase { + readonly baseRevision: SqlRevision; +} + +type SqlRelationCompletionDocumentUpdate< + Context extends SqlDocumentContext, +> = SqlRelationCompletionUpdateBase & { + readonly document: SqlDocumentEdit; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + readonly context?: SqlContextInput; +}; + +type SqlRelationCompletionContextUpdate< + Context extends SqlDocumentContext, +> = SqlRelationCompletionUpdateBase & { + readonly document?: never; + readonly context: SqlContextInput; + readonly embeddedRegions?: readonly SqlEmbeddedRegion[]; +}; + +type SqlRelationCompletionRegionUpdate = + SqlRelationCompletionUpdateBase & { + readonly document?: never; + readonly context?: never; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + }; + +export type SqlRelationCompletionDocumentTransaction< + Context extends SqlDocumentContext, +> = + | SqlRelationCompletionDocumentUpdate + | SqlRelationCompletionContextUpdate + | SqlRelationCompletionRegionUpdate; + +export type SqlSessionChangeReason = + | "catalog" + | "catalog-availability" + | "provider-configuration"; + +export interface SqlSessionChangeEvent { + readonly revision: SqlRevision; + readonly reason: SqlSessionChangeReason; +} + +export type SqlCompletionTrigger = + | { + readonly kind: "invoked"; + } + | { + readonly kind: "trigger-character"; + readonly character: string; + }; + +export interface SqlCompletionRequest { + readonly position: number; + readonly trigger: SqlCompletionTrigger; + readonly signal?: AbortSignal; +} + +export interface SqlCteCompletionProvenance { + readonly kind: "cte"; + readonly declarationPosition: number; +} + +export interface SqlCatalogCompletionProvenance { + readonly kind: "catalog"; + readonly providerId: string; + readonly entityId: string; +} + +interface SqlCompletionItemBase { + readonly label: string; + readonly edit: SqlTextChange; + readonly detail?: string; +} + +export type SqlRelationCompletionItem = + | (SqlCompletionItemBase & { + readonly relationKind: "cte"; + readonly provenance: SqlCteCompletionProvenance; + }) + | (SqlCompletionItemBase & { + readonly relationKind: SqlCatalogRelationKind; + readonly provenance: SqlCatalogCompletionProvenance; + }); + +export type SqlCompletionIssue = + | { + readonly reason: "catalog-loading"; + readonly remainingIntentLeaseMs: number; + } + | { + readonly reason: + | "catalog-partial" + | "catalog-paginated" + | "catalog-failed" + | "catalog-malformed" + | "catalog-overloaded" + | "catalog-queue-timeout" + | "catalog-timeout" + | "query-site-recovery" + | "opaque-template-context" + | "recursive-cte-uncertainty" + | "result-limit"; + }; + +export type SqlRelationCompletionList = + | { + readonly items: readonly SqlRelationCompletionItem[]; + readonly isIncomplete: false; + readonly issues: readonly []; + } + | { + readonly items: readonly SqlRelationCompletionItem[]; + readonly isIncomplete: true; + readonly issues: readonly [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ]; + }; + +export type SqlCompletionUnavailableReason = + | "inactive" + | "unsupported-query-site" + | "opaque-statement" + | "ambiguous-query-site" + | "resource-limit"; + +export type SqlCompletionCancellationReason = + | "caller" + | "superseded" + | "disposed"; + +export type SqlCatalogProviderUnavailableReason = + | "queue-overloaded" + | "queue-timeout" + | "execution-timeout" + | "synchronous-timeout" + | "provider-rejected" + | "malformed-response"; + +interface SqlCatalogProviderReportBase { + readonly feature: "relation-catalog"; + readonly providerId: string; +} + +export type SqlCatalogProviderReport = + | (SqlCatalogProviderReportBase & { + readonly outcome: "ready"; + readonly coverage: SqlCatalogReadyCoverage["kind"]; + }) + | (SqlCatalogProviderReportBase & { + readonly outcome: "loading"; + }) + | (SqlCatalogProviderReportBase & { + readonly outcome: "failed"; + readonly code: SqlCatalogFailureCode; + readonly retry: SqlCatalogRetryPolicy; + }) + | (SqlCatalogProviderReportBase & { + readonly outcome: "unavailable"; + readonly reason: SqlCatalogProviderUnavailableReason; + }); + +export interface SqlServiceFailure { + readonly code: "internal"; + readonly retryable: boolean; +} + +export type SqlRelationCompletionResult = + | { + readonly status: "ready"; + readonly revision: SqlRevision; + readonly value: SqlRelationCompletionList; + readonly sources: readonly SqlCatalogProviderReport[]; + } + | { + readonly status: "unavailable"; + readonly revision: SqlRevision; + readonly reason: SqlCompletionUnavailableReason; + readonly retryable: boolean; + } + | { + readonly status: "cancelled"; + readonly revision: SqlRevision; + readonly reason: SqlCompletionCancellationReason; + } + | { + readonly status: "failed"; + readonly revision: SqlRevision; + readonly failure: SqlServiceFailure; + }; + +export interface SqlRelationCompletionSession< + Context extends SqlDocumentContext, +> { + readonly revision: SqlRevision; + readonly update: ( + transaction: SqlRelationCompletionDocumentTransaction, + ) => SqlRevision; + readonly complete: ( + request: SqlCompletionRequest, + ) => Promise; + readonly onDidChange: ( + listener: (event: SqlSessionChangeEvent) => void, + ) => SqlDisposable; + readonly isCurrent: (revision: SqlRevision) => boolean; + readonly dispose: () => void; +} diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 1848304..5eab07d 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -13,9 +13,16 @@ export function createSqlRevisionToken(): SqlRevision { return revision; } +export interface SqlIdentifierComponent { + readonly value: string; + readonly quoted: boolean; +} + +export type SqlIdentifierPath = readonly SqlIdentifierComponent[]; + export interface SqlCatalogContext { readonly scope: string; - readonly searchPath?: readonly string[]; + readonly searchPath?: readonly SqlIdentifierPath[]; } export interface SqlDocumentContext { diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts new file mode 100644 index 0000000..57ef37f --- /dev/null +++ b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts @@ -0,0 +1,69 @@ +import type { EditorView } from "@codemirror/view"; + +import type { + SqlCompletionInfoResolver, + SqlCompletionInfoResolverContext, +} from "../../src/vnext/codemirror/relation-completion-types.js"; +import type { SqlRelationCompletionItem } from "../../src/vnext/relation-completion-types.js"; + +interface ReactRootLike { + readonly render: (value: unknown) => void; + readonly unmount: () => void; +} + +declare function createReactRoot(container: Element): ReactRootLike; + +const resolveInfo: SqlCompletionInfoResolver = async (item, { signal }) => { + signal.throwIfAborted(); + if (item.provenance.kind !== "catalog") { + return null; + } + + const dom = document.createElement("div"); + const root = createReactRoot(dom); + root.render(`${item.provenance.providerId}:${item.provenance.entityId}`); + return { + destroy: () => root.unmount(), + dom, + }; +}; + +declare const item: SqlRelationCompletionItem; +const resolved = resolveInfo(item, { + signal: new AbortController().signal, +}); + +// @ts-expect-error resolver items are immutable +item.label = "changed"; +if (item.provenance.kind === "catalog") { + // @ts-expect-error catalog provenance is immutable + item.provenance.entityId = "changed"; +} + +// @ts-expect-error live editor state is not a resolver parameter +const resolverWithView: SqlCompletionInfoResolver = ( + _item: SqlRelationCompletionItem, + _context: SqlCompletionInfoResolverContext, + _view: EditorView, +) => null; +// @ts-expect-error arbitrary host values are not CodeMirror completion info +const resolverReturningNumber: SqlCompletionInfoResolver = () => 42; +// @ts-expect-error React-shaped data is not a disposable CodeMirror resource +const resolverReturningReactData: SqlCompletionInfoResolver = () => ({ + props: {}, + type: "table", +}); +const resolverReturningNode: SqlCompletionInfoResolver = () => + // @ts-expect-error custom UI must expose explicit cleanup + document.createElement("div"); +// @ts-expect-error custom UI resources require a destroy hook +const resolverWithoutDestroy: SqlCompletionInfoResolver = () => ({ + dom: document.createElement("div"), +}); + +void resolved; +void resolverReturningNode; +void resolverReturningNumber; +void resolverReturningReactData; +void resolverWithoutDestroy; +void resolverWithView; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts new file mode 100644 index 0000000..dae7078 --- /dev/null +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -0,0 +1,320 @@ +import type { SqlEmbeddedRegion } from "../../src/vnext/source.js"; +import type { + SqlCatalogContext, + SqlDocumentContext, + SqlIdentifierComponent, +} from "../../src/vnext/index.js"; +import type { + SqlCanonicalRelationPath, + SqlCatalogProviderReport, + SqlCatalogReadyCoverage, + SqlCatalogRelation, + SqlCatalogSearchRequest, + SqlCatalogSearchResponse, + SqlCompletionCancellationReason, + SqlCompletionIssue, + SqlRelationCompletionItem, + SqlRelationCompletionList, + SqlRelationCatalogProvider, + SqlRelationCompletionDocumentTransaction, + SqlRelationCompletionDialectRuntime, + SqlRelationCompletionOpenDocument, + SqlRelationCompletionSession, +} from "../../src/vnext/relation-completion-types.js"; + +interface MarimoSqlContext extends SqlDocumentContext { + readonly engine: string; +} + +const catalog: SqlCatalogContext = { + scope: "notebook:demo", + searchPath: [ + [ + { quoted: true, value: "database.with.dot" }, + { quoted: false, value: "main" }, + ], + ], +}; +const context: MarimoSqlContext = { + catalog, + dialect: "duckdb", + engine: "local", +}; +const regions: readonly SqlEmbeddedRegion[] = [ + { from: 14, language: "python", to: 18 }, +]; +const openWithoutRegions: SqlRelationCompletionOpenDocument = { + context, + text: "SELECT * FROM users", +}; +const openWithRegions: SqlRelationCompletionOpenDocument = { + context, + embeddedRegions: regions, + text: "SELECT * FROM {df}", +}; + +declare const session: SqlRelationCompletionSession; +session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT * FROM {next_df}" }, + embeddedRegions: [{ from: 14, language: "python", to: 23 }], +}); +session.update({ + baseRevision: session.revision, + context: { ...context, engine: "remote" }, + document: { kind: "replace", text: "SELECT * FROM remote.users" }, + embeddedRegions: [], +}); +session.update({ + baseRevision: session.revision, + context: { ...context, engine: "remote" }, +}); +session.update({ + baseRevision: session.revision, + embeddedRegions: [], +}); +session.update({ + baseRevision: session.revision, + context, + embeddedRegions: regions, +}); + +const subscription = session.onDidChange((event) => { + const reason: "catalog" | "catalog-availability" | "provider-configuration" = + event.reason; + session.isCurrent(event.revision); + void reason; +}); +subscription.dispose(); +subscription.dispose(); +void session.complete({ + position: 14, + signal: new AbortController().signal, + trigger: { kind: "invoked" }, +}); +void session.complete({ + position: 14, + trigger: { character: ".", kind: "trigger-character" }, +}).then((result) => { + // @ts-expect-error scheduler work identities never enter consumer results + void result.workId; + // @ts-expect-error catalog epochs never enter consumer results + void result.epoch; +}); + +const provider: SqlRelationCatalogProvider = { + id: "marimo", + search: async (request, signal) => { + signal.throwIfAborted(); + const typedRequest: SqlCatalogSearchRequest = request; + void typedRequest; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + subscribe: (_scope, onInvalidation) => { + onInvalidation({ epoch: { generation: 1, token: "tables-updated" } }); + return { dispose: () => undefined }; + }, +}; +void provider; + +const relationPath = [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "relation", value: "users" }, +] satisfies SqlCanonicalRelationPath; +const relation: SqlCatalogRelation = { + canonicalPath: relationPath, + completionPathStart: 1, + entityId: "users", + matchQuality: "exact", + relationKind: "table", +}; +void relation; + +const dialectRuntime: SqlRelationCompletionDialectRuntime = { + cteIdentifiersEqual: (left, right) => + left.quoted === right.quoted && left.value === right.value, + decodeIdentifier: (token) => ({ + component: { quoted: false, value: token }, + quality: "exact", + status: "decoded", + }), + renderRelationPath: (path) => ({ + status: "rendered", + text: path.map((component) => component.value).join("."), + }), +}; +void dialectRuntime; + +// @ts-expect-error semantic catalog roles are a closed set +const invalidRole: SqlCatalogRelation["canonicalPath"][number]["role"] = + "database"; +// @ts-expect-error match evidence is provider-proven and closed +const invalidMatch: SqlCatalogRelation["matchQuality"] = "prefix"; + +const loadingIssue: SqlCompletionIssue = { + reason: "catalog-loading", + remainingIntentLeaseMs: 1_000, +}; +void loadingIssue; + +const flatSearchPath: SqlCatalogContext = { + scope: "local", + // @ts-expect-error a search path is a list of decoded components, not a dot string + searchPath: ["main"], +}; +// @ts-expect-error every decoded component records whether it was quoted +const incompleteComponent: SqlIdentifierComponent = { value: "main" }; +// @ts-expect-error the old update discriminant is not part of the transaction +session.update({ baseRevision: session.revision, kind: "context", context }); +// @ts-expect-error a transaction must change at least one state dimension +session.update({ baseRevision: session.revision }); +// @ts-expect-error document changes require the complete resulting region set +session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, +}); +// @ts-expect-error present undefined is not omission +session.update({ baseRevision: session.revision, context: undefined }); +const missingEngine: SqlRelationCompletionOpenDocument = { + // @ts-expect-error marimo contexts always identify their engine + context: { dialect: "duckdb" }, + text: "", +}; +// @ts-expect-error embedded regions are readonly +regions[0]!.from = 0; +session.onDidChange((event) => { + // @ts-expect-error service-originated event data is readonly + event.reason = "catalog"; +}); +// @ts-expect-error session notifications use a closed reason set +session.onDidChange((_event: { revision: typeof session.revision; reason: "text" }) => {}); + +const missingRelation = [ + { quoted: false, role: "schema", value: "main" }, + // @ts-expect-error a canonical relation path must end in a relation component +] satisfies SqlCanonicalRelationPath; +// @ts-expect-error paginated coverage requires a continuation token +const missingContinuation: SqlCatalogReadyCoverage = { kind: "paginated" }; +const extraContinuation = { + kind: "complete", + // @ts-expect-error complete coverage cannot contain a continuation token + continuationToken: "next", +} satisfies SqlCatalogReadyCoverage; +const leakedRequestState = { + continuationToken: null, + dialectId: "duckdb", + expectedEpoch: null, + limit: 100, + prefix: { quoted: false, value: "" }, + qualifier: [], + scope: "local", + searchPaths: [], + // @ts-expect-error caller cancellation is passed separately + signal: AbortSignal.abort(), +} satisfies SqlCatalogSearchRequest; +const leakedDocumentText = { + continuationToken: null, + dialectId: "duckdb", + expectedEpoch: null, + limit: 100, + prefix: { quoted: false, value: "" }, + qualifier: [], + scope: "local", + searchPaths: [], + // @ts-expect-error providers never receive document text + text: "SELECT * FROM ", +} satisfies SqlCatalogSearchRequest; +const malformedLoading = { + epoch: { generation: 0, token: "zero" }, + // @ts-expect-error loading responses cannot carry relations + relations: [], + status: "loading", +} satisfies SqlCatalogSearchResponse; +const providerRenderedSql = { + canonicalPath: relationPath, + completionPathStart: 1, + entityId: "users", + // @ts-expect-error providers return decoded paths, never SQL insertion text + insertText: "main.users", + matchQuality: "exact", + relationKind: "table", +} satisfies SqlCatalogRelation; +const leakedProviderFailure = { + code: "unavailable", + // @ts-expect-error raw provider errors never cross the completion boundary + error: new Error("secret"), + feature: "relation-catalog", + outcome: "failed", + providerId: "marimo", + retry: "next-request", +} satisfies SqlCatalogProviderReport; +const synchronousProvider: SqlRelationCatalogProvider = { + id: "sync", + // @ts-expect-error relation catalog providers are asynchronous + search: () => ({ + epoch: { generation: 0, token: "zero" }, + status: "loading", + }), +}; +// @ts-expect-error catalog-loading always carries its remaining intent lease +const loadingWithoutLease: SqlCompletionIssue = { reason: "catalog-loading" }; +const mismatchedItem = { + edit: { from: 0, insert: "users", to: 0 }, + label: "users", + provenance: { + // @ts-expect-error CTE items require CTE provenance + entityId: "users", + kind: "catalog", + providerId: "marimo", + }, + relationKind: "cte", +} satisfies SqlRelationCompletionItem; +const contradictoryCompleteList = { + isIncomplete: false, + // @ts-expect-error complete lists cannot carry incomplete issues + issues: [{ reason: "catalog-partial" }], + items: [], +} satisfies SqlRelationCompletionList; +const contradictoryIncompleteList = { + isIncomplete: true, + issues: [], + items: [], + // @ts-expect-error incomplete lists require at least one issue +} satisfies SqlRelationCompletionList; +// @ts-expect-error timeouts are unavailable evidence, not cancellation +const invalidCancellation: SqlCompletionCancellationReason = "timeout"; +// @ts-expect-error a document transaction cannot explicitly omit context +const undefinedContext: SqlRelationCompletionDocumentTransaction = { + baseRevision: session.revision, + context: undefined, + embeddedRegions: [], +}; + +void extraContinuation; +void contradictoryCompleteList; +void contradictoryIncompleteList; +void flatSearchPath; +void incompleteComponent; +void invalidCancellation; +void invalidMatch; +void invalidRole; +void leakedRequestState; +void leakedDocumentText; +void leakedProviderFailure; +void loadingWithoutLease; +void malformedLoading; +void missingContinuation; +void missingEngine; +void missingRelation; +void mismatchedItem; +void openWithRegions; +void openWithoutRegions; +void providerRenderedSql; +void synchronousProvider; +void undefinedContext; From 1ca57512293e8670e1cda7717a074d641f30a22c Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 06:25:51 +0800 Subject: [PATCH 15/42] feat(vnext): make embedded regions atomic session inputs (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - make embedded regions a public, atomic input to document open and update transactions - require every text mutation to carry the complete post-edit region set while preserving omission and explicit-clear semantics for non-document updates - mask embedded regions before statement analysis, with separate source sequencing and conservative cache reuse - align runtime decoding with TypeScript structural typing by copying only declared own data fields and leaving host metadata opaque - migrate the provisional completion contract and packed consumers to the real session transaction types ## Correctness and consumer behavior - text, context, dialect, regions, revision, sequences, and statement cache commit or roll back together - stale revisions are rejected before candidate payload inspection; disposal dominates hostile/reentrant failures - region ranges use resulting-document UTF-16 coordinates and include complete non-SQL delimiters such as marimo's `{df}` - source snapshots and normalized regions are owned and frozen; same-value source transactions still advance revision/source sequence - incremental statement indexing remains limited to identity-to-identity edits; changed masked analysis rebuilds lazily - structurally richer marimo region/envelope values compile and run without invoking or retaining extra host fields - removed outer `kind` and contradictory document fields remain explicitly forbidden in both types and runtime ## Validation - `pnpm run typecheck` - `pnpm exec oxlint` - `pnpm run test:integrity` - `pnpm test` — 1,264 passed, 1 expected failure - `pnpm run test:browser` — 7 passed - `pnpm run test:package` with typed and runtime nonempty embedded regions - `pnpm run test:worker-placement` - `pnpm run demo` - changed coverage — 98.70% statements, 97.42% branches, 100% functions, 98.69% lines - pre-rebase review/fix/re-review loop across SQL/API, marimo ergonomics, and concurrency/performance perspectives - fresh exact-head approval from all three reviewers at `ff35130197fcf69a10d42d195c8dd6324f6d5ed9` Part of #169. --- ## Summary by cubic Make embedded regions a first-class, atomic session input in vNext. Document edits now require the complete post-edit region set, masking happens before analysis, and cache reuse is tied to a separate source sequence. Part of #169. - **New Features** - Embedded regions are accepted on open and update; document mutations must include the full resulting `embeddedRegions`. Region-only and context-only updates are supported. - Region ranges use resulting-document UTF-16 and include full non-SQL delimiters (e.g., marimo `{df}`); masking is length-preserving and runs before statement analysis. - Unchanged text+regions reuse the immutable source and statement index via a separate source sequence; only identity-to-identity edits reuse incrementally. Changed masked analysis invalidates the cache unless analysis text is equal. - Inputs are structural: only declared own data fields are read; host metadata and extras stay opaque. Context accessors and non-enumerable properties are rejected. Stale revisions are rejected before payload inspection; disposal dominates hostile/reentrant failures. - Optional `document`/`embeddedRegions`/`context` fields: `undefined` is treated as omission at runtime for compatibility with and without `exactOptionalPropertyTypes`. - **Migration** - Update `session.update` calls: - Context-only: `{ baseRevision, context }` (no `kind`; `context` must be defined by types). - Document mutation: `{ baseRevision, document: {...}, embeddedRegions: [...] }` (regions required). - Region-only: `{ baseRevision, embeddedRegions: [...] }`. - Open with regions: `service.openDocument({ text, context, embeddedRegions })`. - Use `SqlEmbeddedRegion` and `SqlDocumentUpdate` from `@marimo-team/codemirror-sql/vnext`; stop importing `SqlEmbeddedRegion` from `./source`. Written for commit db6eeee8dd6b31560a327c36e57f2900fa91e4c6. Summary will update on new commits. Review in cubic --- docs/adr/0001-language-service-and-session.md | 15 +- docs/vnext/session-primitives.md | 29 +- docs/vnext/source-coordinates.md | 63 +- package.json | 3 +- scripts/package-smoke.mjs | 25 +- src/vnext/__tests__/session.test.ts | 621 ++++++++++++++++-- src/vnext/__tests__/source.test.ts | 51 +- src/vnext/index.ts | 1 + src/vnext/relation-completion-types.ts | 48 +- src/vnext/session.ts | 257 +++++--- src/vnext/source.ts | 29 +- src/vnext/types.ts | 41 +- .../marimo-relation-completion.test-d.ts | 27 +- test/vnext-types/session.test-d.ts | 92 ++- tsconfig.vnext-tests.loose-optional.json | 6 + 15 files changed, 991 insertions(+), 317 deletions(-) create mode 100644 tsconfig.vnext-tests.loose-optional.json diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index bca9c87..c42390b 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -163,7 +163,9 @@ region set for its resulting text. Without a document mutation, an explicit complete region set can change alone or together with context; omission preserves the current regions. An explicit empty set clears them. Opening a document accepts the same optional complete set, with omission or an empty set -meaning identity source. +meaning identity source. For compatibility with consumers regardless of their +`exactOptionalPropertyTypes` setting, `undefined` on optional state fields has +the same meaning as omission. A full-text replacement is available for simple consumers. Incremental changes are ordered, non-overlapping, absolute UTF-16 ranges in the current document. @@ -463,11 +465,12 @@ set for document or region updates and reuse the existing snapshot only when text and regions are unchanged. Non-identity generated/reordered source remains deferred until a concrete consumer validates the segment model. -Current session edits use identity sources, so validated original-document -changes are also trusted analysis-coordinate changes. A future transformed -source must provide its own validated analysis-coordinate changes for -incremental statement indexing. Without them, changed analysis text -invalidates the index and is rebuilt lazily. +Identity-to-identity session edits can use validated original-document changes +as trusted analysis-coordinate changes. Masked-source edits do not make that +claim: unchanged analysis can reuse its index, while changed analysis +invalidates the index and rebuilds lazily. A future transformed source must +provide its own validated analysis-coordinate changes before incremental +indexing can consume them. Edits crossing an ambiguous or unmapped boundary are rejected. Mapping failure is unavailable analysis, not invalid SQL. diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index e337e02..6c200d3 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -35,12 +35,12 @@ const session = service.openDocument({ }); const revision = session.update({ - kind: "document", baseRevision: session.revision, document: { kind: "changes", changes: [{ from: 14, to: 19, insert: "customers" }], }, + embeddedRegions: [], }); if (session.isCurrent(revision)) { @@ -52,7 +52,9 @@ service.dispose(); ``` Use `{ kind: "replace", text }` for full replacement and -`{ kind: "context", baseRevision, context }` for a context-only update. +`{ baseRevision, context }` for a context-only update. Every document mutation +also supplies the complete embedded-region set for its resulting text. A +region-only transaction can replace or clear that set without a fake text edit. ## Dialect registration @@ -74,7 +76,12 @@ does not itself infer lexical behavior. - `baseRevision` is checked by identity and cannot come from another session. - Incremental ranges are ordered, non-overlapping, half-open UTF-16 offsets in the pre-update document. -- Text and context are validated completely before the session snapshot changes. +- Text, context, and embedded regions are validated completely before the + session snapshot changes. +- Public envelopes, edits, changes, and regions are structural inputs. The + service copies only their declared own data fields; additional host metadata + remains opaque and is never invoked. Removed or contradictory discriminants + are explicitly forbidden rather than treating every object as exact. - Context is structured-cloned and recursively frozen. Accepted values are finite primitives, arrays, and string-keyed plain objects. Cycles and shared references are retained. Accessors, symbols, functions, class instances, @@ -86,13 +93,15 @@ does not itself infer lexical behavior. platform errors are not exposed as causes. The internal statement index is built lazily per session. Context-only updates -reuse it when the lexical-profile identity is unchanged. A no-op document -update or same-text replacement advances the public revision and document -sequence but can reuse the index. Trusted incremental identity-source changes -update a populated cache; a changed replacement, profile change, or changed -transformed source without analysis-coordinate changes clears it for a later -full build. Invalid updates leave both the session snapshot and cache -unchanged, and disposal clears the cache. +reuse it when the lexical-profile identity is unchanged. Source-changing +transactions use a separate source sequence, so region-only changes cannot +reuse a stale index. A no-op document update or same-text replacement advances +the public revision and document sequence but can reuse the index. Trusted +incremental identity-source changes update a populated cache; a changed +replacement, profile change, or changed masked source without verified +analysis-coordinate changes clears it for a later full build. Invalid updates +leave both the session snapshot and cache unchanged, and disposal clears the +cache. The safety envelope currently permits at most 10,000 changes in one update, a 16 Mi-code-unit document, 1,000 registered dialects, and bounded context graph diff --git a/docs/vnext/source-coordinates.md b/docs/vnext/source-coordinates.md index c9a7a0c..92bda6b 100644 --- a/docs/vnext/source-coordinates.md +++ b/docs/vnext/source-coordinates.md @@ -17,10 +17,10 @@ are valid. `SqlTextChange` extends this shape with `insert` and continues to use pre-update document coordinates. The service internally owns an immutable source snapshot with separately named -`originalText` and `analysisText`. A document update creates a new identity -snapshot; a context-only update reuses the same source object. This separation -allows later analysis transforms without making providers depend on editor -state or ambiguous coordinate spaces. +`originalText` and `analysisText`. A source transaction builds the complete +post-update masked snapshot; a context-only update reuses the same source +object. This separation keeps providers independent of editor state and +ambiguous coordinate spaces. ## Length-preserving masking @@ -35,29 +35,52 @@ The first internal transform masks explicit embedded regions: - `analysisText.length` always equals `originalText.length`, so range mapping is identity-preserving while still returning a fresh validated range. +`SqlEmbeddedRegion` is the public document-coordinate input: + +```ts +const session = service.openDocument({ + text: "SELECT * FROM {df}", + context, + embeddedRegions: [{ from: 14, to: 18, language: "python" }], +}); + +session.update({ + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 15, to: 17, insert: "next_df" }], + }, + embeddedRegions: [{ from: 14, to: 23, language: "python" }], +}); +``` + +The half-open interval covers the complete non-SQL fragment, including its +template delimiters. For marimo, `[14, 18)` masks all of `{df}`; masking only +`df` would leave `{}` to be analyzed as SQL. A document transaction always +supplies the complete region set in coordinates of the resulting text. + At most 10,000 regions and 16 Mi UTF-16 code units are accepted. Masking uses bounded 64 Ki-code-unit chunks, including for newline-dense input, rather than a per-code-unit or per-newline array. -The source snapshot, embedded-region model, masking factory, and mapping -functions remain internal. This slice intentionally does not publish a source -transformer or source-map SPI. Generated or reordered source requires a -versioned segment-map design and evidence from real consumers before becoming -public. +The source snapshot, masking factory, and mapping functions remain internal. +Only the document-facing `SqlEmbeddedRegion` input shape is public. This slice +intentionally does not publish a source transformer or source-map SPI. +Generated or reordered source requires a versioned segment-map design and +evidence from real consumers before becoming public. The internal [statement index](./statement-index.md) scans `analysisText` and uses its length-preserving offsets without publishing analysis-coordinate ranges. Statement-index reuse depends on `analysisText` value and internal lexical -profile identity, not source-object identity. A document update therefore -still creates a fresh source snapshot and public revision even when equal -analysis text permits reuse. The index does not retain either the old or -current source text. - -Current sessions create identity sources, so their validated document changes -are also trusted analysis-coordinate changes. The masking primitive is not yet -attached to session updates. A future transformed-source pipeline must produce -validated analysis-coordinate changes before it can use incremental indexing; -otherwise a changed analysis document invalidates the cache and receives a -fresh full build on demand. +profile identity, not source-object identity. Every accepted source transaction +creates a public revision, while an unchanged text-and-region value may reuse +the immutable source snapshot and index. The index does not retain either the +old or current source text. + +Sessions accept complete length-preserving embedded-region sets on open and +source transactions. Original document changes are trusted as analysis changes +only for identity-to-identity updates. A changed masked source invalidates the +cache unless its analysis text is unchanged; it then receives a fresh full +build on demand. diff --git a/package.json b/package.json index d28281b..e16c55a 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ }, "scripts": { "dev": "vite", - "typecheck": "pnpm run typecheck:src && pnpm run typecheck:tests && pnpm run typecheck:vnext-tests && pnpm run typecheck:demo", + "typecheck": "pnpm run typecheck:src && pnpm run typecheck:tests && pnpm run typecheck:vnext-tests && pnpm run typecheck:vnext-tests:loose-optional && pnpm run typecheck:demo", "typecheck:src": "tsc --project tsconfig.json --noEmit", "typecheck:tests": "tsc --project tsconfig.tests.json", "typecheck:vnext-tests": "tsc --project tsconfig.vnext-tests.json", + "typecheck:vnext-tests:loose-optional": "tsc --project tsconfig.vnext-tests.loose-optional.json", "typecheck:demo": "tsc --project tsconfig.demo.json", "lint": "oxlint --fix", "test": "vitest run --config vitest.config.ts", diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index a10c94e..3831876 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -199,12 +199,13 @@ const service = createSqlLanguageService({ }); const session = service.openDocument({ context: { dialect: "duckdb" }, - text: "SELECT 1", + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", }); session.update({ - kind: "document", + embeddedRegions: [{ from: 14, language: "python", to: 23 }], baseRevision: session.revision, - document: { kind: "replace", text: "SELECT 2" }, + document: { kind: "replace", text: "SELECT * FROM {next_df}" }, }); service.dispose(); `, @@ -227,6 +228,7 @@ import { createSqlLanguageService, duckdbDialect, type SqlDocumentContext, + type SqlEmbeddedRegion, type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; @@ -244,15 +246,19 @@ const parser = new NodeSqlParser(); const service = createSqlLanguageService({ dialects: [duckdbDialect()], }); +const embeddedRegions: readonly SqlEmbeddedRegion[] = [ + { from: 14, language: "python", to: 18 }, +]; const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, - text: "SELECT 1", + embeddedRegions, + text: "SELECT * FROM {df}", }); const range: SqlTextRange = { from: 0, to: 6 }; session.update({ - kind: "document", + embeddedRegions: [{ from: 14, language: "python", to: 18 }], baseRevision: session.revision, - document: { kind: "changes", changes: [{ from: 7, insert: "2", to: 8 }] }, + document: { kind: "changes", changes: [] }, }); void extensions; @@ -305,13 +311,14 @@ const service = vnext.createSqlLanguageService({ }); const session = service.openDocument({ context: { dialect: "duckdb" }, - text: "SELECT 1", + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", }); const originalRevision = session.revision; const updatedRevision = session.update({ - kind: "document", + embeddedRegions: [{ from: 14, language: "python", to: 23 }], baseRevision: originalRevision, - document: { kind: "replace", text: "SELECT 2" }, + document: { kind: "replace", text: "SELECT * FROM {next_df}" }, }); if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { throw new Error("The packaged vNext session violated revision identity"); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 16b47b3..d984613 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -177,7 +177,6 @@ describe("statement-index session cache", () => { const { session } = openSession("SELECT 1; SELECT 2"); const index = session.getStatementIndexForTesting(); session.update({ - kind: "context", baseRevision: session.revision, context: { dialect: "duckdb", engine: "remote" }, }); @@ -188,7 +187,6 @@ describe("statement-index session cache", () => { const { session } = openSession("SELECT $$a;b$$;"); const index = session.getStatementIndexForTesting(); session.update({ - kind: "context", baseRevision: session.revision, context: { dialect: "postgresql", engine: "warehouse" }, }); @@ -202,15 +200,15 @@ describe("statement-index session cache", () => { const index = session.getStatementIndexForTesting(); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", changes: [] }, }); - expect(session.snapshotForTesting.source).not.toBe(initialSource); + expect(session.snapshotForTesting.source).toBe(initialSource); expect(session.cachedStatementIndexForTesting).toBe(index); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "SELECT 1" }, }); @@ -221,7 +219,7 @@ describe("statement-index session cache", () => { const { session } = openSession("SELECT 1; SELECT 2; SELECT 3"); const previous = session.getStatementIndexForTesting(); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -247,7 +245,7 @@ describe("statement-index session cache", () => { const revision = session.revision; expectSessionError("stale-revision", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: {} as never, document: { kind: "replace", text: "SELECT 2" }, }); @@ -256,7 +254,7 @@ describe("statement-index session cache", () => { expect(session.cachedStatementIndexForTesting).toBe(index); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "SELECT 2" }, }); @@ -285,12 +283,12 @@ describe("document revisions", () => { const { session } = openSession("A"); const first = session.revision; const second = session.update({ - kind: "document", + embeddedRegions: [], baseRevision: first, document: { kind: "replace", text: "B" }, }); const third = session.update({ - kind: "document", + embeddedRegions: [], baseRevision: second, document: { kind: "replace", text: "A" }, }); @@ -308,7 +306,7 @@ describe("document revisions", () => { expect(first.session.isCurrent(second.session.revision)).toBe(false); expectSessionError("stale-revision", () => { first.session.update({ - kind: "document", + embeddedRegions: [], baseRevision: second.session.revision, document: { kind: "replace", text: "SELECT 1" }, }); @@ -324,7 +322,7 @@ describe("document revisions", () => { const { session } = openSession(); const first = session.revision; const second = session.update({ - kind: "document", + embeddedRegions: [], baseRevision: first, document: { kind: "changes", changes: [] }, }); @@ -336,41 +334,491 @@ describe("document revisions", () => { const { session } = openSession("SELECT 1"); const first = session.revision; const second = session.update({ - kind: "document", + embeddedRegions: [], baseRevision: first, document: { kind: "replace", text: "SELECT 1" }, }); expect(second).not.toBe(first); }); - it("reuses source snapshots only for context-only updates", () => { + it("reuses source snapshots when text and regions are unchanged", () => { const { session } = openSession("SELECT 1"); const initialSource = session.snapshotForTesting.source; expect(Object.isFrozen(initialSource)).toBe(true); expect(initialSource.analysisText).toBe(initialSource.originalText); session.update({ - kind: "context", baseRevision: session.revision, context: { dialect: "duckdb", engine: "remote" }, }); expect(session.snapshotForTesting.source).toBe(initialSource); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", changes: [] }, }); - expect(session.snapshotForTesting.source).not.toBe(initialSource); + expect(session.snapshotForTesting.source).toBe(initialSource); expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); }); }); +describe("embedded-region session transactions", () => { + it("opens omitted, empty, and masked sources with owned frozen regions", () => { + const service = createService(); + const omitted = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text: "SELECT 1", + }); + const empty = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [], + text: "SELECT 1", + }); + const region = { from: 14, language: "python", to: 18 }; + const regions = [region]; + const masked = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: regions, + text: "SELECT * FROM {df}", + }); + + region.from = 0; + regions.length = 0; + expect(omitted.snapshotForTesting.source.analysisText).toBe("SELECT 1"); + expect(empty.snapshotForTesting.source.analysisText).toBe("SELECT 1"); + expect(masked.snapshotForTesting.source).toMatchObject({ + analysisText: "SELECT * FROM ", + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + originalText: "SELECT * FROM {df}", + }); + expect(Object.isFrozen(masked.snapshotForTesting.source)).toBe(true); + expect(Object.isFrozen(masked.snapshotForTesting.source.embeddedRegions)).toBe( + true, + ); + expect( + Object.isFrozen(masked.snapshotForTesting.source.embeddedRegions[0]), + ).toBe(true); + }); + + it("indexes masked analysis rather than embedded SQL-like text", () => { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ from: 7, language: "python", to: 12 }], + text: "SELECT {x;y}; SELECT 2", + }); + + const source = session.snapshotForTesting.source; + expect(session.getStatementIndexForTesting()).toEqual( + buildSqlStatementIndex( + source.analysisText, + DUCKDB_SQL_LEXICAL_PROFILE, + ), + ); + expect(source.analysisText).toBe("SELECT ; SELECT 2"); + }); + + it("commits text, context, and post-edit regions in one revision", () => { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", + }); + const previous = session.revision; + + const revision = session.update({ + baseRevision: previous, + context: { dialect: "postgresql", engine: "warehouse" }, + document: { + changes: [{ from: 15, insert: "next_df", to: 17 }], + kind: "changes", + }, + embeddedRegions: [{ from: 14, language: "python", to: 23 }], + }); + + expect(revision).not.toBe(previous); + expect(session.snapshotForTesting).toMatchObject({ + context: { dialect: "postgresql", engine: "warehouse" }, + source: { + analysisText: "SELECT * FROM ", + embeddedRegions: [{ from: 14, language: "python", to: 23 }], + originalText: "SELECT * FROM {next_df}", + }, + }); + }); + + it("supports region-only, context-plus-region, and explicit clear updates", () => { + const { session } = openSession("SELECT * FROM {df}"); + const initial = session.snapshotForTesting; + + session.update({ + baseRevision: session.revision, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + }); + expect(session.snapshotForTesting.documentSequence).toBe( + initial.documentSequence, + ); + expect(session.snapshotForTesting.source.analysisText).toBe( + "SELECT * FROM ", + ); + + session.update({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "remote" }, + embeddedRegions: [{ from: 14, language: "jinja", to: 18 }], + }); + expect(session.snapshotForTesting.context.engine).toBe("remote"); + expect(session.snapshotForTesting.source.embeddedRegions[0]?.language).toBe( + "jinja", + ); + + session.update({ + baseRevision: session.revision, + embeddedRegions: [], + }); + expect(session.snapshotForTesting.source.analysisText).toBe( + "SELECT * FROM {df}", + ); + }); + + it("owns update regions and advances every accepted source transaction", () => { + const { session } = openSession("SELECT * FROM {df}"); + const region = { from: 14, language: "python", to: 18 }; + const regions = [region]; + const initial = session.snapshotForTesting; + + const firstRevision = session.update({ + baseRevision: initial.revision, + embeddedRegions: regions, + }); + const first = session.snapshotForTesting; + expect(first.revision).toBe(firstRevision); + expect(first.sourceSequence).toBe(initial.sourceSequence + 1); + + region.from = 0; + region.language = "jinja"; + regions.length = 0; + expect(first.source.embeddedRegions).toEqual([ + { from: 14, language: "python", to: 18 }, + ]); + + const secondRevision = session.update({ + baseRevision: firstRevision, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + }); + const second = session.snapshotForTesting; + expect(secondRevision).not.toBe(firstRevision); + expect(second.sourceSequence).toBe(first.sourceSequence + 1); + expect(second.source).toBe(first.source); + + session.update({ + baseRevision: secondRevision, + embeddedRegions: [{ from: 14, language: "jinja", to: 18 }], + }); + session.update({ + baseRevision: session.revision, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + }); + expect(session.snapshotForTesting.source.embeddedRegions).toEqual([ + { from: 14, language: "python", to: 18 }, + ]); + }); + + it("validates complete regions against the resulting document", () => { + const { session } = openSession("A"); + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "ABCDE" }, + embeddedRegions: [{ from: 1, language: "python", to: 5 }], + }); + expect(session.snapshotForTesting.source.analysisText).toBe("A "); + + const snapshot = session.snapshotForTesting; + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: snapshot.revision, + document: { kind: "replace", text: "A" }, + embeddedRegions: [{ from: 1, language: "python", to: 5 }], + }); + }); + expect(session.snapshotForTesting).toBe(snapshot); + }); + + it("creates and removes template delimiters in atomic transactions", () => { + const { session } = openSession("SELECT * FROM df"); + + session.update({ + baseRevision: session.revision, + document: { + changes: [ + { from: 14, insert: "{", to: 14 }, + { from: 16, insert: "}", to: 16 }, + ], + kind: "changes", + }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + }); + expect(session.snapshotForTesting.source).toMatchObject({ + analysisText: "SELECT * FROM ", + originalText: "SELECT * FROM {df}", + }); + + session.update({ + baseRevision: session.revision, + document: { + changes: [ + { from: 14, insert: "", to: 15 }, + { from: 17, insert: "", to: 18 }, + ], + kind: "changes", + }, + embeddedRegions: [], + }); + expect(session.snapshotForTesting.source).toMatchObject({ + analysisText: "SELECT * FROM df", + embeddedRegions: [], + originalText: "SELECT * FROM df", + }); + }); + + it("rolls back document, context, source, revision, and cache together", () => { + const { session } = openSession("SELECT 1; SELECT 2"); + const index = session.getStatementIndexForTesting(); + const snapshot = session.snapshotForTesting; + + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: snapshot.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + document: { kind: "replace", text: "SELECT {x}" }, + embeddedRegions: [{ from: 7, language: "python", to: 100 }], + }); + }); + expect(session.snapshotForTesting).toBe(snapshot); + expect(session.cachedStatementIndexForTesting).toBe(index); + + expectSessionError("invalid-dialect", () => { + session.update({ + baseRevision: snapshot.revision, + context: { dialect: "unknown", engine: "warehouse" }, + document: { kind: "replace", text: "SELECT 2" }, + embeddedRegions: [], + }); + }); + expect(session.snapshotForTesting).toBe(snapshot); + expect(session.cachedStatementIndexForTesting).toBe(index); + }); + + it("checks stale revisions before inspecting candidate payloads", () => { + const { session } = openSession("SELECT 1"); + const stale = session.revision; + session.update({ + baseRevision: stale, + context: { dialect: "duckdb", engine: "remote" }, + }); + let invoked = false; + const update = { + baseRevision: stale, + get document() { + invoked = true; + return { kind: "replace", text: "SELECT 2" }; + }, + get embeddedRegions() { + invoked = true; + return []; + }, + }; + + expectSessionError("stale-revision", () => { + session.update(update as never); + }); + expect(invoked).toBe(false); + }); + + it("reuses equal analysis and invalidates changed masking", () => { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ from: 7, language: "python", to: 12 }], + text: "SELECT {x;y}; SELECT 2", + }); + const index = session.getStatementIndexForTesting(); + + session.update({ + baseRevision: session.revision, + embeddedRegions: [{ from: 7, language: "jinja", to: 12 }], + }); + expect(session.cachedStatementIndexForTesting).toBe(index); + + session.update({ + baseRevision: session.revision, + embeddedRegions: [], + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expect(session.getStatementIndexForTesting()).toEqual( + buildSqlStatementIndex( + session.snapshotForTesting.source.analysisText, + DUCKDB_SQL_LEXICAL_PROFILE, + ), + ); + }); + + it("does not incrementally reuse original edits across masking", () => { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ from: 7, language: "python", to: 12 }], + text: "SELECT {x;y}; SELECT 2", + }); + session.getStatementIndexForTesting(); + + session.update({ + baseRevision: session.revision, + document: { + changes: [{ from: 8, insert: "xx", to: 9 }], + kind: "changes", + }, + embeddedRegions: [{ from: 7, language: "python", to: 13 }], + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expect(session.getStatementIndexForTesting()).toEqual( + buildSqlStatementIndex( + session.snapshotForTesting.source.analysisText, + DUCKDB_SQL_LEXICAL_PROFILE, + ), + ); + }); + + it("accepts structural supersets without inspecting host metadata", () => { + const service = createService(); + let invoked = false; + const input = { + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [ + { from: 0, hostNodeId: "cell-1", language: "python", to: 1 }, + ], + get hostMetadata() { + invoked = true; + throw new Error("must stay opaque"); + }, + text: "x", + }; + const session = service.openDocument(input); + expect(session.snapshotForTesting.source.analysisText).toBe(" "); + + const update = { + baseRevision: session.revision, + embeddedRegions: [ + { from: 0, hostNodeId: "cell-2", language: "jinja", to: 1 }, + ], + get hostMetadata() { + invoked = true; + throw new Error("must stay opaque"); + }, + [Symbol("host")]: true, + }; + session.update(update); + expect(session.snapshotForTesting.source.embeddedRegions).toEqual([ + { from: 0, language: "jinja", to: 1 }, + ]); + expect(invoked).toBe(false); + }); + + it("rejects malformed open and update region contracts", () => { + const { session } = openSession("SELECT 1"); + const snapshot = session.snapshotForTesting; + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, + embeddedRegions: undefined, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + kind: "document", + baseRevision: session.revision, + embeddedRegions: [], + } as never); + }); + expect(session.snapshotForTesting).toBe(snapshot); + }); + + it("keeps a valid outer region update after a rejected reentrant update", () => { + const { session } = openSession("x"); + let nestedError: SqlSessionError | undefined; + let attempted = false; + const region = new Proxy( + { from: 0, language: "python", to: 1 }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "from" && !attempted) { + attempted = true; + try { + session.update({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "nested" }, + }); + } catch (error) { + if (error instanceof SqlSessionError) { + nestedError = error; + } + } + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + session.update({ + baseRevision: session.revision, + embeddedRegions: [region], + }); + expect(nestedError?.code).toBe("reentrant-update"); + expect(session.snapshotForTesting.source.analysisText).toBe(" "); + }); + + it("lets disposal dominate a hostile region failure", () => { + const { session } = openSession("x"); + const snapshot = session.snapshotForTesting; + const region = new Proxy( + { from: 0, language: "python", to: 1 }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "from") { + session.dispose(); + throw new Error("hostile"); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + expectSessionError("session-disposed", () => { + session.update({ + baseRevision: session.revision, + embeddedRegions: [region], + }); + }); + expect(session.snapshotForTesting).toBe(snapshot); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expect(session.isCurrent(snapshot.revision)).toBe(false); + }); +}); + describe("document changes", () => { it("applies ordered changes in pre-update coordinates", () => { const { session } = openSession("SELECT users.id FROM users"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -389,7 +837,7 @@ describe("document changes", () => { it("uses JavaScript UTF-16 offsets", () => { const { session } = openSession("A😀B"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -402,7 +850,7 @@ describe("document changes", () => { it("keeps same-position insertions ordered", () => { const { session } = openSession("AB"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -418,7 +866,7 @@ describe("document changes", () => { it("supports adjacent replacements and document boundaries", () => { const { session } = openSession("ABCDE"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -436,7 +884,7 @@ describe("document changes", () => { it("deletes the entire document", () => { const { session } = openSession("SELECT 1"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", changes: [{ from: 0, insert: "", to: 8 }] }, }); @@ -446,7 +894,7 @@ describe("document changes", () => { it("allows edits at every UTF-16 boundary", () => { const { session } = openSession("A😀e\u0301\r\nZ\uD800"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -477,7 +925,7 @@ describe("document changes", () => { expectSessionError("invalid-change", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: revision, document: { kind: "changes", changes: [change] }, }); @@ -492,7 +940,7 @@ describe("document changes", () => { const revision = session.revision; expectSessionError("invalid-change", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: revision, document: { kind: "changes", @@ -511,7 +959,7 @@ describe("document changes", () => { const { session } = openSession("ABC"); expectSessionError("invalid-change", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -532,7 +980,7 @@ describe("document changes", () => { const { session } = openSession("ABC"); const revision = session.revision; expectSessionError("invalid-update", () => { - session.update({ kind: "document", baseRevision: revision, document } as never); + session.update({ embeddedRegions: [], baseRevision: revision, document } as never); }); expect(session.revision).toBe(revision); expect(session.snapshotForTesting.source.originalText).toBe("ABC"); @@ -541,7 +989,7 @@ describe("document changes", () => { it("rejects an empty JavaScript update", () => { const { session } = openSession("ABC"); expectSessionError("invalid-update", () => { - session.update({ kind: "document", baseRevision: session.revision } as never); + session.update({ baseRevision: session.revision } as never); }); }); @@ -555,28 +1003,26 @@ describe("document changes", () => { }); expectSessionError("invalid-update", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "unknown" }, } as never); }); expectSessionError("invalid-update", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "ABC", changes: [] }, } as never); }); expectSessionError("invalid-update", () => { session.update({ - kind: "context", baseRevision: session.revision, context: undefined, } as never); }); expectSessionError("invalid-update", () => { session.update({ - kind: "context", baseRevision: session.revision, context: { dialect: "duckdb", engine: "local" }, document: { kind: "replace", text: "lost" }, @@ -598,7 +1044,7 @@ describe("document changes", () => { session.update(update as never); }); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "recovered" }, }); @@ -636,25 +1082,14 @@ describe("document changes", () => { expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); - it("rejects undefined and accessor optional update fields", () => { + it("rejects accessor optional update fields", () => { const { session } = openSession("ABC"); const revision = session.revision; - expectSessionError("invalid-update", () => { - session.update({ - kind: "document", - baseRevision: revision, - context: undefined, - document: { kind: "replace", text: "changed" }, - } as never); - }); - expect(session.revision).toBe(revision); - expect(session.snapshotForTesting.source.originalText).toBe("ABC"); - let invoked = false; const update = { baseRevision: session.revision, document: { kind: "replace", text: "changed" }, - kind: "document", + embeddedRegions: [], get context() { invoked = true; return { dialect: "postgresql", engine: "warehouse" }; @@ -668,6 +1103,53 @@ describe("document changes", () => { expect(session.snapshotForTesting.source.originalText).toBe("ABC"); }); + it("treats undefined optional state fields as omission", () => { + const service = createService(); + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: undefined, + text: "SELECT 1", + }); + + const contextRevision = session.update({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "warehouse" }, + document: undefined, + embeddedRegions: undefined, + }); + expect(session.revision).toBe(contextRevision); + expect(session.snapshotForTesting.context.engine).toBe("warehouse"); + + const regionRevision = session.update({ + baseRevision: session.revision, + context: undefined, + document: undefined, + embeddedRegions: [{ from: 7, language: "python", to: 8 }], + }); + expect(session.revision).toBe(regionRevision); + expect(session.snapshotForTesting.source.embeddedRegions).toEqual([ + { from: 7, language: "python", to: 8 }, + ]); + + const replacementRevision = session.update({ + baseRevision: session.revision, + context: undefined, + document: { kind: "replace", text: "SELECT 2" }, + embeddedRegions: [], + }); + expect(session.revision).toBe(replacementRevision); + expect(session.snapshotForTesting.source.originalText).toBe("SELECT 2"); + + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: session.revision, + context: undefined, + document: undefined, + embeddedRegions: undefined, + } as never); + }); + }); + it("rejects accessor document fields without invoking them", () => { const { session } = openSession("ABC"); let invoked = false; @@ -680,7 +1162,7 @@ describe("document changes", () => { }; expectSessionError("invalid-update", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document, }); @@ -702,7 +1184,7 @@ describe("document changes", () => { } expectSessionError("invalid-change", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", changes: [change] }, } as never); @@ -727,7 +1209,7 @@ describe("document changes", () => { attempted = true; try { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: originalRevision, document: { kind: "replace", text: "nested" }, }); @@ -743,7 +1225,7 @@ describe("document changes", () => { ); const revision = session.update({ - kind: "document", + embeddedRegions: [], baseRevision: originalRevision, document, }); @@ -773,7 +1255,7 @@ describe("document changes", () => { expectSessionError("session-disposed", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: revision, document, }); @@ -788,7 +1270,7 @@ describe("document changes", () => { tooManyChanges.length = 10_001; expectSessionError("invalid-change", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", changes: tooManyChanges }, }); @@ -796,7 +1278,7 @@ describe("document changes", () => { expectSessionError("invalid-document", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "changes", @@ -917,7 +1399,7 @@ describe("document context", () => { it("updates text and context atomically", () => { const { session } = openSession("SELECT 1"); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, context: { dialect: "postgresql", engine: "warehouse" }, document: { kind: "replace", text: "SELECT 2" }, @@ -931,7 +1413,7 @@ describe("document context", () => { const snapshot = session.snapshotForTesting; expectSessionError("invalid-update", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: snapshot.revision, context: { dialect: "duckdb", engine: "remote" }, document: { kind: "replace", text: 42 }, @@ -943,7 +1425,6 @@ describe("document context", () => { it("supports context-only updates", () => { const { session } = openSession("SELECT 1"); session.update({ - kind: "context", baseRevision: session.revision, context: { dialect: "postgresql", engine: "warehouse" }, }); @@ -955,7 +1436,7 @@ describe("document context", () => { const { session } = openSession(); const context = session.snapshotForTesting.context; session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "SELECT 1" }, }); @@ -965,9 +1446,9 @@ describe("document context", () => { it("clones a supplied context again on every update", () => { const { session } = openSession(); const context: TestContext = { dialect: "duckdb", engine: "local" }; - session.update({ kind: "context", baseRevision: session.revision, context }); + session.update({ baseRevision: session.revision, context }); const firstOwned = session.snapshotForTesting.context; - session.update({ kind: "context", baseRevision: session.revision, context }); + session.update({ baseRevision: session.revision, context }); expect(session.snapshotForTesting.context).not.toBe(firstOwned); }); @@ -993,7 +1474,7 @@ describe("document context", () => { const revision = session.revision; expectSessionError(error as SqlSessionError["code"], () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: revision, context, document: { kind: "replace", text: "SELECT 2" }, @@ -1019,6 +1500,18 @@ describe("document context", () => { expect(invoked).toBe(false); }); + it("reports non-enumerable context properties accurately", () => { + const context = { dialect: "duckdb" }; + Object.defineProperty(context, "engine", { + enumerable: false, + value: "local", + }); + + expect(() => { + createService().openDocument({ context: context as TestContext, text: "" }); + }).toThrowError("SQL document context cannot contain non-enumerable properties"); + }); + it("rejects symbol keys", () => { const context = { dialect: "duckdb", @@ -1171,7 +1664,7 @@ describe("document context", () => { it("checks a stale base before inspecting candidate context", () => { const { session } = openSession(); const stale = session.revision; - session.update({ kind: "document", baseRevision: stale, document: { kind: "replace", text: "SELECT 1" } }); + session.update({ embeddedRegions: [], baseRevision: stale, document: { kind: "replace", text: "SELECT 1" } }); let inspected = false; const context = { dialect: "duckdb", @@ -1181,7 +1674,7 @@ describe("document context", () => { }, }; expectSessionError("stale-revision", () => { - session.update({ kind: "context", baseRevision: stale, context }); + session.update({ baseRevision: stale, context }); }); expect(inspected).toBe(false); }); @@ -1197,7 +1690,7 @@ describe("lifecycle", () => { }); const { dispose, isCurrent, update } = session; const revision = update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "SELECT 1" }, }); @@ -1225,7 +1718,7 @@ describe("lifecycle", () => { expect(session.isCurrent(revision)).toBe(false); expectSessionError("session-disposed", () => { session.update({ - kind: "document", + embeddedRegions: [], baseRevision: revision, document: { kind: "replace", text: "SELECT 1" }, }); diff --git a/src/vnext/__tests__/source.test.ts b/src/vnext/__tests__/source.test.ts index ce8c2db..6dff950 100644 --- a/src/vnext/__tests__/source.test.ts +++ b/src/vnext/__tests__/source.test.ts @@ -210,7 +210,7 @@ describe("SQL source snapshots", () => { }); }); - it("rejects sparse, oversized, and extended region arrays", () => { + it("rejects sparse and oversized region arrays", () => { const sparse: unknown[] = []; sparse.length = 1; expectSourceError("invalid-source", () => { @@ -222,12 +222,51 @@ describe("SQL source snapshots", () => { expectSourceError("invalid-region", () => { createMaskedSqlSource("a", oversized); }); + }); + it("copies declared region fields and ignores structural extras", () => { const extended = [{ from: 0, language: "python", to: 1 }]; - Object.defineProperty(extended, "custom", { value: true }); - expectSourceError("invalid-region", () => { - createMaskedSqlSource("a", extended); + Object.defineProperty(extended, "custom", { + get() { + throw new Error("must stay opaque"); + }, + }); + expect(createMaskedSqlSource("a", extended).analysisText).toBe(" "); + + const nonEnumerableRegion = { from: 0, language: "python", to: 1 }; + Object.defineProperty(nonEnumerableRegion, "language", { + enumerable: false, + value: "python", }); + expect(createMaskedSqlSource("a", [nonEnumerableRegion]).analysisText).toBe( + " ", + ); + + let invoked = false; + const symbolRegion = { + from: 0, + get hostMetadata() { + invoked = true; + throw new Error("must stay opaque"); + }, + language: "python", + to: 1, + [Symbol("hidden")]: true, + }; + expect(createMaskedSqlSource("a", [symbolRegion]).analysisText).toBe(" "); + expect(invoked).toBe(false); + + const proxiedRegion = new Proxy( + { from: 0, language: "python", to: 1 }, + { + ownKeys() { + invoked = true; + throw new Error("must stay opaque"); + }, + }, + ); + expect(createMaskedSqlSource("a", [proxiedRegion]).analysisText).toBe(" "); + expect(invoked).toBe(false); }); it("does not invoke region accessors or array get traps", () => { @@ -278,7 +317,7 @@ describe("SQL source snapshots", () => { new Proxy( [], { - ownKeys() { + getOwnPropertyDescriptor() { throw new Error("hostile"); }, }, @@ -302,7 +341,7 @@ describe("SQL source snapshots", () => { new Proxy( [], { - ownKeys() { + getOwnPropertyDescriptor() { throw hostileError; }, }, diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 02aea16..d850c9f 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -13,6 +13,7 @@ export type { SqlDocumentChanges, SqlDocumentContext, SqlDocumentEdit, + SqlEmbeddedRegion, SqlDocumentReplacement, SqlDocumentSession, SqlDocumentUpdate, diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index d6262d3..7178557 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -1,8 +1,6 @@ -import type { SqlEmbeddedRegion } from "./source.js"; import type { - SqlContextInput, SqlDocumentContext, - SqlDocumentEdit, + SqlDocumentUpdate, SqlIdentifierComponent, SqlIdentifierPath, SqlRevision, @@ -166,48 +164,6 @@ export interface SqlRelationCompletionDialectRuntime { ) => boolean; } -export interface SqlRelationCompletionOpenDocument< - Context extends SqlDocumentContext, -> { - readonly text: string; - readonly context: SqlContextInput; - readonly embeddedRegions?: readonly SqlEmbeddedRegion[]; -} - -interface SqlRelationCompletionUpdateBase { - readonly baseRevision: SqlRevision; -} - -type SqlRelationCompletionDocumentUpdate< - Context extends SqlDocumentContext, -> = SqlRelationCompletionUpdateBase & { - readonly document: SqlDocumentEdit; - readonly embeddedRegions: readonly SqlEmbeddedRegion[]; - readonly context?: SqlContextInput; -}; - -type SqlRelationCompletionContextUpdate< - Context extends SqlDocumentContext, -> = SqlRelationCompletionUpdateBase & { - readonly document?: never; - readonly context: SqlContextInput; - readonly embeddedRegions?: readonly SqlEmbeddedRegion[]; -}; - -type SqlRelationCompletionRegionUpdate = - SqlRelationCompletionUpdateBase & { - readonly document?: never; - readonly context?: never; - readonly embeddedRegions: readonly SqlEmbeddedRegion[]; - }; - -export type SqlRelationCompletionDocumentTransaction< - Context extends SqlDocumentContext, -> = - | SqlRelationCompletionDocumentUpdate - | SqlRelationCompletionContextUpdate - | SqlRelationCompletionRegionUpdate; - export type SqlSessionChangeReason = | "catalog" | "catalog-availability" @@ -372,7 +328,7 @@ export interface SqlRelationCompletionSession< > { readonly revision: SqlRevision; readonly update: ( - transaction: SqlRelationCompletionDocumentTransaction, + transaction: SqlDocumentUpdate, ) => SqlRevision; readonly complete: ( request: SqlCompletionRequest, diff --git a/src/vnext/session.ts b/src/vnext/session.ts index f18ef0b..4d1dfbe 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -21,6 +21,7 @@ import { } from "./statement-index.js"; import { createIdentitySqlSource, + createMaskedSqlSource, isSqlSourceError, MAX_SQL_SOURCE_LENGTH, normalizeSqlTextRange, @@ -160,6 +161,12 @@ function getDataProperties(value: object): DataProperties { "SQL document context cannot contain accessors", ); } + if (!descriptor.enumerable) { + throw new SqlSessionError( + "invalid-context", + "SQL document context cannot contain non-enumerable properties", + ); + } values.push(descriptor.value); } return { keyLength, values }; @@ -395,6 +402,20 @@ function readRequiredDataProperty( return property.value; } +function rejectOwnProperty( + value: object, + key: PropertyKey, + code: SqlSessionError["code"], + subject: string, +): void { + if (readOwnDataProperty(value, key, code, subject).found) { + throw new SqlSessionError( + code, + `${subject} cannot contain ${String(key)}`, + ); + } +} + function validateDocumentLength(text: string): void { if (text.length > MAX_SQL_SOURCE_LENGTH) { throw new SqlSessionError( @@ -507,6 +528,29 @@ function applyChanges(text: string, changes: readonly SqlTextChange[]): string { return output.join(""); } +function haveEqualEmbeddedRegions( + left: SqlSourceSnapshot, + right: SqlSourceSnapshot, +): boolean { + if (left.embeddedRegions.length !== right.embeddedRegions.length) { + return false; + } + for (let index = 0; index < left.embeddedRegions.length; index += 1) { + const leftRegion = left.embeddedRegions[index]; + const rightRegion = right.embeddedRegions[index]; + if ( + !leftRegion || + !rightRegion || + leftRegion.from !== rightRegion.from || + leftRegion.to !== rightRegion.to || + leftRegion.language !== rightRegion.language + ) { + return false; + } + } + return true; +} + interface SessionSnapshot { readonly contextSequence: number; readonly context: Context; @@ -515,12 +559,13 @@ interface SessionSnapshot { readonly revision: SqlRevision; readonly sequence: number; readonly source: SqlSourceSnapshot; + readonly sourceSequence: number; } interface StatementIndexCache { - readonly documentSequence: number; readonly index: SqlStatementIndex; readonly lexicalProfile: SqlLexicalProfile; + readonly sourceSequence: number; } export class DefaultSqlDocumentSession @@ -544,6 +589,7 @@ export class DefaultSqlDocumentSession const sequence = 0; const contextSequence = 0; const documentSequence = 0; + const sourceSequence = 0; const dialect = resolveDialectRuntime(context, dialects); this.#snapshot = Object.freeze({ contextSequence, @@ -553,6 +599,7 @@ export class DefaultSqlDocumentSession revision: createSqlRevisionToken(), sequence, source, + sourceSequence, }); } @@ -578,7 +625,7 @@ export class DefaultSqlDocumentSession const cached = this.#statementIndexCache; if ( cached && - cached.documentSequence === this.#snapshot.documentSequence && + cached.sourceSequence === this.#snapshot.sourceSequence && cached.lexicalProfile === this.#snapshot.dialect.lexicalProfile ) { return cached.index; @@ -588,9 +635,9 @@ export class DefaultSqlDocumentSession this.#snapshot.dialect.lexicalProfile, ); this.#statementIndexCache = Object.freeze({ - documentSequence: this.#snapshot.documentSequence, index, lexicalProfile: this.#snapshot.dialect.lexicalProfile, + sourceSequence: this.#snapshot.sourceSequence, }); return index; } @@ -609,6 +656,12 @@ export class DefaultSqlDocumentSession try { return this.#applyUpdate(update); } catch (error) { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session was disposed during the update", + ); + } if (error instanceof SqlSessionError) { throw error; } @@ -628,114 +681,88 @@ export class DefaultSqlDocumentSession "SQL document update must be an object", ); } - const kind = readRequiredDataProperty( + const baseRevision = readRequiredDataProperty( + update, + "baseRevision", + "invalid-update", + "SQL document update", + ); + if (baseRevision !== this.#snapshot.revision) { + throw new SqlSessionError("stale-revision", "SQL document revision is stale"); + } + rejectOwnProperty( update, "kind", "invalid-update", "SQL document update", ); - const baseRevision = readRequiredDataProperty( + + const document = readOwnDataProperty( update, - "baseRevision", + "document", "invalid-update", "SQL document update", ); - if (baseRevision !== this.#snapshot.revision) { - throw new SqlSessionError("stale-revision", "SQL document revision is stale"); + const context = readOwnDataProperty( + update, + "context", + "invalid-update", + "SQL document update", + ); + const embeddedRegions = readOwnDataProperty( + update, + "embeddedRegions", + "invalid-update", + "SQL document update", + ); + const hasDocument = document.found && document.value !== undefined; + const hasContext = context.found && context.value !== undefined; + const hasEmbeddedRegions = + embeddedRegions.found && embeddedRegions.value !== undefined; + if (!hasDocument && !hasContext && !hasEmbeddedRegions) { + throw new SqlSessionError( + "invalid-update", + "SQL document update must change document, context, or embedded regions", + ); } - if (kind !== "document" && kind !== "context") { + if (hasDocument && !hasEmbeddedRegions) { throw new SqlSessionError( "invalid-update", - "SQL document update kind must be document or context", + "SQL document mutations require the complete resulting embedded regions", ); } let nextContextSequence = this.#snapshot.contextSequence; let nextContext = this.#snapshot.context; let nextDocumentSequence = this.#snapshot.documentSequence; + let nextSourceSequence = this.#snapshot.sourceSequence; let nextSource = this.#snapshot.source; let documentMutation: "changes" | "none" | "replace" = "none"; let trustedAnalysisChanges: readonly SqlTextChange[] | null = null; - if (kind === "context") { - if ( - readOwnDataProperty( - update, - "document", - "invalid-update", - "SQL context update", - ).found - ) { - throw new SqlSessionError( - "invalid-update", - "SQL context update cannot contain a document mutation", - ); - } - const context = readRequiredDataProperty( - update, - "context", - "invalid-update", - "SQL context update", - ); - if (context === undefined) { - throw new SqlSessionError( - "invalid-update", - "SQL context update requires a context value", - ); - } - nextContext = cloneContext(context); - nextContextSequence += 1; - } else { - const document = readRequiredDataProperty( - update, - "document", - "invalid-update", - "SQL document update", - ); - const context = readOwnDataProperty( - update, - "context", - "invalid-update", - "SQL document update", - ); - if (context.found) { - if (context.value === undefined) { - throw new SqlSessionError( - "invalid-update", - "SQL document update context cannot be undefined", - ); - } - nextContext = cloneContext(context.value); - nextContextSequence += 1; - } - if (document === null || typeof document !== "object") { + let nextText = this.#snapshot.source.originalText; + if (hasDocument) { + if (document.value === null || typeof document.value !== "object") { throw new SqlSessionError( "invalid-update", "SQL document mutation must be an object", ); } const documentKind = readRequiredDataProperty( - document, + document.value, "kind", "invalid-update", "SQL document mutation", ); if (documentKind === "replace") { - if ( - readOwnDataProperty( - document, - "changes", - "invalid-update", - "SQL document replacement", - ).found - ) { - throw new SqlSessionError( - "invalid-update", - "SQL document replacement cannot also contain changes", - ); - } + rejectOwnProperty( + document.value, + "changes", + "invalid-update", + "SQL document replacement", + ); const text = readRequiredDataProperty( - document, + document.value, "text", "invalid-update", "SQL document replacement", @@ -747,24 +774,17 @@ export class DefaultSqlDocumentSession ); } validateDocumentLength(text); - nextSource = createIdentitySqlSource(text); + nextText = text; documentMutation = "replace"; } else if (documentKind === "changes") { - if ( - readOwnDataProperty( - document, - "text", - "invalid-update", - "SQL document changes", - ).found - ) { - throw new SqlSessionError( - "invalid-update", - "SQL document changes cannot also contain replacement text", - ); - } + rejectOwnProperty( + document.value, + "text", + "invalid-update", + "SQL document changes", + ); const changes = readRequiredDataProperty( - document, + document.value, "changes", "invalid-update", "SQL document changes", @@ -776,12 +796,13 @@ export class DefaultSqlDocumentSession ); } const normalizedChanges = normalizeChanges( - nextSource.originalText, + this.#snapshot.source.originalText, changes, ); trustedAnalysisChanges = normalizedChanges; - nextSource = createIdentitySqlSource( - applyChanges(nextSource.originalText, normalizedChanges), + nextText = applyChanges( + this.#snapshot.source.originalText, + normalizedChanges, ); documentMutation = "changes"; } else { @@ -793,6 +814,34 @@ export class DefaultSqlDocumentSession nextDocumentSequence += 1; } + if (hasEmbeddedRegions) { + const candidateSource = createMaskedSqlSource( + nextText, + embeddedRegions.value, + ); + if ( + candidateSource.originalText === this.#snapshot.source.originalText && + haveEqualEmbeddedRegions(candidateSource, this.#snapshot.source) + ) { + nextSource = this.#snapshot.source; + } else { + nextSource = candidateSource; + } + nextSourceSequence += 1; + if ( + documentMutation !== "changes" || + this.#snapshot.source.embeddedRegions.length !== 0 || + nextSource.embeddedRegions.length !== 0 + ) { + trustedAnalysisChanges = null; + } + } + + if (hasContext) { + nextContext = cloneContext(context.value); + nextContextSequence += 1; + } + const nextDialect = resolveDialectRuntime( nextContext, this.#dialects, @@ -814,6 +863,7 @@ export class DefaultSqlDocumentSession revision, sequence, source: nextSource, + sourceSequence: nextSourceSequence, }); let nextStatementIndexCache = this.#statementIndexCache; if ( @@ -821,7 +871,10 @@ export class DefaultSqlDocumentSession nextLexicalProfile !== this.#snapshot.dialect.lexicalProfile ) { nextStatementIndexCache = null; - } else if (nextStatementIndexCache && documentMutation !== "none") { + } else if ( + nextStatementIndexCache && + nextSourceSequence !== this.#snapshot.sourceSequence + ) { let nextIndex: SqlStatementIndex | null = null; if ( nextSource.analysisText === this.#snapshot.source.analysisText @@ -840,9 +893,9 @@ export class DefaultSqlDocumentSession } nextStatementIndexCache = nextIndex ? Object.freeze({ - documentSequence: nextDocumentSequence, index: nextIndex, lexicalProfile: nextLexicalProfile, + sourceSequence: nextSourceSequence, }) : null; } @@ -966,7 +1019,15 @@ export class DefaultSqlLanguageService ); } validateDocumentLength(text); - const source = createIdentitySqlSource(text); + const embeddedRegions = readOwnDataProperty( + input, + "embeddedRegions", + "invalid-document", + "Open SQL document input", + ); + const source = embeddedRegions.found && embeddedRegions.value !== undefined + ? createMaskedSqlSource(text, embeddedRegions.value) + : createIdentitySqlSource(text); const candidateContext = readRequiredDataProperty( input, "context", diff --git a/src/vnext/source.ts b/src/vnext/source.ts index c6f5bbb..bcf400e 100644 --- a/src/vnext/source.ts +++ b/src/vnext/source.ts @@ -1,4 +1,4 @@ -import type { SqlTextRange } from "./types.js"; +import type { SqlEmbeddedRegion, SqlTextRange } from "./types.js"; export const MAX_SQL_SOURCE_LENGTH = 16 * 1024 * 1024; export const MAX_SQL_EMBEDDED_REGIONS = 10_000; @@ -32,10 +32,6 @@ export function isSqlSourceError(error: unknown): error is SqlSourceError { ); } -export interface SqlEmbeddedRegion extends SqlTextRange { - readonly language: string; -} - export interface SqlSourceSnapshot { readonly analysisText: string; readonly embeddedRegions: readonly SqlEmbeddedRegion[]; @@ -171,27 +167,6 @@ function readArrayLength(value: readonly unknown[]): number { return length; } -function validateRegionArrayKeys( - regions: readonly unknown[], - length: number, -): void { - for (const key of Reflect.ownKeys(regions)) { - if (key === "length") { - continue; - } - if ( - typeof key !== "string" || - !/^(0|[1-9]\d*)$/.test(key) || - Number(key) >= length - ) { - throw new SqlSourceError( - "invalid-region", - "SQL embedded regions cannot contain custom properties", - ); - } - } -} - function normalizeEmbeddedRegions( regions: unknown, sourceLength: number, @@ -210,8 +185,6 @@ function normalizeEmbeddedRegions( `SQL source cannot contain more than ${MAX_SQL_EMBEDDED_REGIONS} embedded regions`, ); } - validateRegionArrayKeys(regions, length); - const normalized: SqlEmbeddedRegion[] = []; let previousEnd = 0; for (let index = 0; index < length; index += 1) { diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 5eab07d..8e732ac 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -85,6 +85,7 @@ export interface SqlTextChange extends SqlTextRange { } export interface SqlDocumentReplacement { + readonly changes?: never; readonly kind: "replace"; readonly text: string; } @@ -92,27 +93,43 @@ export interface SqlDocumentReplacement { export interface SqlDocumentChanges { readonly kind: "changes"; readonly changes: readonly SqlTextChange[]; + readonly text?: never; } export type SqlDocumentEdit = SqlDocumentReplacement | SqlDocumentChanges; -/** An atomic document or context transaction against one base revision. */ +export interface SqlEmbeddedRegion extends SqlTextRange { + readonly language: string; +} + +interface SqlDocumentUpdateBase { + readonly baseRevision: SqlRevision; + readonly kind?: never; +} + +type SqlSourceUpdate = + SqlDocumentUpdateBase & { + readonly document?: SqlDocumentEdit | undefined; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + readonly context?: SqlContextInput | undefined; + }; + +type SqlContextUpdate = + SqlDocumentUpdateBase & { + readonly document?: undefined; + readonly context: SqlContextInput; + readonly embeddedRegions?: readonly SqlEmbeddedRegion[] | undefined; + }; + +/** An atomic transaction changing any non-empty subset of session inputs. */ export type SqlDocumentUpdate = - | { - readonly kind: "document"; - readonly baseRevision: SqlRevision; - readonly document: SqlDocumentEdit; - readonly context?: SqlContextInput; - } - | { - readonly kind: "context"; - readonly baseRevision: SqlRevision; - readonly context: SqlContextInput; - }; + | SqlSourceUpdate + | SqlContextUpdate; export interface OpenSqlDocument { readonly text: string; readonly context: SqlContextInput; + readonly embeddedRegions?: readonly SqlEmbeddedRegion[] | undefined; } /** Owns all mutable state for one open SQL document. */ diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index dae7078..a9fc51c 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -1,8 +1,11 @@ -import type { SqlEmbeddedRegion } from "../../src/vnext/source.js"; import type { SqlCatalogContext, SqlDocumentContext, + SqlDocumentEdit, + SqlDocumentUpdate, + SqlEmbeddedRegion, SqlIdentifierComponent, + OpenSqlDocument, } from "../../src/vnext/index.js"; import type { SqlCanonicalRelationPath, @@ -16,9 +19,7 @@ import type { SqlRelationCompletionItem, SqlRelationCompletionList, SqlRelationCatalogProvider, - SqlRelationCompletionDocumentTransaction, SqlRelationCompletionDialectRuntime, - SqlRelationCompletionOpenDocument, SqlRelationCompletionSession, } from "../../src/vnext/relation-completion-types.js"; @@ -40,14 +41,16 @@ const context: MarimoSqlContext = { dialect: "duckdb", engine: "local", }; +declare const maybeContext: MarimoSqlContext | undefined; +declare const maybeDocument: SqlDocumentEdit | undefined; const regions: readonly SqlEmbeddedRegion[] = [ { from: 14, language: "python", to: 18 }, ]; -const openWithoutRegions: SqlRelationCompletionOpenDocument = { +const openWithoutRegions: OpenSqlDocument = { context, text: "SELECT * FROM users", }; -const openWithRegions: SqlRelationCompletionOpenDocument = { +const openWithRegions: OpenSqlDocument = { context, embeddedRegions: regions, text: "SELECT * FROM {df}", @@ -78,6 +81,12 @@ session.update({ context, embeddedRegions: regions, }); +session.update({ + baseRevision: session.revision, + context: maybeContext, + document: maybeDocument, + embeddedRegions: regions, +}); const subscription = session.onDidChange((event) => { const reason: "catalog" | "catalog-availability" | "provider-configuration" = @@ -179,9 +188,9 @@ session.update({ baseRevision: session.revision, document: { kind: "replace", text: "SELECT 1" }, }); -// @ts-expect-error present undefined is not omission +// @ts-expect-error undefined omission leaves an empty transaction session.update({ baseRevision: session.revision, context: undefined }); -const missingEngine: SqlRelationCompletionOpenDocument = { +const missingEngine: OpenSqlDocument = { // @ts-expect-error marimo contexts always identify their engine context: { dialect: "duckdb" }, text: "", @@ -289,10 +298,10 @@ const contradictoryIncompleteList = { } satisfies SqlRelationCompletionList; // @ts-expect-error timeouts are unavailable evidence, not cancellation const invalidCancellation: SqlCompletionCancellationReason = "timeout"; -// @ts-expect-error a document transaction cannot explicitly omit context -const undefinedContext: SqlRelationCompletionDocumentTransaction = { +const undefinedContext: SqlDocumentUpdate = { baseRevision: session.revision, context: undefined, + document: undefined, embeddedRegions: [], }; diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 531c762..7d62a5f 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -3,7 +3,9 @@ import { duckdbDialect, type SqlDialect, type SqlDocumentContext, + type SqlDocumentEdit, type SqlDocumentSession, + type SqlEmbeddedRegion, type SqlLanguageService, type SqlRevision, type SqlTextChange, @@ -18,14 +20,35 @@ interface DateContext extends HostContext { readonly lastUsed: Date; } +interface HostEmbeddedRegion extends SqlEmbeddedRegion { + readonly hostNodeId: string; +} + const dialect = duckdbDialect(); const typedDialect: SqlDialect = dialect; const service = createSqlLanguageService({ dialects: [dialect] }); const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ from: 0, language: "python", to: 1 }], + text: "x", +}); +const identitySession = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, text: "", }); +const hostRegions: readonly HostEmbeddedRegion[] = [ + { from: 0, hostNodeId: "cell-1", language: "python", to: 1 }, +]; +const hostOpen = { + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: hostRegions, + hostMetadata: { cellId: "cell-1" }, + text: "x", +}; +service.openDocument(hostOpen); const revision: SqlRevision = session.revision; +declare const maybeContext: HostContext | undefined; +declare const maybeDocument: SqlDocumentEdit | undefined; // @ts-expect-error host service cannot be widened and fed a weaker context const widenedService: SqlLanguageService = service; @@ -33,28 +56,54 @@ const widenedService: SqlLanguageService = service; const widenedSession: SqlDocumentSession = session; session.update({ - kind: "context", baseRevision: revision, context: { dialect: "duckdb", engine: "remote" }, }); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, document: { kind: "replace", text: "SELECT 1" }, }); session.update({ - kind: "document", + embeddedRegions: [], baseRevision: session.revision, context: { dialect: "duckdb", engine: "local" }, document: { kind: "changes", changes: [{ from: 0, insert: "SELECT 1", to: 0 }] }, }); -// @ts-expect-error an explicitly undefined context is not an omitted context session.update({ - kind: "document", + baseRevision: session.revision, + embeddedRegions: [], +}); +const hostUpdate = { + baseRevision: session.revision, + embeddedRegions: hostRegions, + hostMetadata: { cellId: "cell-1" }, +}; +session.update(hostUpdate); +session.update({ + embeddedRegions: [], baseRevision: session.revision, context: undefined, document: { kind: "replace", text: "SELECT 1" }, }); +session.update({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "local" }, + document: undefined, + embeddedRegions: undefined, +}); +session.update({ + baseRevision: session.revision, + context: undefined, + document: undefined, + embeddedRegions: [], +}); +session.update({ + baseRevision: session.revision, + context: maybeContext, + document: maybeDocument, + embeddedRegions: hostRegions, +}); const change: SqlTextChange = { from: 0, insert: "", to: 0 }; const range: SqlTextRange = change; @@ -63,10 +112,28 @@ const range: SqlTextRange = change; const objectRevision: SqlRevision = {}; // @ts-expect-error revisions cannot be numbers const numberRevision: SqlRevision = 1; -// @ts-expect-error updates require document or context -session.update({ kind: "document", baseRevision: revision }); +// @ts-expect-error updates require a non-empty state change +session.update({ baseRevision: revision }); +const legacyUpdate = { + baseRevision: revision, + context: { dialect: "duckdb", engine: "local" }, + kind: "context" as const, +}; +// @ts-expect-error the removed update discriminant stays forbidden through variables +session.update(legacyUpdate); +// @ts-expect-error document mutations require complete post-edit regions +session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, +}); +// @ts-expect-error document mutations require defined post-edit regions +session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, + embeddedRegions: undefined, +}); // @ts-expect-error document mutation forms are mutually exclusive -session.update({ kind: "document", baseRevision: revision, document: { kind: "changes", changes: [], text: "" } }); +session.update({ embeddedRegions: [], baseRevision: revision, document: { kind: "changes", changes: [], text: "" } }); // @ts-expect-error host context requires engine service.openDocument({ context: { dialect: "duckdb" }, text: "" }); const dateService = createSqlLanguageService({ dialects: [dialect] }); @@ -79,6 +146,13 @@ dateService.openDocument({ change.from = 1; // @ts-expect-error ranges are readonly range.to = 1; +const embeddedRegion: SqlEmbeddedRegion = { + from: 0, + language: "python", + to: 1, +}; +// @ts-expect-error embedded regions are readonly +embeddedRegion.language = "jinja"; // @ts-expect-error session revision is readonly session.revision = revision; // @ts-expect-error statement indexes remain an internal session detail @@ -91,6 +165,8 @@ createSqlLanguageService({ dialects: [{ id: "duckdb", displayName: "DuckDB" }] } void objectRevision; void numberRevision; void range; +void embeddedRegion; +void identitySession; void typedDialect; void widenedService; void widenedSession; diff --git a/tsconfig.vnext-tests.loose-optional.json b/tsconfig.vnext-tests.loose-optional.json new file mode 100644 index 0000000..eb6941b --- /dev/null +++ b/tsconfig.vnext-tests.loose-optional.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.vnext-tests.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false + } +} From ef47c7fbcbf76cdb740a5f6daf2d4ebbc2bbacc9 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 06:54:49 +0800 Subject: [PATCH 16/42] refactor(vnext): share bounded SQL lexical primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - extract dialect lexical profiles and shared UTF-16 identifier, quote, block-comment, and dollar-quote scanners from the statement index - preserve statement-index behavior while adding explicit upper bounds needed by the parser-independent query-site recognizer - make bounded dollar-quote opener/closer classification prefix-deterministic and keep partial close search bounded - add direct boundary regressions for quotes, block comments, dollar quotes, UTF-16 identifiers, and SQL whitespace This is a private prerequisite for ADR 0005 step 3; it adds no public package export. ## Validation - `pnpm run typecheck` (strict and loose optional-property modes) - `pnpm exec oxlint` - `pnpm run test:integrity` - 1,272 Node tests passed + 1 expected failure - 7 Chromium tests passed - packed-package smoke passed - worker-placement/runtime evidence passed - demo build passed - changed coverage: 98.51% statements, 96.75% branches, 100% functions, 98.50% lines - three independent exact-head adversarial approvals at `652b108` ## Review history Adversarial review found a dollar-opener read beyond an explicit scan limit. The final head guards the opener before inspecting a suffix and includes bounded-prefix equivalence coverage. No additional Copilot request will be made after the one request for this PR. --- ## Summary by cubic Shares SQL lexical primitives and adds explicit scan limits to prevent over-reads while keeping statement-index behavior. Also preserves fast, bounded ASCII word scans and fixes dollar-quote opener detection. - **Refactors** - Moved scanners for quotes, block comments, dollar quotes, UTF‑16 identifiers, and SQL whitespace into `src/vnext/lexical.ts` with explicit `limit` handling; preserved fast ASCII word scanning in `statement-index.ts` for performance. - Centralized dialect profiles and helpers; `statement-index.ts` now imports these and drops duplicate logic. - **Bug Fixes** - Dollar-quote opener classification is now prefix-bounded; no suffix reads beyond the scan limit. - Quote and block comment scanners respect limits, including line-break and backslash cases. - Dollar-quote tags over 256 chars return `delimiterTooLong` instead of scanning unbounded. Written for commit 360026948b4351970080a35a576fc95b80296698. Summary will update on new commits. Review in cubic --- src/vnext/__tests__/lexical.test.ts | 80 ++++++ src/vnext/lexical.ts | 342 +++++++++++++++++++++++++ src/vnext/statement-index.ts | 382 ++++------------------------ 3 files changed, 474 insertions(+), 330 deletions(-) create mode 100644 src/vnext/__tests__/lexical.test.ts create mode 100644 src/vnext/lexical.ts diff --git a/src/vnext/__tests__/lexical.test.ts b/src/vnext/__tests__/lexical.test.ts new file mode 100644 index 0000000..8e2900d --- /dev/null +++ b/src/vnext/__tests__/lexical.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + isSqlWhitespace, + scanSqlBlockComment, + scanSqlDollarQuote, + scanSqlQuoted, + sqlIdentifierContinueLengthAt, + sqlIdentifierStartLengthAt, +} from "../lexical.js"; + +describe("shared SQL lexical primitives", () => { + it("uses UTF-16 identifier lengths without splitting valid pairs", () => { + expect(sqlIdentifierStartLengthAt("a", 0)).toBe(1); + expect(sqlIdentifierContinueLengthAt("1", 0)).toBe(1); + expect(sqlIdentifierStartLengthAt("😀", 0)).toBe(2); + expect(sqlIdentifierContinueLengthAt("\uD800", 0)).toBe(1); + expect(sqlIdentifierStartLengthAt("1", 0)).toBe(0); + }); + + it("recognizes only the SQL whitespace set", () => { + for (const code of [9, 10, 11, 12, 13, 32]) { + expect(isSqlWhitespace(code)).toBe(true); + } + expect(isSqlWhitespace(0xa0)).toBe(false); + }); + + it("never scans a quote beyond its explicit limit", () => { + expect(scanSqlQuoted("'abc'x", 0, 4, 39, 1, false, true, false)).toEqual({ + closed: false, + to: 4, + }); + expect(scanSqlQuoted("'abc'x", 0, 5, 39, 1, false, true, false)).toEqual({ + closed: true, + to: 5, + }); + expect(scanSqlQuoted("'a\nfar away", 0, 4, 39, 1, false, true, true)).toEqual({ + closed: false, + to: 4, + }); + expect(scanSqlQuoted("'\\\nfar away", 0, 4, 39, 1, true, true, true)).toEqual({ + closed: false, + to: 4, + }); + }); + + it("never scans a block comment beyond its explicit limit", () => { + expect(scanSqlBlockComment("/*x*/y", 0, 4, false)).toEqual({ + closed: false, + to: 4, + }); + expect(scanSqlBlockComment("/*x*/y", 0, 5, false)).toEqual({ + closed: true, + to: 5, + }); + }); + + it("never accepts a dollar-quote close beyond its explicit limit", () => { + expect(scanSqlDollarQuote("$$x$$y", 0, 4)).toEqual({ + closed: false, + delimiterTooLong: false, + to: 4, + }); + expect(scanSqlDollarQuote("$$x$$y", 0, 5)).toEqual({ + closed: true, + delimiterTooLong: false, + to: 5, + }); + expect(scanSqlDollarQuote("$tag$x$tag$far$tag$", 0, 10)).toEqual({ + closed: false, + delimiterTooLong: false, + to: 10, + }); + }); + + it("classifies dollar-quote openers only from the bounded prefix", () => { + for (const text of ["$", "$$", "$x"]) { + expect(scanSqlDollarQuote(text, 0, 1)).toBeNull(); + } + }); +}); diff --git a/src/vnext/lexical.ts b/src/vnext/lexical.ts new file mode 100644 index 0000000..a27ba85 --- /dev/null +++ b/src/vnext/lexical.ts @@ -0,0 +1,342 @@ +const MAX_DOLLAR_QUOTE_DELIMITER_LENGTH = 256; + +type SqlSingleQuoteBackslash = "always" | "e-prefix" | "never"; +type SqlProceduralGuards = "bigquery" | "none" | "postgresql"; + +export interface SqlLexicalProfile { + readonly backtickQuotedIdentifiers: boolean; + readonly bigQueryStrings: boolean; + readonly dollarQuotedStrings: boolean; + readonly hashLineComments: boolean; + readonly nestedBlockComments: boolean; + readonly proceduralGuards: SqlProceduralGuards; + readonly singleQuoteBackslash: SqlSingleQuoteBackslash; +} + +export const POSTGRESQL_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: true, + hashLineComments: false, + nestedBlockComments: true, + proceduralGuards: "postgresql", + singleQuoteBackslash: "e-prefix", +}); + +export const DUCKDB_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: true, + hashLineComments: false, + nestedBlockComments: true, + proceduralGuards: "none", + singleQuoteBackslash: "e-prefix", +}); + +export const BIGQUERY_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: true, + bigQueryStrings: true, + dollarQuotedStrings: false, + hashLineComments: true, + nestedBlockComments: false, + proceduralGuards: "bigquery", + singleQuoteBackslash: "always", +}); + +export const DREMIO_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ + backtickQuotedIdentifiers: false, + bigQueryStrings: false, + dollarQuotedStrings: false, + hashLineComments: false, + nestedBlockComments: false, + proceduralGuards: "none", + singleQuoteBackslash: "never", +}); + +function isAsciiIdentifierStart(code: number): boolean { + return ( + code === 95 || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) + ); +} + +function isAsciiIdentifierContinue(code: number): boolean { + return isAsciiIdentifierStart(code) || (code >= 48 && code <= 57); +} + +function codePointLengthAt(text: string, index: number): 0 | 1 | 2 { + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + return 0; + } + return codePoint > 0xffff ? 2 : 1; +} + +export function sqlIdentifierStartLengthAt( + text: string, + index: number, +): 0 | 1 | 2 { + const code = text.charCodeAt(index); + if (code <= 0x7f) { + return isAsciiIdentifierStart(code) ? 1 : 0; + } + return codePointLengthAt(text, index); +} + +export function sqlIdentifierContinueLengthAt( + text: string, + index: number, +): 0 | 1 | 2 { + const code = text.charCodeAt(index); + if (code <= 0x7f) { + return isAsciiIdentifierContinue(code) ? 1 : 0; + } + return codePointLengthAt(text, index); +} + +function previousCodePointIndex(text: string, index: number): number { + if (index <= 0) { + return -1; + } + const last = text.charCodeAt(index - 1); + if ( + last >= 0xdc00 && + last <= 0xdfff && + index >= 2 + ) { + const first = text.charCodeAt(index - 2); + if (first >= 0xd800 && first <= 0xdbff) { + return index - 2; + } + } + return index - 1; +} + +function hasSqlIdentifierBefore(text: string, index: number): boolean { + const previous = previousCodePointIndex(text, index); + if (previous < 0) { + return false; + } + return ( + text.charCodeAt(previous) === 36 || + sqlIdentifierContinueLengthAt(text, previous) > 0 + ); +} + +export function isSqlWhitespace(code: number): boolean { + return ( + code === 9 || + code === 10 || + code === 11 || + code === 12 || + code === 13 || + code === 32 + ); +} + +function hasPrefixAtTokenBoundary( + text: string, + quoteAt: number, + prefix: string, +): boolean { + const prefixFrom = quoteAt - prefix.length; + if (prefixFrom < 0) { + return false; + } + for (let index = 0; index < prefix.length; index += 1) { + const actual = text.charCodeAt(prefixFrom + index) | 32; + if (actual !== prefix.charCodeAt(index)) { + return false; + } + } + return prefixFrom === 0 || !hasSqlIdentifierBefore(text, prefixFrom); +} + +export function hasEscapeStringPrefix(text: string, quoteAt: number): boolean { + return hasPrefixAtTokenBoundary(text, quoteAt, "e"); +} + +export function isBigQueryRawString(text: string, quoteAt: number): boolean { + return ( + hasPrefixAtTokenBoundary(text, quoteAt, "r") || + hasPrefixAtTokenBoundary(text, quoteAt, "br") || + hasPrefixAtTokenBoundary(text, quoteAt, "rb") + ); +} + +export interface SqlQuoteScanResult { + readonly closed: boolean; + readonly to: number; +} + +export function scanSqlQuoted( + text: string, + from: number, + limit: number, + quote: number, + quoteLength: 1 | 3, + backslashEscapes: boolean, + doubledQuoteEscapes: boolean, + stopAtLineBreak: boolean, +): SqlQuoteScanResult { + let cursor = from + quoteLength; + while (cursor < limit) { + const code = text.charCodeAt(cursor); + if (stopAtLineBreak && (code === 10 || code === 13)) { + return { closed: false, to: limit }; + } + if (backslashEscapes && code === 92) { + const escapedCode = text.charCodeAt(cursor + 1); + if ( + stopAtLineBreak && + (escapedCode === 10 || escapedCode === 13) + ) { + return { closed: false, to: limit }; + } + cursor += Math.min(2, limit - cursor); + continue; + } + if (code !== quote) { + cursor += 1; + continue; + } + if (quoteLength === 3) { + if ( + cursor + 2 < limit && + text.charCodeAt(cursor + 1) === quote && + text.charCodeAt(cursor + 2) === quote + ) { + return { closed: true, to: cursor + 3 }; + } + cursor += 1; + continue; + } + if ( + doubledQuoteEscapes && + cursor + 1 < limit && + text.charCodeAt(cursor + 1) === quote + ) { + cursor += 2; + continue; + } + return { closed: true, to: cursor + 1 }; + } + return { closed: false, to: limit }; +} + +export interface SqlBlockCommentScanResult { + readonly closed: boolean; + readonly to: number; +} + +export function scanSqlBlockComment( + text: string, + from: number, + limit: number, + nested: boolean, +): SqlBlockCommentScanResult { + let cursor = from + 2; + let depth = 1; + while (cursor < limit) { + const code = text.charCodeAt(cursor); + const next = text.charCodeAt(cursor + 1); + if (nested && cursor + 1 < limit && code === 47 && next === 42) { + depth += 1; + cursor += 2; + continue; + } + if (cursor + 1 < limit && code === 42 && next === 47) { + depth -= 1; + cursor += 2; + if (depth === 0) { + return { closed: true, to: cursor }; + } + continue; + } + cursor += 1; + } + return { closed: false, to: limit }; +} + +export interface SqlDollarQuoteScanResult { + readonly closed: boolean; + readonly delimiterTooLong: boolean; + readonly to: number; +} + +function findDollarQuoteClose( + text: string, + delimiter: string, + from: number, + limit: number, +): number { + if (limit === text.length) { + return text.indexOf(delimiter, from); + } + const finalCandidate = limit - delimiter.length; + for (let candidate = from; candidate <= finalCandidate; candidate += 1) { + if (text.charCodeAt(candidate) === 36 && text.startsWith(delimiter, candidate)) { + return candidate; + } + } + return -1; +} + +export function scanSqlDollarQuote( + text: string, + from: number, + limit: number, +): SqlDollarQuoteScanResult | null { + if (hasSqlIdentifierBefore(text, from)) { + return null; + } + if (from + 1 >= limit) { + return null; + } + const first = text.charCodeAt(from + 1); + let cursor = from + 1; + let delimiterTooLong = false; + if (first !== 36) { + const firstLength = sqlIdentifierStartLengthAt(text, cursor); + if (firstLength === 0) { + return null; + } + cursor += firstLength; + while (cursor < limit) { + const continueLength = sqlIdentifierContinueLengthAt(text, cursor); + if (continueLength === 0) { + break; + } + if (cursor - from + 1 > MAX_DOLLAR_QUOTE_DELIMITER_LENGTH) { + delimiterTooLong = true; + } + cursor += continueLength; + } + if (cursor >= limit || text.charCodeAt(cursor) !== 36) { + return null; + } + if (delimiterTooLong) { + return { + closed: false, + delimiterTooLong: true, + to: limit, + }; + } + } + const delimiterTo = cursor + 1; + const delimiter = text.slice(from, delimiterTo); + const closeAt = findDollarQuoteClose(text, delimiter, delimiterTo, limit); + if (closeAt < 0) { + return { + closed: false, + delimiterTooLong: false, + to: limit, + }; + } + return { + closed: true, + delimiterTooLong: false, + to: closeAt + delimiter.length, + }; +} diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index 171048c..a110725 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -1,9 +1,27 @@ import type { SqlTextChange } from "./types.js"; +import { + hasEscapeStringPrefix, + isBigQueryRawString, + isSqlWhitespace, + scanSqlBlockComment, + scanSqlDollarQuote, + scanSqlQuoted, + sqlIdentifierContinueLengthAt, + sqlIdentifierStartLengthAt, + type SqlLexicalProfile, +} from "./lexical.js"; + +export { + BIGQUERY_SQL_LEXICAL_PROFILE, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "./lexical.js"; +export type { SqlLexicalProfile } from "./lexical.js"; const analysisRangeBrand: unique symbol = Symbol("SqlAnalysisRange"); export const MAX_SQL_STATEMENT_SLOTS = 10_000; -const MAX_DOLLAR_QUOTE_DELIMITER_LENGTH = 256; const MAX_PREFIX_TOKENS = 6; export interface SqlAnalysisRange { @@ -69,59 +87,6 @@ export interface SqlStatementIndex { readonly slots: readonly SqlStatementSlot[]; } -type SqlSingleQuoteBackslash = "always" | "e-prefix" | "never"; -type SqlProceduralGuards = "bigquery" | "none" | "postgresql"; - -export interface SqlLexicalProfile { - readonly backtickQuotedIdentifiers: boolean; - readonly bigQueryStrings: boolean; - readonly dollarQuotedStrings: boolean; - readonly hashLineComments: boolean; - readonly nestedBlockComments: boolean; - readonly proceduralGuards: SqlProceduralGuards; - readonly singleQuoteBackslash: SqlSingleQuoteBackslash; -} - -export const POSTGRESQL_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ - backtickQuotedIdentifiers: false, - bigQueryStrings: false, - dollarQuotedStrings: true, - hashLineComments: false, - nestedBlockComments: true, - proceduralGuards: "postgresql", - singleQuoteBackslash: "e-prefix", -}); - -export const DUCKDB_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ - backtickQuotedIdentifiers: false, - bigQueryStrings: false, - dollarQuotedStrings: true, - hashLineComments: false, - nestedBlockComments: true, - proceduralGuards: "none", - singleQuoteBackslash: "e-prefix", -}); - -export const BIGQUERY_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ - backtickQuotedIdentifiers: true, - bigQueryStrings: true, - dollarQuotedStrings: false, - hashLineComments: true, - nestedBlockComments: false, - proceduralGuards: "bigquery", - singleQuoteBackslash: "always", -}); - -export const DREMIO_SQL_LEXICAL_PROFILE: SqlLexicalProfile = Object.freeze({ - backtickQuotedIdentifiers: false, - bigQueryStrings: false, - dollarQuotedStrings: false, - hashLineComments: false, - nestedBlockComments: false, - proceduralGuards: "none", - singleQuoteBackslash: "never", -}); - const NORMAL_END_STATE: Extract< SqlLexicalEndState, { readonly kind: "normal" } @@ -181,271 +146,8 @@ function createOpaqueSlot( }); } -function isAsciiIdentifierStart(code: number): boolean { - return ( - code === 95 || - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) - ); -} - -function isAsciiIdentifierContinue(code: number): boolean { - return isAsciiIdentifierStart(code) || (code >= 48 && code <= 57); -} - -function codePointLengthAt(text: string, index: number): 0 | 1 | 2 { - const codePoint = text.codePointAt(index); - if (codePoint === undefined) { - return 0; - } - return codePoint > 0xffff ? 2 : 1; -} - -function sqlIdentifierStartLengthAt(text: string, index: number): 0 | 1 | 2 { - const code = text.charCodeAt(index); - if (code <= 0x7f) { - return isAsciiIdentifierStart(code) ? 1 : 0; - } - return codePointLengthAt(text, index); -} - -function sqlIdentifierContinueLengthAt( - text: string, - index: number, -): 0 | 1 | 2 { - const code = text.charCodeAt(index); - if (code <= 0x7f) { - return isAsciiIdentifierContinue(code) ? 1 : 0; - } - return codePointLengthAt(text, index); -} - -function previousCodePointIndex(text: string, index: number): number { - if (index <= 0) { - return -1; - } - const last = text.charCodeAt(index - 1); - if ( - last >= 0xdc00 && - last <= 0xdfff && - index >= 2 - ) { - const first = text.charCodeAt(index - 2); - if (first >= 0xd800 && first <= 0xdbff) { - return index - 2; - } - } - return index - 1; -} - -function hasSqlIdentifierBefore(text: string, index: number): boolean { - const previous = previousCodePointIndex(text, index); - if (previous < 0) { - return false; - } - return ( - text.charCodeAt(previous) === 36 || - sqlIdentifierContinueLengthAt(text, previous) > 0 - ); -} - -function isSqlWhitespace(code: number): boolean { - return ( - code === 9 || - code === 10 || - code === 11 || - code === 12 || - code === 13 || - code === 32 - ); -} - -function hasPrefixAtTokenBoundary( - text: string, - quoteAt: number, - prefix: string, -): boolean { - const prefixFrom = quoteAt - prefix.length; - if (prefixFrom < 0) { - return false; - } - for (let index = 0; index < prefix.length; index += 1) { - const actual = text.charCodeAt(prefixFrom + index) | 32; - if (actual !== prefix.charCodeAt(index)) { - return false; - } - } - return ( - prefixFrom === 0 || - !hasSqlIdentifierBefore(text, prefixFrom) - ); -} - -function hasEscapeStringPrefix(text: string, quoteAt: number): boolean { - return hasPrefixAtTokenBoundary(text, quoteAt, "e"); -} - -function isBigQueryRawString(text: string, quoteAt: number): boolean { - return ( - hasPrefixAtTokenBoundary(text, quoteAt, "r") || - hasPrefixAtTokenBoundary(text, quoteAt, "br") || - hasPrefixAtTokenBoundary(text, quoteAt, "rb") - ); -} - -interface QuoteScanResult { - readonly closed: boolean; - readonly to: number; -} - -function scanQuoted( - text: string, - from: number, - quote: number, - quoteLength: 1 | 3, - backslashEscapes: boolean, - doubledQuoteEscapes: boolean, - stopAtLineBreak: boolean, -): QuoteScanResult { - let cursor = from + quoteLength; - while (cursor < text.length) { - const code = text.charCodeAt(cursor); - if (stopAtLineBreak && (code === 10 || code === 13)) { - return { closed: false, to: text.length }; - } - if (backslashEscapes && code === 92) { - const escapedCode = text.charCodeAt(cursor + 1); - if ( - stopAtLineBreak && - (escapedCode === 10 || escapedCode === 13) - ) { - return { closed: false, to: text.length }; - } - cursor += Math.min(2, text.length - cursor); - continue; - } - if (code !== quote) { - cursor += 1; - continue; - } - if (quoteLength === 3) { - if ( - text.charCodeAt(cursor + 1) === quote && - text.charCodeAt(cursor + 2) === quote - ) { - return { closed: true, to: cursor + 3 }; - } - cursor += 1; - continue; - } - if (doubledQuoteEscapes && text.charCodeAt(cursor + 1) === quote) { - cursor += 2; - continue; - } - return { closed: true, to: cursor + 1 }; - } - return { closed: false, to: text.length }; -} - -interface BlockCommentScanResult { - readonly closed: boolean; - readonly to: number; -} - -function scanBlockComment( - text: string, - from: number, - nested: boolean, -): BlockCommentScanResult { - let cursor = from + 2; - let depth = 1; - while (cursor < text.length) { - const code = text.charCodeAt(cursor); - const next = text.charCodeAt(cursor + 1); - if (nested && code === 47 && next === 42) { - depth += 1; - cursor += 2; - continue; - } - if (code === 42 && next === 47) { - depth -= 1; - cursor += 2; - if (depth === 0) { - return { closed: true, to: cursor }; - } - continue; - } - cursor += 1; - } - return { closed: false, to: text.length }; -} - -interface DollarQuoteScanResult { - readonly detectedAt: number; - readonly endState: ExactSqlStatementSlot["endState"] | null; - readonly opaqueReason: SqlOpaqueBoundaryReason | null; - readonly to: number; -} - -function scanDollarQuote( - text: string, - from: number, -): DollarQuoteScanResult | null { - if (hasSqlIdentifierBefore(text, from)) { - return null; - } - const first = text.charCodeAt(from + 1); - let cursor = from + 1; - let delimiterTooLong = false; - if (first !== 36) { - const firstLength = sqlIdentifierStartLengthAt(text, cursor); - if (firstLength === 0) { - return null; - } - cursor += firstLength; - while (cursor < text.length) { - const continueLength = sqlIdentifierContinueLengthAt(text, cursor); - if (continueLength === 0) { - break; - } - if (cursor - from + 1 > MAX_DOLLAR_QUOTE_DELIMITER_LENGTH) { - delimiterTooLong = true; - } - cursor += continueLength; - } - if (text.charCodeAt(cursor) !== 36) { - return null; - } - if (delimiterTooLong) { - return { - detectedAt: from, - endState: null, - opaqueReason: "resource-limit", - to: text.length, - }; - } - } - const delimiterTo = cursor + 1; - const delimiter = text.slice(from, delimiterTo); - const closeAt = text.indexOf(delimiter, delimiterTo); - if (closeAt < 0) { - return { - detectedAt: from, - endState: createUnterminatedEndState("dollar-quoted-string", from), - opaqueReason: null, - to: text.length, - }; - } - return { - detectedAt: from, - endState: NORMAL_END_STATE, - opaqueReason: null, - to: closeAt + delimiter.length, - }; -} - class SqlPrefixGuard { - readonly #mode: SqlProceduralGuards; + readonly #mode: SqlLexicalProfile["proceduralGuards"]; readonly #tokens: string[] = []; #labelColonAt: number | null = null; #parenthesisDepth = 0; @@ -455,7 +157,7 @@ class SqlPrefixGuard { #reason: SqlOpaqueBoundaryReason | null = null; #reasonAt = 0; - constructor(mode: SqlProceduralGuards) { + constructor(mode: SqlLexicalProfile["proceduralGuards"]) { this.#mode = mode; } @@ -725,9 +427,10 @@ function scanSqlStatementIndex( continue; } if (code === 47 && next === 42) { - const comment = scanBlockComment( + const comment = scanSqlBlockComment( analysisText, cursor, + analysisText.length, profile.nestedBlockComments, ); if (!comment.closed) { @@ -741,18 +444,22 @@ function scanSqlStatementIndex( profile.dollarQuotedStrings && code === 36 ) { - const dollarQuote = scanDollarQuote(analysisText, cursor); + const dollarQuote = scanSqlDollarQuote( + analysisText, + cursor, + analysisText.length, + ); if (dollarQuote) { hasCode = true; prefixGuard.recordNonWord(code); - if (dollarQuote.opaqueReason !== null) { - return finishOpaque( - dollarQuote.opaqueReason, - dollarQuote.detectedAt, - ); + if (dollarQuote.delimiterTooLong) { + return finishOpaque("resource-limit", cursor); } - if (dollarQuote.endState?.kind === "unterminated") { - finalEndState = dollarQuote.endState; + if (!dollarQuote.closed) { + finalEndState = createUnterminatedEndState( + "dollar-quoted-string", + cursor, + ); } cursor = dollarQuote.to; continue; @@ -768,9 +475,10 @@ function scanSqlStatementIndex( if (code === 96) { prefixGuard.recordQuotedIdentifier(); prefixGuard.recordNonWord(code); - const quote = scanQuoted( + const quote = scanSqlQuoted( analysisText, cursor, + analysisText.length, code, 1, true, @@ -806,9 +514,10 @@ function scanSqlStatementIndex( prefixGuard.recordQuotedIdentifier(); } prefixGuard.recordNonWord(code); - const quote = scanQuoted( + const quote = scanSqlQuoted( analysisText, cursor, + analysisText.length, code, quoteLength, backslashEscapes, @@ -830,6 +539,19 @@ function scanSqlStatementIndex( const wordFrom = cursor; cursor += wordStartLength; while (cursor < analysisText.length) { + const continueCode = analysisText.charCodeAt(cursor); + if ( + continueCode === 95 || + (continueCode >= 48 && continueCode <= 57) || + (continueCode >= 65 && continueCode <= 90) || + (continueCode >= 97 && continueCode <= 122) + ) { + cursor += 1; + continue; + } + if (continueCode <= 0x7f) { + break; + } const continueLength = sqlIdentifierContinueLengthAt( analysisText, cursor, From 83478178e5a01d3600ac3c7ae487256a822cc58d Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 08:11:39 +0800 Subject: [PATCH 17/42] feat(vnext): recognize bounded relation query sites (#186) ## Summary - add a private parser-independent partial `SELECT` relation-site recognizer - authenticate full relation paths and statement-relative UTF-16 replacement ranges - enforce dialect-owned identifier/path decoding and path depth under a global safety ceiling - recognize base `FROM`, qualified prefixes, aliases, joins, same-depth commas, nested queries, and dialect-owned `NATURAL` joins - authenticate the bounded `USING(identifier [, identifier ...])` grammar before crossing a join constraint - deliberately fail closed on `ON` until a parser-backed or separately specified expression recognizer can prove its boundary - preserve frame-local nested-query recognition after proven relation transitions and valid `USING` clause exits ## Safety and performance - active statement: 65,536 UTF-16 units - lexemes: 16,384 - nesting: 128 - path: dialect limit under global 32 - decoded identifier segment: 256 UTF-16 units - exact 10 KiB statement: about 0.56 ms mean - 1,000 classified aliases: about 0.36 ms mean - 1,000 authenticated `USING` columns: about 0.21 ms mean - recognizer state remains bounded per query frame and per active `USING` constraint ## Verification - 1,537 tests passed plus 1 expected failure - changed coverage: 96.84% statements, 95.76% branches, 100% functions, 96.83% lines - repository coverage: 95.31% statements, 92.04% branches, 96.14% functions, 95.30% lines - source, test, loose-optional, and demo typechecks pass - repository oxlint, test-integrity, and diff checks pass - browser, package, worker-placement, demo, and benchmark gates pass - three independent adversarial reviewers approved exact commit `01cd8d29bf99a7c07f6a72ca746da328847c6dfb` Part of #169. --- ...-parser-independent-relation-completion.md | 18 +- package.json | 1 + src/vnext/__tests__/query-site.bench.ts | 106 + src/vnext/__tests__/query-site.test.ts | 1627 +++++++++++++++ src/vnext/query-site.ts | 1749 +++++++++++++++++ 5 files changed, 3496 insertions(+), 5 deletions(-) create mode 100644 src/vnext/__tests__/query-site.bench.ts create mode 100644 src/vnext/__tests__/query-site.test.ts create mode 100644 src/vnext/query-site.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index d17d0c6..79680a2 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -97,12 +97,20 @@ embedded region are unavailable or inactive according to the closed result. CTE visibility is the one narrow scope-semantics exception; general parser-derived scope semantics remain deferred. +The recognizer may cross a `USING` join constraint only after authenticating +the complete bounded grammar `USING(identifier [, identifier ...])` with +dialect-owned identifier validation. It does not interpret `ON` expressions: +encountering `ON` makes the query site unavailable until a future +parser-backed or separately specified expression recognizer can prove the +boundary. + The conformance corpus includes positive base `FROM`, qualified prefix, -aliased `JOIN`, same-depth comma, and nested supported-query cases. It includes +aliased `JOIN`, authenticated `USING`, same-depth comma, and nested +supported-query cases. It includes negative `IS DISTINCT FROM`, `substring(... FROM ...)`, `extract(... FROM -...)`, `DELETE FROM`, `COPY ... FROM`, set-operation, `QUALIFY`, `WINDOW`, join -constraint, DML, and expression cases. A keyword match alone never creates a -site. +...)`, `DELETE FROM`, `COPY ... FROM`, set-operation, `QUALIFY`, `WINDOW`, +`ON`, malformed `USING`, DML, and expression cases. A keyword match alone +never creates a site. The result distinguishes: @@ -518,7 +526,7 @@ The initial checked limits are: | Lexical tokens | 16,384 | | Parenthesis/query depth | 128 | | CTE declarations | 256 | -| Identifier path segments | 4 | +| Identifier path segments | 32 global ceiling; dialect runtime sets the checked limit | | Identifier segment | 256 UTF-16 units | | Catalog scope | 512 UTF-16 units | | Search paths | 32 | diff --git a/package.json b/package.json index e16c55a..64b9b9a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", + "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", diff --git a/src/vnext/__tests__/query-site.bench.ts b/src/vnext/__tests__/query-site.bench.ts new file mode 100644 index 0000000..b592b03 --- /dev/null +++ b/src/vnext/__tests__/query-site.bench.ts @@ -0,0 +1,106 @@ +import { bench, describe } from "vitest"; +import { + recognizeSqlRelationQuerySite, + type SqlQuerySiteDialect, +} from "../query-site.js"; +import { createIdentitySqlSource } from "../source.js"; +import { + buildSqlStatementIndex, + DUCKDB_SQL_LEXICAL_PROFILE, + findSqlStatementSlot, +} from "../statement-index.js"; + +const dialect: SqlQuerySiteDialect = { + classifyIdentifierToken: (rawIdentifier) => ({ + status: "identifier", + value: rawIdentifier, + }), + decodeRelationPath: (rawPath, cursorOffset) => ({ + finalSegment: { from: 0, to: rawPath.length }, + prefix: { + quoted: false, + value: rawPath.slice(0, cursorOffset), + }, + qualifier: [], + quality: "exact", + status: "decoded", + }), + lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, + maximumPathDepth: 16, + supportsNaturalJoin: true, +}; +const TEN_KIBIBYTES = 10 * 1_024; +const queryPrefix = "SELECT "; +const querySuffix = " FROM schema_prefix"; +const projectedListLength = + TEN_KIBIBYTES - queryPrefix.length - querySuffix.length; +const projectedList = `${"x,".repeat( + Math.floor((projectedListLength - 1) / 2), +)}x`; +const tenKilobyteQuery = `${queryPrefix}${projectedList}${" ".repeat( + projectedListLength - projectedList.length, +)}${querySuffix}`; +if (tenKilobyteQuery.length !== TEN_KIBIBYTES) { + throw new Error("Query benchmark fixture must be exactly 10 KiB"); +} +const source = createIdentitySqlSource(tenKilobyteQuery); +const index = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, +); +const position = tenKilobyteQuery.length; +const slot = findSqlStatementSlot(index, position, "left"); +const aliasHeavyQuery = `SELECT * FROM ${Array.from( + { length: 1_000 }, + (_, aliasIndex) => `table_name alias_${aliasIndex}`, +).join(", ")}, `; +const aliasHeavySource = createIdentitySqlSource(aliasHeavyQuery); +const aliasHeavyIndex = buildSqlStatementIndex( + aliasHeavySource.analysisText, + dialect.lexicalProfile, +); +const aliasHeavyPosition = aliasHeavyQuery.length; +const aliasHeavySlot = findSqlStatementSlot( + aliasHeavyIndex, + aliasHeavyPosition, + "left", +); +const usingHeavyQuery = `SELECT * FROM first_table JOIN second_table USING(${Array.from( + { length: 1_000 }, + (_, columnIndex) => `column_${columnIndex}`, +).join(", ")}) JOIN target`; +const usingHeavySource = createIdentitySqlSource(usingHeavyQuery); +const usingHeavyIndex = buildSqlStatementIndex( + usingHeavySource.analysisText, + dialect.lexicalProfile, +); +const usingHeavyPosition = usingHeavyQuery.length; +const usingHeavySlot = findSqlStatementSlot( + usingHeavyIndex, + usingHeavyPosition, + "left", +); + +describe("query-site recognizer", () => { + bench("10 KiB active statement", () => { + recognizeSqlRelationQuerySite(source, slot, position, dialect); + }); + + bench("1,000 classified aliases", () => { + recognizeSqlRelationQuerySite( + aliasHeavySource, + aliasHeavySlot, + aliasHeavyPosition, + dialect, + ); + }); + + bench("1,000 authenticated USING columns", () => { + recognizeSqlRelationQuerySite( + usingHeavySource, + usingHeavySlot, + usingHeavyPosition, + dialect, + ); + }); +}); diff --git a/src/vnext/__tests__/query-site.test.ts b/src/vnext/__tests__/query-site.test.ts new file mode 100644 index 0000000..81603a7 --- /dev/null +++ b/src/vnext/__tests__/query-site.test.ts @@ -0,0 +1,1627 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_QUERY_SITE_DEPTH, + MAX_QUERY_SITE_IDENTIFIER_LENGTH, + MAX_QUERY_SITE_LEXEMES, + MAX_QUERY_SITE_PATH_COMPONENTS, + MAX_QUERY_SITE_STATEMENT_LENGTH, + recognizeSqlRelationQuerySite, + type SqlDecodedQueryPath, + type SqlQuerySiteDialect, + type SqlQuerySiteResult, +} from "../query-site.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, + type SqlSourceSnapshot, +} from "../source.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DUCKDB_SQL_LEXICAL_PROFILE, + findSqlStatementSlot, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "../statement-index.js"; + +function decodeSegment(raw: string, prefix = false): { + readonly quoted: boolean; + readonly value: string; + readonly recovered: boolean; +} | null { + if (raw.startsWith("\"")) { + const closed = raw.endsWith("\"") && raw.length > 1; + if (!closed && !prefix) { + return null; + } + const content = raw.slice(1, closed ? -1 : undefined); + return { + quoted: true, + recovered: !closed, + value: content.replaceAll("\"\"", "\""), + }; + } + if (raw.length === 0 && prefix) { + return { quoted: false, recovered: false, value: "" }; + } + return /^[\p{L}_][\p{L}\p{N}_$]*$/u.test(raw) + ? { quoted: false, recovered: false, value: raw } + : null; +} + +function decodeStandardPath( + rawPath: string, + cursorOffset: number, +): SqlDecodedQueryPath { + const rawSegments = rawPath.split("."); + const qualifier = []; + let segmentFrom = 0; + for (let index = 0; index < rawSegments.length - 1; index += 1) { + const raw = rawSegments[index] ?? ""; + const decoded = decodeSegment(raw); + if (!decoded) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + qualifier.push({ quoted: decoded.quoted, value: decoded.value }); + segmentFrom += raw.length + 1; + } + const finalRaw = rawSegments[rawSegments.length - 1] ?? ""; + const cursorInFinal = cursorOffset - segmentFrom; + if (cursorInFinal < 0 || cursorInFinal > finalRaw.length) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + let prefixRaw = finalRaw.slice(0, cursorInFinal); + if (finalRaw.startsWith("\"") && finalRaw.endsWith("\"")) { + prefixRaw = `${prefixRaw}"`; + } + const prefix = decodeSegment(prefixRaw, true); + if (!prefix) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + return { + finalSegment: { + from: segmentFrom, + to: segmentFrom + finalRaw.length, + }, + prefix: { quoted: prefix.quoted, value: prefix.value }, + qualifier, + quality: + prefix.recovered || + (finalRaw.startsWith("\"") && !finalRaw.endsWith("\"")) + ? "recovered" + : "exact", + status: "decoded", + }; +} + +function classifyIdentifierToken( + rawIdentifier: string, + quoted: boolean, +): + | { + readonly status: "identifier"; + readonly value: string; + } + | { + readonly status: "unsupported"; + } { + if (quoted) { + const delimiter = rawIdentifier.at(0) ?? ""; + const value = rawIdentifier + .slice(1, -1) + .replaceAll(`${delimiter}${delimiter}`, delimiter); + return value.length > 0 + ? { status: "identifier", value } + : { status: "unsupported" }; + } + const word = rawIdentifier.toLowerCase(); + const unsupported = + word === "assert_rows_modified" || + word === "as" || + word === "collate" || + word === "cross" || + word === "default" || + word === "distinct" || + word === "end" || + word === "escape" || + word === "except" || + word === "fetch" || + word === "filter" || + word === "for" || + word === "from" || + word === "full" || + word === "group" || + word === "having" || + word === "inner" || + word === "intersect" || + word === "join" || + word === "lateral" || + word === "left" || + word === "limit" || + word === "match_recognize" || + word === "natural" || + word === "on" || + word === "offset" || + word === "order" || + word === "outer" || + word === "over" || + word === "pivot" || + word === "qualify" || + word === "right" || + word === "select" || + word === "tablesample" || + word === "unpivot" || + word === "union" || + word === "using" || + word === "where" || + word === "window" || + word === "within"; + return unsupported + ? { status: "unsupported" } + : { status: "identifier", value: rawIdentifier }; +} + +const postgresDialect: SqlQuerySiteDialect = { + classifyIdentifierToken, + decodeRelationPath: decodeStandardPath, + lexicalProfile: POSTGRESQL_SQL_LEXICAL_PROFILE, + maximumPathDepth: 4, + supportsNaturalJoin: true, +}; + +const duckdbDialect: SqlQuerySiteDialect = { + classifyIdentifierToken, + decodeRelationPath: decodeStandardPath, + lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, + maximumPathDepth: 16, + supportsNaturalJoin: true, +}; + +const bigQueryDialect: SqlQuerySiteDialect = { + classifyIdentifierToken, + decodeRelationPath: (rawPath, cursorOffset) => { + if (!rawPath.startsWith("`")) { + if (!rawPath.includes("-")) { + return decodeStandardPath(rawPath, cursorOffset); + } + const segments = rawPath.split("."); + const finalRaw = segments.pop() ?? ""; + const segmentFrom = rawPath.length - finalRaw.length; + const cursorInFinal = cursorOffset - segmentFrom; + if ( + cursorInFinal < 0 || + cursorInFinal > finalRaw.length || + !/^[\p{L}_][\p{L}\p{N}_-]*$/u.test(segments[0] ?? "") || + segments.slice(1).some((segment) => !decodeSegment(segment)) + ) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + const prefix = decodeSegment(finalRaw.slice(0, cursorInFinal), true); + if (!prefix) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + return { + finalSegment: { from: segmentFrom, to: rawPath.length }, + prefix: { quoted: prefix.quoted, value: prefix.value }, + qualifier: segments.map((value) => ({ quoted: false, value })), + quality: "exact", + status: "decoded", + }; + } + const closed = rawPath.endsWith("`") && rawPath.length > 1; + const contentTo = closed ? rawPath.length - 1 : rawPath.length; + const content = rawPath.slice(1, contentTo); + const lastDot = content.lastIndexOf("."); + const finalFrom = lastDot + 2; + const cursorInContent = Math.min(cursorOffset, contentTo) - 1; + if (cursorInContent < finalFrom - 1) { + return { reason: "invalid-identifier", status: "unavailable" }; + } + const qualifier = + lastDot < 0 + ? [] + : content + .slice(0, lastDot) + .split(".") + .map((value) => ({ quoted: true, value })); + const prefix = content.slice(finalFrom - 1, cursorInContent); + return { + finalSegment: { from: finalFrom, to: rawPath.length }, + prefix: { quoted: true, value: prefix }, + qualifier, + quality: closed ? "exact" : "recovered", + status: "decoded", + }; + }, + lexicalProfile: BIGQUERY_SQL_LEXICAL_PROFILE, + maximumPathDepth: 3, + supportsNaturalJoin: false, +}; + +function markedSource(marked: string): { + readonly position: number; + readonly text: string; +} { + const position = marked.indexOf("|"); + if (position < 0 || marked.indexOf("|", position + 1) >= 0) { + throw new Error("Query fixture requires exactly one cursor marker"); + } + return { + position, + text: marked.slice(0, position) + marked.slice(position + 1), + }; +} + +function recognize( + marked: string, + options: { + readonly dialect?: SqlQuerySiteDialect; + readonly regions?: readonly { + readonly from: number; + readonly language: string; + readonly to: number; + }[]; + } = {}, +): SqlQuerySiteResult { + const { position, text } = markedSource(marked); + const source: SqlSourceSnapshot = options.regions + ? createMaskedSqlSource(text, options.regions) + : createIdentitySqlSource(text); + const dialect = options.dialect ?? postgresDialect; + const index = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + position, + position === 0 ? "right" : "left", + ); + return recognizeSqlRelationQuerySite(source, slot, position, dialect); +} + +function expectReady( + result: SqlQuerySiteResult, +): Extract { + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + throw new Error(`Expected ready, received ${result.status}`); + } + return result; +} + +describe("partial SELECT relation query sites", () => { + it.each([ + ["SELECT * FROM |", "from"], + ["SELECT * FROM users u JOIN |", "join"], + ["SELECT * FROM users LEFT OUTER JOIN |", "join"], + ["SELECT * FROM users, |", "comma"], + ["SELECT * FROM |, other", "from"], + ["SELECT * FROM a, |, b", "comma"], + ["SELECT * FROM a JOIN |, b", "join"], + ["SELECT * FROM a JOIN b USING(id), |", "comma"], + ["SELECT * FROM a JOIN b USING(id) LEFT JOIN |", "join"], + ['SELECT * FROM a JOIN b USING("id", other) JOIN |', "join"], + [ + "SELECT * FROM a JOIN b USING(id) WHERE EXISTS (SELECT * FROM |)", + "from", + ], + ["SELECT (SELECT * FROM |)", "from"], + ["SELECT * FROM (SELECT * FROM |) q", "from"], + ["SELECT * FROM /* closed */ |", "from"], + ] as const)("recognizes %s", (marked, anchor) => { + const result = expectReady(recognize(marked)); + expect(result.anchor).toBe(anchor); + expect(result.prefix).toEqual({ quoted: false, value: "" }); + expect(result.recognition).toEqual({ issues: [], quality: "exact" }); + }); + + it("returns decoded paths and statement-relative replacement ranges", () => { + const result = expectReady(recognize(" SELECT * FROM schema.us|")); + expect(result.qualifier).toEqual([{ quoted: false, value: "schema" }]); + expect(result.prefix).toEqual({ quoted: false, value: "us" }); + expect(result.typedPathRange).toMatchObject({ from: 16, to: 25 }); + expect(result.finalSegmentRange).toMatchObject({ from: 23, to: 25 }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.qualifier)).toBe(true); + expect(Object.isFrozen(result.prefix)).toBe(true); + }); + + it("authenticates the complete token when the cursor is mid-prefix", () => { + const result = expectReady(recognize("SELECT * FROM schema.us|ers")); + expect(result.prefix.value).toBe("us"); + expect(result.typedPathRange).toMatchObject({ from: 14, to: 26 }); + expect(result.finalSegmentRange).toMatchObject({ from: 21, to: 26 }); + }); + + it("authenticates the complete path before returning a replacement", () => { + expect(recognize("SELECT * FROM sch|ema.users").status).toBe( + "unavailable", + ); + expect(recognize("SELECT * FROM schema|.users").status).toBe( + "unavailable", + ); + const result = expectReady(recognize("SELECT * FROM schema.|users")); + expect(result.prefix).toEqual({ quoted: false, value: "" }); + expect(result.typedPathRange).toMatchObject({ from: 14, to: 26 }); + }); + + it("recognizes a trailing-dot empty prefix", () => { + const result = expectReady(recognize("SELECT * FROM schema.|")); + expect(result.qualifier).toEqual([{ quoted: false, value: "schema" }]); + expect(result.prefix).toEqual({ quoted: false, value: "" }); + }); + + it.each([ + "SELECT * FROM schema.| JOIN other ON true", + "SELECT * FROM schema.| WHERE true", + "SELECT * FROM schema.| /*c*/ JOIN other ON true", + "SELECT * FROM schema.|)", + "SELECT * FROM schema.|, other", + ])("recognizes a trailing-dot site before an authenticated suffix in %s", (marked) => { + const result = expectReady(recognize(marked)); + expect(result.qualifier).toEqual([{ quoted: false, value: "schema" }]); + expect(result.prefix).toEqual({ quoted: false, value: "" }); + }); + + it("recovers an incomplete quoted final identifier", () => { + const result = expectReady( + recognize("SELECT * FROM \"schema\".\"us|", { + dialect: postgresDialect, + }), + ); + expect(result.qualifier).toEqual([{ quoted: true, value: "schema" }]); + expect(result.prefix).toEqual({ quoted: true, value: "us" }); + expect(result.recognition).toEqual({ + issues: ["incomplete-identifier"], + quality: "recovered", + }); + }); + + it("delegates whole-path BigQuery quotes to the dialect", () => { + const result = expectReady( + recognize("SELECT * FROM `project.dataset.us|", { + dialect: bigQueryDialect, + }), + ); + expect(result.qualifier).toEqual([ + { quoted: true, value: "project" }, + { quoted: true, value: "dataset" }, + ]); + expect(result.prefix).toEqual({ quoted: true, value: "us" }); + expect(result.recognition.quality).toBe("recovered"); + expect( + recognize("SELECT * FROM `project.|dataset.us`", { + dialect: bigQueryDialect, + }).status, + ).toBe("unavailable"); + expect( + recognize("SELECT * FROM `project.dataset.us`|", { + dialect: bigQueryDialect, + }).status, + ).toBe("ready"); + }); + + it("supports DuckDB independently of parser compatibility", () => { + expect(expectReady(recognize("SELECT * FROM main.us|", { + dialect: duckdbDialect, + })).prefix.value).toBe("us"); + }); + + it("collects a bounded dialect-neutral identifier superset", () => { + expect( + expectReady(recognize("SELECT * FROM foo$use|r")).prefix.value, + ).toBe("foo$use"); + expect( + expectReady( + recognize("SELECT * FROM my-project.dataset.ta|", { + dialect: bigQueryDialect, + }), + ).qualifier, + ).toEqual([ + { quoted: false, value: "my-project" }, + { quoted: false, value: "dataset" }, + ]); + expect( + recognize("SELECT * FROM a.b.c.d.e.f|", { + dialect: duckdbDialect, + }).status, + ).toBe("ready"); + }); + + it.each([ + "SELECT -- FROM is trivia\n * FROM |", + "SELECT /* nested /* FROM */ comment */ * FROM |", + "SELECT $tag$FROM$tag$ FROM |", + ])("shares PostgreSQL lexical handling for %s", (marked) => { + expect(recognize(marked).status).toBe("ready"); + }); + + it.each([ + "SELECT # FROM is trivia\n * FROM |", + "SELECT 'FROM' FROM |", + "SELECT r'FROM' FROM |", + "SELECT 'one''two' FROM |", + "SELECT '''FROM''' FROM |", + "SELECT \"\"\"FROM\"\"\" FROM |", + ])("shares BigQuery lexical handling for %s", (marked) => { + expect(recognize(marked, { dialect: bigQueryDialect }).status).toBe( + "ready", + ); + }); + + it.each([ + "SELECT * FROM users AS u JOIN |", + "SELECT * FROM users AS \"u\" JOIN |", + "SELECT * FROM users AS \"LEFT\" JOIN |", + "SELECT * FROM users \"u\" JOIN |", + "SELECT * FROM users INNER JOIN |", + "SELECT * FROM users CROSS JOIN |", + "SELECT * FROM users NATURAL JOIN |", + "SELECT * FROM users NATURAL INNER JOIN |", + "SELECT * FROM users NATURAL LEFT JOIN |", + "SELECT * FROM users NATURAL LEFT OUTER JOIN |", + "SELECT * FROM users NATURAL RIGHT JOIN |", + "SELECT * FROM users NATURAL RIGHT OUTER JOIN |", + "SELECT * FROM users NATURAL FULL JOIN |", + "SELECT * FROM users NATURAL FULL OUTER JOIN |", + "SELECT * FROM users RIGHT OUTER JOIN |", + "SELECT * FROM users FULL OUTER JOIN |", + ])("supports explicit join transitions in %s", (marked) => { + expect(expectReady(recognize(marked)).anchor).toBe("join"); + }); + + it("keeps NATURAL JOIN support dialect-owned", () => { + expect( + recognize("SELECT * FROM users NATURAL LEFT OUTER JOIN |", { + dialect: duckdbDialect, + }).status, + ).toBe("ready"); + for (const marked of [ + "SELECT * FROM users NATURAL JOIN |", + "SELECT * FROM users NATURAL INNER JOIN |", + "SELECT * FROM users NATURAL LEFT OUTER JOIN |", + "SELECT * FROM users NATURAL RIGHT JOIN |", + "SELECT * FROM users NATURAL FULL JOIN |", + ]) { + expect( + recognize(marked, { dialect: bigQueryDialect }).status, + ).toBe("unavailable"); + } + + const malformedDialect: SqlQuerySiteDialect = { + ...postgresDialect, + }; + Object.defineProperty(malformedDialect, "supportsNaturalJoin", { + value: "yes", + }); + expect( + recognize("SELECT * FROM users JOIN |", { + dialect: malformedDialect, + }).status, + ).toBe("unavailable"); + }); +}); + +describe("fail-closed query-site behavior", () => { + it.each([ + ["SELECT x IS DISTINCT FROM |", "inactive"], + ["SELECT substring(x FROM |)", "inactive"], + ["SELECT extract(YEAR FROM |)", "inactive"], + ["\"prefix\" SELECT * FROM |", "inactive"], + [". SELECT * FROM |", "inactive"], + [", SELECT * FROM |", "inactive"], + ["(SELECT 1) SELECT * FROM |", "inactive"], + ["((SELECT 1)) SELECT * FROM |", "inactive"], + ["DELETE FROM |", "inactive"], + ["COPY x FROM |", "inactive"], + ["SELECT * FROM users alias |", "inactive"], + ["SELECT * FROM|", "inactive"], + ["SELECT * FROM users JOIN|", "inactive"], + ["SELECT * FROM (|", "unavailable"], + ["SELECT * FROM fn(|", "unavailable"], + ["SELECT * FROM fn|()", "unavailable"], + ["SELECT * FROM fn| ()", "unavailable"], + ["SELECT * FROM users| (x)", "unavailable"], + ["SELECT * FROM sch|ema . users", "unavailable"], + ["SELECT * FROM sch|ema /*c*/ . users", "unavailable"], + ["SELECT * FROM LATERAL users JOIN |", "unavailable"], + ["SELECT * FROM users LATERAL JOIN |", "unavailable"], + ["SELECT * FROM users ON true JOIN |", "unavailable"], + [ + "SELECT * FROM a JOIN b ON a.id = b.id JOIN |", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b ON (SELECT true) JOIN |", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b ON true WHERE EXISTS (SELECT * FROM |)", + "unavailable", + ], + ["SELECT * FROM a CROSS JOIN b ON true JOIN |", "unavailable"], + ["SELECT * FROM a NATURAL JOIN b USING(x) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON x ON y JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(x) USING(y) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON LEFT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON () JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON + JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON true + JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON true AND JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a = JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON (a =) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON (JOIN) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON END JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON DISTINCT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON DEFAULT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a IS DISTINCT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a NOT DISTINCT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a SIMILAR TO JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a COLLATE JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a AT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON a LIKE 'x' ESCAPE JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON COLLATE x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON DISTINCT x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON ESCAPE x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON TO x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON ZONE x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON AT x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON BETWEEN x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON AND x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON IS x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON WHEN x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON THEN x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON ELSE x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON (AND x) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON = x JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON true LEFT() JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON WHERE JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING WHERE JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING id JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING() JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(id,) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(, id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(id.name) JOIN |", "unavailable"], + [ + "SELECT * FROM a JOIN b USING((SELECT * FROM |))", + "unavailable", + ], + ["SELECT * FROM a JOIN b USING('id') JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(JOIN) JOIN |", "unavailable"], + ['SELECT * FROM a JOIN b USING("") JOIN |', "unavailable"], + ["SELECT * FROM a JOIN b USING(id other) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(id) other JOIN |", "unavailable"], + ['SELECT * FROM a JOIN b USING(id) "other" JOIN |', "unavailable"], + ["SELECT * FROM a JOIN b USING 'id' JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING FETCH JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING GROUP JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING HAVING JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING LIMIT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING OFFSET JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING ORDER JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING LEFT(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING RIGHT(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING FULL(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING INNER(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING CROSS(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING NATURAL(id) JOIN |", "unavailable"], + [ + "SELECT * FROM a JOIN b USING LEFT OUTER(id) JOIN |", + "unavailable", + ], + ["SELECT * FROM a JOIN b LEFT USING(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b RIGHT USING(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b FULL USING(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b INNER USING(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b CROSS USING(id) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b NATURAL USING(id) JOIN |", "unavailable"], + [ + "SELECT * FROM a JOIN b LEFT OUTER USING(id) JOIN |", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b ON true CROSS JOIN c ON true JOIN |", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b ON true NATURAL JOIN c USING(x) JOIN |", + "unavailable", + ], + ["SELECT * FROM a JOIN b ON true LEFT, |", "unavailable"], + ["SELECT * FROM a JOIN b ON true NATURAL, |", "unavailable"], + ["SELECT * FROM a JOIN b ON true OUTER JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON true LEFT RIGHT JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON true LEFT potato JOIN |", "unavailable"], + ["SELECT * FROM a LEFT, |", "unavailable"], + ["SELECT * FROM a NATURAL, |", "unavailable"], + ["SELECT * FROM a NATURAL CROSS JOIN |", "unavailable"], + ["SELECT * FROM a LEFT NATURAL JOIN |", "unavailable"], + ["SELECT * FROM a NATURAL OUTER JOIN |", "unavailable"], + [ + "SELECT * FROM a NATURAL LEFT JOIN b ON true JOIN |", + "unavailable", + ], + ["SELECT * FROM a LEFT /*x*/, |", "unavailable"], + [ + "SELECT * FROM a LEFT WHERE EXISTS (SELECT * FROM |)", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b USING(id) LEFT WHERE EXISTS (SELECT * FROM |)", + "unavailable", + ], + [ + "SELECT * FROM a JOIN b USING(id) NATURAL GROUP BY (SELECT * FROM |)", + "unavailable", + ], + ["SELECT * FROM users + JOIN |", "unavailable"], + ["SELECT * FROM users 'garbage' JOIN |", "unavailable"], + ["SELECT * FROM users . junk JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b ON [x, y], |", "unavailable"], + ["SELECT * FROM users TABLESAMPLE JOIN |", "unavailable"], + ["SELECT * FROM users AS TABLESAMPLE JOIN |", "unavailable"], + ["SELECT * FROM users AS LEFT JOIN |", "unavailable"], + ["SELECT * FROM users AS CROSS JOIN |", "unavailable"], + ["SELECT * FROM users AS NATURAL JOIN |", "unavailable"], + ["SELECT * FROM users AS INNER JOIN |", "unavailable"], + ["SELECT * FROM 'not a relation' JOIN |", "unavailable"], + ["SELECT * FROM , |", "unavailable"], + ["SELECT * FROM schema..|", "unavailable"], + ["SELECT * FROM a UNION SELECT * FROM |", "unavailable"], + ["SELECT * FROM a QUALIFY x JOIN |", "unavailable"], + ] as const)("does not invent a site for %s", (marked, status) => { + expect(recognize(marked).status).toBe(status); + }); + + it("rejects a BigQuery quoted prefix before SELECT", () => { + expect( + recognize("`prefix` SELECT * FROM |", { + dialect: bigQueryDialect, + }).status, + ).toBe("inactive"); + }); + + it.each([ + "SELECT * FROM users ASSERT_ROWS_MODIFIED JOIN |", + "SELECT * FROM users AS ASSERT_ROWS_MODIFIED JOIN |", + "SELECT * FROM users PIVOT JOIN |", + "SELECT * FROM users UNPIVOT JOIN |", + "SELECT * FROM users FOR JOIN |", + "SELECT * FROM users MATCH_RECOGNIZE JOIN |", + ])("fails closed for a BigQuery relation continuation in %s", (marked) => { + expect( + recognize(marked, { dialect: bigQueryDialect }).status, + ).toBe("unavailable"); + }); + + it("fails closed when dialect continuation policy is malformed", () => { + const throwingDialect: SqlQuerySiteDialect = { + ...postgresDialect, + classifyIdentifierToken: () => { + throw new Error("classification failed"); + }, + }; + expect( + recognize("SELECT * FROM users alias |", { + dialect: throwingDialect, + }).status, + ).toBe("unavailable"); + + const malformedDialect: SqlQuerySiteDialect = { + ...postgresDialect, + }; + Object.defineProperty(malformedDialect, "classifyIdentifierToken", { + value: () => "bogus", + }); + expect( + recognize("SELECT * FROM users alias |", { + dialect: malformedDialect, + }).status, + ).toBe("unavailable"); + + const inherited = Object.create({ + status: "identifier", + value: "alias", + }); + const accessor = Object.create(null); + Object.defineProperties(accessor, { + status: { get: () => "identifier" }, + value: { get: () => "alias" }, + }); + const throwingProxy = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error("prototype unavailable"); + }, + }, + ); + for (const result of [inherited, accessor, throwingProxy]) { + const invalidShapeDialect: SqlQuerySiteDialect = { + ...postgresDialect, + }; + Object.defineProperty( + invalidShapeDialect, + "classifyIdentifierToken", + { + value: () => result, + }, + ); + expect( + recognize("SELECT * FROM users alias JOIN |", { + dialect: invalidShapeDialect, + }).status, + ).toBe("unavailable"); + } + + for (const value of [ + "", + "x".repeat(MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1), + ]) { + const invalidDecodedDialect: SqlQuerySiteDialect = { + ...postgresDialect, + classifyIdentifierToken: () => ({ + status: "identifier", + value, + }), + }; + expect( + recognize("SELECT * FROM users alias JOIN |", { + dialect: invalidDecodedDialect, + }).status, + ).toBe("unavailable"); + } + }); + + it("routes quoted aliases through the dialect policy with their role", () => { + const calls: { + rawAlias: string; + quoted: boolean; + role: + | "explicit-alias" + | "implicit-alias" + | "using-column"; + }[] = []; + const dialect: SqlQuerySiteDialect = { + ...postgresDialect, + classifyIdentifierToken: (rawIdentifier, quoted, role) => { + calls.push({ quoted, rawAlias: rawIdentifier, role }); + return rawIdentifier === '"blocked"' + ? { status: "unsupported" } + : { + status: "identifier", + value: rawIdentifier.toLowerCase(), + }; + }, + }; + expect( + recognize("SELECT * FROM users CamelAlias JOIN |", { + dialect, + }).status, + ).toBe("ready"); + expect( + recognize('SELECT * FROM users "blocked" JOIN |', { + dialect, + }).status, + ).toBe("unavailable"); + expect( + recognize('SELECT * FROM users AS "blocked" JOIN |', { + dialect, + }).status, + ).toBe("unavailable"); + expect( + recognize("SELECT * FROM users JOIN other USING(CamelColumn) JOIN |", { + dialect, + }).status, + ).toBe("ready"); + expect(calls).toEqual([ + { + quoted: false, + rawAlias: "CamelAlias", + role: "implicit-alias", + }, + { + quoted: true, + rawAlias: '"blocked"', + role: "implicit-alias", + }, + { + quoted: true, + rawAlias: '"blocked"', + role: "explicit-alias", + }, + { + quoted: false, + rawAlias: "CamelColumn", + role: "using-column", + }, + ]); + }); + + it("never substitutes an empty word for an oversized alias token", () => { + const alias = "x".repeat(MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1); + expect( + recognize(`SELECT * FROM users ${alias} JOIN |`).status, + ).toBe("unavailable"); + expect( + recognize(`SELECT * FROM users AS ${alias} JOIN |`).status, + ).toBe("unavailable"); + }); + + it("enforces the bare identifier ceiling before dialect code for every role", () => { + const maximumIdentifier = "x".repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH, + ); + const oversizedIdentifier = "x".repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1, + ); + const roles: ( + | "explicit-alias" + | "implicit-alias" + | "using-column" + )[] = []; + const dialect: SqlQuerySiteDialect = { + ...postgresDialect, + classifyIdentifierToken: (_rawIdentifier, _quoted, role) => { + roles.push(role); + return { status: "identifier", value: "decoded" }; + }, + }; + for (const marked of [ + `SELECT * FROM users ${maximumIdentifier} JOIN |`, + `SELECT * FROM users AS ${maximumIdentifier} JOIN |`, + `SELECT * FROM a JOIN b USING(${maximumIdentifier}) JOIN |`, + ]) { + expect(recognize(marked, { dialect }).status).toBe("ready"); + } + expect(roles).toEqual([ + "implicit-alias", + "explicit-alias", + "using-column", + ]); + for (const marked of [ + `SELECT * FROM users ${oversizedIdentifier} JOIN |`, + `SELECT * FROM users AS ${oversizedIdentifier} JOIN |`, + `SELECT * FROM a JOIN b USING(${oversizedIdentifier}) JOIN |`, + ]) { + expect(recognize(marked, { dialect }).status).toBe("unavailable"); + } + expect(roles).toHaveLength(3); + }); + + it.each([ + 'SELECT * FROM users "" JOIN |', + 'SELECT * FROM users AS "" JOIN |', + ])("rejects an empty PostgreSQL quoted alias in %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it.each([ + "SELECT * FROM users `` JOIN |", + "SELECT * FROM users AS `` JOIN |", + ])("rejects an empty BigQuery quoted alias in %s", (marked) => { + expect( + recognize(marked, { dialect: bigQueryDialect }).status, + ).toBe("unavailable"); + }); + + it("bounds explicit and implicit quoted aliases", () => { + const alias = `"${"x".repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1, + )}"`; + expect( + recognize(`SELECT * FROM users ${alias} JOIN |`).status, + ).toBe("unavailable"); + expect( + recognize(`SELECT * FROM users AS ${alias} JOIN |`).status, + ).toBe("unavailable"); + }); + + it("bounds the decoded alias instead of its escaped source token", () => { + const alias = `"${'""'.repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH, + )}"`; + expect(alias.length).toBe( + MAX_QUERY_SITE_IDENTIFIER_LENGTH * 2 + 2, + ); + expect( + recognize(`SELECT * FROM users ${alias} JOIN |`).status, + ).toBe("ready"); + expect( + recognize(`SELECT * FROM users AS ${alias} JOIN |`).status, + ).toBe("ready"); + }); + + it.each([ + 'SELECT * FROM users LEFT "alias" JOIN |', + 'SELECT * FROM users NATURAL "alias" JOIN |', + 'SELECT * FROM a JOIN b ON true LEFT "alias" JOIN |', + "SELECT * FROM a JOIN b ON true LEFT . JOIN |", + "SELECT * FROM a JOIN b ON true LEFT 'x' JOIN |", + "SELECT * FROM a JOIN b ON true NATURAL 'x' JOIN |", + "SELECT * FROM a JOIN b ON true LEFT + JOIN |", + ])("does not launder a pending join modifier through %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it.each([ + "SELECT * FROM users alias(garbage +) JOIN |", + "SELECT * FROM users MATCH_RECOGNIZE(garbage) JOIN |", + ])("does not launder a parenthesized relation suffix in %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it.each([ + "SELECT * FROM users AS + alias JOIN |", + "SELECT * FROM users AS . alias JOIN |", + "SELECT * FROM users AS , alias JOIN |", + "SELECT * FROM users AS 'junk' alias JOIN |", + ])("does not skip invalid explicit-alias grammar in %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it.each([ + "SELECT * FROM users AS WHERE JOIN |", + "SELECT * FROM users AS FROM JOIN |", + "SELECT * FROM users AS AS JOIN |", + "SELECT * FROM users AS SELECT JOIN |", + "SELECT * FROM users AS OFFSET JOIN |", + "SELECT * FROM users AS UNION JOIN |", + "SELECT * FROM users AS QUALIFY JOIN |", + "SELECT * FROM users AS LATERAL JOIN |", + ])("classifies a structural word in explicit-alias state for %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it.each([ + "SELECT 'on|e''two' FROM users", + "SELECT 'one''tw|o' FROM users", + ])("keeps adjacent BigQuery strings opaque at %s", (marked) => { + expect(recognize(marked, { dialect: bigQueryDialect })).toEqual({ + reason: "cursor-in-string", + status: "inactive", + }); + }); + + it("fails closed when an embedded region is adjacent to a path", () => { + const marked = "SELECT * FROM u|s{x}"; + const regionFrom = marked.indexOf("{x}"); + expect( + recognize(marked, { + regions: [ + { from: regionFrom - 1, language: "template", to: regionFrom + 2 }, + ], + }).status, + ).toBe("unavailable"); + }); + + it.each([ + ["SELECT -- {x} FROM |", postgresDialect, "inactive"], + ["SELECT # {x} FROM |", bigQueryDialect, "inactive"], + ["SELECT \"ab{x} FROM |", postgresDialect, "unavailable"], + ["SELECT `ab{x} FROM |", bigQueryDialect, "unavailable"], + ] as const)( + "preserves lexical state across a masked region in %s", + (marked, dialect, status) => { + const regionFrom = marked.indexOf("{x}"); + expect( + recognize(marked, { + dialect, + regions: [ + { from: regionFrom, language: "template", to: regionFrom + 3 }, + ], + }).status, + ).toBe(status); + }, + ); + + it.each([ + "SELECT (SELECT {x}) FROM |", + "{x} (SELECT * FROM |)", + ])("retains embedded-region evidence across nested frames in %s", (marked) => { + const regionFrom = marked.indexOf("{x}"); + const result = expectReady( + recognize(marked, { + regions: [ + { from: regionFrom, language: "template", to: regionFrom + 3 }, + ], + }), + ); + expect(result.recognition).toEqual({ + issues: ["opaque-template-context"], + quality: "recovered", + }); + }); + + it("distinguishes cursors inside comments and strings", () => { + expect(recognize("SELECT * FROM /* he|re */ users")).toEqual({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect(recognize("SELECT '|FROM users'")).toEqual({ + reason: "cursor-in-string", + status: "inactive", + }); + expect(recognize("SELECT /* unclosed|")).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect(recognize("SELECT 'unclosed|")).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect(recognize("SELECT * FROM -- comment|")).toEqual({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect(recognize("SELECT * FROM -- comment|\n")).toEqual({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect(recognize("SELECT * FROM -- comment\n|").status).toBe("ready"); + expect(recognize("SELECT * FROM /* comment */|").status).toBe( + "ready", + ); + expect( + recognize("SELECT * FROM # comment|", { + dialect: bigQueryDialect, + }), + ).toEqual({ + reason: "cursor-in-comment", + status: "inactive", + }); + }); + + it.each([ + "SELECT * FROM users WHERE value JOIN |", + "SELECT * FROM users GROUP BY value JOIN |", + "SELECT * FROM users HAVING value JOIN |", + "SELECT * FROM users ORDER BY value JOIN |", + "SELECT * FROM users LIMIT value JOIN |", + "SELECT * FROM users OFFSET value JOIN |", + "SELECT * FROM users FETCH value JOIN |", + ])("closes supported relation state after a later clause in %s", (marked) => { + expect(recognize(marked).status).toBe("inactive"); + }); + + it.each([ + "SELECT * FROM users OUTER JOIN |", + "SELECT * FROM users LEFT RIGHT JOIN |", + "SELECT * FROM users LEFT potato JOIN |", + "SELECT * FROM users a b JOIN |", + "SELECT * FROM users EXCEPT SELECT * FROM |", + "SELECT * FROM users INTERSECT SELECT * FROM |", + "SELECT * FROM users WINDOW value JOIN |", + "SELECT * FROM users alias . JOIN |", + "SELECT * FROM users alias + JOIN |", + "SELECT * FROM users \"one\" \"two\" JOIN |", + ])("makes ambiguous transitions unavailable in %s", (marked) => { + expect(recognize(marked).status).toBe("unavailable"); + }); + + it("suppresses the three-word IS NOT DISTINCT FROM expression", () => { + expect(recognize("SELECT x IS NOT DISTINCT FROM |").status).toBe( + "inactive", + ); + }); + + it("handles cursor and range input boundaries explicitly", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + postgresDialect.lexicalProfile, + ); + const slot = index.slots[0]!; + expect( + recognizeSqlRelationQuerySite( + source, + slot, + Number.NaN, + postgresDialect, + ), + ).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect( + recognizeSqlRelationQuerySite(source, slot, -1, postgresDialect), + ).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect(recognize("SELECT|")).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect(recognize("(|x) SELECT * FROM ")).toEqual({ + reason: "not-select-query", + status: "inactive", + }); + expect(recognize("(+ x) |")).toEqual({ + reason: "not-select-query", + status: "inactive", + }); + expect(recognize("((SELECT * FROM x)) |")).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect(recognize("an_identifier_longer_than_sixteen|")).toEqual({ + reason: "not-select-query", + status: "inactive", + }); + }); + + it("validates every dialect-decoder result before branding ranges", () => { + const dialectWith = ( + decodeRelationPath: SqlQuerySiteDialect["decodeRelationPath"], + ): SqlQuerySiteDialect => ({ + classifyIdentifierToken, + decodeRelationPath, + lexicalProfile: POSTGRESQL_SQL_LEXICAL_PROFILE, + maximumPathDepth: 4, + supportsNaturalJoin: true, + }); + expect( + recognize("SELECT * FROM x|", { + dialect: dialectWith(() => ({ + reason: "undecodable-identifier", + status: "unavailable", + })), + }), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect( + recognize("SELECT * FROM x|", { + dialect: dialectWith(() => ({ + finalSegment: { from: 0, to: 1 }, + prefix: { quoted: false, value: "x" }, + qualifier: Array.from({ length: 4 }, () => ({ + quoted: false, + value: "q", + })), + quality: "exact", + status: "decoded", + })), + }), + ).toEqual({ + reason: "resource-limit", + resource: "identifier-path", + status: "unavailable", + }); + expect( + recognize("SELECT * FROM x|", { + dialect: dialectWith(() => ({ + finalSegment: { from: -1, to: 1 }, + prefix: { quoted: false, value: "x" }, + qualifier: [], + quality: "exact", + status: "decoded", + })), + }), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + + const validDecoded = (): Extract< + SqlDecodedQueryPath, + { readonly status: "decoded" } + > => ({ + finalSegment: { from: 0, to: 1 }, + prefix: { quoted: false, value: "x" }, + qualifier: [], + quality: "exact", + status: "decoded", + }); + const malformedResults: SqlQuerySiteDialect["decodeRelationPath"][] = [ + () => { + throw new Error("decoder failed"); + }, + () => { + const decoded = validDecoded(); + Object.setPrototypeOf(decoded, []); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded, "status", { value: "bogus" }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperties(decoded, { + reason: { value: "bogus" }, + status: { value: "unavailable" }, + }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded, "quality", { value: "bogus" }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded, "qualifier", { value: { length: 0 } }); + return decoded; + }, + () => { + const decoded = validDecoded(); + const qualifier = [{ quoted: false, value: "schema" }]; + Reflect.deleteProperty(qualifier, "0"); + Object.defineProperty(decoded, "qualifier", { value: qualifier }); + return decoded; + }, + () => { + const decoded = validDecoded(); + const component = { quoted: false, value: "schema" }; + Object.defineProperty(component, "quoted", { value: "false" }); + Object.defineProperty(decoded, "qualifier", { + value: [component], + }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded, "prefix", { value: null }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.setPrototypeOf(decoded.finalSegment, []); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded.finalSegment, "to", { value: "1" }); + return decoded; + }, + () => { + const decoded = validDecoded(); + Object.defineProperty(decoded.finalSegment, "from", { + get: () => { + throw new Error("accessed decoder getter"); + }, + }); + return decoded; + }, + ]; + for (const decodeRelationPath of malformedResults) { + expect( + recognize("SELECT * FROM x|", { + dialect: dialectWith(decodeRelationPath), + }), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + } + + expect( + recognize("SELECT * FROM x|", { + dialect: dialectWith(() => ({ + ...validDecoded(), + qualifier: [ + { + quoted: false, + value: "x".repeat(MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1), + }, + ], + })), + }), + ).toEqual({ + reason: "resource-limit", + resource: "identifier-segment", + status: "unavailable", + }); + }); + + it("reports opaque statement boundaries", () => { + const { position, text } = markedSource("DELIMITER // SELECT * FROM |"); + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const result = recognizeSqlRelationQuerySite( + source, + findSqlStatementSlot(index, position, "left"), + position, + postgresDialect, + ); + expect(result).toEqual({ + reason: "opaque-statement", + status: "unavailable", + }); + }); +}); + +describe("embedded-region barriers", () => { + it("leaves completion inside a region to the host language", () => { + expect( + recognize("SELECT * FROM {d|f}", { + regions: [{ from: 14, language: "python", to: 18 }], + }), + ).toEqual({ + reason: "cursor-in-embedded-region", + status: "inactive", + }); + }); + + it("recovers only after a visible supported anchor", () => { + const joined = expectReady( + recognize("SELECT * FROM {df} JOIN |", { + regions: [{ from: 14, language: "python", to: 18 }], + }), + ); + expect(joined.recognition).toEqual({ + issues: ["opaque-template-context"], + quality: "recovered", + }); + + const selected = expectReady( + recognize("SELECT {expr} FROM |", { + regions: [{ from: 7, language: "python", to: 13 }], + }), + ); + expect(selected.recognition.quality).toBe("recovered"); + + const prefixed = expectReady( + recognize("SELECT {expr} FROM us|", { + regions: [{ from: 7, language: "python", to: 13 }], + }), + ); + expect(prefixed.recognition).toEqual({ + issues: ["opaque-template-context"], + quality: "recovered", + }); + }); + + it("fails closed when a barrier occupies an alias position", () => { + expect( + recognize("SELECT * FROM users AS {x} JOIN |", { + regions: [{ from: 23, language: "python", to: 26 }], + }).status, + ).toBe("unavailable"); + expect( + recognize("{x} SELECT * FROM |", { + regions: [{ from: 0, language: "python", to: 3 }], + }).status, + ).toBe("inactive"); + }); + + it("keeps unsupported derived-relation continuations unavailable", () => { + expect( + recognize("SELECT * FROM users (x) JOIN |").status, + ).toBe("unavailable"); + }); + + it("never authenticates a path across a barrier", () => { + expect( + recognize("SELECT * FROM sch{x}.us|", { + regions: [{ from: 17, language: "python", to: 20 }], + }).status, + ).not.toBe("ready"); + }); +}); + +describe("query-site resource limits", () => { + it("bounds the active statement", () => { + const exact = `SELECT * FROM ${" ".repeat( + MAX_QUERY_SITE_STATEMENT_LENGTH - "SELECT * FROM ".length, + )}|`; + expect(recognize(exact).status).toBe("ready"); + const marked = `SELECT * FROM ${"x".repeat( + MAX_QUERY_SITE_STATEMENT_LENGTH, + )}|`; + expect(recognize(marked)).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + }); + + it("bounds lexical work and parenthesis depth", () => { + const exactTokens = `SELECT ${"x+".repeat( + MAX_QUERY_SITE_LEXEMES / 2 - 1, + )} FROM |`; + expect(recognize(exactTokens).status).toBe("ready"); + const tokens = `SELECT ${"x+".repeat(MAX_QUERY_SITE_LEXEMES / 2 + 1)} FROM |`; + expect(recognize(tokens)).toEqual({ + reason: "resource-limit", + resource: "lexical-token", + status: "unavailable", + }); + const nested = `${"(".repeat(MAX_QUERY_SITE_DEPTH + 1)}SELECT * FROM |`; + expect(recognize(nested)).toEqual({ + reason: "resource-limit", + resource: "parenthesis-depth", + status: "unavailable", + }); + expect( + recognize(`${"(".repeat(MAX_QUERY_SITE_DEPTH)}SELECT * FROM |`).status, + ).toBe("ready"); + }); + + it("keeps authenticated USING-list work linear and bounded", () => { + let usingColumnCalls = 0; + const dialect: SqlQuerySiteDialect = { + ...postgresDialect, + classifyIdentifierToken: (rawIdentifier, quoted, role) => { + if (role === "using-column") { + usingColumnCalls += 1; + } + return classifyIdentifierToken(rawIdentifier, quoted); + }, + }; + const columns = Array.from( + { length: 1_000 }, + (_, index) => `column_${index}`, + ).join(", "); + expect( + recognize( + `SELECT * FROM a JOIN b USING(${columns}) JOIN |`, + { dialect }, + ).status, + ).toBe("ready"); + expect(usingColumnCalls).toBe(1_000); + }); + + it("bounds path depth and decoded identifier length", () => { + expect(recognize("SELECT * FROM a.b.c.d|").status).toBe("ready"); + expect(recognize("SELECT * FROM a.b.c.d.e|")).toEqual({ + reason: "resource-limit", + resource: "identifier-path", + status: "unavailable", + }); + expect( + recognize(`SELECT * FROM ${"x".repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH, + )}|`).status, + ).toBe("ready"); + expect( + recognize(`SELECT * FROM ${"x".repeat( + MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1, + )}|`), + ).toEqual({ + reason: "resource-limit", + resource: "identifier-segment", + status: "unavailable", + }); + + const globalPath = Array.from( + { length: MAX_QUERY_SITE_PATH_COMPONENTS }, + () => "a", + ).join("."); + const globalDialect: SqlQuerySiteDialect = { + ...postgresDialect, + maximumPathDepth: MAX_QUERY_SITE_PATH_COMPONENTS, + }; + expect( + recognize(`SELECT * FROM ${globalPath}|`, { + dialect: globalDialect, + }).status, + ).toBe("ready"); + expect( + recognize("SELECT * FROM a|", { + dialect: { + ...globalDialect, + maximumPathDepth: MAX_QUERY_SITE_PATH_COMPONENTS + 1, + }, + }).status, + ).toBe("unavailable"); + }); +}); + +describe("query-site invariants", () => { + it("does not split astral or lone-surrogate cursor ranges", () => { + expect(recognize("SELECT * FROM \ud801|\udc00name").status).toBe( + "unavailable", + ); + const astral = expectReady(recognize("SELECT * FROM \ud801\udc00na|me")); + expect(astral.typedPathRange).toMatchObject({ from: 14, to: 20 }); + expect(recognize("SELECT * FROM \ud801|name").status).toBe( + "unavailable", + ); + }); + + it("remains bounded with the maximum embedded-region count", () => { + const body = "x ".repeat(10_000); + const marked = `SELECT ${body}FROM |`; + const regions = Array.from({ length: 10_000 }, (_, index) => ({ + from: "SELECT ".length + index * 2, + language: "template", + to: "SELECT ".length + index * 2 + 1, + })); + const result = expectReady(recognize(marked, { regions })); + expect(result.recognition).toEqual({ + issues: ["opaque-template-context"], + quality: "recovered", + }); + }); + + it("keeps every ready range inside its exact statement under fuzzed input", () => { + let state = 0x5eed1234; + const random = (): number => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state; + }; + const alphabet = [ + " ", + "\n", + "'", + "\"", + "$", + "(", + ")", + ",", + ".", + "/", + "-", + "*", + "_", + "a", + "F", + "0", + "😀", + ]; + + for (let fixture = 0; fixture < 200; fixture += 1) { + let text = ""; + const length = 32 + (random() % 160); + for (let index = 0; index < length; index += 1) { + text += alphabet[random() % alphabet.length]; + } + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + postgresDialect.lexicalProfile, + ); + for (let sample = 0; sample < 3; sample += 1) { + const position = random() % (text.length + 1); + const slot = findSqlStatementSlot( + index, + position, + position === 0 ? "right" : "left", + ); + const result = recognizeSqlRelationQuerySite( + source, + slot, + position, + postgresDialect, + ); + if (result.status !== "ready" || slot.boundaryQuality !== "exact") { + continue; + } + const statementLength = slot.source.to - slot.source.from; + expect(result.typedPathRange.from).toBeGreaterThanOrEqual(0); + expect(result.typedPathRange.to).toBeLessThanOrEqual(statementLength); + expect(result.finalSegmentRange.from).toBeGreaterThanOrEqual( + result.typedPathRange.from, + ); + expect(result.finalSegmentRange.to).toBeLessThanOrEqual( + result.typedPathRange.to, + ); + expect(Object.isFrozen(result.recognition.issues)).toBe(true); + } + } + }); + + it("is invariant to supported keyword casing and closed leading trivia", () => { + const variants = [ + "SELECT * FROM schema.us|", + "select * from schema.us|", + "SeLeCt * FrOm schema.us|", + "-- lead\nSELECT /* select */ * FROM schema.us|", + ]; + const results = variants.map((variant) => expectReady(recognize(variant))); + for (const result of results) { + expect(result.qualifier).toEqual([{ quoted: false, value: "schema" }]); + expect(result.prefix).toEqual({ quoted: false, value: "us" }); + expect(result.recognition.quality).toBe("exact"); + } + }); +}); diff --git a/src/vnext/query-site.ts b/src/vnext/query-site.ts new file mode 100644 index 0000000..ca5eabf --- /dev/null +++ b/src/vnext/query-site.ts @@ -0,0 +1,1749 @@ +import { + hasEscapeStringPrefix, + isBigQueryRawString, + isSqlWhitespace, + scanSqlBlockComment, + scanSqlDollarQuote, + scanSqlQuoted, + sqlIdentifierContinueLengthAt, + sqlIdentifierStartLengthAt, + type SqlLexicalProfile, +} from "./lexical.js"; +import type { SqlSourceSnapshot } from "./source.js"; +import type { + ExactSqlStatementSlot, + SqlStatementSlot, +} from "./statement-index.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +const querySiteRangeBrand: unique symbol = Symbol("SqlQuerySiteRange"); + +export const MAX_QUERY_SITE_STATEMENT_LENGTH = 65_536; +export const MAX_QUERY_SITE_LEXEMES = 16_384; +export const MAX_QUERY_SITE_DEPTH = 128; +export const MAX_QUERY_SITE_PATH_COMPONENTS = 32; +export const MAX_QUERY_SITE_IDENTIFIER_LENGTH = 256; + +export interface SqlQuerySiteRange { + readonly [querySiteRangeBrand]: "SqlQuerySiteRange"; + readonly from: number; + readonly to: number; +} + +export type SqlQuerySiteResource = + | "active-statement" + | "identifier-path" + | "identifier-segment" + | "lexical-token" + | "parenthesis-depth"; + +export type SqlQuerySiteIssue = + | "incomplete-identifier" + | "opaque-template-context"; + +export type SqlQuerySiteResult = + | { + readonly status: "inactive"; + readonly reason: + | "not-select-query" + | "not-relation-position" + | "cursor-in-comment" + | "cursor-in-string" + | "cursor-in-embedded-region"; + } + | { + readonly status: "unavailable"; + readonly reason: + | "opaque-statement" + | "resource-limit" + | "ambiguous-query-site" + | "unsupported-query-site"; + readonly resource?: SqlQuerySiteResource; + } + | { + readonly status: "ready"; + readonly anchor: "comma" | "from" | "join"; + readonly qualifier: SqlIdentifierPath; + readonly prefix: SqlIdentifierComponent; + readonly finalSegmentRange: SqlQuerySiteRange; + readonly typedPathRange: SqlQuerySiteRange; + readonly recognition: + | { + readonly quality: "exact"; + readonly issues: readonly []; + } + | { + readonly quality: "recovered"; + readonly issues: readonly [ + SqlQuerySiteIssue, + ...SqlQuerySiteIssue[], + ]; + }; + }; + +export type SqlDecodedQueryPath = + | { + readonly status: "decoded"; + readonly qualifier: SqlIdentifierPath; + readonly prefix: SqlIdentifierComponent; + readonly finalSegment: { + readonly from: number; + readonly to: number; + }; + readonly quality: "exact" | "recovered"; + } + | { + readonly status: "unavailable"; + readonly reason: + | "invalid-identifier" + | "unsupported-quote" + | "undecodable-identifier"; + }; + +export interface SqlQuerySiteDialect { + /** Accepts only a dialect-valid identifier and returns its decoded value. */ + readonly classifyIdentifierToken: ( + rawIdentifier: string, + quoted: boolean, + role: + | "explicit-alias" + | "implicit-alias" + | "using-column", + ) => + | { + readonly status: "identifier"; + readonly value: string; + } + | { + readonly status: "unsupported"; + }; + readonly lexicalProfile: SqlLexicalProfile; + readonly maximumPathDepth: number; + readonly supportsNaturalJoin: boolean; + readonly decodeRelationPath: ( + rawPath: string, + cursorOffset: number, + ) => SqlDecodedQueryPath; +} + +type LexemeKind = + | "barrier" + | "comment" + | "line-comment" + | "other" + | "punctuation" + | "quoted-identifier" + | "string" + | "word"; + +interface Lexeme { + readonly closed: boolean; + readonly from: number; + readonly kind: LexemeKind; + readonly to: number; +} + +type FrameState = + | "after-alias" + | "after-relation" + | "closed" + | "expect-alias" + | "expect-relation" + | "join-constraint" + | "select-list" + | "unavailable"; + +type JoinPrefix = + | "cross" + | "full" + | "full-outer" + | "inner" + | "left" + | "left-outer" + | "natural" + | "natural-full" + | "natural-full-outer" + | "natural-inner" + | "natural-left" + | "natural-left-outer" + | "natural-right" + | "natural-right-outer" + | "right" + | "right-outer" + | null; + +interface UsingConstraint { + complete: boolean; + expectIdentifier: boolean; + identifierCount: number; + listDepth: number | null; +} + +interface QueryFrame { + readonly baseDepth: number; + anchor: "comma" | "from" | "join"; + blocksNestedQuery: boolean; + joinPrefix: JoinPrefix; + joinConstraint: UsingConstraint | null; + joinConstraintAllowed: boolean; + selectWords: [string | null, string | null, string | null]; + state: FrameState; + tainted: boolean; + unavailableReason: "ambiguous-query-site" | "unsupported-query-site"; +} + +function inactive( + reason: Extract["reason"], +): SqlQuerySiteResult { + return Object.freeze({ reason, status: "inactive" }); +} + +function unavailable( + reason: Extract["reason"], + resource?: SqlQuerySiteResource, +): SqlQuerySiteResult { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); +} + +function createRange(from: number, to: number): SqlQuerySiteRange { + const range: SqlQuerySiteRange = { + [querySiteRangeBrand]: "SqlQuerySiteRange", + from, + to, + }; + return Object.freeze(range); +} + +function findRegionAtOrAfter(source: SqlSourceSnapshot, position: number): number { + let low = 0; + let high = source.embeddedRegions.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const region = source.embeddedRegions[middle]; + if (!region || region.to <= position) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +} + +function regionContains(source: SqlSourceSnapshot, position: number): boolean { + const region = source.embeddedRegions[ + findRegionAtOrAfter(source, position) + ]; + return Boolean(region && region.from <= position && position < region.to); +} + +class QueryLexer { + readonly #profile: SqlLexicalProfile; + readonly #source: SqlSourceSnapshot; + readonly #to: number; + #cursor: number; + #lexemeCount = 0; + #pushed: Lexeme | null = null; + #regionIndex: number; + resource: SqlQuerySiteResource | null = null; + + constructor( + source: SqlSourceSnapshot, + from: number, + to: number, + profile: SqlLexicalProfile, + ) { + this.#source = source; + this.#cursor = from; + this.#to = to; + this.#profile = profile; + this.#regionIndex = findRegionAtOrAfter(source, from); + } + + next(): Lexeme | null { + if (this.#pushed) { + const lexeme = this.#pushed; + this.#pushed = null; + return lexeme; + } + const text = this.#source.analysisText; + while (this.#cursor < this.#to) { + const region = this.#source.embeddedRegions[this.#regionIndex]; + if (region && region.from <= this.#cursor) { + const from = this.#cursor; + this.#cursor = Math.min(region.to, this.#to); + this.#regionIndex += 1; + return this.#record({ + closed: true, + from, + kind: "barrier", + to: this.#cursor, + }); + } + const lexicalLimit = Math.min(region?.from ?? this.#to, this.#to); + const code = text.charCodeAt(this.#cursor); + if (isSqlWhitespace(code)) { + this.#cursor += 1; + continue; + } + const from = this.#cursor; + const next = text.charCodeAt(from + 1); + if (code === 45 && next === 45) { + this.#cursor += 2; + while ( + this.#cursor < this.#to && + text.charCodeAt(this.#cursor) !== 10 && + text.charCodeAt(this.#cursor) !== 13 + ) { + this.#cursor += 1; + } + this.#advanceCoveredRegions(); + return this.#record({ + closed: true, + from, + kind: "line-comment", + to: this.#cursor, + }); + } + if (this.#profile.hashLineComments && code === 35) { + this.#cursor += 1; + while ( + this.#cursor < this.#to && + text.charCodeAt(this.#cursor) !== 10 && + text.charCodeAt(this.#cursor) !== 13 + ) { + this.#cursor += 1; + } + this.#advanceCoveredRegions(); + return this.#record({ + closed: true, + from, + kind: "line-comment", + to: this.#cursor, + }); + } + if (code === 47 && next === 42) { + const result = scanSqlBlockComment( + text, + from, + lexicalLimit, + this.#profile.nestedBlockComments, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: "comment", + to: result.to, + }); + } + if (this.#profile.dollarQuotedStrings && code === 36) { + const result = scanSqlDollarQuote(text, from, lexicalLimit); + if (result) { + this.#cursor = result.to; + if (result.delimiterTooLong) { + this.resource = "identifier-segment"; + return null; + } + return this.#record({ + closed: result.closed, + from, + kind: "string", + to: result.to, + }); + } + } + if (code === 96 && this.#profile.backtickQuotedIdentifiers) { + const result = scanSqlQuoted( + text, + from, + lexicalLimit, + code, + 1, + true, + false, + false, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: "quoted-identifier", + to: result.to, + }); + } + if (code === 39 || code === 34) { + const triple = + this.#profile.bigQueryStrings && + text.charCodeAt(from + 1) === code && + text.charCodeAt(from + 2) === code; + const quotedIdentifier = code === 34 && !this.#profile.bigQueryStrings; + const rawBigQueryString = + this.#profile.bigQueryStrings && + isBigQueryRawString(text, from); + const backslashEscapes = + !rawBigQueryString && + (this.#profile.bigQueryStrings || + (code === 39 && + (this.#profile.singleQuoteBackslash === "always" || + (this.#profile.singleQuoteBackslash === "e-prefix" && + hasEscapeStringPrefix(text, from))))); + const result = scanSqlQuoted( + text, + from, + lexicalLimit, + code, + triple ? 3 : 1, + backslashEscapes, + !this.#profile.bigQueryStrings, + this.#profile.bigQueryStrings && !triple, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: quotedIdentifier ? "quoted-identifier" : "string", + to: result.to, + }); + } + const startLength = sqlIdentifierStartLengthAt(text, from); + if (startLength > 0) { + this.#cursor += startLength; + while (this.#cursor < lexicalLimit) { + const length = sqlIdentifierContinueLengthAt(text, this.#cursor); + if (length === 0) { + break; + } + this.#cursor += length; + } + return this.#record({ + closed: true, + from, + kind: "word", + to: this.#cursor, + }); + } + this.#cursor += 1; + return this.#record({ + closed: true, + from, + kind: + code === 40 || + code === 41 || + code === 44 || + code === 46 || + code === 59 + ? "punctuation" + : "other", + to: this.#cursor, + }); + } + return null; + } + + pushBack(lexeme: Lexeme): void { + this.#pushed = lexeme; + } + + #advanceCoveredRegions(): void { + while ( + (this.#source.embeddedRegions[this.#regionIndex]?.to ?? Infinity) <= + this.#cursor + ) { + this.#regionIndex += 1; + } + } + + #record(lexeme: Lexeme): Lexeme | null { + this.#lexemeCount += 1; + if (this.#lexemeCount > MAX_QUERY_SITE_LEXEMES) { + this.resource = "lexical-token"; + return null; + } + return lexeme; + } +} + +function wordEquals(text: string, token: Lexeme, expected: string): boolean { + if (token.to - token.from !== expected.length) { + return false; + } + for (let index = 0; index < expected.length; index += 1) { + if ( + (text.charCodeAt(token.from + index) | 32) !== + expected.charCodeAt(index) + ) { + return false; + } + } + return true; +} + +function wordValue(text: string, token: Lexeme): string { + return token.to - token.from <= MAX_QUERY_SITE_IDENTIFIER_LENGTH + ? text.slice(token.from, token.to).toLowerCase() + : ""; +} + +function topFrame(frames: readonly QueryFrame[]): QueryFrame | null { + return frames[frames.length - 1] ?? null; +} + +function isCommentLexeme(token: Lexeme): boolean { + return token.kind === "comment" || token.kind === "line-comment"; +} + +function cursorIsInComment(token: Lexeme, position: number): boolean { + return ( + token.from <= position && + (position < token.to || + (token.kind === "line-comment" && position === token.to)) + ); +} + +function createFrame(depth: number, tainted: boolean): QueryFrame { + return { + anchor: "from", + baseDepth: depth, + blocksNestedQuery: false, + joinConstraint: null, + joinConstraintAllowed: false, + joinPrefix: null, + selectWords: [null, null, null], + state: "select-list", + tainted, + unavailableReason: "ambiguous-query-site", + }; +} + +function isClauseCloser(word: string): boolean { + return ( + word === "fetch" || + word === "group" || + word === "having" || + word === "limit" || + word === "offset" || + word === "order" || + word === "where" + ); +} + +function isSetOperation(word: string): boolean { + return word === "except" || word === "intersect" || word === "union"; +} + +function pushSelectWord(frame: QueryFrame, word: string): void { + frame.selectWords = [frame.selectWords[1], frame.selectWords[2], word]; +} + +function isExpressionFrom(frame: QueryFrame): boolean { + const [first, second] = [frame.selectWords[1], frame.selectWords[2]]; + return ( + (first === "is" && second === "distinct") || + (frame.selectWords[0] === "is" && + first === "not" && + second === "distinct") + ); +} + +function markUnavailable( + frame: QueryFrame, + reason: QueryFrame["unavailableReason"], +): void { + frame.state = "unavailable"; + frame.unavailableReason = reason; +} + +function joinAllowsConstraint( + prefix: JoinPrefix, +): boolean { + return ( + prefix !== "cross" && + (prefix === null || !prefix.startsWith("natural")) + ); +} + +function processJoinPrefixWord( + frame: QueryFrame, + word: string, + supportsNaturalJoin: boolean, +): boolean { + const prefix = frame.joinPrefix; + if (word === "outer") { + if (prefix === "left") { + frame.joinPrefix = "left-outer"; + } else if (prefix === "right") { + frame.joinPrefix = "right-outer"; + } else if (prefix === "full") { + frame.joinPrefix = "full-outer"; + } else if (prefix === "natural-left") { + frame.joinPrefix = "natural-left-outer"; + } else if (prefix === "natural-right") { + frame.joinPrefix = "natural-right-outer"; + } else if (prefix === "natural-full") { + frame.joinPrefix = "natural-full-outer"; + } else { + markUnavailable(frame, "ambiguous-query-site"); + } + return true; + } + if (word === "natural") { + if (!supportsNaturalJoin) { + markUnavailable(frame, "unsupported-query-site"); + } else if (prefix === null) { + frame.joinPrefix = "natural"; + } else { + markUnavailable(frame, "ambiguous-query-site"); + } + return true; + } + if (word === "cross") { + if (prefix === null) { + frame.joinPrefix = "cross"; + } else { + markUnavailable(frame, "ambiguous-query-site"); + } + return true; + } + if (word === "inner") { + if (prefix === null) { + frame.joinPrefix = "inner"; + } else if (prefix === "natural") { + frame.joinPrefix = "natural-inner"; + } else { + markUnavailable(frame, "ambiguous-query-site"); + } + return true; + } + if (word === "left" || word === "right" || word === "full") { + if (prefix === null) { + frame.joinPrefix = word; + } else if (prefix === "natural") { + frame.joinPrefix = + word === "left" + ? "natural-left" + : word === "right" + ? "natural-right" + : "natural-full"; + } else { + markUnavailable(frame, "ambiguous-query-site"); + } + return true; + } + return false; +} + +function classifyIdentifierToken( + dialect: SqlQuerySiteDialect, + rawIdentifier: string, + quoted: boolean, + role: + | "explicit-alias" + | "implicit-alias" + | "using-column", +): "identifier" | "unsupported" | null { + if ( + rawIdentifier.length === 0 || + (!quoted && + rawIdentifier.length > MAX_QUERY_SITE_IDENTIFIER_LENGTH) || + (quoted && rawIdentifier.length <= 2) + ) { + return null; + } + try { + const classification = dialect.classifyIdentifierToken( + rawIdentifier, + quoted, + role, + ); + if (!isPlainRecord(classification)) { + return null; + } + const status = ownDataProperty(classification, "status"); + const value = ownDataProperty(classification, "value"); + if ( + status === "identifier" && + typeof value === "string" && + value.length > 0 && + value.length <= MAX_QUERY_SITE_IDENTIFIER_LENGTH + ) { + return "identifier"; + } + return status === "unsupported" && + value === INVALID_DATA_PROPERTY + ? "unsupported" + : null; + } catch { + return null; + } +} + +function processFrameWord( + frame: QueryFrame, + dialect: SqlQuerySiteDialect, + supportsNaturalJoin: boolean, + text: string, + token: Lexeme, +): void { + if (frame.state === "unavailable" || frame.state === "closed") { + return; + } + const word = wordValue(text, token); + if (frame.state === "expect-alias") { + if (word.length === 0) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + const classification = classifyIdentifierToken( + dialect, + text.slice(token.from, token.to), + false, + "explicit-alias", + ); + if (classification === "identifier") { + frame.state = "after-alias"; + return; + } + markUnavailable( + frame, + classification === "unsupported" + ? "unsupported-query-site" + : "ambiguous-query-site", + ); + return; + } + if ( + frame.state === "join-constraint" && + !frame.joinConstraint?.complete && + (isSetOperation(word) || + isClauseCloser(word) || + word === "lateral" || + word === "qualify" || + word === "window") + ) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (isSetOperation(word)) { + markUnavailable(frame, "unsupported-query-site"); + return; + } + if (word === "lateral" || word === "qualify" || word === "window") { + markUnavailable(frame, "unsupported-query-site"); + return; + } + if (isClauseCloser(word)) { + if (frame.joinPrefix !== null) { + frame.blocksNestedQuery = true; + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if ( + frame.state === "join-constraint" && + frame.joinConstraint?.complete + ) { + frame.blocksNestedQuery = false; + } + frame.state = "closed"; + frame.joinPrefix = null; + return; + } + if (frame.state === "select-list") { + if (word === "from" && !isExpressionFrom(frame)) { + frame.anchor = "from"; + frame.blocksNestedQuery = false; + frame.joinConstraintAllowed = false; + frame.joinConstraint = null; + frame.state = "expect-relation"; + frame.joinPrefix = null; + return; + } + pushSelectWord(frame, word); + return; + } + if (frame.state === "join-constraint") { + const constraint = frame.joinConstraint; + if (!constraint) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (!constraint.complete) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (word === "on" || word === "using") { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (processJoinPrefixWord(frame, word, supportsNaturalJoin)) { + return; + } + if (word === "join") { + frame.anchor = "join"; + frame.blocksNestedQuery = false; + frame.joinConstraintAllowed = joinAllowsConstraint(frame.joinPrefix); + frame.joinConstraint = null; + frame.joinPrefix = null; + frame.state = "expect-relation"; + return; + } + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (frame.state !== "after-relation" && frame.state !== "after-alias") { + return; + } + if (word === "on") { + frame.blocksNestedQuery = true; + markUnavailable(frame, "unsupported-query-site"); + return; + } + if (word === "using") { + if ( + frame.anchor !== "join" || + !frame.joinConstraintAllowed || + frame.joinConstraint !== null || + frame.joinPrefix !== null + ) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + frame.state = "join-constraint"; + frame.blocksNestedQuery = true; + frame.joinConstraint = { + complete: false, + expectIdentifier: true, + identifierCount: 0, + listDepth: null, + }; + return; + } + if (word === "join") { + frame.anchor = "join"; + frame.blocksNestedQuery = false; + frame.joinConstraintAllowed = joinAllowsConstraint(frame.joinPrefix); + frame.joinConstraint = null; + frame.state = "expect-relation"; + frame.joinPrefix = null; + return; + } + if (word === "as" && frame.state === "after-relation") { + frame.state = "expect-alias"; + return; + } + if (processJoinPrefixWord(frame, word, supportsNaturalJoin)) { + return; + } + if (frame.joinPrefix !== null) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + if (frame.state === "after-relation") { + if (word.length === 0) { + markUnavailable(frame, "ambiguous-query-site"); + return; + } + const classification = classifyIdentifierToken( + dialect, + text.slice(token.from, token.to), + false, + "implicit-alias", + ); + if (classification === "identifier") { + frame.state = "after-alias"; + return; + } + markUnavailable( + frame, + classification === "unsupported" + ? "unsupported-query-site" + : "ambiguous-query-site", + ); + return; + } + markUnavailable(frame, "ambiguous-query-site"); +} + +function processBarrier(frame: QueryFrame | null): void { + if (!frame || frame.state === "closed" || frame.state === "unavailable") { + return; + } + frame.tainted = true; + frame.joinPrefix = null; + if (frame.state === "expect-relation") { + frame.state = "after-relation"; + } else if (frame.state === "expect-alias") { + markUnavailable(frame, "ambiguous-query-site"); + } else if ( + frame.state === "join-constraint" && + !frame.joinConstraint?.complete + ) { + markUnavailable(frame, "ambiguous-query-site"); + } +} + +function intersectsRegion( + source: SqlSourceSnapshot, + from: number, + to: number, +): boolean { + const region = source.embeddedRegions[findRegionAtOrAfter(source, from)]; + return Boolean(region && region.from < to && from < region.to); +} + +const INVALID_DATA_PROPERTY: unique symbol = Symbol("invalid-data-property"); + +function ownDataProperty( + value: object, + key: PropertyKey, +): unknown | typeof INVALID_DATA_PROPERTY { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor + ? descriptor.value + : INVALID_DATA_PROPERTY; +} + +function isPlainRecord(value: unknown): value is object { + if (typeof value !== "object" || value === null) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +type ComponentValidation = + | { + readonly status: "valid"; + readonly component: SqlIdentifierComponent; + } + | { + readonly status: "invalid"; + } + | { + readonly status: "resource-limit"; + }; + +function validateDecodedComponent(value: unknown): ComponentValidation { + if (!isPlainRecord(value)) { + return { status: "invalid" }; + } + const quoted = ownDataProperty(value, "quoted"); + const componentValue = ownDataProperty(value, "value"); + if (typeof quoted !== "boolean" || typeof componentValue !== "string") { + return { status: "invalid" }; + } + if (componentValue.length > MAX_QUERY_SITE_IDENTIFIER_LENGTH) { + return { status: "resource-limit" }; + } + return { + component: Object.freeze({ quoted, value: componentValue }), + status: "valid", + }; +} + +type DecoderValidation = + | { + readonly status: "decoded"; + readonly finalFrom: number; + readonly finalTo: number; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly quality: "exact" | "recovered"; + } + | { + readonly status: "invalid"; + } + | { + readonly status: "unavailable"; + } + | { + readonly status: "resource-limit"; + readonly resource: "identifier-path" | "identifier-segment"; + }; + +function validateDecoderResult( + value: unknown, + maximumPathDepth: number, +): DecoderValidation { + if (!isPlainRecord(value)) { + return { status: "invalid" }; + } + const status = ownDataProperty(value, "status"); + if (status === "unavailable") { + const reason = ownDataProperty(value, "reason"); + return reason === "invalid-identifier" || + reason === "unsupported-quote" || + reason === "undecodable-identifier" + ? { status: "unavailable" } + : { status: "invalid" }; + } + if (status !== "decoded") { + return { status: "invalid" }; + } + const quality = ownDataProperty(value, "quality"); + if (quality !== "exact" && quality !== "recovered") { + return { status: "invalid" }; + } + const rawQualifier = ownDataProperty(value, "qualifier"); + if (!Array.isArray(rawQualifier)) { + return { status: "invalid" }; + } + const qualifierLength = ownDataProperty(rawQualifier, "length"); + if ( + typeof qualifierLength !== "number" || + !Number.isSafeInteger(qualifierLength) || + qualifierLength < 0 + ) { + return { status: "invalid" }; + } + if (qualifierLength + 1 > maximumPathDepth) { + return { resource: "identifier-path", status: "resource-limit" }; + } + const qualifier: SqlIdentifierComponent[] = []; + for (let index = 0; index < qualifierLength; index += 1) { + const component = validateDecodedComponent( + ownDataProperty(rawQualifier, index), + ); + if (component.status === "resource-limit") { + return { resource: "identifier-segment", status: "resource-limit" }; + } + if (component.status === "invalid") { + return { status: "invalid" }; + } + qualifier.push(component.component); + } + const prefix = validateDecodedComponent( + ownDataProperty(value, "prefix"), + ); + if (prefix.status === "resource-limit") { + return { resource: "identifier-segment", status: "resource-limit" }; + } + if (prefix.status === "invalid") { + return { status: "invalid" }; + } + const finalSegment = ownDataProperty(value, "finalSegment"); + if (!isPlainRecord(finalSegment)) { + return { status: "invalid" }; + } + const finalFrom = ownDataProperty(finalSegment, "from"); + const finalTo = ownDataProperty(finalSegment, "to"); + if (typeof finalFrom !== "number" || typeof finalTo !== "number") { + return { status: "invalid" }; + } + return { + finalFrom, + finalTo, + prefix: prefix.component, + qualifier: Object.freeze(qualifier), + quality, + status: "decoded", + }; +} + +function readyEmpty( + slot: ExactSqlStatementSlot, + frame: QueryFrame, + position: number, +): SqlQuerySiteResult { + const offset = position - slot.source.from; + const emptyPrefix = Object.freeze({ quoted: false, value: "" }); + const recognition = frame.tainted + ? Object.freeze({ + issues: Object.freeze([ + "opaque-template-context", + ]) as readonly ["opaque-template-context"], + quality: "recovered" as const, + }) + : Object.freeze({ + issues: Object.freeze([]) as readonly [], + quality: "exact" as const, + }); + return Object.freeze({ + anchor: frame.anchor, + finalSegmentRange: createRange(offset, offset), + prefix: emptyPrefix, + qualifier: Object.freeze([]), + recognition, + status: "ready", + typedPathRange: createRange(offset, offset), + }); +} + +function decodeReadyPath( + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + frame: QueryFrame, + dialect: SqlQuerySiteDialect, + maximumPathDepth: number, + rawFrom: number, + rawTo: number, + position: number, +): SqlQuerySiteResult { + if (intersectsRegion(source, rawFrom, rawTo)) { + return unavailable("ambiguous-query-site"); + } + const rawPath = source.originalText.slice(rawFrom, rawTo); + let decoded: DecoderValidation; + try { + decoded = validateDecoderResult( + dialect.decodeRelationPath(rawPath, position - rawFrom), + maximumPathDepth, + ); + } catch { + return unavailable("ambiguous-query-site"); + } + if (decoded.status === "resource-limit") { + return unavailable("resource-limit", decoded.resource); + } + if (decoded.status === "invalid" || decoded.status === "unavailable") { + return unavailable("ambiguous-query-site"); + } + const finalFrom = decoded.finalFrom; + const finalTo = decoded.finalTo; + const cursorOffset = position - rawFrom; + if ( + !Number.isSafeInteger(finalFrom) || + !Number.isSafeInteger(finalTo) || + finalFrom < 0 || + finalFrom > cursorOffset || + cursorOffset > finalTo || + finalTo > rawPath.length + ) { + return unavailable("ambiguous-query-site"); + } + const issues: SqlQuerySiteIssue[] = []; + if (frame.tainted) { + issues.push("opaque-template-context"); + } + if (decoded.quality === "recovered") { + issues.push("incomplete-identifier"); + } + const recognition = + issues.length === 0 + ? Object.freeze({ + issues: Object.freeze([]) as readonly [], + quality: "exact" as const, + }) + : Object.freeze({ + issues: Object.freeze(issues) as readonly [ + SqlQuerySiteIssue, + ...SqlQuerySiteIssue[], + ], + quality: "recovered" as const, + }); + return Object.freeze({ + anchor: frame.anchor, + finalSegmentRange: createRange( + rawFrom - slot.source.from + finalFrom, + rawFrom - slot.source.from + finalTo, + ), + prefix: decoded.prefix, + qualifier: decoded.qualifier, + recognition, + status: "ready", + typedPathRange: createRange( + rawFrom - slot.source.from, + rawTo - slot.source.from, + ), + }); +} + +function recognizePath( + lexer: QueryLexer, + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + frame: QueryFrame, + dialect: SqlQuerySiteDialect, + maximumPathDepth: number, + first: Lexeme, + position: number, +): SqlQuerySiteResult | null { + let bareSegment = first.kind === "word"; + let expectingSegment = false; + let pathComponents = 1; + const rawFrom = first.from; + let rawTo = first.to; + while (true) { + const next = lexer.next(); + if (lexer.resource) { + return unavailable("resource-limit", lexer.resource); + } + if (next?.from === rawTo) { + if (expectingSegment) { + if ( + next.kind === "word" || + next.kind === "quoted-identifier" + ) { + bareSegment = next.kind === "word"; + expectingSegment = false; + rawTo = next.to; + continue; + } + } + if ( + !expectingSegment && + next.kind === "punctuation" && + source.analysisText.charCodeAt(next.from) === 46 + ) { + pathComponents += 1; + if (pathComponents > maximumPathDepth) { + return unavailable("resource-limit", "identifier-path"); + } + expectingSegment = true; + rawTo = next.to; + continue; + } + if ( + !expectingSegment && + bareSegment && + (next.kind === "word" || + (next.kind === "other" && + (source.analysisText.charCodeAt(next.from) === 36 || + source.analysisText.charCodeAt(next.from) === 45 || + (source.analysisText.charCodeAt(next.from) >= 48 && + source.analysisText.charCodeAt(next.from) <= 57)))) + ) { + rawTo = next.to; + continue; + } + } + if (expectingSegment) { + if (position !== rawTo) { + return unavailable("ambiguous-query-site"); + } + expectingSegment = false; + } + { + let terminator = next; + let separated = Boolean(terminator && terminator.from > rawTo); + while (terminator && isCommentLexeme(terminator)) { + if (cursorIsInComment(terminator, position)) { + return inactive("cursor-in-comment"); + } + if (!terminator.closed) { + return unavailable("ambiguous-query-site"); + } + separated = true; + terminator = lexer.next(); + if (lexer.resource) { + return unavailable("resource-limit", lexer.resource); + } + } + if (terminator?.kind === "barrier") { + return unavailable("ambiguous-query-site"); + } + if (terminator?.kind === "punctuation") { + const code = source.analysisText.charCodeAt(terminator.from); + if (code === 40) { + return unavailable("unsupported-query-site"); + } + if (code !== 41 && code !== 44 && code !== 59) { + return unavailable("ambiguous-query-site"); + } + } else if ( + terminator && + (terminator.kind === "string" || + terminator.kind === "other" || + !separated) + ) { + return unavailable("ambiguous-query-site"); + } + if (terminator) { + lexer.pushBack(terminator); + } + if (position >= rawFrom && position <= rawTo) { + return decodeReadyPath( + source, + slot, + frame, + dialect, + maximumPathDepth, + rawFrom, + rawTo, + position, + ); + } + frame.state = "after-relation"; + return null; + } + } +} + +function resultAtGap( + slot: ExactSqlStatementSlot, + frame: QueryFrame | null, + position: number, + sawSelect: boolean, +): SqlQuerySiteResult { + if (!frame) { + return inactive(sawSelect ? "not-relation-position" : "not-select-query"); + } + if (frame.state === "unavailable") { + return unavailable(frame.unavailableReason); + } + if (frame.state === "expect-relation") { + return readyEmpty(slot, frame, position); + } + return inactive("not-relation-position"); +} + +export function recognizeSqlRelationQuerySite( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, + position: number, + dialect: SqlQuerySiteDialect, +): SqlQuerySiteResult { + if (slot.boundaryQuality === "opaque") { + return unavailable("opaque-statement"); + } + if ( + !Number.isSafeInteger(position) || + position < slot.source.from || + position > slot.source.to + ) { + return inactive("not-relation-position"); + } + if (regionContains(source, position)) { + return inactive("cursor-in-embedded-region"); + } + if ( + slot.source.to - slot.source.from > + MAX_QUERY_SITE_STATEMENT_LENGTH + ) { + return unavailable("resource-limit", "active-statement"); + } + + let lexicalProfile: SqlLexicalProfile; + let maximumPathDepth: number; + let supportsNaturalJoin: boolean; + try { + lexicalProfile = dialect.lexicalProfile; + maximumPathDepth = dialect.maximumPathDepth; + supportsNaturalJoin = dialect.supportsNaturalJoin; + } catch { + return unavailable("ambiguous-query-site"); + } + if ( + !Number.isSafeInteger(maximumPathDepth) || + maximumPathDepth < 1 || + maximumPathDepth > MAX_QUERY_SITE_PATH_COMPONENTS || + typeof supportsNaturalJoin !== "boolean" + ) { + return unavailable("ambiguous-query-site"); + } + const lexer = new QueryLexer( + source, + slot.source.from, + slot.source.to, + lexicalProfile, + ); + const frames: QueryFrame[] = []; + const queryCandidates = new Set([0]); + let depth = 0; + let sawSelect = false; + let statementTainted = false; + + while (true) { + const token = lexer.next(); + if (lexer.resource) { + return unavailable("resource-limit", lexer.resource); + } + const frame = topFrame(frames); + if (!token || token.from > position) { + return resultAtGap(slot, frame, position, sawSelect); + } + if ( + token.from === position && + frame?.state === "expect-relation" && + token.kind === "punctuation" && + (source.analysisText.charCodeAt(token.from) === 41 || + source.analysisText.charCodeAt(token.from) === 44) + ) { + return readyEmpty(slot, frame, position); + } + const cursorInside = token.from <= position && position < token.to; + if (isCommentLexeme(token)) { + if (cursorIsInComment(token, position)) { + return inactive("cursor-in-comment"); + } + if (!token.closed) { + return unavailable("ambiguous-query-site"); + } + continue; + } + if (token.kind === "string") { + if (cursorInside) { + return inactive("cursor-in-string"); + } + if (!token.closed) { + return unavailable("ambiguous-query-site"); + } + } + if ( + token.kind === "quoted-identifier" && + !token.closed && + token.to < slot.source.to + ) { + return unavailable("ambiguous-query-site"); + } + if (token.kind === "barrier") { + statementTainted = true; + processBarrier(frame); + queryCandidates.delete(depth); + continue; + } + if ( + cursorInside && + frame?.state === "unavailable" && + frame.blocksNestedQuery + ) { + return unavailable(frame.unavailableReason); + } + + const active = topFrame(frames); + const activeConstraint = active?.joinConstraint; + if ( + active?.state === "join-constraint" && + activeConstraint && + activeConstraint.listDepth === depth && + token.kind !== "punctuation" + ) { + if (cursorInside) { + return inactive("not-relation-position"); + } + queryCandidates.delete(depth); + if ( + activeConstraint.expectIdentifier && + (token.kind === "word" || + token.kind === "quoted-identifier") + ) { + const rawIdentifier = token.closed + ? source.analysisText.slice(token.from, token.to) + : ""; + const classification = classifyIdentifierToken( + dialect, + rawIdentifier, + token.kind === "quoted-identifier", + "using-column", + ); + if (classification === "identifier") { + activeConstraint.expectIdentifier = false; + activeConstraint.identifierCount += 1; + continue; + } + } + markUnavailable(active, "ambiguous-query-site"); + continue; + } + if ( + active?.state === "expect-relation" && + (token.kind === "word" || token.kind === "quoted-identifier") + ) { + if ( + token.kind === "word" && + wordEquals(source.analysisText, token, "lateral") + ) { + markUnavailable(active, "unsupported-query-site"); + continue; + } + const pathResult = recognizePath( + lexer, + source, + slot, + active, + dialect, + maximumPathDepth, + token, + position, + ); + if (pathResult) { + return pathResult; + } + continue; + } + if (cursorInside) { + return inactive("not-relation-position"); + } + if ( + active?.state === "expect-relation" && + (token.kind === "other" || token.kind === "string") + ) { + markUnavailable( + active, + token.kind === "string" + ? "unsupported-query-site" + : "ambiguous-query-site", + ); + continue; + } + + if (token.kind === "punctuation") { + const code = source.analysisText.charCodeAt(token.from); + const punctuationFrame = topFrame(frames); + queryCandidates.delete(depth); + const usingConstraint = + punctuationFrame?.state === "join-constraint" && + punctuationFrame.joinConstraint + ? punctuationFrame.joinConstraint + : null; + if (usingConstraint && punctuationFrame) { + if ( + usingConstraint.listDepth === null && + !usingConstraint.complete && + punctuationFrame.baseDepth === depth + ) { + if (code === 40) { + usingConstraint.listDepth = depth + 1; + } else { + markUnavailable( + punctuationFrame, + "ambiguous-query-site", + ); + } + } else if (usingConstraint.listDepth === depth) { + if (code === 44) { + if (usingConstraint.expectIdentifier) { + markUnavailable( + punctuationFrame, + "ambiguous-query-site", + ); + } else { + usingConstraint.expectIdentifier = true; + } + } else if (code === 41) { + if ( + usingConstraint.identifierCount === 0 || + usingConstraint.expectIdentifier + ) { + markUnavailable( + punctuationFrame, + "ambiguous-query-site", + ); + } else { + usingConstraint.complete = true; + usingConstraint.listDepth = null; + } + } else { + markUnavailable( + punctuationFrame, + "ambiguous-query-site", + ); + } + } else if ( + usingConstraint.complete && + punctuationFrame.baseDepth === depth && + code !== 41 && + code !== 44 && + code !== 59 + ) { + markUnavailable(punctuationFrame, "ambiguous-query-site"); + } + } + if ( + punctuationFrame?.baseDepth === depth && + punctuationFrame.state === "join-constraint" && + punctuationFrame.joinPrefix !== null + ) { + if (code === 40) { + punctuationFrame.joinPrefix = null; + } else { + markUnavailable(punctuationFrame, "ambiguous-query-site"); + } + } + if ( + punctuationFrame?.baseDepth === depth && + punctuationFrame.state === "expect-relation" && + code !== 41 + ) { + markUnavailable( + punctuationFrame, + code === 40 + ? "unsupported-query-site" + : "ambiguous-query-site", + ); + } + if ( + punctuationFrame?.baseDepth === depth && + punctuationFrame.state === "expect-alias" + ) { + markUnavailable(punctuationFrame, "ambiguous-query-site"); + } + if (code === 40) { + const activeBeforeOpen = topFrame(frames); + if ( + activeBeforeOpen?.baseDepth === depth && + (activeBeforeOpen.state === "after-relation" || + activeBeforeOpen.state === "after-alias") + ) { + markUnavailable(activeBeforeOpen, "unsupported-query-site"); + } + depth += 1; + if (depth > MAX_QUERY_SITE_DEPTH) { + return unavailable("resource-limit", "parenthesis-depth"); + } + queryCandidates.add(depth); + } else if (code === 41) { + queryCandidates.delete(depth); + let poppedTaint = false; + while ( + frames.length > 0 && + (topFrame(frames)?.baseDepth ?? -1) >= depth + ) { + const poppedFrame = frames.pop(); + if (poppedFrame?.tainted) { + poppedTaint = true; + } + } + if (poppedTaint) { + const enclosingFrame = topFrame(frames); + if (enclosingFrame) { + enclosingFrame.tainted = true; + } + } + depth = Math.max(0, depth - 1); + } else if (code === 44) { + const commaFrame = topFrame(frames); + if ( + commaFrame?.baseDepth === depth && + (commaFrame.state === "after-relation" || + commaFrame.state === "after-alias" || + commaFrame.state === "join-constraint") + ) { + if ( + commaFrame.joinPrefix !== null || + (commaFrame.state === "join-constraint" && + !commaFrame.joinConstraint?.complete) + ) { + markUnavailable(commaFrame, "ambiguous-query-site"); + } else { + commaFrame.anchor = "comma"; + commaFrame.blocksNestedQuery = false; + commaFrame.joinConstraintAllowed = false; + commaFrame.joinConstraint = null; + commaFrame.state = "expect-relation"; + } + } + } else if ( + punctuationFrame?.baseDepth === depth && + (punctuationFrame.state === "after-relation" || + punctuationFrame.state === "after-alias") + ) { + markUnavailable(punctuationFrame, "ambiguous-query-site"); + } + if (token.to === position) { + return resultAtGap(slot, topFrame(frames), position, sawSelect); + } + continue; + } + + if (token.kind === "word") { + const isSelect = wordEquals(source.analysisText, token, "select"); + if (queryCandidates.has(depth)) { + queryCandidates.delete(depth); + if ( + isSelect && + !topFrame(frames)?.blocksNestedQuery + ) { + frames.push(createFrame(depth, statementTainted)); + sawSelect = true; + if (token.to === position) { + return inactive("not-relation-position"); + } + continue; + } + } + const activeFrame = topFrame(frames); + let openedRelation = false; + if (activeFrame?.baseDepth === depth) { + const previousState = activeFrame.state; + processFrameWord( + activeFrame, + dialect, + supportsNaturalJoin, + source.analysisText, + token, + ); + openedRelation = + previousState !== "expect-relation" && + activeFrame.state === "expect-relation"; + } + if (openedRelation && token.to === position) { + return inactive("not-relation-position"); + } + } else if (token.kind === "quoted-identifier") { + queryCandidates.delete(depth); + const activeFrame = topFrame(frames); + if ( + activeFrame?.baseDepth === depth && + (activeFrame.state === "after-relation" || + activeFrame.state === "expect-alias") + ) { + if (activeFrame.joinPrefix !== null) { + markUnavailable(activeFrame, "ambiguous-query-site"); + continue; + } + const role = + activeFrame.state === "expect-alias" + ? "explicit-alias" + : "implicit-alias"; + const rawAlias = token.closed + ? source.analysisText.slice(token.from, token.to) + : ""; + const classification = classifyIdentifierToken( + dialect, + rawAlias, + true, + role, + ); + if (classification === "identifier") { + activeFrame.state = "after-alias"; + } else { + markUnavailable( + activeFrame, + classification === "unsupported" + ? "unsupported-query-site" + : "ambiguous-query-site", + ); + } + } else if ( + activeFrame?.baseDepth === depth && + activeFrame.state === "join-constraint" + ) { + markUnavailable(activeFrame, "ambiguous-query-site"); + } else if ( + activeFrame?.baseDepth === depth && + activeFrame.state === "after-alias" + ) { + markUnavailable(activeFrame, "ambiguous-query-site"); + } + } else if (queryCandidates.has(depth)) { + queryCandidates.delete(depth); + } + const remainingFrame = topFrame(frames); + if ( + (token.kind === "other" || token.kind === "string") && + remainingFrame?.baseDepth === depth && + remainingFrame.state === "join-constraint" + ) { + markUnavailable(remainingFrame, "ambiguous-query-site"); + } + if ( + (token.kind === "other" || token.kind === "string") && + remainingFrame?.baseDepth === depth && + (remainingFrame.state === "after-relation" || + remainingFrame.state === "after-alias" || + remainingFrame.state === "expect-alias") + ) { + markUnavailable(remainingFrame, "ambiguous-query-site"); + } + if (token.to === position) { + return resultAtGap(slot, topFrame(frames), position, sawSelect); + } + } +} From 7397e6039135011fb0c710419e51fb37ef39370e Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 08:41:32 +0800 Subject: [PATCH 18/42] refactor(vnext): share bounded SQL lexer (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - extract the streaming bounded SQL lexer from the relation-site state machine into one package-private module - preserve the exact PostgreSQL, DuckDB, BigQuery, and Dremio lexical profiles, UTF-16 offsets, embedded-region barriers, quote/comment behavior, one-token pushback, and 16,384-lexeme ceiling - keep query-site keyword, comment-cursor, region, and resource semantics local to the consumer - translate generic lexer resource evidence through an exhaustive package-owned map - add direct boundary tests without exposing tokens or lexer APIs from the package This is a zero-semantics prerequisite for the separate bounded CTE layout/visibility recognizer. The recognizers will initially use separate streaming traversals over the same lexical implementation; any traversal fusion remains benchmark-driven. ## Performance An initial broader helper extraction caused a reproducible Vite SSR namespace-call regression on the hot path. The boundary was narrowed before commit. Stable means at the exact head are back at the PR #186 baseline: - exact 10 KiB statement: about 0.56–0.59 ms - 1,000 classified aliases: about 0.36–0.38 ms - 1,000 authenticated `USING` columns: about 0.21 ms The lexer remains streaming and does not allocate a token tape. ## Verification - 1,543 tests passed plus 1 expected failure - changed coverage: 97.05% statements, 95.79% branches, 100% functions, 97.04% lines - bounded lexer coverage: 98.92% statements/lines, 98.78% branches, 100% functions - repository coverage: 95.34% statements, 92.05% branches, 96.15% functions, 95.33% lines - source, test, loose-optional, and demo typechecks pass - repository oxlint, test-integrity, and diff checks pass - browser, package, worker-placement, demo, and benchmark gates pass - 20,000 deterministic differential lexer comparisons passed across dialects, masked regions, subranges, UTF-16, comments, quotes, punctuation, and pushback - independent SQL/API and concurrency/performance reviewers approved exact commit `ed079df0f68aadaf8957a7ca5e6c497e91b74860` Part of #169. --- ## Summary by cubic Refactored vNext to share a streaming bounded SQL lexer and the embedded-region lookup, and switched `query-site` to use them. Behavior is unchanged across PostgreSQL, DuckDB, BigQuery, and Dremio; this unblocks the bounded CTE recognizer in #169. - **Refactors** - Moved the lexer into package-private `src/vnext/bounded-sql-lexer.ts`. - Centralized `findSqlEmbeddedRegionAtOrAfter` in `src/vnext/source.ts` and reused it in the lexer and `query-site` (with tests). - Preserves lexical profiles, UTF-16 offsets, embedded-region barriers, quote/comment rules, one-token pushback, and the 16,384-lexeme cap. - Kept consumer-specific semantics in `query-site`; mapped lexer resource signals to local `query-site` resources. - Removed duplicated lexer and region-lookup code from `src/vnext/query-site.ts` and wired it to the shared modules. Written for commit 76190f9f50a17ddad5ca52ba4d2731932530b045. Summary will update on new commits. Review in cubic --- src/vnext/__tests__/bounded-sql-lexer.test.ts | 140 +++++++ src/vnext/__tests__/source.test.ts | 15 + src/vnext/bounded-sql-lexer.ts | 269 ++++++++++++++ src/vnext/query-site.ts | 346 ++++-------------- src/vnext/source.ts | 18 + 5 files changed, 504 insertions(+), 284 deletions(-) create mode 100644 src/vnext/__tests__/bounded-sql-lexer.test.ts create mode 100644 src/vnext/bounded-sql-lexer.ts diff --git a/src/vnext/__tests__/bounded-sql-lexer.test.ts b/src/vnext/__tests__/bounded-sql-lexer.test.ts new file mode 100644 index 0000000..4635706 --- /dev/null +++ b/src/vnext/__tests__/bounded-sql-lexer.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + BoundedSqlLexer, + MAX_BOUNDED_SQL_LEXEMES, + type BoundedSqlLexeme, +} from "../bounded-sql-lexer.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, + type SqlLexicalProfile, +} from "../lexical.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, + type SqlSourceSnapshot, +} from "../source.js"; + +function lex( + source: SqlSourceSnapshot, + profile: SqlLexicalProfile = POSTGRESQL_SQL_LEXICAL_PROFILE, +): { + readonly lexemes: readonly BoundedSqlLexeme[]; + readonly resource: BoundedSqlLexer["resource"]; +} { + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + profile, + ); + const lexemes: BoundedSqlLexeme[] = []; + while (true) { + const lexeme = lexer.next(); + if (!lexeme) { + return { lexemes, resource: lexer.resource }; + } + lexemes.push(lexeme); + } +} + +describe("bounded SQL lexer", () => { + it("streams words, punctuation, strings, comments, and UTF-16 ranges", () => { + const text = "Se😀lect . 'x' -- note\n/* nested /* x */ */ end"; + expect(lex(createIdentitySqlSource(text))).toEqual({ + lexemes: [ + { closed: true, from: 0, kind: "word", to: 8 }, + { closed: true, from: 9, kind: "punctuation", to: 10 }, + { closed: true, from: 11, kind: "string", to: 14 }, + { closed: true, from: 15, kind: "line-comment", to: 22 }, + { closed: true, from: 23, kind: "comment", to: 43 }, + { closed: true, from: 44, kind: "word", to: 47 }, + ], + resource: null, + }); + }); + + it("keeps BigQuery backticks, raw triples, and hash comments atomic", () => { + const text = "`a.b` R'''raw\\value''' # comment"; + expect( + lex( + createIdentitySqlSource(text), + BIGQUERY_SQL_LEXICAL_PROFILE, + ), + ).toEqual({ + lexemes: [ + { + closed: true, + from: 0, + kind: "quoted-identifier", + to: 5, + }, + { closed: true, from: 6, kind: "word", to: 7 }, + { closed: true, from: 7, kind: "string", to: 22 }, + { + closed: true, + from: 23, + kind: "line-comment", + to: 32, + }, + ], + resource: null, + }); + }); + + it("emits embedded regions as barriers and finds exact boundaries", () => { + const source = createMaskedSqlSource("a {value} b", [ + { from: 2, language: "python", to: 9 }, + ]); + expect(lex(source).lexemes).toEqual([ + { closed: true, from: 0, kind: "word", to: 1 }, + { closed: true, from: 2, kind: "barrier", to: 9 }, + { closed: true, from: 10, kind: "word", to: 11 }, + ]); + }); + + it("pushes back one token without spending the budget twice", () => { + const source = createIdentitySqlSource("one two"); + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const first = lexer.next(); + expect(first).not.toBeNull(); + if (!first) { + throw new Error("Expected first lexeme"); + } + lexer.pushBack(first); + expect(lexer.next()).toBe(first); + expect(lexer.next()).toEqual({ + closed: true, + from: 4, + kind: "word", + to: 7, + }); + expect(lexer.next()).toBeNull(); + expect(lexer.resource).toBeNull(); + }); + + it("fails closed immediately after the shared lexeme budget", () => { + const words = Array.from( + { length: MAX_BOUNDED_SQL_LEXEMES + 1 }, + () => "x", + ).join(" "); + const result = lex(createIdentitySqlSource(words)); + expect(result.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES); + expect(result.resource).toBe("lexical-token"); + }); + + it("reports oversized dollar-quote delimiters without emitting a token", () => { + const source = createIdentitySqlSource( + `$${"a".repeat(257)}$unterminated`, + ); + expect(lex(source)).toEqual({ + lexemes: [], + resource: "dollar-quote-delimiter", + }); + }); +}); diff --git a/src/vnext/__tests__/source.test.ts b/src/vnext/__tests__/source.test.ts index 6dff950..c98bf7e 100644 --- a/src/vnext/__tests__/source.test.ts +++ b/src/vnext/__tests__/source.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createIdentitySqlSource, createMaskedSqlSource, + findSqlEmbeddedRegionAtOrAfter, mapAnalysisRangeToOriginal, mapOriginalRangeToAnalysis, MAX_SQL_EMBEDDED_REGIONS, @@ -188,6 +189,20 @@ describe("SQL source snapshots", () => { expect(source.analysisText).toBe(" b "); }); + it("finds the first embedded region ending after a position", () => { + const source = createMaskedSqlSource("abcdef", [ + { from: 1, language: "python", to: 2 }, + { from: 3, language: "jinja", to: 5 }, + ]); + + expect(findSqlEmbeddedRegionAtOrAfter(source, 0)).toBe(0); + expect(findSqlEmbeddedRegionAtOrAfter(source, 1)).toBe(0); + expect(findSqlEmbeddedRegionAtOrAfter(source, 2)).toBe(1); + expect(findSqlEmbeddedRegionAtOrAfter(source, 4)).toBe(1); + expect(findSqlEmbeddedRegionAtOrAfter(source, 5)).toBe(2); + expect(findSqlEmbeddedRegionAtOrAfter(source, 6)).toBe(2); + }); + it.each([ null, {}, diff --git a/src/vnext/bounded-sql-lexer.ts b/src/vnext/bounded-sql-lexer.ts new file mode 100644 index 0000000..de86136 --- /dev/null +++ b/src/vnext/bounded-sql-lexer.ts @@ -0,0 +1,269 @@ +import { + hasEscapeStringPrefix, + isBigQueryRawString, + isSqlWhitespace, + scanSqlBlockComment, + scanSqlDollarQuote, + scanSqlQuoted, + sqlIdentifierContinueLengthAt, + sqlIdentifierStartLengthAt, + type SqlLexicalProfile, +} from "./lexical.js"; +import { + findSqlEmbeddedRegionAtOrAfter, + type SqlSourceSnapshot, +} from "./source.js"; + +export const MAX_BOUNDED_SQL_LEXEMES = 16_384; + +export type BoundedSqlLexemeKind = + | "barrier" + | "comment" + | "line-comment" + | "other" + | "punctuation" + | "quoted-identifier" + | "string" + | "word"; + +export interface BoundedSqlLexeme { + readonly closed: boolean; + readonly from: number; + readonly kind: BoundedSqlLexemeKind; + readonly to: number; +} + +export type BoundedSqlLexerResource = + | "dollar-quote-delimiter" + | "lexical-token"; + +export class BoundedSqlLexer { + readonly #profile: SqlLexicalProfile; + readonly #source: SqlSourceSnapshot; + readonly #to: number; + #cursor: number; + #lexemeCount = 0; + #pushed: BoundedSqlLexeme | null = null; + #regionIndex: number; + resource: BoundedSqlLexerResource | null = null; + + constructor( + source: SqlSourceSnapshot, + from: number, + to: number, + profile: SqlLexicalProfile, + ) { + this.#source = source; + this.#cursor = from; + this.#to = to; + this.#profile = profile; + this.#regionIndex = findSqlEmbeddedRegionAtOrAfter(source, from); + } + + next(): BoundedSqlLexeme | null { + if (this.#pushed) { + const lexeme = this.#pushed; + this.#pushed = null; + return lexeme; + } + const text = this.#source.analysisText; + while (this.#cursor < this.#to) { + const region = this.#source.embeddedRegions[this.#regionIndex]; + if (region && region.from <= this.#cursor) { + const from = this.#cursor; + this.#cursor = Math.min(region.to, this.#to); + this.#regionIndex += 1; + return this.#record({ + closed: true, + from, + kind: "barrier", + to: this.#cursor, + }); + } + const lexicalLimit = Math.min(region?.from ?? this.#to, this.#to); + const code = text.charCodeAt(this.#cursor); + if (isSqlWhitespace(code)) { + this.#cursor += 1; + continue; + } + const from = this.#cursor; + const next = text.charCodeAt(from + 1); + if (code === 45 && next === 45) { + this.#cursor += 2; + while ( + this.#cursor < this.#to && + text.charCodeAt(this.#cursor) !== 10 && + text.charCodeAt(this.#cursor) !== 13 + ) { + this.#cursor += 1; + } + this.#advanceCoveredRegions(); + return this.#record({ + closed: true, + from, + kind: "line-comment", + to: this.#cursor, + }); + } + if (this.#profile.hashLineComments && code === 35) { + this.#cursor += 1; + while ( + this.#cursor < this.#to && + text.charCodeAt(this.#cursor) !== 10 && + text.charCodeAt(this.#cursor) !== 13 + ) { + this.#cursor += 1; + } + this.#advanceCoveredRegions(); + return this.#record({ + closed: true, + from, + kind: "line-comment", + to: this.#cursor, + }); + } + if (code === 47 && next === 42) { + const result = scanSqlBlockComment( + text, + from, + lexicalLimit, + this.#profile.nestedBlockComments, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: "comment", + to: result.to, + }); + } + if (this.#profile.dollarQuotedStrings && code === 36) { + const result = scanSqlDollarQuote(text, from, lexicalLimit); + if (result) { + this.#cursor = result.to; + if (result.delimiterTooLong) { + this.resource = "dollar-quote-delimiter"; + return null; + } + return this.#record({ + closed: result.closed, + from, + kind: "string", + to: result.to, + }); + } + } + if (code === 96 && this.#profile.backtickQuotedIdentifiers) { + const result = scanSqlQuoted( + text, + from, + lexicalLimit, + code, + 1, + true, + false, + false, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: "quoted-identifier", + to: result.to, + }); + } + if (code === 39 || code === 34) { + const triple = + this.#profile.bigQueryStrings && + text.charCodeAt(from + 1) === code && + text.charCodeAt(from + 2) === code; + const quotedIdentifier = + code === 34 && !this.#profile.bigQueryStrings; + const rawBigQueryString = + this.#profile.bigQueryStrings && + isBigQueryRawString(text, from); + const backslashEscapes = + !rawBigQueryString && + (this.#profile.bigQueryStrings || + (code === 39 && + (this.#profile.singleQuoteBackslash === "always" || + (this.#profile.singleQuoteBackslash === "e-prefix" && + hasEscapeStringPrefix(text, from))))); + const result = scanSqlQuoted( + text, + from, + lexicalLimit, + code, + triple ? 3 : 1, + backslashEscapes, + !this.#profile.bigQueryStrings, + this.#profile.bigQueryStrings && !triple, + ); + this.#cursor = result.to; + return this.#record({ + closed: result.closed, + from, + kind: quotedIdentifier ? "quoted-identifier" : "string", + to: result.to, + }); + } + const startLength = sqlIdentifierStartLengthAt(text, from); + if (startLength > 0) { + this.#cursor += startLength; + while (this.#cursor < lexicalLimit) { + const length = sqlIdentifierContinueLengthAt( + text, + this.#cursor, + ); + if (length === 0) { + break; + } + this.#cursor += length; + } + return this.#record({ + closed: true, + from, + kind: "word", + to: this.#cursor, + }); + } + this.#cursor += 1; + return this.#record({ + closed: true, + from, + kind: + code === 40 || + code === 41 || + code === 44 || + code === 46 || + code === 59 + ? "punctuation" + : "other", + to: this.#cursor, + }); + } + return null; + } + + pushBack(lexeme: BoundedSqlLexeme): void { + this.#pushed = lexeme; + } + + #advanceCoveredRegions(): void { + while ( + (this.#source.embeddedRegions[this.#regionIndex]?.to ?? Infinity) <= + this.#cursor + ) { + this.#regionIndex += 1; + } + } + + #record(lexeme: BoundedSqlLexeme): BoundedSqlLexeme | null { + this.#lexemeCount += 1; + if (this.#lexemeCount > MAX_BOUNDED_SQL_LEXEMES) { + this.resource = "lexical-token"; + return null; + } + return lexeme; + } +} diff --git a/src/vnext/query-site.ts b/src/vnext/query-site.ts index ca5eabf..f08321e 100644 --- a/src/vnext/query-site.ts +++ b/src/vnext/query-site.ts @@ -1,15 +1,14 @@ import { - hasEscapeStringPrefix, - isBigQueryRawString, - isSqlWhitespace, - scanSqlBlockComment, - scanSqlDollarQuote, - scanSqlQuoted, - sqlIdentifierContinueLengthAt, - sqlIdentifierStartLengthAt, - type SqlLexicalProfile, -} from "./lexical.js"; -import type { SqlSourceSnapshot } from "./source.js"; + BoundedSqlLexer, + MAX_BOUNDED_SQL_LEXEMES, + type BoundedSqlLexeme as Lexeme, + type BoundedSqlLexerResource, +} from "./bounded-sql-lexer.js"; +import type { SqlLexicalProfile } from "./lexical.js"; +import { + findSqlEmbeddedRegionAtOrAfter, + type SqlSourceSnapshot, +} from "./source.js"; import type { ExactSqlStatementSlot, SqlStatementSlot, @@ -22,7 +21,7 @@ import type { const querySiteRangeBrand: unique symbol = Symbol("SqlQuerySiteRange"); export const MAX_QUERY_SITE_STATEMENT_LENGTH = 65_536; -export const MAX_QUERY_SITE_LEXEMES = 16_384; +export const MAX_QUERY_SITE_LEXEMES = MAX_BOUNDED_SQL_LEXEMES; export const MAX_QUERY_SITE_DEPTH = 128; export const MAX_QUERY_SITE_PATH_COMPONENTS = 32; export const MAX_QUERY_SITE_IDENTIFIER_LENGTH = 256; @@ -129,23 +128,6 @@ export interface SqlQuerySiteDialect { ) => SqlDecodedQueryPath; } -type LexemeKind = - | "barrier" - | "comment" - | "line-comment" - | "other" - | "punctuation" - | "quoted-identifier" - | "string" - | "word"; - -interface Lexeme { - readonly closed: boolean; - readonly from: number; - readonly kind: LexemeKind; - readonly to: number; -} - type FrameState = | "after-alias" | "after-relation" @@ -221,256 +203,24 @@ function createRange(from: number, to: number): SqlQuerySiteRange { return Object.freeze(range); } -function findRegionAtOrAfter(source: SqlSourceSnapshot, position: number): number { - let low = 0; - let high = source.embeddedRegions.length; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const region = source.embeddedRegions[middle]; - if (!region || region.to <= position) { - low = middle + 1; - } else { - high = middle; - } - } - return low; -} - -function regionContains(source: SqlSourceSnapshot, position: number): boolean { - const region = source.embeddedRegions[ - findRegionAtOrAfter(source, position) - ]; - return Boolean(region && region.from <= position && position < region.to); -} - -class QueryLexer { - readonly #profile: SqlLexicalProfile; - readonly #source: SqlSourceSnapshot; - readonly #to: number; - #cursor: number; - #lexemeCount = 0; - #pushed: Lexeme | null = null; - #regionIndex: number; - resource: SqlQuerySiteResource | null = null; - - constructor( - source: SqlSourceSnapshot, - from: number, - to: number, - profile: SqlLexicalProfile, - ) { - this.#source = source; - this.#cursor = from; - this.#to = to; - this.#profile = profile; - this.#regionIndex = findRegionAtOrAfter(source, from); - } - - next(): Lexeme | null { - if (this.#pushed) { - const lexeme = this.#pushed; - this.#pushed = null; - return lexeme; - } - const text = this.#source.analysisText; - while (this.#cursor < this.#to) { - const region = this.#source.embeddedRegions[this.#regionIndex]; - if (region && region.from <= this.#cursor) { - const from = this.#cursor; - this.#cursor = Math.min(region.to, this.#to); - this.#regionIndex += 1; - return this.#record({ - closed: true, - from, - kind: "barrier", - to: this.#cursor, - }); - } - const lexicalLimit = Math.min(region?.from ?? this.#to, this.#to); - const code = text.charCodeAt(this.#cursor); - if (isSqlWhitespace(code)) { - this.#cursor += 1; - continue; - } - const from = this.#cursor; - const next = text.charCodeAt(from + 1); - if (code === 45 && next === 45) { - this.#cursor += 2; - while ( - this.#cursor < this.#to && - text.charCodeAt(this.#cursor) !== 10 && - text.charCodeAt(this.#cursor) !== 13 - ) { - this.#cursor += 1; - } - this.#advanceCoveredRegions(); - return this.#record({ - closed: true, - from, - kind: "line-comment", - to: this.#cursor, - }); - } - if (this.#profile.hashLineComments && code === 35) { - this.#cursor += 1; - while ( - this.#cursor < this.#to && - text.charCodeAt(this.#cursor) !== 10 && - text.charCodeAt(this.#cursor) !== 13 - ) { - this.#cursor += 1; - } - this.#advanceCoveredRegions(); - return this.#record({ - closed: true, - from, - kind: "line-comment", - to: this.#cursor, - }); - } - if (code === 47 && next === 42) { - const result = scanSqlBlockComment( - text, - from, - lexicalLimit, - this.#profile.nestedBlockComments, - ); - this.#cursor = result.to; - return this.#record({ - closed: result.closed, - from, - kind: "comment", - to: result.to, - }); - } - if (this.#profile.dollarQuotedStrings && code === 36) { - const result = scanSqlDollarQuote(text, from, lexicalLimit); - if (result) { - this.#cursor = result.to; - if (result.delimiterTooLong) { - this.resource = "identifier-segment"; - return null; - } - return this.#record({ - closed: result.closed, - from, - kind: "string", - to: result.to, - }); - } - } - if (code === 96 && this.#profile.backtickQuotedIdentifiers) { - const result = scanSqlQuoted( - text, - from, - lexicalLimit, - code, - 1, - true, - false, - false, - ); - this.#cursor = result.to; - return this.#record({ - closed: result.closed, - from, - kind: "quoted-identifier", - to: result.to, - }); - } - if (code === 39 || code === 34) { - const triple = - this.#profile.bigQueryStrings && - text.charCodeAt(from + 1) === code && - text.charCodeAt(from + 2) === code; - const quotedIdentifier = code === 34 && !this.#profile.bigQueryStrings; - const rawBigQueryString = - this.#profile.bigQueryStrings && - isBigQueryRawString(text, from); - const backslashEscapes = - !rawBigQueryString && - (this.#profile.bigQueryStrings || - (code === 39 && - (this.#profile.singleQuoteBackslash === "always" || - (this.#profile.singleQuoteBackslash === "e-prefix" && - hasEscapeStringPrefix(text, from))))); - const result = scanSqlQuoted( - text, - from, - lexicalLimit, - code, - triple ? 3 : 1, - backslashEscapes, - !this.#profile.bigQueryStrings, - this.#profile.bigQueryStrings && !triple, - ); - this.#cursor = result.to; - return this.#record({ - closed: result.closed, - from, - kind: quotedIdentifier ? "quoted-identifier" : "string", - to: result.to, - }); - } - const startLength = sqlIdentifierStartLengthAt(text, from); - if (startLength > 0) { - this.#cursor += startLength; - while (this.#cursor < lexicalLimit) { - const length = sqlIdentifierContinueLengthAt(text, this.#cursor); - if (length === 0) { - break; - } - this.#cursor += length; - } - return this.#record({ - closed: true, - from, - kind: "word", - to: this.#cursor, - }); - } - this.#cursor += 1; - return this.#record({ - closed: true, - from, - kind: - code === 40 || - code === 41 || - code === 44 || - code === 46 || - code === 59 - ? "punctuation" - : "other", - to: this.#cursor, - }); - } - return null; - } - - pushBack(lexeme: Lexeme): void { - this.#pushed = lexeme; - } - - #advanceCoveredRegions(): void { - while ( - (this.#source.embeddedRegions[this.#regionIndex]?.to ?? Infinity) <= - this.#cursor - ) { - this.#regionIndex += 1; - } - } - - #record(lexeme: Lexeme): Lexeme | null { - this.#lexemeCount += 1; - if (this.#lexemeCount > MAX_QUERY_SITE_LEXEMES) { - this.resource = "lexical-token"; - return null; - } - return lexeme; - } +function regionContains( + source: SqlSourceSnapshot, + position: number, +): boolean { + const region = + source.embeddedRegions[ + findSqlEmbeddedRegionAtOrAfter(source, position) + ]; + return Boolean( + region && region.from <= position && position < region.to, + ); } -function wordEquals(text: string, token: Lexeme, expected: string): boolean { +function wordEquals( + text: string, + token: Lexeme, + expected: string, +): boolean { if (token.to - token.from !== expected.length) { return false; } @@ -491,6 +241,19 @@ function wordValue(text: string, token: Lexeme): string { : ""; } +const QUERY_SITE_LEXER_RESOURCES: Readonly< + Record +> = Object.freeze({ + "dollar-quote-delimiter": "identifier-segment", + "lexical-token": "lexical-token", +}); + +function querySiteLexerResource( + resource: BoundedSqlLexerResource, +): SqlQuerySiteResource { + return QUERY_SITE_LEXER_RESOURCES[resource]; +} + function topFrame(frames: readonly QueryFrame[]): QueryFrame | null { return frames[frames.length - 1] ?? null; } @@ -499,7 +262,10 @@ function isCommentLexeme(token: Lexeme): boolean { return token.kind === "comment" || token.kind === "line-comment"; } -function cursorIsInComment(token: Lexeme, position: number): boolean { +function cursorIsInComment( + token: Lexeme, + position: number, +): boolean { return ( token.from <= position && (position < token.to || @@ -893,7 +659,10 @@ function intersectsRegion( from: number, to: number, ): boolean { - const region = source.embeddedRegions[findRegionAtOrAfter(source, from)]; + const region = + source.embeddedRegions[ + findSqlEmbeddedRegionAtOrAfter(source, from) + ]; return Boolean(region && region.from < to && from < region.to); } @@ -1155,7 +924,7 @@ function decodeReadyPath( } function recognizePath( - lexer: QueryLexer, + lexer: BoundedSqlLexer, source: SqlSourceSnapshot, slot: ExactSqlStatementSlot, frame: QueryFrame, @@ -1172,7 +941,10 @@ function recognizePath( while (true) { const next = lexer.next(); if (lexer.resource) { - return unavailable("resource-limit", lexer.resource); + return unavailable( + "resource-limit", + querySiteLexerResource(lexer.resource), + ); } if (next?.from === rawTo) { if (expectingSegment) { @@ -1232,7 +1004,10 @@ function recognizePath( separated = true; terminator = lexer.next(); if (lexer.resource) { - return unavailable("resource-limit", lexer.resource); + return unavailable( + "resource-limit", + querySiteLexerResource(lexer.resource), + ); } } if (terminator?.kind === "barrier") { @@ -1337,7 +1112,7 @@ export function recognizeSqlRelationQuerySite( ) { return unavailable("ambiguous-query-site"); } - const lexer = new QueryLexer( + const lexer = new BoundedSqlLexer( source, slot.source.from, slot.source.to, @@ -1352,7 +1127,10 @@ export function recognizeSqlRelationQuerySite( while (true) { const token = lexer.next(); if (lexer.resource) { - return unavailable("resource-limit", lexer.resource); + return unavailable( + "resource-limit", + querySiteLexerResource(lexer.resource), + ); } const frame = topFrame(frames); if (!token || token.from > position) { diff --git a/src/vnext/source.ts b/src/vnext/source.ts index bcf400e..5da81ba 100644 --- a/src/vnext/source.ts +++ b/src/vnext/source.ts @@ -38,6 +38,24 @@ export interface SqlSourceSnapshot { readonly originalText: string; } +export function findSqlEmbeddedRegionAtOrAfter( + source: SqlSourceSnapshot, + position: number, +): number { + let low = 0; + let high = source.embeddedRegions.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const region = source.embeddedRegions[middle]; + if (!region || region.to <= position) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +} + interface MissingDataProperty { readonly found: false; } From d6ed03af3e85fde28e78bab0280374e7025c85b3 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 09:38:55 +0800 Subject: [PATCH 19/42] feat(vnext): index bounded CTE visibility (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a private, cursor-independent bounded CTE layout and visibility index for PostgreSQL, DuckDB, BigQuery, and Dremio grammar subsets - preserve committed declarations separately from unfinished-body drafts, with correct nonrecursive order, nested shadowing, recursive withholding, duplicate blocking, and source-faithful insertion evidence - use dialect-owned symmetric tri-state identifier comparison rather than generic folding or a second nullable comparison-key model - preserve exact proven prefixes across lexer exhaustion and model cursor positions correctly at EOF, closing delimiters, barriers, and scope boundaries - fail closed on hostile dialect callbacks/data, embedded regions, malformed syntax, unknown equivalence, and every checked resource boundary - extend ADR 0005 and the provisional public relation-completion type contract for tri-state equality/prefix behavior and CTE-scope uncertainty ## Bounds and performance - active statement: 65,536 UTF-16 units - shared lexical tokens: 16,384 - parenthesis/query depth: 128 - CTE frames: 256 - CTE declarations: 256 - identifier segment: 256 UTF-16 units Representative local means: - ordinary 10 KiB statement: ~0.37 ms - 256 declarations with pairwise equivalence validation: ~3.0 ms - 128 nested CTE frames: ~1.0 ms - 256 sequential incomplete frames: ~0.15 ms - cached 256-declaration projection: ~0.022 ms ## Verification - 1,587 tests passed, plus 1 governed expected failure - changed runtime coverage: 96.96% statements, 95.88% branches, 100% functions, 96.94% lines - all source, test, vNext exact/loose-optional, and demo typechecks pass - oxlint and test-integrity gates pass - package smoke, production build, demo build, and 7 Playwright browser tests pass - three independent exact-head adversarial reviewers approved `8a1572369a171f49d3e23aeed4993aceb88a2cb2` ## Deferred follow-up The bounded grammar currently authenticates `SELECT` query leaders. Dialect-owned support for PostgreSQL `VALUES`/data-modifying CTE bodies, DuckDB `FROM`-first queries, and additionally parenthesized BigQuery recursive terms remains an explicit follow-up before relation completion is feature-complete. Part of #169. --- ## Summary by cubic Adds a bounded CTE layout and visibility index for vNext to improve relation completion across PostgreSQL, DuckDB, BigQuery, and Dremio, and now rejects invalid CTE cursor positions to avoid bogus suggestions. Introduces tri-state CTE identifier comparison and strict, fail-closed resource limits; part of #169. - **New Features** - Private, cursor‑independent CTE index with correct order, shadowing, duplicate blocking, and recursive withholding. - Closed, dialect‑owned grammar per engine with a documented acceptance matrix; no cross‑dialect borrowing. - Tri‑state equality and prefix matching through the dialect runtime; preserves exact prefixes at EOF and scope boundaries. - Fast, bounded visibility via `visibleSqlCtesAt`; now rejects invalid cursor positions and the lexer reports limit hits with `resourceAt`, failing closed on malformed input. - **Migration** - In `SqlRelationCompletionDialectRuntime`, replace `cteIdentifiersEqual` with `compareCteIdentifiers` returning `"equal" | "distinct" | "unknown"`. - Implement `cteIdentifierMatchesPrefix` returning `"match" | "no-match" | "unknown"`. Written for commit 7302de43ed34692dc79ee3eff5027ce2c9be7e4c. Summary will update on new commits. Review in cubic --- ...-parser-independent-relation-completion.md | 110 +- src/vnext/__tests__/bounded-sql-lexer.test.ts | 45 + src/vnext/__tests__/cte-layout.bench.ts | 138 ++ src/vnext/__tests__/cte-layout.test.ts | 1199 +++++++++++ src/vnext/bounded-sql-lexer.ts | 3 + src/vnext/cte-layout.ts | 1779 +++++++++++++++++ src/vnext/relation-completion-types.ts | 19 +- .../marimo-relation-completion.test-d.ts | 8 +- 8 files changed, 3291 insertions(+), 10 deletions(-) create mode 100644 src/vnext/__tests__/cte-layout.bench.ts create mode 100644 src/vnext/__tests__/cte-layout.test.ts create mode 100644 src/vnext/cte-layout.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 79680a2..87d9ab2 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -171,8 +171,49 @@ WITH [RECURSIVE] main query ``` -It records only proven declaration names, body boundaries, declaration order, -and visibility. It is not a miniature general SQL AST. +The accepted grammar is dialect-owned and closed: + +| Dialect | Declarations per frame | `RECURSIVE` | Declared columns | Materialization modifier | +| --- | ---: | --- | --- | --- | +| PostgreSQL | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` | +| DuckDB | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` | +| BigQuery | up to the global bound | accepted | rejected | rejected | +| Dremio | one | rejected | accepted | rejected | + +This matrix describes only syntax the bounded recognizer can prove. Rejection +does not claim that a database engine rejects every extension or future +version; it makes the current completion result explicitly incomplete or +unavailable instead of borrowing another dialect's grammar. + +The recognizer records only: + +- flat query-block frames and their parent frame; +- proven declaration names, exact source spelling and name ranges; +- body ranges and declaration order; +- authenticated main-query `SELECT` entrypoints; and +- bounded candidate-name and uncertainty evidence for incomplete headers. + +It stores no AST, SQL substring, token tape, inferred output columns, or +recursive dependency graph. A declaration is committed only after its `AS` +body has a proven matching close parenthesis. Seeing `WITH name`, `name AS`, or +an opening body alone never invents a visible relation. Once a body opening is +proven, a separate bounded draft record retains its name, ordinal, body range, +and comparison evidence. Drafts can establish the current body phase and +fail-closed shadow uncertainty, but never become completion candidates. + +Candidate-name evidence, visible-candidate evidence, and shadow evidence are +distinct. Before a body opens, an active incomplete header has no supported +relation site; direct private projection still reports recovered unknown +coverage rather than exact absence. Once a body opens, its draft name is not +returned as a completion candidate, but it prevents the service from claiming +exact scope or exact non-shadowing where recursive or uncertain equality could +make it visible. Such a result is marked incomplete with +`cte-scope-uncertainty`. A proven declaration shadows an equivalent outer CTE +or unqualified catalog insertion only over its proven visibility range. A +duplicate equivalence class produces no arbitrary first- or last-wins +candidate and blocks an equivalent outer or catalog name wherever that class +would be visible. Uncertainty in one nested frame does not erase independently +proven candidates outside that frame. For non-recursive CTEs: @@ -184,13 +225,69 @@ For non-recursive CTEs: - a nested declaration shadows an outer name only inside its query block; and - nested declarations never leak outward. -Identifier equality follows the dialect. Duplicate names and structurally -ambiguous headers make the affected frame partial. `WITH RECURSIVE` may expose -proven names in the main query, but self and mutual-recursive body visibility -remain explicitly incomplete until implemented. +Identifier comparison follows the dialect and is tri-state: `equal`, +`distinct`, or `unknown`. PostgreSQL non-ASCII folding can depend on server +encoding and locale, while BigQuery and Dremio document case-insensitivity +without giving this package an authoritative general Unicode folding +algorithm. `unknown` therefore degrades namespace coverage and never means +`distinct`. The private layout builder consumes the same pairwise +`compareCteIdentifiers` operation exposed by the relation-completion dialect +runtime; it does not define a second nullable comparison key. Within the +256-name bound it snapshots symmetric pairwise results into frozen +equivalence classes and scoped uncertainty evidence. Throws, invalid values, +asymmetry, non-reflexivity, or inconsistent equivalence results fail closed. +Duplicate detection uses those classes rather than generic case folding. + +A declaration retains both its decoded value and exact source token. The +decoded value is the completion label; the exact token is the insertion text, +so required quoting, case, and escapes are never reconstructed by the catalog +renderer. CTEs are considered only for unqualified relation sites. Prefix +eligibility is also a dialect-owned tri-state operation distinct from equality; +generic locale-sensitive or Unicode case folding is never used. + +`WITH RECURSIVE` may expose proven names in the main query. The initial +recognizer also retains independently proven earlier-sibling and outer +visibility inside a recursive body, but self and forward or mutual-recursive +body candidates remain incomplete and add `recursive-cte-uncertainty`. Every +known frame-local recursive name still contributes shadow evidence inside a +recursive body, so withholding a self candidate cannot incorrectly reveal an +equivalent outer or catalog relation. The recognizer does not infer a recursive +dependency graph or output columns. DuckDB `USING KEY`, PostgreSQL +`SEARCH`/`CYCLE`, and any Dremio recursive extension remain unsupported. + +The index is cursor-independent and cached with the immutable source, +statement, embedded-region, and dialect-runtime identities. Visibility is a +pure projection over its flat frozen ranges. Building the index and recognizing +the relation query site may initially make two independent linear streaming +passes over the shared lexer; traversal fusion is allowed only after benchmark +evidence. Neither cursor movement nor catalog invalidation rebuilds the index. + +Stored source ranges remain half-open. Visibility projection accepts cursor +positions, so a cursor exactly before a proven closing delimiter remains in +the body or nested frame, and a top-level cursor at statement EOF remains in +the main query. A cursor after the delimiter is outside. At the first +untrusted boundary the enclosing draft phase may still contribute proven +positive evidence, but quality and shadow coverage are recovered rather than +exact. + +An opaque region in a CTE header or body, an unterminated quoted token, an +unsupported dialect modifier, a duplicate declaration, or a structurally +plausible but unfinished declaration makes the affected frame partial. The +first opaque region terminates exact structural coverage: visible punctuation +after an untyped barrier never closes a body or frame that started before it. +A resource limit records the same first-untrusted boundary. Partial artifacts +may still contribute declarations, entrypoints, and visibility ranges proven +entirely before that boundary, but they never produce exact absence or shadow +claims across it. An empty positive-only local-relation list never proves that no CTE is visible. +The first bounded grammar intentionally authenticates only `SELECT` query +leaders. PostgreSQL `VALUES` and data-modifying CTE bodies, DuckDB `FROM`-first +queries, and additionally parenthesized BigQuery recursive terms currently +fail closed. Their query-leader sets will become dialect-owned in a follow-up +before relation completion is declared feature-complete. + ### Embedded regions The first public template input is a complete set of length-preserving embedded @@ -525,6 +622,7 @@ The initial checked limits are: | Active statement scanned | 65,536 UTF-16 units | | Lexical tokens | 16,384 | | Parenthesis/query depth | 128 | +| CTE frames | 256 | | CTE declarations | 256 | | Identifier path segments | 32 global ceiling; dialect runtime sets the checked limit | | Identifier segment | 256 UTF-16 units | diff --git a/src/vnext/__tests__/bounded-sql-lexer.test.ts b/src/vnext/__tests__/bounded-sql-lexer.test.ts index 4635706..70837af 100644 --- a/src/vnext/__tests__/bounded-sql-lexer.test.ts +++ b/src/vnext/__tests__/bounded-sql-lexer.test.ts @@ -116,9 +116,18 @@ describe("bounded SQL lexer", () => { }); expect(lexer.next()).toBeNull(); expect(lexer.resource).toBeNull(); + expect(lexer.resourceAt).toBeNull(); }); it("fails closed immediately after the shared lexeme budget", () => { + const acceptedWords = Array.from( + { length: MAX_BOUNDED_SQL_LEXEMES }, + () => "x", + ).join(" "); + const accepted = lex(createIdentitySqlSource(acceptedWords)); + expect(accepted.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES); + expect(accepted.resource).toBeNull(); + const words = Array.from( { length: MAX_BOUNDED_SQL_LEXEMES + 1 }, () => "x", @@ -126,6 +135,34 @@ describe("bounded SQL lexer", () => { const result = lex(createIdentitySqlSource(words)); expect(result.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES); expect(result.resource).toBe("lexical-token"); + const source = createIdentitySqlSource(words); + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + while (lexer.next()) { + // Consume the bounded prefix. + } + expect(lexer.resourceAt).toBe( + words.lastIndexOf("x"), + ); + + const prefixed = ` ${words}`; + const prefixedSource = createIdentitySqlSource(prefixed); + const prefixedLexer = new BoundedSqlLexer( + prefixedSource, + 2, + prefixed.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + while (prefixedLexer.next()) { + // Consume the bounded prefix. + } + expect(prefixedLexer.resourceAt).toBe( + prefixed.lastIndexOf("x"), + ); }); it("reports oversized dollar-quote delimiters without emitting a token", () => { @@ -136,5 +173,13 @@ describe("bounded SQL lexer", () => { lexemes: [], resource: "dollar-quote-delimiter", }); + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(lexer.next()).toBeNull(); + expect(lexer.resourceAt).toBe(0); }); }); diff --git a/src/vnext/__tests__/cte-layout.bench.ts b/src/vnext/__tests__/cte-layout.bench.ts new file mode 100644 index 0000000..a3a7700 --- /dev/null +++ b/src/vnext/__tests__/cte-layout.bench.ts @@ -0,0 +1,138 @@ +import { bench, describe } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + type SqlCteLayoutDialect, + visibleSqlCtesAt, +} from "../cte-layout.js"; +import { DUCKDB_SQL_LEXICAL_PROFILE } from "../lexical.js"; +import { createIdentitySqlSource } from "../source.js"; +import { + buildSqlStatementIndex, + type ExactSqlStatementSlot, +} from "../statement-index.js"; + +const dialect: SqlCteLayoutDialect = { + classifyIdentifierToken: (rawIdentifier, quoted) => ({ + status: "identifier", + value: { + component: { quoted, value: rawIdentifier }, + }, + }), + compareCteIdentifiers: (left, right) => + left.value.toLowerCase() === right.value.toLowerCase() + ? "equal" + : "distinct", + grammar: { + declaredColumns: true, + materialization: true, + maximumDeclarationsPerFrame: MAX_CTE_DECLARATIONS, + recursive: true, + }, + lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, +}; + +function fixture(text: string): { + readonly source: ReturnType; + readonly slot: ExactSqlStatementSlot; +} { + const source = createIdentitySqlSource(text); + const slot = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ).slots[0]; + if (!slot || slot.boundaryQuality !== "exact") { + throw new Error("CTE benchmark fixture requires an exact statement"); + } + return { slot, source }; +} + +const tenKibibytes = 10 * 1_024; +const ordinaryPrefix = "SELECT "; +const ordinary = `${ordinaryPrefix}${"x,".repeat( + Math.floor((tenKibibytes - ordinaryPrefix.length - 1) / 2), +)}x`; +const ordinaryText = `${ordinary}${" ".repeat( + tenKibibytes - ordinary.length, +)}`; +const ordinaryFixture = fixture(ordinaryText); + +const declarationHeavyText = `WITH ${Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, +).join(", ")} SELECT * FROM c255`; +const declarationHeavyFixture = fixture(declarationHeavyText); + +const depthHeavyText = Array.from( + { length: MAX_CTE_DEPTH }, + (_, index) => index, +).reduce( + (body, index) => + `WITH c${index} AS (${body}) SELECT * FROM c${index}`, + "SELECT 1", +); +const depthHeavyFixture = fixture(depthHeavyText); +const bareFrameHeavyText = `SELECT ${Array.from( + { length: MAX_CTE_FRAMES }, + () => "(WITH)", +).join(",")}`; +const bareFrameHeavyFixture = fixture(bareFrameHeavyText); +const depthLayout = analyzeSqlCteLayout( + depthHeavyFixture.source, + depthHeavyFixture.slot, + dialect, +); +if (depthLayout.status !== "ready") { + throw new Error("Depth benchmark requires a ready CTE layout"); +} +const projectedLayout = analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.slot, + dialect, +); +if (projectedLayout.status === "unavailable") { + throw new Error("CTE projection benchmark requires a layout"); +} + +describe("CTE layout", () => { + bench("ordinary 10 KiB statement", () => { + analyzeSqlCteLayout( + ordinaryFixture.source, + ordinaryFixture.slot, + dialect, + ); + }); + + bench("256 declarations", () => { + analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.slot, + dialect, + ); + }); + + bench("128-depth nested CTE", () => { + analyzeSqlCteLayout( + depthHeavyFixture.source, + depthHeavyFixture.slot, + dialect, + ); + }); + + bench("256 sequential incomplete frames", () => { + analyzeSqlCteLayout( + bareFrameHeavyFixture.source, + bareFrameHeavyFixture.slot, + dialect, + ); + }); + + bench("cached 256-declaration projection", () => { + visibleSqlCtesAt( + projectedLayout, + declarationHeavyText.length - 1, + ); + }); +}); diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/vnext/__tests__/cte-layout.test.ts new file mode 100644 index 0000000..a232c34 --- /dev/null +++ b/src/vnext/__tests__/cte-layout.test.ts @@ -0,0 +1,1199 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + MAX_CTE_IDENTIFIER_LENGTH, + MAX_CTE_STATEMENT_LENGTH, + type SqlCteLayout, + type SqlCteLayoutDialect, + type SqlCteLayoutIssue, + type SqlCteIdentifierResult, + visibleSqlCtesAt, +} from "../cte-layout.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "../lexical.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, + type ExactSqlStatementSlot, +} from "../statement-index.js"; + +function decodeQuoted(raw: string): string { + const quote = raw.at(0) ?? ""; + return raw + .slice(1, -1) + .replaceAll(`${quote}${quote}`, quote) + .replaceAll(`\\${quote}`, quote); +} + +function isAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) > 0x7f) { + return false; + } + } + return true; +} + +function createDialect( + kind: "bigquery" | "dremio" | "duckdb" | "postgresql", +): SqlCteLayoutDialect { + const lexicalProfile = + kind === "bigquery" + ? BIGQUERY_SQL_LEXICAL_PROFILE + : kind === "dremio" + ? DREMIO_SQL_LEXICAL_PROFILE + : kind === "duckdb" + ? DUCKDB_SQL_LEXICAL_PROFILE + : POSTGRESQL_SQL_LEXICAL_PROFILE; + return { + classifyIdentifierToken: (raw, quoted) => { + const value = quoted ? decodeQuoted(raw) : raw; + if ( + value.length === 0 || + (!quoted && + /^(?:as|materialized|not|recursive|select|with)$/i.test( + value, + )) + ) { + return { status: "unsupported" }; + } + return { + status: "identifier", + value: { + component: { quoted, value }, + }, + }; + }, + compareCteIdentifiers: (left, right) => { + const leftKey = + kind === "postgresql" && left.quoted + ? isAscii(left.value) + ? left.value + : null + : isAscii(left.value) + ? left.value.toLowerCase() + : null; + const rightKey = + kind === "postgresql" && right.quoted + ? isAscii(right.value) + ? right.value + : null + : isAscii(right.value) + ? right.value.toLowerCase() + : null; + if (leftKey !== null && rightKey !== null) { + return leftKey === rightKey ? "equal" : "distinct"; + } + return left.quoted === right.quoted && + left.value === right.value + ? "equal" + : "unknown"; + }, + grammar: { + declaredColumns: kind !== "bigquery", + materialization: + kind === "duckdb" || kind === "postgresql", + maximumDeclarationsPerFrame: + kind === "dremio" ? 1 : MAX_CTE_DECLARATIONS, + recursive: kind !== "dremio", + }, + lexicalProfile, + }; +} + +const postgres = createDialect("postgresql"); +const duckdb = createDialect("duckdb"); +const bigquery = createDialect("bigquery"); +const dremio = createDialect("dremio"); + +function analyze( + text: string, + dialect: SqlCteLayoutDialect = postgres, + regions: readonly { + readonly from: number; + readonly language: string; + readonly to: number; + }[] = [], +): Exclude { + const source = + regions.length === 0 + ? createIdentitySqlSource(text) + : createMaskedSqlSource(text, regions); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ); + const slot = index.slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + const result = analyzeSqlCteLayout( + source, + slot as ExactSqlStatementSlot, + dialect, + ); + expect(result.status).not.toBe("unavailable"); + return result as Exclude< + SqlCteLayout, + { status: "unavailable" } + >; +} + +function analyzeRaw( + text: string, + dialect: SqlCteLayoutDialect = postgres, +): SqlCteLayout { + const source = createIdentitySqlSource(text); + const slot = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ).slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + return analyzeSqlCteLayout( + source, + slot as ExactSqlStatementSlot, + dialect, + ); +} + +function expectPartial( + text: string, + issue: SqlCteLayoutIssue, + dialect: SqlCteLayoutDialect = postgres, +): void { + const layout = analyze(text, dialect); + expect(layout.status).toBe("partial"); + expect(layout.issues).toContain(issue); +} + +function names( + layout: Exclude, + position: number, +): readonly string[] { + return visibleSqlCtesAt(layout, position).ctes.map( + (cte) => cte.name.value, + ); +} + +describe("bounded CTE layout", () => { + it("proves nonrecursive declaration order and main visibility", () => { + const text = + "WITH a AS (SELECT * FROM base), " + + "b AS (SELECT * FROM a) SELECT * FROM b"; + const layout = analyze(text); + expect(layout.status).toBe("ready"); + expect(layout.mainQueryEntrypoints).toEqual([ + { + depth: 0, + frameIndex: 0, + from: text.lastIndexOf("SELECT"), + }, + ]); + expect(names(layout, text.indexOf("base"))).toEqual([]); + expect( + names(layout, text.indexOf("FROM a") + "FROM ".length), + ).toEqual(["a"]); + expect( + names(layout, text.lastIndexOf("FROM b") + "FROM ".length), + ).toEqual(["a", "b"]); + }); + + it("keeps nested shadowing inside the nested query block", () => { + const text = + "WITH x AS (SELECT * FROM base) SELECT * FROM (" + + "WITH x AS (SELECT * FROM x) SELECT * FROM x) q, x"; + const layout = analyze(text); + const innerBody = text.indexOf("FROM x"); + const innerMain = text.indexOf("FROM x", innerBody + 1); + expect(names(layout, innerBody + 5)).toEqual(["x"]); + expect(names(layout, innerMain + 5)).toEqual(["x"]); + expect( + visibleSqlCtesAt(layout, innerBody + 5).ctes[0] + ?.declarationPosition, + ).toBe(text.indexOf("x")); + expect( + visibleSqlCtesAt(layout, innerMain + 5).ctes[0] + ?.declarationPosition, + ).toBe(text.indexOf("x", text.indexOf("(WITH") + 1)); + expect(names(layout, text.lastIndexOf(", x") + 2)).toEqual([ + "x", + ]); + }); + + it("treats cursor positions at closing delimiters as inside", () => { + const text = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH y AS (SELECT 2) SELECT * FROM y) q"; + const layout = analyze(text); + const close = text.lastIndexOf(")"); + expect(names(layout, close)).toEqual(["x", "y"]); + expect(names(layout, close + 1)).toEqual(["x"]); + }); + + it("keeps nested frame issues local to their proven scope", () => { + const text = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT 1), X AS (SELECT 2) SELECT * FROM x" + + ") q, outer_cte"; + const layout = analyze(text); + const nested = visibleSqlCtesAt( + layout, + text.indexOf("FROM x") + 5, + ); + expect(nested.quality).toBe("recovered"); + expect(nested.issues).toContain("duplicate-cte-name"); + + const outer = visibleSqlCtesAt( + layout, + text.lastIndexOf("outer_cte"), + ); + expect(outer).toMatchObject({ + issues: [], + quality: "exact", + }); + expect(outer.ctes.map((cte) => cte.name.value)).toEqual([ + "outer_cte", + ]); + + const trailingText = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT 1), X AS (SELECT 2) SELECT * FROM x" + + ") q,"; + expect( + visibleSqlCtesAt(analyze(trailingText), trailingText.length), + ).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: [], + quality: "exact", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "outer_cte" }], + }, + }); + }); + + it("withholds recursive self and forward candidates but shadows them", () => { + const text = + "WITH RECURSIVE r AS (SELECT * FROM r), " + + "s AS (SELECT * FROM r) SELECT * FROM s"; + const layout = analyze(text); + const firstBody = visibleSqlCtesAt( + layout, + text.indexOf("FROM r") + 5, + ); + expect(firstBody.ctes).toEqual([]); + expect(firstBody.issues).toContain("recursive-cte-position"); + expect(firstBody.shadowing).toEqual({ + coverage: "complete", + names: [ + { quoted: false, value: "r" }, + { quoted: false, value: "s" }, + ], + }); + const secondBody = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM r") + 5, + ); + expect(secondBody.ctes.map((cte) => cte.name.value)).toEqual([ + "r", + ]); + expect(names(layout, text.lastIndexOf("FROM s") + 5)).toEqual([ + "r", + "s", + ]); + + const unfinishedText = + "WITH RECURSIVE a AS (SELECT * FROM target), "; + expect( + visibleSqlCtesAt( + analyze(unfinishedText), + unfinishedText.indexOf("target"), + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + }); + + it("retains fail-closed visibility evidence for unfinished bodies", () => { + const text = + "WITH a AS (SELECT 1), b AS (SELECT * FROM target"; + const layout = analyze(text); + const visibility = visibleSqlCtesAt( + layout, + text.indexOf("target"), + ); + expect(layout.status).toBe("partial"); + expect(visibility).toMatchObject({ + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "a" }], + }, + }); + expect(visibility.ctes.map((cte) => cte.name.value)).toEqual([ + "a", + ]); + expect( + visibleSqlCtesAt(layout, text.length).ctes.map( + (cte) => cte.name.value, + ), + ).toEqual(["a"]); + + const recursiveText = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE x AS (SELECT * FROM target"; + const recursiveLayout = analyze(recursiveText); + const recursiveVisibility = visibleSqlCtesAt( + recursiveLayout, + recursiveText.indexOf("target"), + ); + expect(recursiveVisibility.ctes).toEqual([]); + expect(recursiveVisibility.issues).toEqual([ + "ambiguous-cte-header", + "recursive-cte-position", + ]); + expect(recursiveVisibility.shadowing).toEqual({ + coverage: "unknown", + }); + + const nonrecursiveText = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT * FROM target"; + const nonrecursiveLayout = analyze(nonrecursiveText); + const fallback = visibleSqlCtesAt( + nonrecursiveLayout, + nonrecursiveText.indexOf("target"), + ); + expect(fallback.ctes.map((cte) => cte.declarationPosition)).toEqual([ + nonrecursiveText.indexOf("x"), + ]); + expect(fallback.quality).toBe("recovered"); + }); + + it("blocks duplicate equivalence classes instead of choosing a winner", () => { + const text = + "WITH a AS (SELECT 1), A AS (SELECT 2) SELECT * FROM a"; + const layout = analyze(text); + expect(layout.status).toBe("partial"); + const visibility = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM a") + 5, + ); + expect(visibility.ctes).toEqual([]); + expect(visibility.issues).toContain("duplicate-cte-name"); + expect(visibility.shadowing).toEqual({ + coverage: "complete", + names: [{ quoted: false, value: "A" }], + }); + expect(visibleSqlCtesAt(layout, text.length)).toMatchObject({ + ctes: [], + issues: ["duplicate-cte-name"], + quality: "recovered", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "A" }], + }, + }); + }); + + it("preserves decoded labels and exact quoted insertion spelling", () => { + const text = + 'WITH "a""b" AS MATERIALIZED (SELECT 1) ' + + 'SELECT * FROM "a""b"'; + const layout = analyze(text); + const cte = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM") + 5, + ).ctes[0]; + expect(cte).toEqual({ + declarationPosition: text.indexOf('"a""b"'), + name: { quoted: true, value: 'a"b' }, + sourceSpelling: '"a""b"', + }); + }); + + it("uses dialect-owned quoted equality without generic folding", () => { + const duplicateCases = [ + { + dialect: postgres, + text: + 'WITH foo AS (SELECT 1), "foo" AS (SELECT 2) ' + + "SELECT 1", + }, + { + dialect: duckdb, + text: + 'WITH foo AS (SELECT 1), "FOO" AS (SELECT 2) ' + + "SELECT 1", + }, + { + dialect: bigquery, + text: + "WITH foo AS (SELECT 1), `FOO` AS (SELECT 2) " + + "SELECT 1", + }, + ]; + for (const duplicate of duplicateCases) { + expect(analyze(duplicate.text, duplicate.dialect).issues).toContain( + "duplicate-cte-name", + ); + } + + const distinctText = + 'WITH foo AS (SELECT 1), "Foo" AS (SELECT 2) ' + + "SELECT * FROM foo"; + expect( + names(analyze(distinctText), distinctText.lastIndexOf("foo")), + ).toEqual(["foo", "Foo"]); + }); + + it("keeps every stored range statement-relative", () => { + const text = + "SELECT 0;\n WITH a AS (SELECT 1) SELECT * FROM a"; + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + postgres.lexicalProfile, + ); + const absolutePosition = text.lastIndexOf("FROM a") + 5; + const slot = findSqlStatementSlot( + index, + absolutePosition, + "left", + ); + expect(slot.boundaryQuality).toBe("exact"); + if (slot.boundaryQuality !== "exact") { + throw new Error("Expected an exact second statement"); + } + const layout = analyzeSqlCteLayout( + source, + slot, + postgres, + ); + expect(layout.status).toBe("ready"); + if (layout.status !== "ready") { + throw new Error("Expected an exact second-statement layout"); + } + expect(layout.frames[0]?.withStart).toBe( + text.indexOf("WITH") - slot.source.from, + ); + expect(layout.declarations[0]?.nameRange).toMatchObject({ + from: text.indexOf("a AS") - slot.source.from, + to: text.indexOf("a AS") - slot.source.from + 1, + }); + expect( + names(layout, absolutePosition - slot.source.from), + ).toEqual(["a"]); + }); + + it("uses the closed dialect grammar matrix", () => { + expect( + analyze( + "WITH a(x) AS NOT MATERIALIZED (SELECT 1) SELECT * FROM a", + duckdb, + ).status, + ).toBe("ready"); + expect( + analyze( + "WITH a(x) AS (SELECT 1) SELECT * FROM a", + bigquery, + ).issues, + ).toContain("unsupported-cte-extension"); + expect( + analyze( + "WITH a(x) AS (SELECT 1) SELECT * FROM a", + dremio, + ).status, + ).toBe("ready"); + expect( + analyze( + "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT 1", + dremio, + ).issues, + ).toContain("unsupported-cte-extension"); + }); + + it("balances bodies through dialect strings and comments", () => { + const text = + "WITH /* lead */ a /* name */ (x) AS NOT /* hint */ " + + "MATERIALIZED (SELECT '(' AS x /* ) , WITH */), " + + "b AS (SELECT ')' AS y -- )\n) SELECT * FROM b"; + const layout = analyze(text); + expect(layout.status).toBe("ready"); + expect( + names(layout, text.lastIndexOf("FROM b") + 5), + ).toEqual(["a", "b"]); + }); + + it.each([ + ["WITH", "ambiguous-cte-header"], + ["WITH RECURSIVE", "ambiguous-cte-header"], + ["WITH SELECT", "ambiguous-cte-header"], + ["WITH a", "ambiguous-cte-header"], + ["WITH a nope", "ambiguous-cte-header"], + ["WITH a() AS (SELECT 1) SELECT 1", "ambiguous-cte-header"], + [ + "WITH a(x,) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + [ + "WITH a(x y) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + ["WITH a(x) nope (SELECT 1)", "ambiguous-cte-header"], + ["WITH a AS nope", "ambiguous-cte-header"], + ["WITH a AS () SELECT 1", "ambiguous-cte-header"], + ["WITH a AS NOT nope", "ambiguous-cte-header"], + [ + "WITH a AS MATERIALIZED nope", + "ambiguous-cte-header", + ], + ["WITH a AS (VALUES (1)) SELECT 1", "unsupported-cte-extension"], + ["WITH a AS ('SELECT 1') SELECT 1", "unsupported-cte-extension"], + [ + "WITH a AS (WITH b AS (SELECT 1)) SELECT 1", + "ambiguous-cte-header", + ], + ["WITH a AS (WITH) SELECT 1", "ambiguous-cte-header"], + ["WITH a AS (SELECT 1) DELETE FROM t", "unsupported-cte-extension"], + ["WITH a AS (SELECT 1),", "ambiguous-cte-header"], + [ + "WITH a AS (SELECT 1), SELECT", + "ambiguous-cte-header", + ], + [ + "WITH a(SELECT) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + ] as const)( + "fails closed for malformed or unsupported CTE syntax %#", + (text, issue) => { + expectPartial(text, issue); + }, + ); + + it("rejects unsupported recursive and materialization modifiers", () => { + expectPartial( + "WITH RECURSIVE a AS (SELECT 1) SELECT 1", + "unsupported-cte-extension", + dremio, + ); + expectPartial( + "WITH a AS NOT MATERIALIZED (SELECT 1) SELECT 1", + "unsupported-cte-extension", + bigquery, + ); + expectPartial( + "WITH a AS MATERIALIZED (SELECT 1) SELECT 1", + "unsupported-cte-extension", + bigquery, + ); + }); + + it("stops exact structural coverage at an embedded barrier", () => { + const text = + "WITH a AS (SELECT {value}), b AS (SELECT 2) " + + "SELECT * FROM b"; + const from = text.indexOf("{value}"); + const layout = analyze(text, postgres, [ + { from, language: "python", to: from + "{value}".length }, + ]); + expect(layout.status).toBe("partial"); + expect(layout.exactThrough).toBe(from); + expect(layout.declarations).toEqual([]); + expect(layout.draftDeclarations).toHaveLength(1); + expect(layout.draftDeclarations[0]).toMatchObject({ + bodyRange: { from: text.indexOf("SELECT"), to: from }, + name: { quoted: false, value: "a" }, + }); + expect(layout.issues).toContain("opaque-template-context"); + expect( + visibleSqlCtesAt(layout, text.lastIndexOf("FROM b") + 5), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect(visibleSqlCtesAt(layout, from)).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + + const laterBarrierText = + "WITH a AS (SELECT 1), b AS (SELECT * FROM target {value}) " + + "SELECT * FROM b"; + const laterFrom = laterBarrierText.indexOf("{value}"); + const laterLayout = analyze(laterBarrierText, postgres, [ + { + from: laterFrom, + language: "python", + to: laterFrom + "{value}".length, + }, + ]); + const beforeBarrier = visibleSqlCtesAt( + laterLayout, + laterBarrierText.indexOf("target"), + ); + expect(beforeBarrier.ctes.map((cte) => cte.name.value)).toEqual([ + "a", + ]); + expect(beforeBarrier.quality).toBe("recovered"); + }); + + it("ignores deceptive WITH text in comments and strings", () => { + const layout = analyze( + "SELECT 'WITH a AS (SELECT 1)' /* WITH b */ FROM t", + ); + expect(layout).toMatchObject({ + declarations: [], + frames: [], + mainQueryEntrypoints: [], + status: "ready", + }); + }); + + it("validates classifier results without invoking accessors", () => { + let accessorInvoked = false; + const accessorDialect: SqlCteLayoutDialect = { + ...postgres, + classifyIdentifierToken: () => ({ + get status(): "identifier" { + accessorInvoked = true; + return "identifier"; + }, + value: { + component: { quoted: false, value: "a" }, + }, + }), + }; + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + accessorDialect, + ); + expect(accessorInvoked).toBe(false); + + const malformedResults: readonly SqlCteIdentifierResult[] = [ + { status: "unsupported" }, + { + status: "identifier", + value: { + component: { quoted: true, value: "a" }, + }, + }, + { + status: "identifier", + value: new Proxy( + { + component: { quoted: false, value: "a" }, + }, + { + getOwnPropertyDescriptor() { + return undefined; + }, + }, + ), + }, + { + status: "identifier", + value: { + component: { quoted: false, value: "" }, + }, + }, + { + status: "identifier", + value: { + component: { + quoted: false, + value: "a".repeat(MAX_CTE_IDENTIFIER_LENGTH + 1), + }, + }, + }, + ]; + for (const result of malformedResults) { + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + { ...postgres, classifyIdentifierToken: () => result }, + ); + } + expectPartial( + 'WITH "a" AS (SELECT 1) SELECT 1', + "ambiguous-cte-header", + { + ...postgres, + classifyIdentifierToken: () => ({ + status: "identifier", + value: { + component: { quoted: false, value: "a" }, + }, + }), + }, + ); + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + { + ...postgres, + classifyIdentifierToken: () => { + throw new Error("hostile"); + }, + }, + ); + }); + + it("bounds raw and decoded identifier work before retention", () => { + let calls = 0; + const countingDialect: SqlCteLayoutDialect = { + ...postgres, + classifyIdentifierToken: (...arguments_) => { + calls += 1; + return postgres.classifyIdentifierToken(...arguments_); + }, + }; + const accepted = "a".repeat(MAX_CTE_IDENTIFIER_LENGTH); + expect( + analyze( + `WITH ${accepted} AS (SELECT 1) SELECT 1`, + countingDialect, + ).status, + ).toBe("ready"); + expect(calls).toBe(1); + calls = 0; + + const oversized = "a".repeat(MAX_CTE_IDENTIFIER_LENGTH + 1); + expectPartial( + `WITH ${oversized} AS (SELECT 1) SELECT 1`, + "ambiguous-cte-header", + countingDialect, + ); + expect(calls).toBe(0); + + const oversizedQuoted = `"${"a".repeat( + MAX_CTE_IDENTIFIER_LENGTH * 2 + 1, + )}"`; + expectPartial( + `WITH ${oversizedQuoted} AS (SELECT 1) SELECT 1`, + "ambiguous-cte-header", + countingDialect, + ); + expect(calls).toBe(0); + }); + + it("fails closed on hostile identifier comparators", () => { + const text = + "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a"; + const hostileComparators = [ + () => { + throw new Error("hostile"); + }, + () => "invalid", + (left: { value: string }, right: { value: string }) => + left.value <= right.value ? "equal" : "distinct", + (left: { value: string }, right: { value: string }) => + left.value === right.value || + (left.value === "a" && right.value === "b") || + (left.value === "b" && right.value === "a") || + (left.value === "b" && right.value === "c") || + (left.value === "c" && right.value === "b") + ? "equal" + : "distinct", + ] as const; + for (const compareCteIdentifiers of hostileComparators) { + const candidateText = + compareCteIdentifiers === hostileComparators.at(-1) + ? "WITH a AS (SELECT 1), b AS (SELECT 2), " + + "c AS (SELECT 3) SELECT * FROM c" + : text; + const layout = analyze(candidateText, { + ...postgres, + compareCteIdentifiers: + compareCteIdentifiers as SqlCteLayoutDialect["compareCteIdentifiers"], + }); + expect(layout.issues).toContain( + "unknown-cte-identifier-equivalence", + ); + expect( + visibleSqlCtesAt( + layout, + candidateText.lastIndexOf("FROM") + 5, + ), + ).toMatchObject({ + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); + + it("keeps exact non-ASCII identity but fails closed on unknown pairs", () => { + const text = "WITH café AS (SELECT 1) SELECT * FROM café"; + const layout = analyze(text); + const visibility = visibleSqlCtesAt( + layout, + text.lastIndexOf("café"), + ); + expect(visibility).toMatchObject({ + quality: "exact", + shadowing: { coverage: "complete" }, + }); + expect(visibility.ctes.map((cte) => cte.name.value)).toEqual([ + "café", + ]); + + const uncertainText = + "WITH café AS (SELECT 1), CAFÉ AS (SELECT 2) " + + "SELECT * FROM café"; + const uncertainLayout = analyze(uncertainText); + const uncertainVisibility = visibleSqlCtesAt( + uncertainLayout, + uncertainText.lastIndexOf("café"), + ); + expect(uncertainVisibility).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect(uncertainVisibility.issues).toContain( + "unknown-cte-identifier-equivalence", + ); + + const recursiveText = + "WITH RECURSIVE café AS (SELECT * FROM café) " + + "SELECT * FROM café"; + const recursiveLayout = analyze(recursiveText); + expect( + visibleSqlCtesAt( + recursiveLayout, + recursiveText.indexOf("FROM café") + 5, + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "complete" }, + }); + + const recursiveShadowCases = [ + "WITH café AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE CAFÉ AS (SELECT * FROM target) " + + "SELECT * FROM CAFÉ) q", + "WITH café AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE b AS (SELECT * FROM target), " + + "CAFÉ AS (SELECT 2) SELECT * FROM b) q", + ]; + for (const recursiveShadowText of recursiveShadowCases) { + expect( + visibleSqlCtesAt( + analyze(recursiveShadowText), + recursiveShadowText.indexOf("target"), + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); + + it("fails partial at exact resource boundaries plus one", () => { + const nested = + "(".repeat(MAX_CTE_DEPTH + 1) + + "SELECT 1" + + ")".repeat(MAX_CTE_DEPTH + 1); + const depthLayout = analyze(nested); + expect(depthLayout.status).toBe("partial"); + expect(depthLayout).toMatchObject({ + resource: "parenthesis-depth", + }); + + const declarations = Array.from( + { length: MAX_CTE_DECLARATIONS + 1 }, + (_, index) => `c${index} AS (SELECT ${index})`, + ).join(", "); + const declarationLayout = analyze( + `WITH ${declarations} SELECT 1`, + ); + expect(declarationLayout.status).toBe("partial"); + expect(declarationLayout).toMatchObject({ + resource: "cte-declaration", + }); + + const acceptedDeclarations = Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, + ).join(", "); + expect( + analyze(`WITH ${acceptedDeclarations} SELECT 1`).status, + ).toBe("ready"); + + const acceptedDepth = + "(".repeat(MAX_CTE_DEPTH) + + "SELECT 1" + + ")".repeat(MAX_CTE_DEPTH); + expect(analyzeRaw(acceptedDepth).status).toBe("ready"); + + expect( + analyzeRaw(" ".repeat(MAX_CTE_STATEMENT_LENGTH)).status, + ).toBe("ready"); + + expect( + analyzeRaw("x".repeat(MAX_CTE_STATEMENT_LENGTH + 1)), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + + const manyTokens = Array.from( + { length: 8_193 }, + () => "x", + ).join(" "); + expect(analyzeRaw(`SELECT ${manyTokens.replaceAll(" ", ",")}`)).toMatchObject({ + resource: "lexical-token", + status: "partial", + }); + + const provenPrefix = + "WITH a AS (SELECT 1) SELECT * FROM a "; + const exhaustedSuffix = Array.from( + { length: 8_193 }, + () => "x", + ).join(","); + const prefixLayout = analyzeRaw( + `${provenPrefix}${exhaustedSuffix}`, + ); + expect(prefixLayout).toMatchObject({ + resource: "lexical-token", + status: "partial", + }); + if (prefixLayout.status === "unavailable") { + throw new Error("Expected a partial prefix layout"); + } + const prefixVisibility = visibleSqlCtesAt( + prefixLayout, + provenPrefix.indexOf("FROM a") + 5, + ); + expect(prefixVisibility).toMatchObject({ + issues: [], + quality: "exact", + }); + expect( + prefixVisibility.ctes.map((cte) => cte.name.value), + ).toEqual(["a"]); + + const nestedPrefix = `SELECT ${"(".repeat(MAX_CTE_DEPTH)}`; + expect( + analyzeRaw( + `${nestedPrefix}WITH a(x) AS (SELECT 1) SELECT 1`, + ), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + expect( + analyzeRaw(`${nestedPrefix}WITH a AS (SELECT 1) SELECT 1`), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + expect( + analyzeRaw( + `${nestedPrefix}WITH a AS MATERIALIZED (SELECT 1) SELECT 1`, + ), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + + const manyFrames = Array.from( + { length: MAX_CTE_DECLARATIONS + 1 }, + (_, index) => + `(WITH f${index} AS (SELECT 1) SELECT 1)`, + ).join(","); + expect(analyzeRaw(`SELECT ${manyFrames}`)).toMatchObject({ + resource: "cte-frame", + status: "partial", + }); + + const acceptedBareFrames = Array.from( + { length: MAX_CTE_FRAMES }, + () => "(WITH)", + ).join(","); + const acceptedFrameLayout = analyzeRaw( + `SELECT ${acceptedBareFrames}`, + ); + expect(acceptedFrameLayout).toMatchObject({ + status: "partial", + }); + expect(acceptedFrameLayout).not.toHaveProperty("resource"); + if (acceptedFrameLayout.status === "unavailable") { + throw new Error("Expected a bounded partial frame layout"); + } + expect(acceptedFrameLayout.frames).toHaveLength(MAX_CTE_FRAMES); + + const rejectedBareFrames = `${acceptedBareFrames},(WITH)`; + expect(analyzeRaw(`SELECT ${rejectedBareFrames}`)).toMatchObject({ + resource: "cte-frame", + status: "partial", + }); + }); + + it("rejects malformed dialect grammar without scanning", () => { + const result = analyzeRaw("SELECT 1", { + ...postgres, + grammar: { + ...postgres.grammar, + maximumDeclarationsPerFrame: 0, + }, + }); + expect(result).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + + const throwingDialect: SqlCteLayoutDialect = { + ...postgres, + get grammar(): SqlCteLayoutDialect["grammar"] { + throw new Error("hostile"); + }, + }; + expect(analyzeRaw("SELECT 1", throwingDialect)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + + let getterInvoked = false; + const getterGrammar = { + ...postgres, + grammar: { + ...postgres.grammar, + get recursive(): boolean { + getterInvoked = true; + return true; + }, + }, + }; + expect(analyzeRaw("SELECT 1", getterGrammar)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(getterInvoked).toBe(false); + + const getterLexicalProfile = { + ...postgres, + lexicalProfile: { + ...postgres.lexicalProfile, + get nestedBlockComments(): boolean { + getterInvoked = true; + return true; + }, + }, + }; + expect(analyzeRaw("SELECT 1", getterLexicalProfile)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(getterInvoked).toBe(false); + }); + + it("projects only proven frame phases and validates positions", () => { + const text = + "WITH a AS (SELECT 1) SELECT * FROM a"; + const layout = analyze(text); + expect(visibleSqlCtesAt(layout, 1)).toMatchObject({ + ctes: [], + issues: [], + quality: "exact", + }); + expect(names(layout, text.length)).toEqual(["a"]); + + const partial = analyze("WITH a AS ("); + expect(visibleSqlCtesAt(partial, partial.exactThrough)).toMatchObject({ + ctes: [], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "complete", names: [] }, + }); + for (const incompleteHeader of [ + "WITH", + "WITH /* trailing comment */", + "WITH a", + "WITH a AS", + "WITH a(x)", + "WITH a AS NOT", + ]) { + expect( + visibleSqlCtesAt( + analyze(incompleteHeader), + incompleteHeader.length, + ), + ).toMatchObject({ + ctes: [], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + + const nestedBareWith = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (WITH) q,"; + const nestedLayout = analyze(nestedBareWith); + const nestedClose = nestedBareWith.lastIndexOf(")"); + expect(visibleSqlCtesAt(nestedLayout, nestedClose)).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect( + visibleSqlCtesAt(nestedLayout, nestedBareWith.length), + ).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: [], + quality: "exact", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "outer_cte" }], + }, + }); + for (const position of [ + -1, + 0.5, + text.length - 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + text.length + 1, + ]) { + expect(visibleSqlCtesAt(layout, position)).toMatchObject({ + ctes: [], + issues: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); +}); diff --git a/src/vnext/bounded-sql-lexer.ts b/src/vnext/bounded-sql-lexer.ts index de86136..5996b9c 100644 --- a/src/vnext/bounded-sql-lexer.ts +++ b/src/vnext/bounded-sql-lexer.ts @@ -46,6 +46,7 @@ export class BoundedSqlLexer { #pushed: BoundedSqlLexeme | null = null; #regionIndex: number; resource: BoundedSqlLexerResource | null = null; + resourceAt: number | null = null; constructor( source: SqlSourceSnapshot, @@ -143,6 +144,7 @@ export class BoundedSqlLexer { this.#cursor = result.to; if (result.delimiterTooLong) { this.resource = "dollar-quote-delimiter"; + this.resourceAt = from; return null; } return this.#record({ @@ -262,6 +264,7 @@ export class BoundedSqlLexer { this.#lexemeCount += 1; if (this.#lexemeCount > MAX_BOUNDED_SQL_LEXEMES) { this.resource = "lexical-token"; + this.resourceAt = lexeme.from; return null; } return lexeme; diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts new file mode 100644 index 0000000..cb3b6a1 --- /dev/null +++ b/src/vnext/cte-layout.ts @@ -0,0 +1,1779 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme as Lexeme, + type BoundedSqlLexerResource, +} from "./bounded-sql-lexer.js"; +import type { SqlLexicalProfile } from "./lexical.js"; +import type { SqlSourceSnapshot } from "./source.js"; +import type { ExactSqlStatementSlot } from "./statement-index.js"; +import type { SqlIdentifierComponent } from "./types.js"; + +const cteRangeBrand: unique symbol = Symbol("SqlCteRange"); + +export const MAX_CTE_STATEMENT_LENGTH = 65_536; +export const MAX_CTE_DEPTH = 128; +export const MAX_CTE_DECLARATIONS = 256; +export const MAX_CTE_FRAMES = 256; +export const MAX_CTE_IDENTIFIER_LENGTH = 256; + +export interface SqlCteRange { + readonly [cteRangeBrand]: "SqlCteRange"; + readonly from: number; + readonly to: number; +} + +export type SqlCteLayoutIssue = + | "ambiguous-cte-header" + | "duplicate-cte-name" + | "opaque-template-context" + | "recursive-cte-position" + | "unknown-cte-identifier-equivalence" + | "unsupported-cte-extension"; + +export type SqlCteLayoutResource = + | "active-statement" + | "cte-declaration" + | "cte-frame" + | "identifier-segment" + | "lexical-token" + | "parenthesis-depth"; + +export interface SqlCteIdentifier { + readonly component: SqlIdentifierComponent; +} + +export type SqlCteIdentifierResult = + | { + readonly status: "identifier"; + readonly value: SqlCteIdentifier; + } + | { + readonly status: "unsupported"; + }; + +export interface SqlCteGrammar { + readonly declaredColumns: boolean; + readonly materialization: boolean; + readonly maximumDeclarationsPerFrame: number; + readonly recursive: boolean; +} + +export interface SqlCteLayoutDialect { + readonly classifyIdentifierToken: ( + rawIdentifier: string, + quoted: boolean, + role: "cte-column" | "cte-name", + ) => SqlCteIdentifierResult; + readonly compareCteIdentifiers: ( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, + ) => "distinct" | "equal" | "unknown"; + readonly grammar: SqlCteGrammar; + readonly lexicalProfile: SqlLexicalProfile; +} + +export interface SqlCteDeclaration { + readonly ambiguous: boolean; + readonly bodyRange: SqlCteRange; + readonly equivalenceClass: number; + readonly frameIndex: number; + readonly name: SqlIdentifierComponent; + readonly nameRange: SqlCteRange; + readonly ordinal: number; + readonly sourceSpelling: string; + readonly unknownEquivalenceClasses: readonly number[]; +} + +export interface SqlCteDraftDeclaration { + readonly ambiguous: boolean; + readonly bodyRange: SqlCteRange; + readonly equivalenceClass: number; + readonly frameIndex: number; + readonly name: SqlIdentifierComponent; + readonly nameRange: SqlCteRange; + readonly ordinal: number; + readonly sourceSpelling: string; + readonly unknownEquivalenceClasses: readonly number[]; +} + +export interface SqlCteFrame { + readonly baseDepth: number; + readonly declarationIndexes: readonly number[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly mainQueryStart: number | null; + readonly parentFrameIndex: number | null; + readonly recursive: boolean; + readonly scopeRange: SqlCteRange; + readonly withStart: number; +} + +export interface SqlCteMainQueryEntrypoint { + readonly depth: number; + readonly frameIndex: number; + readonly from: number; +} + +interface SqlCteLayoutBase { + readonly declarations: readonly SqlCteDeclaration[]; + readonly draftDeclarations: readonly SqlCteDraftDeclaration[]; + readonly exactThrough: number; + readonly frames: readonly SqlCteFrame[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly mainQueryEntrypoints: readonly SqlCteMainQueryEntrypoint[]; + readonly statementLength: number; +} + +export type SqlCteLayout = + | (SqlCteLayoutBase & { + readonly status: "ready"; + readonly issues: readonly []; + }) + | (SqlCteLayoutBase & { + readonly status: "partial"; + readonly issues: readonly [ + SqlCteLayoutIssue, + ...SqlCteLayoutIssue[], + ]; + readonly resource?: SqlCteLayoutResource; + }) + | { + readonly status: "unavailable"; + readonly reason: "opaque-statement" | "resource-limit"; + readonly resource?: SqlCteLayoutResource; + }; + +export interface SqlVisibleCte { + readonly declarationPosition: number; + readonly name: SqlIdentifierComponent; + readonly sourceSpelling: string; +} + +export type SqlCteShadowing = + | { + readonly coverage: "complete"; + readonly names: readonly SqlIdentifierComponent[]; + } + | { + readonly coverage: "unknown"; + }; + +export interface SqlCteVisibility { + readonly ctes: readonly SqlVisibleCte[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly quality: "exact" | "recovered"; + readonly shadowing: SqlCteShadowing; +} + +type HeaderState = + | "after-as" + | "after-body" + | "after-name" + | "columns" + | "expect-as" + | "expect-body" + | "expect-materialized" + | "expect-name" + | "main" + | "modifier-or-name" + | "waiting-body"; + +interface DraftDeclaration { + bodyFrom: number; + bodyLead: "select" | "with" | null; + bodyLeadFrameIndex: number | null; + component: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + sourceSpelling: string; +} + +interface MutableDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + identityIndex: number; + frameIndex: number; + name: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + ordinal: number; + sourceSpelling: string; +} + +interface MutableDraftDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + identityIndex: number; + frameIndex: number; + name: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + ordinal: number; + sourceSpelling: string; +} + +interface MutableFrame { + baseDepth: number; + columnCount: number; + columnExpectIdentifier: boolean; + current: DraftDeclaration | null; + declarationIndexes: number[]; + index: number; + issues: Set; + mainQueryStart: number | null; + parentFrameIndex: number | null; + recursive: boolean; + scopeFrom: number; + scopeTo: number | null; + state: HeaderState; + withStart: number; +} + +interface Builder { + frame: MutableFrame | null; + leadingParent: MutableFrame | null; + withStart: number; +} + +interface IdentifierRelations { + readonly components: SqlIdentifierComponent[]; + readonly frameIndexes: number[]; + readonly unknownIndexes: Map>; + readonly parents: number[]; +} + +const LEXER_RESOURCES: Readonly< + Record +> = Object.freeze({ + "dollar-quote-delimiter": "identifier-segment", + "lexical-token": "lexical-token", +}); + +const missingDataProperty: unique symbol = Symbol( + "missingDataProperty", +); + +function readOwnDataProperty( + value: unknown, + key: PropertyKey, +): unknown | typeof missingDataProperty { + if (value === null || typeof value !== "object") { + return missingDataProperty; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor + ? descriptor.value + : missingDataProperty; +} + +function createRange(from: number, to: number): SqlCteRange { + const range: SqlCteRange = { + [cteRangeBrand]: "SqlCteRange", + from, + to, + }; + return Object.freeze(range); +} + +function wordEquals( + text: string, + token: Lexeme, + expected: string, +): boolean { + if (token.to - token.from !== expected.length) { + return false; + } + for (let index = 0; index < expected.length; index += 1) { + if ( + (text.charCodeAt(token.from + index) | 32) !== + expected.charCodeAt(index) + ) { + return false; + } + } + return true; +} + +function isComment(token: Lexeme): boolean { + return token.kind === "comment" || token.kind === "line-comment"; +} + +function isIdentifierToken(token: Lexeme): boolean { + return token.kind === "word" || token.kind === "quoted-identifier"; +} + +function punctuation(text: string, token: Lexeme): number { + return token.kind === "punctuation" + ? text.charCodeAt(token.from) + : -1; +} + +function freezeComponent( + component: SqlIdentifierComponent, +): SqlIdentifierComponent { + return Object.freeze({ + quoted: component.quoted, + value: component.value, + }); +} + +function normalizeIdentifier( + dialect: SqlCteLayoutDialect, + text: string, + token: Lexeme, + role: "cte-column" | "cte-name", +): SqlCteIdentifier | null { + if (!token.closed || !isIdentifierToken(token)) { + return null; + } + const raw = text.slice(token.from, token.to); + const maximumRawLength = + token.kind === "quoted-identifier" + ? MAX_CTE_IDENTIFIER_LENGTH * 2 + 2 + : MAX_CTE_IDENTIFIER_LENGTH; + if (raw.length > maximumRawLength) { + return null; + } + let result: SqlCteIdentifierResult; + try { + result = dialect.classifyIdentifierToken( + raw, + token.kind === "quoted-identifier", + role, + ); + const status = readOwnDataProperty(result, "status"); + if (status !== "identifier") { + return null; + } + const identifier = readOwnDataProperty(result, "value"); + const component = readOwnDataProperty(identifier, "component"); + const componentValue = readOwnDataProperty(component, "value"); + const componentQuoted = readOwnDataProperty( + component, + "quoted", + ); + if ( + typeof componentValue !== "string" || + typeof componentQuoted !== "boolean" || + componentQuoted !== (token.kind === "quoted-identifier") || + componentValue.length === 0 || + componentValue.length > MAX_CTE_IDENTIFIER_LENGTH + ) { + return null; + } + return Object.freeze({ + component: freezeComponent({ + quoted: componentQuoted, + value: componentValue, + }), + }); + } catch { + return null; + } +} + +function validateDialect( + dialect: SqlCteLayoutDialect, +): SqlCteLayoutDialect | null { + try { + const grammar = readOwnDataProperty(dialect, "grammar"); + const lexicalProfile = readOwnDataProperty( + dialect, + "lexicalProfile", + ); + const classifyIdentifierToken = readOwnDataProperty( + dialect, + "classifyIdentifierToken", + ); + const compareCteIdentifiers = readOwnDataProperty( + dialect, + "compareCteIdentifiers", + ); + const declaredColumns = readOwnDataProperty( + grammar, + "declaredColumns", + ); + const materialization = readOwnDataProperty( + grammar, + "materialization", + ); + const maximumDeclarationsPerFrame = readOwnDataProperty( + grammar, + "maximumDeclarationsPerFrame", + ); + const recursive = readOwnDataProperty(grammar, "recursive"); + const backtickQuotedIdentifiers = readOwnDataProperty( + lexicalProfile, + "backtickQuotedIdentifiers", + ); + const bigQueryStrings = readOwnDataProperty( + lexicalProfile, + "bigQueryStrings", + ); + const dollarQuotedStrings = readOwnDataProperty( + lexicalProfile, + "dollarQuotedStrings", + ); + const hashLineComments = readOwnDataProperty( + lexicalProfile, + "hashLineComments", + ); + const nestedBlockComments = readOwnDataProperty( + lexicalProfile, + "nestedBlockComments", + ); + const proceduralGuards = readOwnDataProperty( + lexicalProfile, + "proceduralGuards", + ); + const singleQuoteBackslash = readOwnDataProperty( + lexicalProfile, + "singleQuoteBackslash", + ); + if ( + typeof classifyIdentifierToken !== "function" || + typeof compareCteIdentifiers !== "function" || + typeof declaredColumns !== "boolean" || + typeof materialization !== "boolean" || + typeof recursive !== "boolean" || + !Number.isSafeInteger(maximumDeclarationsPerFrame) || + (maximumDeclarationsPerFrame as number) < 1 || + (maximumDeclarationsPerFrame as number) > + MAX_CTE_DECLARATIONS || + typeof backtickQuotedIdentifiers !== "boolean" || + typeof bigQueryStrings !== "boolean" || + typeof dollarQuotedStrings !== "boolean" || + typeof hashLineComments !== "boolean" || + typeof nestedBlockComments !== "boolean" || + !["bigquery", "none", "postgresql"].includes( + proceduralGuards as string, + ) || + !["always", "e-prefix", "never"].includes( + singleQuoteBackslash as string, + ) + ) { + return null; + } + return Object.freeze({ + classifyIdentifierToken: + classifyIdentifierToken as SqlCteLayoutDialect["classifyIdentifierToken"], + compareCteIdentifiers: + compareCteIdentifiers as SqlCteLayoutDialect["compareCteIdentifiers"], + grammar: Object.freeze({ + declaredColumns, + materialization, + maximumDeclarationsPerFrame: + maximumDeclarationsPerFrame as number, + recursive, + }), + lexicalProfile: Object.freeze({ + backtickQuotedIdentifiers, + bigQueryStrings, + dollarQuotedStrings, + hashLineComments, + nestedBlockComments, + proceduralGuards: + proceduralGuards as SqlLexicalProfile["proceduralGuards"], + singleQuoteBackslash: + singleQuoteBackslash as SqlLexicalProfile["singleQuoteBackslash"], + }), + }); + } catch { + return null; + } +} + +function relationRoot( + relations: IdentifierRelations, + index: number, +): number { + let root = index; + while (relations.parents[root] !== root) { + root = relations.parents[root] ?? root; + } + let cursor = index; + while (relations.parents[cursor] !== root) { + const parent = relations.parents[cursor] ?? root; + relations.parents[cursor] = root; + cursor = parent; + } + return root; +} + +function compareIdentifiers( + dialect: SqlCteLayoutDialect, + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): "distinct" | "equal" | "unknown" { + try { + const forward = dialect.compareCteIdentifiers(left, right); + const reverse = dialect.compareCteIdentifiers(right, left); + return forward === reverse && + (forward === "distinct" || + forward === "equal" || + forward === "unknown") + ? forward + : "unknown"; + } catch { + return "unknown"; + } +} + +function isAncestorFrame( + frames: readonly MutableFrame[], + possibleAncestor: number, + descendant: number, +): boolean { + let cursor: number | null = descendant; + while (cursor !== null) { + if (cursor === possibleAncestor) { + return true; + } + cursor = frames[cursor]?.parentFrameIndex ?? null; + } + return false; +} + +function registerIdentifier( + dialect: SqlCteLayoutDialect, + frames: readonly MutableFrame[], + frame: MutableFrame, + component: SqlIdentifierComponent, + relations: IdentifierRelations, +): number { + const markUnknown = (left: number, right: number): void => { + frame.issues.add("unknown-cte-identifier-equivalence"); + const leftUnknown = + relations.unknownIndexes.get(left) ?? new Set(); + leftUnknown.add(right); + relations.unknownIndexes.set(left, leftUnknown); + const rightUnknown = + relations.unknownIndexes.get(right) ?? new Set(); + rightUnknown.add(left); + relations.unknownIndexes.set(right, rightUnknown); + }; + const identityIndex = relations.components.length; + relations.components.push(component); + relations.frameIndexes.push(frame.index); + relations.parents.push(identityIndex); + if ( + compareIdentifiers(dialect, component, component) !== "equal" + ) { + markUnknown(identityIndex, identityIndex); + } + const comparisonsByRoot = new Map< + number, + Map<"distinct" | "equal" | "unknown", number[]> + >(); + for (let priorIndex = 0; priorIndex < identityIndex; priorIndex += 1) { + const priorFrameIndex = relations.frameIndexes[priorIndex]; + const priorComponent = relations.components[priorIndex]; + if ( + priorFrameIndex === undefined || + !priorComponent || + (priorFrameIndex !== frame.index && + !isAncestorFrame( + frames, + priorFrameIndex, + frame.index, + )) + ) { + continue; + } + const comparison = compareIdentifiers( + dialect, + priorComponent, + component, + ); + const root = relationRoot(relations, priorIndex); + const byComparison = + comparisonsByRoot.get(root) ?? new Map(); + const indexes = byComparison.get(comparison) ?? []; + indexes.push(priorIndex); + byComparison.set(comparison, indexes); + comparisonsByRoot.set(root, byComparison); + } + const equalRoots = [...comparisonsByRoot.entries()].filter( + ([, comparisons]) => comparisons.has("equal"), + ); + const inconsistent = + equalRoots.length > 1 || + equalRoots.some(([, comparisons]) => comparisons.size > 1); + for (const [root, comparisons] of comparisonsByRoot) { + if (inconsistent && comparisons.has("equal")) { + for (const indexes of comparisons.values()) { + for (const priorIndex of indexes) { + markUnknown(identityIndex, priorIndex); + } + } + continue; + } + for (const priorIndex of comparisons.get("unknown") ?? []) { + markUnknown(identityIndex, priorIndex); + } + if (comparisons.has("equal")) { + relations.parents[identityIndex] = root; + } + } + return identityIndex; +} + +function freezeIssues( + issues: Iterable, +): readonly SqlCteLayoutIssue[] { + return Object.freeze([...new Set(issues)].sort()); +} + +function layoutUnavailable( + reason: "opaque-statement" | "resource-limit", + resource?: SqlCteLayoutResource, +): SqlCteLayout { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); +} + +function findOpenParentFrame( + frames: readonly MutableFrame[], + depth: number, +): number | null { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index]; + if ( + frame && + frame.baseDepth < depth && + frame.scopeTo === null + ) { + return frame.index; + } + } + return null; +} + +function createFrame( + frames: MutableFrame[], + depth: number, + withStart: number, + leadingParent: MutableFrame | null, +): MutableFrame { + const frame: MutableFrame = { + baseDepth: depth, + columnCount: 0, + columnExpectIdentifier: true, + current: null, + declarationIndexes: [], + index: frames.length, + issues: new Set(), + mainQueryStart: null, + parentFrameIndex: findOpenParentFrame(frames, depth), + recursive: false, + scopeFrom: withStart, + scopeTo: null, + state: "modifier-or-name", + withStart, + }; + frames.push(frame); + if (leadingParent?.current) { + leadingParent.current.bodyLeadFrameIndex = frame.index; + } + return frame; +} + +function markPartial( + frame: MutableFrame | null, + issues: Set, + issue: SqlCteLayoutIssue, +): void { + issues.add(issue); + frame?.issues.add(issue); +} + +function processName( + frame: MutableFrame, + dialect: SqlCteLayoutDialect, + text: string, + token: Lexeme, + statementFrom: number, + declarationAttempts: { value: number }, +): boolean { + const identifier = normalizeIdentifier( + dialect, + text, + token, + "cte-name", + ); + if (!identifier) { + return false; + } + declarationAttempts.value += 1; + if (declarationAttempts.value > MAX_CTE_DECLARATIONS) { + return false; + } + frame.current = { + bodyFrom: -1, + bodyLead: null, + bodyLeadFrameIndex: null, + component: identifier.component, + nameFrom: token.from - statementFrom, + nameTo: token.to - statementFrom, + sourceSpelling: text.slice(token.from, token.to), + }; + frame.state = "after-name"; + return true; +} + +function commitDeclaration( + frame: MutableFrame, + current: DraftDeclaration, + declarations: MutableDeclaration[], + dialect: SqlCteLayoutDialect, + frames: readonly MutableFrame[], + relations: IdentifierRelations, + bodyTo: number, +): void { + const declarationIndex = declarations.length; + const identityIndex = registerIdentifier( + dialect, + frames, + frame, + current.component, + relations, + ); + const declaration: MutableDeclaration = { + ambiguous: false, + bodyFrom: current.bodyFrom, + bodyTo, + frameIndex: frame.index, + identityIndex, + name: current.component, + nameFrom: current.nameFrom, + nameTo: current.nameTo, + ordinal: frame.declarationIndexes.length, + sourceSpelling: current.sourceSpelling, + }; + const root = relationRoot(relations, identityIndex); + for (const priorIndex of frame.declarationIndexes) { + const priorDeclaration = declarations[priorIndex]; + if ( + priorDeclaration && + relationRoot(relations, priorDeclaration.identityIndex) === root + ) { + declaration.ambiguous = true; + priorDeclaration.ambiguous = true; + frame.issues.add("duplicate-cte-name"); + } + } + declarations.push(declaration); + frame.declarationIndexes.push(declarationIndex); + frame.current = null; + frame.state = "after-body"; +} + +function collectActiveBodyEvidence( + frames: readonly MutableFrame[], + declarations: MutableDeclaration[], + dialect: SqlCteLayoutDialect, + relations: IdentifierRelations, + exactThrough: number, +): MutableDraftDeclaration[] { + const drafts: MutableDraftDeclaration[] = []; + for (const frame of frames) { + const current = frame.current; + if ( + current && + current.bodyFrom >= 0 && + current.bodyFrom <= exactThrough + ) { + const identityIndex = registerIdentifier( + dialect, + frames, + frame, + current.component, + relations, + ); + let ambiguous = false; + const root = relationRoot(relations, identityIndex); + for (const priorIndex of frame.declarationIndexes) { + const priorDeclaration = declarations[priorIndex]; + if ( + priorDeclaration && + relationRoot( + relations, + priorDeclaration.identityIndex, + ) === root + ) { + ambiguous = true; + priorDeclaration.ambiguous = true; + frame.issues.add("duplicate-cte-name"); + } + } + drafts.push({ + ambiguous, + bodyFrom: current.bodyFrom, + bodyTo: exactThrough, + frameIndex: frame.index, + identityIndex, + name: current.component, + nameFrom: current.nameFrom, + nameTo: current.nameTo, + ordinal: frame.declarationIndexes.length, + sourceSpelling: current.sourceSpelling, + }); + } + } + return drafts; +} + +function closeNestedFrame( + frames: MutableFrame[], + builders: Map, + depth: number, + at: number, + issues: Set, +): boolean { + const builder = builders.get(depth); + if (!builder) { + return true; + } + if (!builder.frame && frames.length >= MAX_CTE_FRAMES) { + return false; + } + const frame = + builder.frame ?? + createFrame( + frames, + depth, + builder.withStart, + builder.leadingParent, + ); + if (frame.baseDepth !== depth) { + return true; + } + if (!builder.frame) { + builder.frame = frame; + markPartial(frame, issues, "ambiguous-cte-header"); + } + frame.scopeTo = at; + builders.delete(depth); + return true; +} + +function freezeLayout( + frames: readonly MutableFrame[], + declarations: readonly MutableDeclaration[], + draftDeclarations: readonly MutableDraftDeclaration[], + relations: IdentifierRelations, + statementLength: number, + exactThrough: number, + issues: Set, + resource?: SqlCteLayoutResource, +): SqlCteLayout { + const unknownClasses = (identityIndex: number): readonly number[] => + Object.freeze( + [ + ...new Set( + [...(relations.unknownIndexes.get(identityIndex) ?? [])].map( + (index) => relationRoot(relations, index), + ), + ), + ].sort((left, right) => left - right), + ); + const frozenDeclarations = Object.freeze( + declarations.map((declaration) => + Object.freeze({ + ambiguous: declaration.ambiguous, + bodyRange: createRange( + declaration.bodyFrom, + declaration.bodyTo, + ), + equivalenceClass: relationRoot( + relations, + declaration.identityIndex, + ), + frameIndex: declaration.frameIndex, + name: declaration.name, + nameRange: createRange( + declaration.nameFrom, + declaration.nameTo, + ), + ordinal: declaration.ordinal, + sourceSpelling: declaration.sourceSpelling, + unknownEquivalenceClasses: unknownClasses( + declaration.identityIndex, + ), + }), + ), + ); + const frozenDraftDeclarations = Object.freeze( + draftDeclarations.map((declaration) => + Object.freeze({ + ambiguous: declaration.ambiguous, + bodyRange: createRange( + declaration.bodyFrom, + declaration.bodyTo, + ), + equivalenceClass: relationRoot( + relations, + declaration.identityIndex, + ), + frameIndex: declaration.frameIndex, + name: declaration.name, + nameRange: createRange( + declaration.nameFrom, + declaration.nameTo, + ), + ordinal: declaration.ordinal, + sourceSpelling: declaration.sourceSpelling, + unknownEquivalenceClasses: unknownClasses( + declaration.identityIndex, + ), + }), + ), + ); + const frozenFrames = Object.freeze( + frames.map((frame) => { + const frameIssues = freezeIssues(frame.issues); + return Object.freeze({ + baseDepth: frame.baseDepth, + declarationIndexes: Object.freeze([ + ...frame.declarationIndexes, + ]), + issues: frameIssues, + mainQueryStart: frame.mainQueryStart, + parentFrameIndex: frame.parentFrameIndex, + recursive: frame.recursive, + scopeRange: createRange( + frame.scopeFrom, + frame.scopeTo ?? exactThrough, + ), + withStart: frame.withStart, + }); + }), + ); + const mainQueryEntrypoints = Object.freeze( + frozenFrames + .map((frame, frameIndex) => + frame.mainQueryStart === null + ? null + : Object.freeze({ + depth: frame.baseDepth, + frameIndex, + from: frame.mainQueryStart, + }), + ) + .filter( + ( + entrypoint, + ): entrypoint is SqlCteMainQueryEntrypoint => + entrypoint !== null, + ) + .sort((left, right) => left.from - right.from), + ); + for (const frame of frozenFrames) { + for (const issue of frame.issues) { + issues.add(issue); + } + } + const frozenIssues = freezeIssues(issues); + const base = { + declarations: frozenDeclarations, + draftDeclarations: frozenDraftDeclarations, + exactThrough, + frames: frozenFrames, + issues: frozenIssues, + mainQueryEntrypoints, + statementLength, + }; + if (frozenIssues.length === 0 && resource === undefined) { + const noIssues: readonly [] = Object.freeze([]); + return Object.freeze({ + ...base, + issues: noIssues, + status: "ready", + }); + } + const partial = { + ...base, + issues: frozenIssues as readonly [ + SqlCteLayoutIssue, + ...SqlCteLayoutIssue[], + ], + status: "partial" as const, + }; + return Object.freeze( + resource === undefined ? partial : { ...partial, resource }, + ); +} + +export function analyzeSqlCteLayout( + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): SqlCteLayout { + const statementLength = slot.source.to - slot.source.from; + if (statementLength > MAX_CTE_STATEMENT_LENGTH) { + return layoutUnavailable("resource-limit", "active-statement"); + } + const validatedDialect = validateDialect(dialect); + if (!validatedDialect) { + return layoutUnavailable("resource-limit"); + } + const { grammar, lexicalProfile } = validatedDialect; + const text = source.analysisText; + const statementFrom = slot.source.from; + const lexer = new BoundedSqlLexer( + source, + statementFrom, + slot.source.to, + lexicalProfile, + ); + const declarations: MutableDeclaration[] = []; + const relations: IdentifierRelations = { + components: [], + frameIndexes: [], + unknownIndexes: new Map(), + parents: [], + }; + const frames: MutableFrame[] = []; + const builders = new Map(); + const bodyOwners = new Map(); + const columnOwners = new Map(); + const queryCandidates = new Set([0]); + const issues = new Set(); + const declarationAttempts = { value: 0 }; + let depth = 0; + let exactThrough = statementLength; + let resource: SqlCteLayoutResource | undefined; + + scan: while (true) { + const token = lexer.next(); + if (lexer.resource) { + exactThrough = Math.max( + 0, + (lexer.resourceAt ?? statementFrom) - statementFrom, + ); + resource = LEXER_RESOURCES[lexer.resource]; + issues.add("ambiguous-cte-header"); + break; + } + if (!token) { + break; + } + if (isComment(token)) { + continue; + } + if (token.kind === "barrier") { + exactThrough = token.from - statementFrom; + issues.add("opaque-template-context"); + for (const frame of frames) { + if (frame.scopeTo === null) { + frame.issues.add("opaque-template-context"); + } + } + break; + } + const code = punctuation(text, token); + + if (code === 41) { + const columnFrame = columnOwners.get(depth); + if (columnFrame) { + if ( + columnFrame.columnExpectIdentifier || + columnFrame.columnCount === 0 + ) { + exactThrough = token.from - statementFrom; + markPartial( + columnFrame, + issues, + "ambiguous-cte-header", + ); + break; + } + columnFrame.state = "expect-as"; + columnOwners.delete(depth); + depth -= 1; + queryCandidates.delete(depth + 1); + continue; + } + + if ( + !closeNestedFrame( + frames, + builders, + depth, + token.from - statementFrom, + issues, + ) + ) { + exactThrough = + builders.get(depth)?.withStart ?? + token.from - statementFrom; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + const current = bodyOwner.current; + if ( + !current || + current.bodyLead === null || + (current.bodyLead === "with" && + (current.bodyLeadFrameIndex === null || + frames[current.bodyLeadFrameIndex]?.mainQueryStart === + null)) + ) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + commitDeclaration( + bodyOwner, + current, + declarations, + validatedDialect, + frames, + relations, + token.from - statementFrom, + ); + bodyOwners.delete(depth); + depth -= 1; + queryCandidates.delete(depth + 1); + continue; + } + queryCandidates.delete(depth); + depth = Math.max(0, depth - 1); + continue; + } + + const queryCandidate = queryCandidates.has(depth); + if (queryCandidate && token.kind === "word") { + if (wordEquals(text, token, "with")) { + const leadingParent = bodyOwners.get(depth) ?? null; + builders.set(depth, { + frame: null, + leadingParent, + withStart: token.from - statementFrom, + }); + if (leadingParent?.current) { + leadingParent.current.bodyLead = "with"; + } + queryCandidates.delete(depth); + continue; + } + if (wordEquals(text, token, "select")) { + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner?.current) { + bodyOwner.current.bodyLead = "select"; + } + queryCandidates.delete(depth); + } else { + queryCandidates.delete(depth); + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "unsupported-cte-extension", + ); + break; + } + } + } else if (queryCandidate && code !== 40) { + queryCandidates.delete(depth); + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "unsupported-cte-extension", + ); + break; + } + } + + const builder = builders.get(depth); + let frame = builder?.frame ?? null; + if (builder && !frame) { + if (frames.length >= MAX_CTE_FRAMES) { + exactThrough = builder.withStart; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + frame = createFrame( + frames, + depth, + builder.withStart, + builder.leadingParent, + ); + builder.frame = frame; + } + + if (frame && depth === frame.baseDepth) { + if (frame.state === "modifier-or-name") { + if ( + token.kind === "word" && + wordEquals(text, token, "recursive") + ) { + if (!grammar.recursive) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.recursive = true; + frame.state = "expect-name"; + continue; + } + if ( + !processName( + frame, + validatedDialect, + text, + token, + statementFrom, + declarationAttempts, + ) + ) { + exactThrough = token.from - statementFrom; + if ( + declarationAttempts.value > MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + continue; + } + if (frame.state === "expect-name") { + if ( + frame.declarationIndexes.length >= + grammar.maximumDeclarationsPerFrame + ) { + exactThrough = token.from - statementFrom; + if ( + grammar.maximumDeclarationsPerFrame === + MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + resource === "cte-declaration" + ? "ambiguous-cte-header" + : "unsupported-cte-extension", + ); + break; + } + if ( + !processName( + frame, + validatedDialect, + text, + token, + statementFrom, + declarationAttempts, + ) + ) { + exactThrough = token.from - statementFrom; + if ( + declarationAttempts.value > MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + continue; + } + if (frame.state === "after-name") { + if (code === 40) { + if (!grammar.declaredColumns) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + frame.columnCount = 0; + frame.columnExpectIdentifier = true; + frame.state = "columns"; + columnOwners.set(depth, frame); + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "as") + ) { + frame.state = "after-as"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "expect-as") { + if ( + token.kind === "word" && + wordEquals(text, token, "as") + ) { + frame.state = "after-as"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "after-as") { + if ( + token.kind === "word" && + wordEquals(text, token, "not") + ) { + if (!grammar.materialization) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.state = "expect-materialized"; + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "materialized") + ) { + if (!grammar.materialization) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.state = "expect-body"; + continue; + } + if (code !== 40 || !frame.current) { + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + frame.current.bodyFrom = token.to - statementFrom; + frame.state = "waiting-body"; + bodyOwners.set(depth, frame); + queryCandidates.add(depth); + continue; + } + if (frame.state === "expect-body") { + if (code !== 40 || !frame.current) { + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + frame.current.bodyFrom = token.to - statementFrom; + frame.state = "waiting-body"; + bodyOwners.set(depth, frame); + queryCandidates.add(depth); + continue; + } + if (frame.state === "expect-materialized") { + if ( + token.kind === "word" && + wordEquals(text, token, "materialized") + ) { + frame.state = "expect-body"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "after-body") { + if (code === 44) { + frame.state = "expect-name"; + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "select") + ) { + frame.mainQueryStart = token.from - statementFrom; + frame.state = "main"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + } + + const columnOwner = columnOwners.get(depth); + if (columnOwner) { + if (columnOwner.columnExpectIdentifier) { + if ( + !normalizeIdentifier( + validatedDialect, + text, + token, + "cte-column", + ) + ) { + exactThrough = token.from - statementFrom; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + columnOwner.columnCount += 1; + columnOwner.columnExpectIdentifier = false; + continue; + } + if (code === 44) { + columnOwner.columnExpectIdentifier = true; + continue; + } + exactThrough = token.from - statementFrom; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + + if (code === 40) { + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + issues.add("ambiguous-cte-header"); + break scan; + } + queryCandidates.add(depth); + } + } + + if (exactThrough === statementLength) { + for (const [builderDepth, builder] of builders) { + if (!builder.frame) { + if (frames.length >= MAX_CTE_FRAMES) { + exactThrough = builder.withStart; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + builder.frame = createFrame( + frames, + builderDepth, + builder.withStart, + builder.leadingParent, + ); + } + } + } + for (const frame of frames) { + if (frame.scopeTo === null) { + frame.scopeTo = exactThrough; + } + if ( + frame.state !== "main" && + exactThrough === statementLength + ) { + markPartial(frame, issues, "ambiguous-cte-header"); + } + } + const draftDeclarations = collectActiveBodyEvidence( + frames, + declarations, + validatedDialect, + relations, + exactThrough, + ); + return freezeLayout( + frames, + declarations, + draftDeclarations, + relations, + statementLength, + exactThrough, + issues, + resource, + ); +} + +function framePhase( + frame: SqlCteFrame, + frameIndex: number, + declarations: readonly SqlCteDeclaration[], + draftDeclarations: readonly SqlCteDraftDeclaration[], + position: number, +): { readonly kind: "body"; readonly ordinal: number } | { + readonly kind: "main"; +} | null { + for (const declarationIndex of frame.declarationIndexes) { + const declaration = declarations[declarationIndex]; + if ( + declaration && + declaration.bodyRange.from <= position && + position <= declaration.bodyRange.to + ) { + return { kind: "body", ordinal: declaration.ordinal }; + } + } + for (const declaration of draftDeclarations) { + if ( + declaration.frameIndex === frameIndex && + declaration.bodyRange.from <= position && + position <= declaration.bodyRange.to + ) { + return { kind: "body", ordinal: declaration.ordinal }; + } + } + if ( + frame.mainQueryStart !== null && + frame.mainQueryStart <= position + ) { + return { kind: "main" }; + } + return null; +} + +export function visibleSqlCtesAt( + layout: Exclude, + position: number, +): SqlCteVisibility { + if ( + !Number.isSafeInteger(position) || + position < 0 || + position > layout.statementLength + ) { + return Object.freeze({ + ctes: Object.freeze([]), + issues: Object.freeze([]), + quality: "recovered", + shadowing: Object.freeze({ coverage: "unknown" }), + }); + } + const namespace = new Map< + number, + SqlCteDeclaration | null + >(); + const shadowNames = new Map(); + const issues = new Set(); + const beyondExactCoverage = + position > layout.exactThrough || + (position === layout.exactThrough && + layout.exactThrough < layout.statementLength); + let shadowingUnknown = beyondExactCoverage; + + if (beyondExactCoverage) { + for (const issue of layout.issues) { + issues.add(issue); + } + } + for ( + let frameIndex = 0; + frameIndex < layout.frames.length; + frameIndex += 1 + ) { + const frame = layout.frames[frameIndex]; + if (!frame) { + continue; + } + if ( + position < frame.scopeRange.from || + position > frame.scopeRange.to + ) { + continue; + } + const phase = framePhase( + frame, + frameIndex, + layout.declarations, + layout.draftDeclarations, + position, + ); + if (!phase) { + if ( + frame.mainQueryStart === null && + frame.issues.length > 0 + ) { + for (const issue of frame.issues) { + issues.add(issue); + } + shadowingUnknown = true; + } + continue; + } + for (const issue of frame.issues) { + issues.add(issue); + if (issue === "unknown-cte-identifier-equivalence") { + shadowingUnknown = true; + } + } + const eligibleOrdinal = + phase.kind === "main" + ? Number.POSITIVE_INFINITY + : phase.ordinal; + const frameDeclarations = frame.declarationIndexes + .map((index) => layout.declarations[index]) + .filter( + (declaration): declaration is SqlCteDeclaration => + declaration !== undefined, + ); + const frameDrafts = layout.draftDeclarations.filter( + (declaration) => declaration.frameIndex === frameIndex, + ); + for (const declaration of frameDrafts) { + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + shadowingUnknown = true; + } + } + } + + if (frame.recursive && phase.kind === "body") { + issues.add("recursive-cte-position"); + if (frame.mainQueryStart === null) { + shadowingUnknown = true; + } + for (const declaration of frameDeclarations) { + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + shadowingUnknown = true; + } + } + namespace.set(declaration.equivalenceClass, null); + shadowNames.set( + declaration.equivalenceClass, + declaration.name, + ); + } + for (const declaration of frameDrafts) { + namespace.set(declaration.equivalenceClass, null); + shadowNames.set( + declaration.equivalenceClass, + declaration.name, + ); + } + } + + for (const declaration of frameDeclarations) { + if (declaration.ordinal >= eligibleOrdinal) { + continue; + } + const key = declaration.equivalenceClass; + let uncertainCandidate = + declaration.unknownEquivalenceClasses.includes(key); + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + uncertainCandidate = true; + } + } + shadowNames.set(key, declaration.name); + namespace.set( + key, + declaration.ambiguous || uncertainCandidate + ? null + : declaration, + ); + } + } + + const ctes = Object.freeze( + [...namespace.values()] + .filter( + (declaration): declaration is SqlCteDeclaration => + declaration !== null, + ) + .sort( + (left, right) => + left.nameRange.from - right.nameRange.from, + ) + .map((declaration) => + Object.freeze({ + declarationPosition: declaration.nameRange.from, + name: declaration.name, + sourceSpelling: declaration.sourceSpelling, + }), + ), + ); + const frozenIssues = freezeIssues(issues); + return Object.freeze({ + ctes, + issues: frozenIssues, + quality: + shadowingUnknown || frozenIssues.length > 0 + ? "recovered" + : "exact", + shadowing: shadowingUnknown + ? Object.freeze({ coverage: "unknown" as const }) + : Object.freeze({ + coverage: "complete" as const, + names: Object.freeze([...shadowNames.values()]), + }), + }); +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 7178557..0c8ded8 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -150,6 +150,16 @@ export type SqlRenderedRelationPath = readonly reason: "illegal-role-sequence"; }; +export type SqlCteIdentifierComparison = + | "distinct" + | "equal" + | "unknown"; + +export type SqlCteIdentifierPrefixMatch = + | "match" + | "no-match" + | "unknown"; + export interface SqlRelationCompletionDialectRuntime { readonly decodeIdentifier: ( token: string, @@ -158,10 +168,14 @@ export interface SqlRelationCompletionDialectRuntime { readonly renderRelationPath: ( path: SqlCanonicalRelationPath, ) => SqlRenderedRelationPath; - readonly cteIdentifiersEqual: ( + readonly compareCteIdentifiers: ( left: SqlIdentifierComponent, right: SqlIdentifierComponent, - ) => boolean; + ) => SqlCteIdentifierComparison; + readonly cteIdentifierMatchesPrefix: ( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, + ) => SqlCteIdentifierPrefixMatch; } export type SqlSessionChangeReason = @@ -230,6 +244,7 @@ export type SqlCompletionIssue = | "catalog-overloaded" | "catalog-queue-timeout" | "catalog-timeout" + | "cte-scope-uncertainty" | "query-site-recovery" | "opaque-template-context" | "recursive-cte-uncertainty" diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index a9fc51c..9847138 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -146,8 +146,12 @@ const relation: SqlCatalogRelation = { void relation; const dialectRuntime: SqlRelationCompletionDialectRuntime = { - cteIdentifiersEqual: (left, right) => - left.quoted === right.quoted && left.value === right.value, + compareCteIdentifiers: (left, right) => + left.quoted === right.quoted && left.value === right.value + ? "equal" + : "distinct", + cteIdentifierMatchesPrefix: (candidate, prefix) => + candidate.value.startsWith(prefix.value) ? "match" : "no-match", decodeIdentifier: (token) => ({ component: { quoted: false, value: token }, quality: "exact", From 19bbded00e7d28d97419098c6ec4804e6a1005f8 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 10:53:04 +0800 Subject: [PATCH 20/42] feat(vnext): unify built-in relation dialect policy (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add one package-owned relation dialect runtime for PostgreSQL, DuckDB, BigQuery, and Dremio - unify identifier decoding, CTE equality/prefix rules, query-path decoding, role-aware rendering, reserved words, and CTE/query recognizer policy - authenticate each runtime through the opaque built-in dialect handle while keeping callbacks private - add hostile-input bounds and fail-closed behavior, exact dialect corpora, production-backed recognizer tests, and bundle budgets ## Correctness and performance - changed production coverage: 97.10% statements, 96.18% branches, 100% functions, 97.05% lines - exact packed core: 15,028 gzip / 47,141 raw bytes under 16 KiB / 52 KiB limits - exact packed worker output: 125,937 gzip / 572,982 raw bytes under 128 KiB / 570 KiB limits - comparator hot paths use bounded allocation-free ASCII loops; private path scratch arrays are not copied or frozen ## Verification - 1,646 tests passed, 1 governed expected failure - all five TypeScript configurations passed - oxlint and test-integrity checks passed - browser suite passed in Chromium - exact tarball package smoke and SSR import passed - worker placement, strict CSP, lazy loading, and parser isolation passed - query-site and CTE benchmarks passed - exact head 6e80f2fbe70460abb1d6a770f47efe0314b4084c approved independently by three adversarial reviewers Part of #169. --- ## Summary by cubic Unifies the relation dialect runtime across PostgreSQL, DuckDB, BigQuery, and Dremio with a single, package-owned policy for identifiers, CTEs, query paths, and role-aware rendering. Tightens the public API and enforces strict size budgets and hostile-input boundaries. Part of #169. - **New Features** - Added a unified `relation-dialect` runtime for completion, CTE layout, query-site policy, reserved words, and rendering across all built-ins. - Introduced fail-closed decoding with hostile-input bounds and exact corpora; added adversarial/production-backed tests. - Enforced bundle budgets (core ≤16 KiB gzip/52 KiB raw; worker ≤128 KiB gzip/570 KiB raw) and a smoke check to prevent dialect policy leaks. - **Refactors** - Public dialect handles (`bigQueryDialect()`, `dremioDialect()`, `duckdbDialect()`, `postgresDialect()`) now expose only `displayName` and `id`; callbacks are private and authenticated in `session`. - Migrated `cte-layout` and `query-site` to `..._SQL_RELATION_DIALECT` constants; added `MAX_CTE_QUOTED_IDENTIFIER_LENGTH`. - Simplified and hardened tests by removing ad-hoc dialects; updated worker placement and docs to report core/worker sizes. Written for commit 6e80f2fbe70460abb1d6a770f47efe0314b4084c. Summary will update on new commits. Review in cubic --- scripts/package-smoke.mjs | 16 + scripts/worker-placement.mjs | 28 +- src/vnext/__tests__/cte-layout.bench.ts | 23 +- src/vnext/__tests__/cte-layout.test.ts | 105 +- src/vnext/__tests__/query-site.bench.ts | 23 +- src/vnext/__tests__/query-site.test.ts | 281 +--- .../relation-dialect-boundaries.test.ts | 520 ++++++ src/vnext/__tests__/relation-dialect.test.ts | 1038 ++++++++++++ src/vnext/__tests__/session.test.ts | 33 +- src/vnext/cte-layout.ts | 4 +- src/vnext/relation-dialect.ts | 1482 +++++++++++++++++ src/vnext/relation-reserved-words.ts | 99 ++ src/vnext/session.ts | 40 +- .../marimo-relation-completion.test-d.ts | 20 - test/vnext-types/session.test-d.ts | 6 + test/worker-placement/README.md | 22 +- 16 files changed, 3339 insertions(+), 401 deletions(-) create mode 100644 src/vnext/__tests__/relation-dialect-boundaries.test.ts create mode 100644 src/vnext/__tests__/relation-dialect.test.ts create mode 100644 src/vnext/relation-dialect.ts create mode 100644 src/vnext/relation-reserved-words.ts diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 3831876..fec61df 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -296,6 +296,22 @@ if ( ) { throw new Error("vNext package exports are incomplete"); } +for (const dialect of [ + vnext.bigQueryDialect(), + vnext.dremioDialect(), + vnext.duckdbDialect(), + vnext.postgresDialect(), +]) { + if ( + Object.keys(dialect).join(",") !== "displayName,id" || + "decodeIdentifier" in dialect || + "grammar" in dialect || + "relationDialect" in dialect || + "renderRelationPath" in dialect + ) { + throw new Error("vNext dialect implementation policy leaked publicly"); + } +} if (!commonKeywords.keywords || !duckdbKeywords.keywords) { throw new Error("Keyword data exports are incomplete"); } diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index f5ac3df..351e535 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,8 +35,10 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 16 * 1024; +const CORE_TOTAL_RAW_LIMIT = 52 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 120 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 128 * 1024; const WORKER_TOTAL_RAW_LIMIT = 570 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], @@ -485,6 +487,27 @@ function verifyCoreExcludesParser(coreDirectory) { return new Set(moduleIds).size; } +function verifyCoreSize(coreDirectory) { + const report = bundleReport(coreDirectory); + if (report.gzipBytes > CORE_TOTAL_GZIP_LIMIT) { + throw new Error( + `Core output exceeded ${CORE_TOTAL_GZIP_LIMIT} gzip bytes: ${report.gzipBytes}`, + ); + } + if (report.rawBytes > CORE_TOTAL_RAW_LIMIT) { + throw new Error( + `Core output exceeded ${CORE_TOTAL_RAW_LIMIT} raw bytes: ${report.rawBytes}`, + ); + } + return { + ...report, + limits: { + gzipBytes: CORE_TOTAL_GZIP_LIMIT, + rawBytes: CORE_TOTAL_RAW_LIMIT, + }, + }; +} + function verifySsrImport(fixtureDirectory) { const source = ` Object.defineProperty(globalThis, "window", { @@ -751,6 +774,7 @@ try { const coreDirectory = join(fixtureDirectory, "core-dist"); const workersDirectory = join(fixtureDirectory, "workers-dist"); const coreModuleCount = verifyCoreExcludesParser(coreDirectory); + const coreBundle = verifyCoreSize(coreDirectory); const chromiumResult = await runChromium( fixtureDirectory, workersDirectory, @@ -763,7 +787,7 @@ try { } const report = { bundles: { - core: bundleReport(coreDirectory), + core: coreBundle, workers: workerBundles, }, evidence: { diff --git a/src/vnext/__tests__/cte-layout.bench.ts b/src/vnext/__tests__/cte-layout.bench.ts index a3a7700..0d179b5 100644 --- a/src/vnext/__tests__/cte-layout.bench.ts +++ b/src/vnext/__tests__/cte-layout.bench.ts @@ -4,35 +4,16 @@ import { MAX_CTE_DECLARATIONS, MAX_CTE_DEPTH, MAX_CTE_FRAMES, - type SqlCteLayoutDialect, visibleSqlCtesAt, } from "../cte-layout.js"; -import { DUCKDB_SQL_LEXICAL_PROFILE } from "../lexical.js"; +import { DUCKDB_SQL_RELATION_DIALECT } from "../relation-dialect.js"; import { createIdentitySqlSource } from "../source.js"; import { buildSqlStatementIndex, type ExactSqlStatementSlot, } from "../statement-index.js"; -const dialect: SqlCteLayoutDialect = { - classifyIdentifierToken: (rawIdentifier, quoted) => ({ - status: "identifier", - value: { - component: { quoted, value: rawIdentifier }, - }, - }), - compareCteIdentifiers: (left, right) => - left.value.toLowerCase() === right.value.toLowerCase() - ? "equal" - : "distinct", - grammar: { - declaredColumns: true, - materialization: true, - maximumDeclarationsPerFrame: MAX_CTE_DECLARATIONS, - recursive: true, - }, - lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, -}; +const dialect = DUCKDB_SQL_RELATION_DIALECT.cteLayout; function fixture(text: string): { readonly source: ReturnType; diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/vnext/__tests__/cte-layout.test.ts index a232c34..97ce704 100644 --- a/src/vnext/__tests__/cte-layout.test.ts +++ b/src/vnext/__tests__/cte-layout.test.ts @@ -5,6 +5,7 @@ import { MAX_CTE_DEPTH, MAX_CTE_FRAMES, MAX_CTE_IDENTIFIER_LENGTH, + MAX_CTE_QUOTED_IDENTIFIER_LENGTH, MAX_CTE_STATEMENT_LENGTH, type SqlCteLayout, type SqlCteLayoutDialect, @@ -13,11 +14,11 @@ import { visibleSqlCtesAt, } from "../cte-layout.js"; import { - BIGQUERY_SQL_LEXICAL_PROFILE, - DREMIO_SQL_LEXICAL_PROFILE, - DUCKDB_SQL_LEXICAL_PROFILE, - POSTGRESQL_SQL_LEXICAL_PROFILE, -} from "../lexical.js"; + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; import { createIdentitySqlSource, createMaskedSqlSource, @@ -28,94 +29,10 @@ import { type ExactSqlStatementSlot, } from "../statement-index.js"; -function decodeQuoted(raw: string): string { - const quote = raw.at(0) ?? ""; - return raw - .slice(1, -1) - .replaceAll(`${quote}${quote}`, quote) - .replaceAll(`\\${quote}`, quote); -} - -function isAscii(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - if (value.charCodeAt(index) > 0x7f) { - return false; - } - } - return true; -} - -function createDialect( - kind: "bigquery" | "dremio" | "duckdb" | "postgresql", -): SqlCteLayoutDialect { - const lexicalProfile = - kind === "bigquery" - ? BIGQUERY_SQL_LEXICAL_PROFILE - : kind === "dremio" - ? DREMIO_SQL_LEXICAL_PROFILE - : kind === "duckdb" - ? DUCKDB_SQL_LEXICAL_PROFILE - : POSTGRESQL_SQL_LEXICAL_PROFILE; - return { - classifyIdentifierToken: (raw, quoted) => { - const value = quoted ? decodeQuoted(raw) : raw; - if ( - value.length === 0 || - (!quoted && - /^(?:as|materialized|not|recursive|select|with)$/i.test( - value, - )) - ) { - return { status: "unsupported" }; - } - return { - status: "identifier", - value: { - component: { quoted, value }, - }, - }; - }, - compareCteIdentifiers: (left, right) => { - const leftKey = - kind === "postgresql" && left.quoted - ? isAscii(left.value) - ? left.value - : null - : isAscii(left.value) - ? left.value.toLowerCase() - : null; - const rightKey = - kind === "postgresql" && right.quoted - ? isAscii(right.value) - ? right.value - : null - : isAscii(right.value) - ? right.value.toLowerCase() - : null; - if (leftKey !== null && rightKey !== null) { - return leftKey === rightKey ? "equal" : "distinct"; - } - return left.quoted === right.quoted && - left.value === right.value - ? "equal" - : "unknown"; - }, - grammar: { - declaredColumns: kind !== "bigquery", - materialization: - kind === "duckdb" || kind === "postgresql", - maximumDeclarationsPerFrame: - kind === "dremio" ? 1 : MAX_CTE_DECLARATIONS, - recursive: kind !== "dremio", - }, - lexicalProfile, - }; -} - -const postgres = createDialect("postgresql"); -const duckdb = createDialect("duckdb"); -const bigquery = createDialect("bigquery"); -const dremio = createDialect("dremio"); +const postgres = POSTGRESQL_SQL_RELATION_DIALECT.cteLayout; +const duckdb = DUCKDB_SQL_RELATION_DIALECT.cteLayout; +const bigquery = BIGQUERY_SQL_RELATION_DIALECT.cteLayout; +const dremio = DREMIO_SQL_RELATION_DIALECT.cteLayout; function analyze( text: string, @@ -787,7 +704,7 @@ describe("bounded CTE layout", () => { expect(calls).toBe(0); const oversizedQuoted = `"${"a".repeat( - MAX_CTE_IDENTIFIER_LENGTH * 2 + 1, + MAX_CTE_QUOTED_IDENTIFIER_LENGTH, )}"`; expectPartial( `WITH ${oversizedQuoted} AS (SELECT 1) SELECT 1`, diff --git a/src/vnext/__tests__/query-site.bench.ts b/src/vnext/__tests__/query-site.bench.ts index b592b03..ca19f79 100644 --- a/src/vnext/__tests__/query-site.bench.ts +++ b/src/vnext/__tests__/query-site.bench.ts @@ -1,34 +1,15 @@ import { bench, describe } from "vitest"; import { recognizeSqlRelationQuerySite, - type SqlQuerySiteDialect, } from "../query-site.js"; +import { DUCKDB_SQL_RELATION_DIALECT } from "../relation-dialect.js"; import { createIdentitySqlSource } from "../source.js"; import { buildSqlStatementIndex, - DUCKDB_SQL_LEXICAL_PROFILE, findSqlStatementSlot, } from "../statement-index.js"; -const dialect: SqlQuerySiteDialect = { - classifyIdentifierToken: (rawIdentifier) => ({ - status: "identifier", - value: rawIdentifier, - }), - decodeRelationPath: (rawPath, cursorOffset) => ({ - finalSegment: { from: 0, to: rawPath.length }, - prefix: { - quoted: false, - value: rawPath.slice(0, cursorOffset), - }, - qualifier: [], - quality: "exact", - status: "decoded", - }), - lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, - maximumPathDepth: 16, - supportsNaturalJoin: true, -}; +const dialect = DUCKDB_SQL_RELATION_DIALECT.querySite; const TEN_KIBIBYTES = 10 * 1_024; const queryPrefix = "SELECT "; const querySuffix = " FROM schema_prefix"; diff --git a/src/vnext/__tests__/query-site.test.ts b/src/vnext/__tests__/query-site.test.ts index 81603a7..10f268b 100644 --- a/src/vnext/__tests__/query-site.test.ts +++ b/src/vnext/__tests__/query-site.test.ts @@ -10,232 +10,27 @@ import { type SqlQuerySiteDialect, type SqlQuerySiteResult, } from "../query-site.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; import { createIdentitySqlSource, createMaskedSqlSource, type SqlSourceSnapshot, } from "../source.js"; import { - BIGQUERY_SQL_LEXICAL_PROFILE, buildSqlStatementIndex, - DUCKDB_SQL_LEXICAL_PROFILE, findSqlStatementSlot, POSTGRESQL_SQL_LEXICAL_PROFILE, } from "../statement-index.js"; -function decodeSegment(raw: string, prefix = false): { - readonly quoted: boolean; - readonly value: string; - readonly recovered: boolean; -} | null { - if (raw.startsWith("\"")) { - const closed = raw.endsWith("\"") && raw.length > 1; - if (!closed && !prefix) { - return null; - } - const content = raw.slice(1, closed ? -1 : undefined); - return { - quoted: true, - recovered: !closed, - value: content.replaceAll("\"\"", "\""), - }; - } - if (raw.length === 0 && prefix) { - return { quoted: false, recovered: false, value: "" }; - } - return /^[\p{L}_][\p{L}\p{N}_$]*$/u.test(raw) - ? { quoted: false, recovered: false, value: raw } - : null; -} - -function decodeStandardPath( - rawPath: string, - cursorOffset: number, -): SqlDecodedQueryPath { - const rawSegments = rawPath.split("."); - const qualifier = []; - let segmentFrom = 0; - for (let index = 0; index < rawSegments.length - 1; index += 1) { - const raw = rawSegments[index] ?? ""; - const decoded = decodeSegment(raw); - if (!decoded) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - qualifier.push({ quoted: decoded.quoted, value: decoded.value }); - segmentFrom += raw.length + 1; - } - const finalRaw = rawSegments[rawSegments.length - 1] ?? ""; - const cursorInFinal = cursorOffset - segmentFrom; - if (cursorInFinal < 0 || cursorInFinal > finalRaw.length) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - let prefixRaw = finalRaw.slice(0, cursorInFinal); - if (finalRaw.startsWith("\"") && finalRaw.endsWith("\"")) { - prefixRaw = `${prefixRaw}"`; - } - const prefix = decodeSegment(prefixRaw, true); - if (!prefix) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - return { - finalSegment: { - from: segmentFrom, - to: segmentFrom + finalRaw.length, - }, - prefix: { quoted: prefix.quoted, value: prefix.value }, - qualifier, - quality: - prefix.recovered || - (finalRaw.startsWith("\"") && !finalRaw.endsWith("\"")) - ? "recovered" - : "exact", - status: "decoded", - }; -} - -function classifyIdentifierToken( - rawIdentifier: string, - quoted: boolean, -): - | { - readonly status: "identifier"; - readonly value: string; - } - | { - readonly status: "unsupported"; - } { - if (quoted) { - const delimiter = rawIdentifier.at(0) ?? ""; - const value = rawIdentifier - .slice(1, -1) - .replaceAll(`${delimiter}${delimiter}`, delimiter); - return value.length > 0 - ? { status: "identifier", value } - : { status: "unsupported" }; - } - const word = rawIdentifier.toLowerCase(); - const unsupported = - word === "assert_rows_modified" || - word === "as" || - word === "collate" || - word === "cross" || - word === "default" || - word === "distinct" || - word === "end" || - word === "escape" || - word === "except" || - word === "fetch" || - word === "filter" || - word === "for" || - word === "from" || - word === "full" || - word === "group" || - word === "having" || - word === "inner" || - word === "intersect" || - word === "join" || - word === "lateral" || - word === "left" || - word === "limit" || - word === "match_recognize" || - word === "natural" || - word === "on" || - word === "offset" || - word === "order" || - word === "outer" || - word === "over" || - word === "pivot" || - word === "qualify" || - word === "right" || - word === "select" || - word === "tablesample" || - word === "unpivot" || - word === "union" || - word === "using" || - word === "where" || - word === "window" || - word === "within"; - return unsupported - ? { status: "unsupported" } - : { status: "identifier", value: rawIdentifier }; -} - -const postgresDialect: SqlQuerySiteDialect = { - classifyIdentifierToken, - decodeRelationPath: decodeStandardPath, - lexicalProfile: POSTGRESQL_SQL_LEXICAL_PROFILE, - maximumPathDepth: 4, - supportsNaturalJoin: true, -}; - -const duckdbDialect: SqlQuerySiteDialect = { - classifyIdentifierToken, - decodeRelationPath: decodeStandardPath, - lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, - maximumPathDepth: 16, - supportsNaturalJoin: true, -}; - -const bigQueryDialect: SqlQuerySiteDialect = { - classifyIdentifierToken, - decodeRelationPath: (rawPath, cursorOffset) => { - if (!rawPath.startsWith("`")) { - if (!rawPath.includes("-")) { - return decodeStandardPath(rawPath, cursorOffset); - } - const segments = rawPath.split("."); - const finalRaw = segments.pop() ?? ""; - const segmentFrom = rawPath.length - finalRaw.length; - const cursorInFinal = cursorOffset - segmentFrom; - if ( - cursorInFinal < 0 || - cursorInFinal > finalRaw.length || - !/^[\p{L}_][\p{L}\p{N}_-]*$/u.test(segments[0] ?? "") || - segments.slice(1).some((segment) => !decodeSegment(segment)) - ) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - const prefix = decodeSegment(finalRaw.slice(0, cursorInFinal), true); - if (!prefix) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - return { - finalSegment: { from: segmentFrom, to: rawPath.length }, - prefix: { quoted: prefix.quoted, value: prefix.value }, - qualifier: segments.map((value) => ({ quoted: false, value })), - quality: "exact", - status: "decoded", - }; - } - const closed = rawPath.endsWith("`") && rawPath.length > 1; - const contentTo = closed ? rawPath.length - 1 : rawPath.length; - const content = rawPath.slice(1, contentTo); - const lastDot = content.lastIndexOf("."); - const finalFrom = lastDot + 2; - const cursorInContent = Math.min(cursorOffset, contentTo) - 1; - if (cursorInContent < finalFrom - 1) { - return { reason: "invalid-identifier", status: "unavailable" }; - } - const qualifier = - lastDot < 0 - ? [] - : content - .slice(0, lastDot) - .split(".") - .map((value) => ({ quoted: true, value })); - const prefix = content.slice(finalFrom - 1, cursorInContent); - return { - finalSegment: { from: finalFrom, to: rawPath.length }, - prefix: { quoted: true, value: prefix }, - qualifier, - quality: closed ? "exact" : "recovered", - status: "decoded", - }; - }, - lexicalProfile: BIGQUERY_SQL_LEXICAL_PROFILE, - maximumPathDepth: 3, - supportsNaturalJoin: false, -}; +const postgresDialect = POSTGRESQL_SQL_RELATION_DIALECT.querySite; +const duckdbDialect = DUCKDB_SQL_RELATION_DIALECT.querySite; +const bigQueryDialect = BIGQUERY_SQL_RELATION_DIALECT.querySite; +const classifyIdentifierToken = + postgresDialect.classifyIdentifierToken; function markedSource(marked: string): { readonly position: number; @@ -407,7 +202,7 @@ describe("partial SELECT relation query sites", () => { })).prefix.value).toBe("us"); }); - it("collects a bounded dialect-neutral identifier superset", () => { + it("applies each built-in identifier and path policy", () => { expect( expectReady(recognize("SELECT * FROM foo$use|r")).prefix.value, ).toBe("foo$use"); @@ -422,10 +217,19 @@ describe("partial SELECT relation query sites", () => { { quoted: false, value: "dataset" }, ]); expect( - recognize("SELECT * FROM a.b.c.d.e.f|", { + recognize("SELECT * FROM memory.main.users|", { dialect: duckdbDialect, }).status, ).toBe("ready"); + expect( + recognize("SELECT * FROM a.b.c.d|", { + dialect: duckdbDialect, + }), + ).toEqual({ + reason: "resource-limit", + resource: "identifier-path", + status: "unavailable", + }); }); it.each([ @@ -968,12 +772,20 @@ describe("fail-closed query-site behavior", () => { "SELECT * FROM users AS SELECT JOIN |", "SELECT * FROM users AS OFFSET JOIN |", "SELECT * FROM users AS UNION JOIN |", - "SELECT * FROM users AS QUALIFY JOIN |", "SELECT * FROM users AS LATERAL JOIN |", ])("classifies a structural word in explicit-alias state for %s", (marked) => { expect(recognize(marked).status).toBe("unavailable"); }); + it("accepts a dialect-unreserved structural word after AS", () => { + expect( + recognize("SELECT * FROM users AS QUALIFY JOIN |").status, + ).toBe("ready"); + expect( + recognize("SELECT * FROM users PIVOT JOIN |").status, + ).toBe("ready"); + }); + it.each([ "SELECT 'on|e''two' FROM users", "SELECT 'one''tw|o' FROM users", @@ -1454,7 +1266,7 @@ describe("query-site resource limits", () => { if (role === "using-column") { usingColumnCalls += 1; } - return classifyIdentifierToken(rawIdentifier, quoted); + return classifyIdentifierToken(rawIdentifier, quoted, role); }, }; const columns = Array.from( @@ -1471,8 +1283,10 @@ describe("query-site resource limits", () => { }); it("bounds path depth and decoded identifier length", () => { - expect(recognize("SELECT * FROM a.b.c.d|").status).toBe("ready"); - expect(recognize("SELECT * FROM a.b.c.d.e|")).toEqual({ + expect(recognize("SELECT * FROM public.users|").status).toBe( + "ready", + ); + expect(recognize("SELECT * FROM a.b.c|")).toEqual({ reason: "resource-limit", resource: "identifier-path", status: "unavailable", @@ -1487,8 +1301,7 @@ describe("query-site resource limits", () => { MAX_QUERY_SITE_IDENTIFIER_LENGTH + 1, )}|`), ).toEqual({ - reason: "resource-limit", - resource: "identifier-segment", + reason: "ambiguous-query-site", status: "unavailable", }); @@ -1498,6 +1311,28 @@ describe("query-site resource limits", () => { ).join("."); const globalDialect: SqlQuerySiteDialect = { ...postgresDialect, + decodeRelationPath: (rawPath, cursorOffset) => { + if (cursorOffset !== rawPath.length) { + return { + reason: "invalid-identifier", + status: "unavailable", + }; + } + const parts = rawPath.split("."); + const prefix = parts.at(-1) ?? ""; + return { + finalSegment: { + from: rawPath.length - prefix.length, + to: rawPath.length, + }, + prefix: { quoted: false, value: prefix }, + qualifier: parts + .slice(0, -1) + .map((value) => ({ quoted: false, value })), + quality: "exact", + status: "decoded", + }; + }, maximumPathDepth: MAX_QUERY_SITE_PATH_COMPONENTS, }; expect( diff --git a/src/vnext/__tests__/relation-dialect-boundaries.test.ts b/src/vnext/__tests__/relation-dialect-boundaries.test.ts new file mode 100644 index 0000000..2cbb508 --- /dev/null +++ b/src/vnext/__tests__/relation-dialect-boundaries.test.ts @@ -0,0 +1,520 @@ +import { describe, expect, it } from "vitest"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; + +const INVALID_IDENTIFIER = { + reason: "invalid-identifier", + status: "unavailable", +}; +const UNSUPPORTED_PATH = { + reason: "illegal-role-sequence", + status: "unsupported", +}; + +function identifier(value: string, quoted = false) { + return { quoted, value }; +} + +describe("relation dialect boundary behavior", () => { + it("rejects malformed standard quoted identifiers at every boundary", () => { + const decode = + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier; + for (const token of [ + "\"\"", + "\"nul\0name\"", + "\"bad\"quote\"", + `"${"x".repeat(257)}"`, + "\"\ud800\"", + "\"\udc00\"", + ]) { + expect(decode(token, "complete")).toEqual(INVALID_IDENTIFIER); + } + expect(decode("\"a😀z\"", "complete")).toMatchObject({ + component: { quoted: true, value: "a😀z" }, + status: "decoded", + }); + }); + + it("accepts all BigQuery escape classes and rejects invalid values", () => { + const decode = + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier; + expect( + decode( + "`\\\"\\'\\?\\\\\\`\\a\\b\\f\\n\\r\\t\\v\\101\\x42\\X43\\u0044\\U0001F600`", + "complete", + ), + ).toMatchObject({ + component: { + quoted: true, + value: "\"'?\\`\u0007\b\f\n\r\t\vABCD😀", + }, + quality: "exact", + status: "decoded", + }); + for (const token of [ + "``", + "`line\nbreak`", + "`line\rbreak`", + "`nul\0byte`", + "`trailing\\`", + "`\\8`", + "`\\xGG`", + "`\\u0g00`", + "`\\U0000D800`", + `\`${"x".repeat(257)}\``, + ]) { + expect(decode(token, "complete")).toEqual(INVALID_IDENTIFIER); + } + expect(decode("`ends\\\\`", "complete")).toMatchObject({ + component: { quoted: true, value: "ends\\" }, + status: "decoded", + }); + expect(decode("`\\777`", "complete")).toMatchObject({ + component: { quoted: true, value: "ǿ" }, + status: "decoded", + }); + }); + + it("bounds hostile raw identifier inputs before decoding", () => { + const oversized = "x".repeat(100_000); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier( + `"${oversized}`, + "completion-prefix", + ), + ).toEqual(INVALID_IDENTIFIER); + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier( + `\`${oversized}`, + "completion-prefix", + ), + ).toEqual(INVALID_IDENTIFIER); + }); + + it("keeps classification fail-closed across raw runtime calls", () => { + const query = + POSTGRESQL_SQL_RELATION_DIALECT.querySite + .classifyIdentifierToken; + const cte = + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout + .classifyIdentifierToken; + + expect( + Reflect.apply(query, undefined, [null, false, "explicit-alias"]), + ).toEqual({ status: "unsupported" }); + expect( + Reflect.apply(query, undefined, ["users", "false", "explicit-alias"]), + ).toEqual({ status: "unsupported" }); + expect( + Reflect.apply(query, undefined, ["users", false, "bad-role"]), + ).toEqual({ status: "unsupported" }); + expect(query("Users", false, "explicit-alias")).toEqual({ + status: "identifier", + value: "Users", + }); + expect(query("Users", false, "using-column")).toEqual({ + status: "identifier", + value: "Users", + }); + expect(query("\"Users\"", false, "implicit-alias")).toEqual({ + status: "unsupported", + }); + expect(query("Éclair", false, "implicit-alias")).toEqual({ + status: "identifier", + value: "Éclair", + }); + + expect( + Reflect.apply(cte, undefined, [null, false, "cte-name"]), + ).toEqual({ status: "unsupported" }); + expect( + Reflect.apply(cte, undefined, ["users", "false", "cte-name"]), + ).toEqual({ status: "unsupported" }); + expect( + Reflect.apply(cte, undefined, ["users", false, "bad-role"]), + ).toEqual({ status: "unsupported" }); + expect(cte("not", false, "cte-column")).toEqual({ + status: "unsupported", + }); + expect(cte("Users", false, "cte-column")).toMatchObject({ + status: "identifier", + value: { component: { quoted: false, value: "Users" } }, + }); + expect(cte("\"Users\"", false, "cte-name")).toEqual({ + status: "unsupported", + }); + }); +}); + +describe("relation path boundary behavior", () => { + it("handles quoted cursor edges and rejects malformed qualifiers", () => { + const decode = + POSTGRESQL_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + expect(decode("\"Users\"", 0)).toMatchObject({ + finalSegment: { from: 0, to: 7 }, + prefix: { quoted: true, value: "" }, + quality: "exact", + status: "decoded", + }); + expect(decode("\"a\"\"b\".us", 9)).toMatchObject({ + prefix: { value: "us" }, + qualifier: [{ quoted: true, value: "a\"b" }], + status: "decoded", + }); + expect(decode("public.\"unterminated.users", 26)).toMatchObject({ + prefix: { quoted: true, value: "unterminated.users" }, + qualifier: [{ value: "public" }], + quality: "recovered", + status: "decoded", + }); + for (const path of [ + "public.us\"ers", + "\"public\"x.users", + "select.users", + "\ud800.users", + ]) { + expect(decode(path, path.length)).toEqual(INVALID_IDENTIFIER); + } + }); + + it("enforces BigQuery bare-component and dash rules", () => { + const decode = + BIGQUERY_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + for (const path of [ + "-project.dataset.users", + "project-.dataset.users", + "project--x.dataset.users", + "select.dataset.users", + `${"x".repeat(257)}.dataset.users`, + "\ud800.dataset.users", + ]) { + expect(decode(path, path.length)).toEqual(INVALID_IDENTIFIER); + } + expect(decode("project-123.dataset.us", 22)).toMatchObject({ + prefix: { value: "us" }, + qualifier: [ + { value: "project-123" }, + { value: "dataset" }, + ], + status: "decoded", + }); + }); + + it("decodes every whole-backtick escape class in path components", () => { + const decode = + BIGQUERY_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + for (const [path, values] of [ + ["`\\141.\\x62.\\u0063`", ["a", "b", "c"]], + ["`\\X61.\\U00000062.c`", ["a", "b", "c"]], + ["`a\\?.b.c`", ["a?", "b", "c"]], + ] as const) { + const result = decode(path, path.length); + expect(result.status).toBe("decoded"); + if (result.status === "decoded") { + expect([ + ...result.qualifier.map((item) => item.value), + result.prefix.value, + ]).toEqual(values); + } + } + }); + + it("rejects malformed whole-backtick paths and invalid cursor regions", () => { + const decode = + BIGQUERY_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + for (const [path, cursor] of [ + ["`a..b`", 6], + ["`a.b.`", 6], + ["`a.b.c.d`", 9], + ["`a.b.c`", 0], + ["`a.b.c`", 2], + ["`\\xGG.b.c`", 11], + ["`a.b.\\xGG`", 11], + ["`a`junk", 7], + ] as const) { + expect(decode(path, cursor)).toEqual(INVALID_IDENTIFIER); + } + expect(decode("`a.b\\`", 6)).toMatchObject({ + prefix: { quoted: true, value: "b`" }, + qualifier: [{ quoted: true, value: "a" }], + quality: "recovered", + status: "decoded", + }); + }); + + it("fails closed for non-string and hostile path inputs", () => { + const decode = + DREMIO_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + expect( + Reflect.apply(decode, undefined, [null, 0]), + ).toEqual(INVALID_IDENTIFIER); + expect( + Reflect.apply(decode, undefined, ["users", "5"]), + ).toEqual(INVALID_IDENTIFIER); + expect( + decode("x".repeat(100_000), 100_000), + ).toEqual(INVALID_IDENTIFIER); + }); +}); + +describe("comparison and prefix boundaries", () => { + it("covers exact, short-prefix, and mixed quoting decisions", () => { + const postgres = POSTGRESQL_SQL_RELATION_DIALECT.completion; + expect( + postgres.compareCteIdentifiers( + identifier("Users", true), + identifier("Users", true), + ), + ).toBe("equal"); + expect( + postgres.cteIdentifierMatchesPrefix( + identifier("Users"), + identifier(""), + ), + ).toBe("match"); + expect( + postgres.cteIdentifierMatchesPrefix( + identifier("Users", true), + identifier("Use", true), + ), + ).toBe("match"); + expect( + postgres.cteIdentifierMatchesPrefix( + identifier("us"), + identifier("users"), + ), + ).toBe("no-match"); + expect( + postgres.cteIdentifierMatchesPrefix( + identifier("É", true), + identifier("Éclair", true), + ), + ).toBe("no-match"); + + const duckdb = DUCKDB_SQL_RELATION_DIALECT.completion; + expect( + duckdb.cteIdentifierMatchesPrefix( + identifier("Users"), + identifier(""), + ), + ).toBe("match"); + expect( + duckdb.cteIdentifierMatchesPrefix( + identifier("Users"), + identifier("Use"), + ), + ).toBe("match"); + expect( + duckdb.cteIdentifierMatchesPrefix( + identifier("us"), + identifier("users"), + ), + ).toBe("no-match"); + }); + + it("rejects every malformed comparison component shape", () => { + const runtime = DUCKDB_SQL_RELATION_DIALECT.completion; + for (const malformed of [ + null, + "users", + {}, + { quoted: "false", value: "users" }, + { quoted: false, value: 1 }, + { quoted: false, value: "" }, + { quoted: false, value: "x".repeat(257) }, + { quoted: false, value: "nul\0name" }, + { quoted: false, value: "\udc00" }, + ]) { + expect( + Reflect.apply(runtime.compareCteIdentifiers, undefined, [ + malformed, + identifier("users"), + ]), + ).toBe("unknown"); + } + expect( + Reflect.apply(runtime.cteIdentifierMatchesPrefix, undefined, [ + identifier("users"), + { quoted: false, value: 1 }, + ]), + ).toBe("unknown"); + }); + + it("snapshots comparison getters once", () => { + let reads = 0; + const stateful = { + get quoted() { + return false; + }, + get value() { + reads += 1; + if (reads > 1) { + throw new Error("value was read twice"); + } + return "Users"; + }, + }; + expect( + Reflect.apply( + DUCKDB_SQL_RELATION_DIALECT.completion + .compareCteIdentifiers, + undefined, + [stateful, identifier("users")], + ), + ).toBe("equal"); + expect(reads).toBe(1); + }); +}); + +describe("relation rendering boundaries", () => { + it("escapes every BigQuery control and delimiter class", () => { + const rendered = + BIGQUERY_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { + quoted: true, + role: "relation", + value: "`\\\u0007\b\t\n\v\f\r\u0001\u007f", + }, + ]); + expect(rendered).toEqual({ + status: "rendered", + text: "`\\`\\\\\\a\\b\\t\\n\\v\\f\\r\\x01\\x7f`", + }); + }); + + it("accepts only dialect-legal role sequences", () => { + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { quoted: false, role: "dataset", value: "analytics" }, + { quoted: false, role: "relation", value: "events" }, + ]).status, + ).toBe("rendered"); + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { quoted: false, role: "relation", value: "events-2026" }, + ]), + ).toEqual({ status: "rendered", text: "events-2026" }); + expect( + DREMIO_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { quoted: false, role: "schema", value: "folder" }, + { quoted: false, role: "schema", value: "nested" }, + { quoted: false, role: "relation", value: "events" }, + ]).status, + ).toBe("rendered"); + + for (const [render, path] of [ + [ + POSTGRESQL_SQL_RELATION_DIALECT.completion.renderRelationPath, + [{ quoted: false, role: "schema", value: "public" }], + ], + [ + DUCKDB_SQL_RELATION_DIALECT.completion.renderRelationPath, + [ + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "relation", value: "events" }, + ], + ], + [ + DREMIO_SQL_RELATION_DIALECT.completion.renderRelationPath, + [ + { quoted: false, role: "catalog", value: "source" }, + { quoted: false, role: "project", value: "bad" }, + { quoted: false, role: "relation", value: "events" }, + ], + ], + ] as const) { + expect(Reflect.apply(render, undefined, [path])).toEqual( + UNSUPPORTED_PATH, + ); + } + }); + + it("fails closed for malformed and hostile rendering inputs", () => { + const render = + POSTGRESQL_SQL_RELATION_DIALECT.completion.renderRelationPath; + for (const path of [ + null, + "users", + [null], + ["users"], + [{ quoted: false, role: 1, value: "users" }], + [{ quoted: "false", role: "relation", value: "users" }], + [{ quoted: false, role: "relation", value: 1 }], + [{ quoted: false, role: "relation", value: "" }], + [{ quoted: false, role: "relation", value: "x".repeat(257) }], + [{ quoted: false, role: "relation", value: "nul\0name" }], + [{ quoted: false, role: "relation", value: "\ud800" }], + ]) { + expect(Reflect.apply(render, undefined, [path])).toEqual( + UNSUPPORTED_PATH, + ); + } + + const hostile = new Proxy([], { + get() { + throw new Error("hostile path"); + }, + }); + expect( + Reflect.apply(render, undefined, [hostile]), + ).toEqual(UNSUPPORTED_PATH); + }); + + it("uses bounded indexing and snapshots rendered component getters", () => { + const path: [ + { + readonly quoted: false; + readonly role: "relation"; + readonly value: "users"; + }, + ] = [ + { + quoted: false, + role: "relation", + value: "users", + }, + ]; + Object.defineProperty(path, Symbol.iterator, { + value: function* () { + while (true) { + yield path[0]; + } + }, + }); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(path), + ).toEqual({ status: "rendered", text: "users" }); + + let reads = 0; + const stateful = { + get quoted() { + return false; + }, + get role() { + return "relation"; + }, + get value() { + reads += 1; + if (reads > 1) { + throw new Error("value was read twice"); + } + return "users"; + }, + }; + expect( + Reflect.apply( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath, + undefined, + [[stateful]], + ), + ).toEqual({ status: "rendered", text: "users" }); + expect(reads).toBe(1); + }); +}); diff --git a/src/vnext/__tests__/relation-dialect.test.ts b/src/vnext/__tests__/relation-dialect.test.ts new file mode 100644 index 0000000..8dca8d5 --- /dev/null +++ b/src/vnext/__tests__/relation-dialect.test.ts @@ -0,0 +1,1038 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + type SqlCteLayout, +} from "../cte-layout.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import type { + SqlCanonicalRelationPath, + SqlIdentifierDecodeResult, +} from "../relation-completion-types.js"; +import { + createIdentitySqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, +} from "../statement-index.js"; +import type { SqlIdentifierComponent } from "../types.js"; + +const RUNTIMES = Object.freeze({ + bigquery: BIGQUERY_SQL_RELATION_DIALECT, + dremio: DREMIO_SQL_RELATION_DIALECT, + duckdb: DUCKDB_SQL_RELATION_DIALECT, + postgresql: POSTGRESQL_SQL_RELATION_DIALECT, +}); + +function decoded( + result: SqlIdentifierDecodeResult, +): Extract { + expect(result.status).toBe("decoded"); + if (result.status !== "decoded") { + throw new Error("Expected a decoded identifier"); + } + return result; +} + +function readyPath( + runtime: SqlRelationDialectRuntime, + text: string, + position = text.length, +) { + const result = runtime.querySite.decodeRelationPath(text, position); + expect(result.status).toBe("decoded"); + if (result.status !== "decoded") { + throw new Error("Expected a decoded relation path"); + } + return result; +} + +function component( + value: string, + quoted = false, +): SqlIdentifierComponent { + return { quoted, value }; +} + +function analyze( + runtime: SqlRelationDialectRuntime, + text: string, +): SqlCteLayout { + const source = createIdentitySqlSource(text); + const slot = buildSqlStatementIndex( + source.analysisText, + runtime.querySite.lexicalProfile, + ).slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + if (!slot || slot.boundaryQuality !== "exact") { + throw new Error("Expected one exact SQL statement"); + } + return analyzeSqlCteLayout(source, slot, runtime.cteLayout); +} + +function expectDeepFrozenRuntime( + runtime: SqlRelationDialectRuntime, +): void { + expect(Object.isFrozen(runtime)).toBe(true); + expect(Object.isFrozen(runtime.completion)).toBe(true); + expect(Object.isFrozen(runtime.cteLayout)).toBe(true); + expect(Object.isFrozen(runtime.cteLayout.grammar)).toBe(true); + expect(Object.isFrozen(runtime.querySite)).toBe(true); +} + +describe("built-in relation dialect runtime", () => { + it("owns stable, deeply frozen, coherent views", () => { + for (const runtime of Object.values(RUNTIMES)) { + expectDeepFrozenRuntime(runtime); + expect(runtime.cteLayout.lexicalProfile).toBe( + runtime.querySite.lexicalProfile, + ); + expect(runtime.cteLayout.compareCteIdentifiers).toBe( + runtime.completion.compareCteIdentifiers, + ); + } + expect(POSTGRESQL_SQL_RELATION_DIALECT).toBe( + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + it("records the closed dialect capability matrix", () => { + expect(POSTGRESQL_SQL_RELATION_DIALECT.querySite).toMatchObject({ + maximumPathDepth: 2, + supportsNaturalJoin: true, + }); + expect(POSTGRESQL_SQL_RELATION_DIALECT.cteLayout.grammar).toEqual({ + declaredColumns: true, + materialization: true, + maximumDeclarationsPerFrame: 256, + recursive: true, + }); + expect(DUCKDB_SQL_RELATION_DIALECT.querySite).toMatchObject({ + maximumPathDepth: 3, + supportsNaturalJoin: true, + }); + expect(DUCKDB_SQL_RELATION_DIALECT.cteLayout.grammar).toEqual({ + declaredColumns: true, + materialization: true, + maximumDeclarationsPerFrame: 256, + recursive: true, + }); + expect(BIGQUERY_SQL_RELATION_DIALECT.querySite).toMatchObject({ + maximumPathDepth: 3, + supportsNaturalJoin: false, + }); + expect(BIGQUERY_SQL_RELATION_DIALECT.cteLayout.grammar).toEqual({ + declaredColumns: false, + materialization: false, + maximumDeclarationsPerFrame: 256, + recursive: true, + }); + expect(DREMIO_SQL_RELATION_DIALECT.querySite).toMatchObject({ + maximumPathDepth: 32, + supportsNaturalJoin: false, + }); + expect(DREMIO_SQL_RELATION_DIALECT.cteLayout.grammar).toEqual({ + declaredColumns: true, + materialization: false, + maximumDeclarationsPerFrame: 1, + recursive: false, + }); + }); +}); + +describe("identifier decoding and classification", () => { + it.each([ + ["postgresql", POSTGRESQL_SQL_RELATION_DIALECT], + ["duckdb", DUCKDB_SQL_RELATION_DIALECT], + ["dremio", DREMIO_SQL_RELATION_DIALECT], + ] as const)("decodes standard identifiers for %s", (_name, runtime) => { + expect( + decoded( + runtime.completion.decodeIdentifier("Users", "complete"), + ), + ).toMatchObject({ + component: { quoted: false, value: "Users" }, + quality: "exact", + }); + expect( + decoded( + runtime.completion.decodeIdentifier( + "\"User \"\"Events\"\"\"", + "complete", + ), + ), + ).toMatchObject({ + component: { quoted: true, value: "User \"Events\"" }, + quality: "exact", + }); + expect( + decoded( + runtime.completion.decodeIdentifier( + "\"line\nname\"", + "complete", + ), + ).component, + ).toEqual({ quoted: true, value: "line\nname" }); + expect( + decoded( + runtime.completion.decodeIdentifier( + "\"User", + "completion-prefix", + ), + ), + ).toMatchObject({ + component: { quoted: true, value: "User" }, + quality: "recovered", + }); + expect( + decoded( + runtime.completion.decodeIdentifier( + "\"", + "completion-prefix", + ), + ), + ).toMatchObject({ + component: { quoted: true, value: "" }, + quality: "recovered", + }); + expect( + runtime.completion.decodeIdentifier("\"User", "complete"), + ).toEqual({ + reason: "invalid-identifier", + status: "unavailable", + }); + }); + + it("keeps PostgreSQL dollar identifiers dialect-specific", () => { + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "user$id", + "complete", + ).status, + ).toBe("decoded"); + expect( + DUCKDB_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "user$id", + "complete", + ).status, + ).toBe("unavailable"); + }); + + it("keeps BigQuery bare identifiers ASCII-only", () => { + for (const token of ["Éclair", "😀", "\u00a0name"]) { + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier( + token, + "complete", + ), + ).toEqual({ + reason: "invalid-identifier", + status: "unavailable", + }); + } + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "Éclair", + "complete", + ).status, + ).toBe("decoded"); + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { + quoted: false, + role: "relation", + value: "Éclair", + }, + ]), + ).toEqual({ status: "rendered", text: "`Éclair`" }); + }); + + it("preserves DuckDB's high-bit bare identifier behavior", () => { + for (const token of ["Éclair", "🦆", "\u00a0name", "名字"]) { + expect( + DUCKDB_SQL_RELATION_DIALECT.completion.decodeIdentifier( + token, + "complete", + ).status, + ).toBe("decoded"); + expect( + DUCKDB_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { + quoted: false, + role: "relation", + value: token, + }, + ]), + ).toEqual({ status: "rendered", text: token }); + } + }); + + it("uses Dremio's Unicode letter and number rules for bare names", () => { + for (const token of ["Éclair", "名字", "_é2"]) { + expect( + DREMIO_SQL_RELATION_DIALECT.completion.decodeIdentifier( + token, + "complete", + ).status, + ).toBe("decoded"); + } + for (const token of ["🦆", "\u00a0name"]) { + expect( + DREMIO_SQL_RELATION_DIALECT.completion.decodeIdentifier( + token, + "complete", + ).status, + ).toBe("unavailable"); + expect( + DREMIO_SQL_RELATION_DIALECT.completion.renderRelationPath([ + { + quoted: false, + role: "relation", + value: token, + }, + ]), + ).toEqual({ + status: "rendered", + text: `"${token}"`, + }); + } + }); + + it("decodes the documented BigQuery identifier escapes", () => { + expect( + decoded( + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "`line\\nquote\\`slash\\\\A\\x42\\u0043\\U00000044`", + "complete", + ), + ), + ).toMatchObject({ + component: { + quoted: true, + value: "line\nquote`slash\\ABCD", + }, + quality: "exact", + }); + expect( + decoded( + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "`partial", + "completion-prefix", + ), + ), + ).toMatchObject({ + component: { quoted: true, value: "partial" }, + quality: "recovered", + }); + expect( + readyPath(BIGQUERY_SQL_RELATION_DIALECT, "`"), + ).toMatchObject({ + prefix: { quoted: true, value: "" }, + quality: "recovered", + }); + for (const token of [ + "`bad\\q`", + "`bad\\x4`", + "`bad\\uD800`", + "`bad\\U00110000`", + "`\\000`", + ]) { + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion.decodeIdentifier( + token, + "complete", + ).status, + ).toBe("unavailable"); + } + }); + + it("rejects reserved, malformed, oversized, and ill-formed names", () => { + for (const [name, runtime] of Object.entries(RUNTIMES)) { + for (const token of [ + "", + "select", + "1table", + "has space", + "\ud800", + "a".repeat(257), + ]) { + expect( + runtime.completion.decodeIdentifier(token, "complete") + .status, + `${name}: ${JSON.stringify(token)}`, + ).toBe("unavailable"); + } + expect( + decoded( + runtime.completion.decodeIdentifier( + "", + "completion-prefix", + ), + ).component, + ).toEqual({ quoted: false, value: "" }); + } + }); + + it("uses the same decoder for query and CTE token classification", () => { + for (const runtime of Object.values(RUNTIMES)) { + expect( + runtime.querySite.classifyIdentifierToken( + "select", + false, + "implicit-alias", + ), + ).toEqual({ status: "unsupported" }); + expect( + runtime.cteLayout.classifyIdentifierToken( + "select", + false, + "cte-name", + ), + ).toEqual({ status: "unsupported" }); + expect( + runtime.querySite.classifyIdentifierToken( + runtime === BIGQUERY_SQL_RELATION_DIALECT + ? "`select`" + : "\"select\"", + true, + "using-column", + ), + ).toMatchObject({ + status: "identifier", + value: "select", + }); + } + }); + + it("rejects control words only where they are structurally ambiguous", () => { + const query = + BIGQUERY_SQL_RELATION_DIALECT.querySite + .classifyIdentifierToken; + expect(query("pivot", false, "implicit-alias")).toEqual({ + status: "unsupported", + }); + expect(query("pivot", false, "explicit-alias")).toEqual({ + status: "identifier", + value: "pivot", + }); + expect(query("pivot", false, "using-column")).toEqual({ + status: "identifier", + value: "pivot", + }); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.querySite + .classifyIdentifierToken( + "pivot", + false, + "implicit-alias", + ), + ).toEqual({ + status: "identifier", + value: "pivot", + }); + + const cte = + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout + .classifyIdentifierToken; + expect(cte("materialized", false, "cte-name").status).toBe( + "identifier", + ); + expect(cte("materialized", false, "cte-column").status).toBe( + "identifier", + ); + }); + + it("uses dialect-specific reserved-word tables", () => { + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier( + "abs", + "complete", + ).status, + ).toBe("decoded"); + for (const [runtime, word, renderedText] of [ + [ + POSTGRESQL_SQL_RELATION_DIALECT, + "authorization", + "\"authorization\"", + ], + [DUCKDB_SQL_RELATION_DIALECT, "describe", "\"describe\""], + [BIGQUERY_SQL_RELATION_DIALECT, "graph_table", "`graph_table`"], + [DREMIO_SQL_RELATION_DIALECT, "abs", "\"abs\""], + ] as const) { + expect( + runtime.completion.decodeIdentifier(word, "complete").status, + ).toBe("unavailable"); + expect( + runtime.completion.renderRelationPath([ + { + quoted: false, + role: "relation", + value: word, + }, + ]), + ).toEqual({ + status: "rendered", + text: renderedText, + }); + } + }); + + it("keeps the CTE raw bound coherent with BigQuery escapes", () => { + const escaped = "\\U00000041".repeat(256); + const layout = analyze( + BIGQUERY_SQL_RELATION_DIALECT, + `WITH \`${escaped}\` AS (SELECT 1) SELECT 1`, + ); + expect(layout.status).toBe("ready"); + if (layout.status !== "unavailable") { + expect(layout.declarations[0]?.name.value).toBe("A".repeat(256)); + } + }); +}); + +describe("relation path decoding", () => { + it.each([ + ["postgresql", POSTGRESQL_SQL_RELATION_DIALECT], + ["duckdb", DUCKDB_SQL_RELATION_DIALECT], + ["dremio", DREMIO_SQL_RELATION_DIALECT], + ] as const)("preserves quoted dots for %s", (_name, runtime) => { + expect(readyPath(runtime, "\"my.schema\".us")).toMatchObject({ + finalSegment: { from: 12, to: 14 }, + prefix: { quoted: false, value: "us" }, + qualifier: [{ quoted: true, value: "my.schema" }], + quality: "exact", + }); + }); + + it("decodes a cursor inside the final quoted token", () => { + const text = "public.\"Users\""; + const cursor = text.indexOf("ers"); + expect( + readyPath(POSTGRESQL_SQL_RELATION_DIALECT, text, cursor), + ).toMatchObject({ + finalSegment: { from: 7, to: text.length }, + prefix: { quoted: true, value: "Us" }, + qualifier: [{ quoted: false, value: "public" }], + quality: "exact", + }); + }); + + it("recovers only the final incomplete quoted component", () => { + expect( + readyPath(POSTGRESQL_SQL_RELATION_DIALECT, "public.\"Us"), + ).toMatchObject({ + prefix: { quoted: true, value: "Us" }, + qualifier: [{ quoted: false, value: "public" }], + quality: "recovered", + }); + }); + + it("supports a trailing empty final segment", () => { + expect( + readyPath(POSTGRESQL_SQL_RELATION_DIALECT, "public."), + ).toMatchObject({ + finalSegment: { from: 7, to: 7 }, + prefix: { quoted: false, value: "" }, + qualifier: [{ quoted: false, value: "public" }], + }); + }); + + it("rejects invalid offsets, malformed paths, and excess depth", () => { + const decoder = + POSTGRESQL_SQL_RELATION_DIALECT.querySite.decodeRelationPath; + for (const [text, position] of [ + ["public.users", -1], + ["public.users", 0.5], + ["public.users", Number.NaN], + ["public.users", 13], + ["a..b", 4], + ["a.b.c", 5], + ["\"a\"x.b", 6], + [".users", 6], + ] as const) { + expect(decoder(text, position).status).toBe("unavailable"); + } + }); + + it("supports BigQuery dashed-first and whole-backtick paths", () => { + expect( + readyPath( + BIGQUERY_SQL_RELATION_DIALECT, + "my-project.dataset.ta", + ), + ).toMatchObject({ + prefix: { quoted: false, value: "ta" }, + qualifier: [ + { quoted: false, value: "my-project" }, + { quoted: false, value: "dataset" }, + ], + }); + expect( + readyPath( + BIGQUERY_SQL_RELATION_DIALECT, + "`my-project.dataset.ta`", + "`my-project.dataset.ta".length, + ), + ).toMatchObject({ + finalSegment: { from: 20, to: 23 }, + prefix: { quoted: true, value: "ta" }, + qualifier: [ + { quoted: true, value: "my-project" }, + { quoted: true, value: "dataset" }, + ], + quality: "exact", + }); + expect( + readyPath( + BIGQUERY_SQL_RELATION_DIALECT, + "`project.with.dot`.ta", + ), + ).toMatchObject({ + prefix: { quoted: false, value: "ta" }, + qualifier: [{ quoted: true, value: "project.with.dot" }], + }); + expect( + readyPath( + BIGQUERY_SQL_RELATION_DIALECT, + "abc5.GROUP", + ), + ).toMatchObject({ + prefix: { quoted: false, value: "GROUP" }, + qualifier: [{ quoted: false, value: "abc5" }], + }); + expect( + BIGQUERY_SQL_RELATION_DIALECT.querySite.decodeRelationPath( + "GROUP", + 5, + ).status, + ).toBe("unavailable"); + }); + + it("rejects a BigQuery dash outside the first path component", () => { + for (const text of [ + "project.my-dataset.table", + "project.dataset.my-table", + "table-287a", + "table-", + ]) { + expect( + BIGQUERY_SQL_RELATION_DIALECT.querySite.decodeRelationPath( + text, + text.length, + ).status, + ).toBe("unavailable"); + } + }); + + it("recovers an incomplete BigQuery whole-backtick path", () => { + expect( + readyPath( + BIGQUERY_SQL_RELATION_DIALECT, + "`project.dataset.ta", + ), + ).toMatchObject({ + prefix: { quoted: true, value: "ta" }, + qualifier: [ + { quoted: true, value: "project" }, + { quoted: true, value: "dataset" }, + ], + quality: "recovered", + }); + }); +}); + +describe("CTE equality and prefix policy", () => { + it("implements PostgreSQL quoted and unquoted resolution", () => { + const runtime = POSTGRESQL_SQL_RELATION_DIALECT.completion; + expect( + runtime.compareCteIdentifiers( + component("Users"), + component("users"), + ), + ).toBe("equal"); + expect( + runtime.compareCteIdentifiers( + component("users", true), + component("USERS"), + ), + ).toBe("equal"); + expect( + runtime.compareCteIdentifiers( + component("Users", true), + component("users"), + ), + ).toBe("distinct"); + expect( + runtime.compareCteIdentifiers( + component("É"), + component("é"), + ), + ).toBe("unknown"); + expect( + runtime.cteIdentifierMatchesPrefix( + component("Users"), + component("us"), + ), + ).toBe("match"); + expect( + runtime.cteIdentifierMatchesPrefix( + component("Users", true), + component("us"), + ), + ).toBe("no-match"); + expect( + runtime.cteIdentifierMatchesPrefix( + component("Éclair"), + component("é"), + ), + ).toBe("unknown"); + }); + + it("keeps DuckDB ASCII comparison fully decidable", () => { + const runtime = DUCKDB_SQL_RELATION_DIALECT.completion; + expect( + runtime.compareCteIdentifiers( + component("Users", true), + component("users"), + ), + ).toBe("equal"); + expect( + runtime.compareCteIdentifiers( + component("É"), + component("é"), + ), + ).toBe("distinct"); + expect( + runtime.cteIdentifierMatchesPrefix( + component("Éclair", true), + component("é"), + ), + ).toBe("no-match"); + }); + + it.each([ + ["bigquery", BIGQUERY_SQL_RELATION_DIALECT], + ["dremio", DREMIO_SQL_RELATION_DIALECT], + ] as const)( + "uses conservative non-ASCII comparison for %s", + (_name, dialect) => { + const runtime = dialect.completion; + expect( + runtime.compareCteIdentifiers( + component("Users", true), + component("users"), + ), + ).toBe("equal"); + expect( + runtime.compareCteIdentifiers( + component("É"), + component("É", true), + ), + ).toBe("equal"); + expect( + runtime.compareCteIdentifiers( + component("É"), + component("é"), + ), + ).toBe("unknown"); + expect( + runtime.cteIdentifierMatchesPrefix( + component("Éclair"), + component("é"), + ), + ).toBe("unknown"); + }, + ); + + it("fails closed for malformed comparison inputs", () => { + expect( + Reflect.apply( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .compareCteIdentifiers, + undefined, + [null, component("users")], + ), + ).toBe("unknown"); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .compareCteIdentifiers(component(""), component("")), + ).toBe("unknown"); + expect( + DUCKDB_SQL_RELATION_DIALECT.completion + .cteIdentifierMatchesPrefix( + component("\ud800"), + component("u"), + ), + ).toBe("unknown"); + const hostile = new Proxy(component("users"), { + get() { + throw new Error("hostile"); + }, + }); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .compareCteIdentifiers(hostile, component("users")), + ).toBe("unknown"); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .cteIdentifierMatchesPrefix(hostile, component("u")), + ).toBe("unknown"); + const malformedDecode = Reflect.apply( + POSTGRESQL_SQL_RELATION_DIALECT.completion.decodeIdentifier, + undefined, + [null, "complete"], + ); + expect(malformedDecode).toEqual({ + reason: "invalid-identifier", + status: "unavailable", + }); + }); +}); + +describe("safe role-aware rendering", () => { + it("renders PostgreSQL relation suffixes and rejects catalog roles", () => { + const relation = [ + { quoted: false, role: "relation", value: "users" }, + ] satisfies SqlCanonicalRelationPath; + const schemaRelation = [ + { quoted: false, role: "schema", value: "public" }, + { quoted: true, role: "relation", value: "User Events" }, + ] satisfies SqlCanonicalRelationPath; + const catalogRelation = [ + { quoted: false, role: "catalog", value: "warehouse" }, + { quoted: false, role: "relation", value: "users" }, + ] satisfies SqlCanonicalRelationPath; + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(relation), + ).toEqual({ status: "rendered", text: "users" }); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(schemaRelation), + ).toEqual({ + status: "rendered", + text: "public.\"User Events\"", + }); + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(catalogRelation), + ).toEqual({ + reason: "illegal-role-sequence", + status: "unsupported", + }); + }); + + it("renders every closed DuckDB qualification shape", () => { + const paths = [ + [ + { quoted: false, role: "relation", value: "users" }, + ], + [ + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "relation", value: "users" }, + ], + [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "relation", value: "users" }, + ], + [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "relation", value: "users" }, + ], + ] as const; + expect( + paths.map((path) => + DUCKDB_SQL_RELATION_DIALECT.completion + .renderRelationPath(path).status, + ), + ).toEqual(["rendered", "rendered", "rendered", "rendered"]); + }); + + it("renders BigQuery roles with role-specific dash handling", () => { + const path = [ + { quoted: false, role: "project", value: "my-project" }, + { quoted: false, role: "dataset", value: "analytics" }, + { quoted: true, role: "relation", value: "select`events" }, + ] satisfies SqlCanonicalRelationPath; + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion + .renderRelationPath(path), + ).toEqual({ + status: "rendered", + text: "my-project.analytics.`select\\`events`", + }); + const illegal = [ + { quoted: false, role: "project", value: "my-project" }, + { quoted: false, role: "relation", value: "events" }, + ] satisfies SqlCanonicalRelationPath; + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion + .renderRelationPath(illegal).status, + ).toBe("unsupported"); + }); + + it("renders a bounded Dremio source and folder hierarchy", () => { + const path = [ + { quoted: false, role: "catalog", value: "Samples" }, + { + quoted: true, + role: "schema", + value: "samples.dremio.com", + }, + { quoted: false, role: "schema", value: "folder" }, + { + quoted: true, + role: "relation", + value: "NYC \"trips\"", + }, + ] satisfies SqlCanonicalRelationPath; + expect( + DREMIO_SQL_RELATION_DIALECT.completion + .renderRelationPath(path), + ).toEqual({ + status: "rendered", + text: + "Samples.\"samples.dremio.com\".\"folder\"." + + "\"NYC \"\"trips\"\"\"", + }); + }); + + it("quotes reserved words even when provider spelling is bare", () => { + const path = [ + { quoted: false, role: "relation", value: "select" }, + ] satisfies SqlCanonicalRelationPath; + expect( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(path), + ).toEqual({ status: "rendered", text: "\"select\"" }); + expect( + BIGQUERY_SQL_RELATION_DIALECT.completion + .renderRelationPath(path), + ).toEqual({ status: "rendered", text: "`select`" }); + }); + + it("round-trips rendered paths through the query decoder", () => { + const cases = [ + { + path: [ + { quoted: false, role: "schema", value: "public" }, + { + quoted: true, + role: "relation", + value: "User.Events", + }, + ] satisfies SqlCanonicalRelationPath, + runtime: POSTGRESQL_SQL_RELATION_DIALECT, + }, + { + path: [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "relation", value: "users" }, + ] satisfies SqlCanonicalRelationPath, + runtime: DUCKDB_SQL_RELATION_DIALECT, + }, + { + path: [ + { quoted: false, role: "project", value: "my-project" }, + { quoted: false, role: "dataset", value: "analytics" }, + { + quoted: true, + role: "relation", + value: "event`stream", + }, + ] satisfies SqlCanonicalRelationPath, + runtime: BIGQUERY_SQL_RELATION_DIALECT, + }, + { + path: [ + { quoted: false, role: "catalog", value: "Samples" }, + { + quoted: true, + role: "schema", + value: "samples.dremio.com", + }, + { quoted: false, role: "relation", value: "trips" }, + ] satisfies SqlCanonicalRelationPath, + runtime: DREMIO_SQL_RELATION_DIALECT, + }, + ]; + for (const { path, runtime } of cases) { + const rendered = runtime.completion.renderRelationPath(path); + expect(rendered.status).toBe("rendered"); + if (rendered.status !== "rendered") { + throw new Error("Expected a rendered path"); + } + const decodedPath = readyPath(runtime, rendered.text); + expect([ + ...decodedPath.qualifier.map((item) => item.value), + decodedPath.prefix.value, + ]).toEqual(path.map((item) => item.value)); + } + }); + + it("returns frozen results and fails closed on malformed paths", () => { + const valid = [ + { quoted: false, role: "relation", value: "users" }, + ] satisfies SqlCanonicalRelationPath; + const rendered = + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath(valid); + expect(Object.isFrozen(rendered)).toBe(true); + expect( + Reflect.apply( + POSTGRESQL_SQL_RELATION_DIALECT.completion + .renderRelationPath, + undefined, + [[]], + ), + ).toEqual({ + reason: "illegal-role-sequence", + status: "unsupported", + }); + }); +}); + +describe("recognizer integration", () => { + it.each([ + ["postgresql", POSTGRESQL_SQL_RELATION_DIALECT, "\"Recent\""], + ["duckdb", DUCKDB_SQL_RELATION_DIALECT, "\"Recent\""], + ["bigquery", BIGQUERY_SQL_RELATION_DIALECT, "`Recent`"], + ["dremio", DREMIO_SQL_RELATION_DIALECT, "\"Recent\""], + ] as const)( + "feeds one coherent runtime into CTE analysis for %s", + (_name, runtime, cteName) => { + const text = + `WITH ${cteName} AS (SELECT 1) ` + + `SELECT * FROM ${cteName}`; + const layout = analyze(runtime, text); + expect(layout.status).not.toBe("unavailable"); + if (layout.status === "unavailable") { + throw new Error("Expected CTE layout evidence"); + } + expect(layout.declarations).toHaveLength(1); + expect(layout.declarations[0]?.name.value).toBe("Recent"); + }, + ); + + it("accepts contextual materialized CTE and column names", () => { + const layout = analyze( + POSTGRESQL_SQL_RELATION_DIALECT, + "WITH materialized(materialized) AS (SELECT 1) " + + "SELECT * FROM materialized", + ); + expect(layout.status).not.toBe("unavailable"); + if (layout.status === "unavailable") { + throw new Error("Expected CTE layout evidence"); + } + expect(layout.declarations[0]).toMatchObject({ + name: { value: "materialized" }, + }); + }); +}); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index d984613..ed581dd 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -7,7 +7,16 @@ import { postgresDialect, SqlSessionError, } from "../index.js"; -import { DefaultSqlLanguageService } from "../session.js"; +import { + DefaultSqlLanguageService, + getSqlRelationDialectRuntime, +} from "../session.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; import { BIGQUERY_SQL_LEXICAL_PROFILE, buildSqlStatementIndex, @@ -69,6 +78,28 @@ describe("dialect definitions", () => { expect(dremioDialect()).toBe(dremioDialect()); }); + it("authenticates one coherent relation runtime per built-in handle", () => { + for (const [dialect, relationDialect] of [ + [bigQueryDialect(), BIGQUERY_SQL_RELATION_DIALECT], + [dremioDialect(), DREMIO_SQL_RELATION_DIALECT], + [duckdbDialect(), DUCKDB_SQL_RELATION_DIALECT], + [postgresDialect(), POSTGRESQL_SQL_RELATION_DIALECT], + ] as const) { + expect(getSqlRelationDialectRuntime(dialect)).toBe( + relationDialect, + ); + expect(relationDialect.querySite.lexicalProfile).toBe( + relationDialect.cteLayout.lexicalProfile, + ); + } + expect( + getSqlRelationDialectRuntime({ + displayName: "DuckDB", + id: "duckdb", + }), + ).toBeNull(); + }); + it("rejects duplicate IDs", () => { expectSessionError("duplicate-dialect", () => { createSqlLanguageService({ diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts index cb3b6a1..d5c9bb5 100644 --- a/src/vnext/cte-layout.ts +++ b/src/vnext/cte-layout.ts @@ -15,6 +15,8 @@ export const MAX_CTE_DEPTH = 128; export const MAX_CTE_DECLARATIONS = 256; export const MAX_CTE_FRAMES = 256; export const MAX_CTE_IDENTIFIER_LENGTH = 256; +export const MAX_CTE_QUOTED_IDENTIFIER_LENGTH = + MAX_CTE_IDENTIFIER_LENGTH * 10 + 2; export interface SqlCteRange { readonly [cteRangeBrand]: "SqlCteRange"; @@ -330,7 +332,7 @@ function normalizeIdentifier( const raw = text.slice(token.from, token.to); const maximumRawLength = token.kind === "quoted-identifier" - ? MAX_CTE_IDENTIFIER_LENGTH * 2 + 2 + ? MAX_CTE_QUOTED_IDENTIFIER_LENGTH : MAX_CTE_IDENTIFIER_LENGTH; if (raw.length > maximumRawLength) { return null; diff --git a/src/vnext/relation-dialect.ts b/src/vnext/relation-dialect.ts new file mode 100644 index 0000000..fe01d17 --- /dev/null +++ b/src/vnext/relation-dialect.ts @@ -0,0 +1,1482 @@ +import { + MAX_CTE_DECLARATIONS, + MAX_CTE_QUOTED_IDENTIFIER_LENGTH, + type SqlCteIdentifierResult, + type SqlCteLayoutDialect, +} from "./cte-layout.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, + sqlIdentifierContinueLengthAt, + sqlIdentifierStartLengthAt, + type SqlLexicalProfile, +} from "./lexical.js"; +import { + MAX_QUERY_SITE_IDENTIFIER_LENGTH, + MAX_QUERY_SITE_PATH_COMPONENTS, + type SqlDecodedQueryPath, + type SqlQuerySiteDialect, +} from "./query-site.js"; +import { isSqlRelationReservedWord } from "./relation-reserved-words.js"; +import type { + SqlCteIdentifierComparison, + SqlCteIdentifierPrefixMatch, + SqlIdentifierDecodeResult, + SqlRelationCompletionDialectRuntime, + SqlRenderedRelationPath, +} from "./relation-completion-types.js"; +import type { SqlIdentifierComponent } from "./types.js"; + +export interface SqlRelationDialectRuntime { + readonly completion: SqlRelationCompletionDialectRuntime; + readonly cteLayout: SqlCteLayoutDialect; + readonly querySite: SqlQuerySiteDialect; +} + +type DialectKind = + | "bigquery" + | "dremio" + | "duckdb" + | "postgresql"; + +interface RelationDialectSpec { + readonly cteGrammar: SqlCteLayoutDialect["grammar"]; + readonly kind: DialectKind; + readonly lexicalProfile: SqlLexicalProfile; + readonly maximumPathDepth: number; + readonly supportsNaturalJoin: boolean; +} + +interface RawSegment { + readonly closed: boolean; + readonly from: number; + readonly quoted: boolean; + readonly to: number; +} + +const MAX_IDENTIFIER_LENGTH = MAX_QUERY_SITE_IDENTIFIER_LENGTH; +const MAX_BIGQUERY_QUOTED_IDENTIFIER_RAW_LENGTH = + MAX_CTE_QUOTED_IDENTIFIER_LENGTH; +const MAX_STANDARD_QUOTED_IDENTIFIER_RAW_LENGTH = + MAX_IDENTIFIER_LENGTH * 2 + 2; +const DREMIO_BARE_IDENTIFIER = /^[\p{L}_][\p{L}\p{N}_]*$/u; +const INVALID_IDENTIFIER = Object.freeze({ + reason: "invalid-identifier" as const, + status: "unavailable" as const, +}); +const UNSUPPORTED_IDENTIFIER = Object.freeze({ + status: "unsupported" as const, +}); +const BIGQUERY_IMPLICIT_ALIAS_CONTROL_WORDS = Object.freeze([ + "match_recognize", + "pivot", + "unpivot", +]); + +function asciiFoldCode(code: number): number { + return code >= 65 && code <= 90 ? code + 32 : code; +} + +function identifierCodeEquals( + left: string, + right: string, + foldLeft: boolean, + foldRight: boolean, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftCode = left.charCodeAt(index); + const rightCode = right.charCodeAt(index); + if ( + (foldLeft ? asciiFoldCode(leftCode) : leftCode) !== + (foldRight ? asciiFoldCode(rightCode) : rightCode) + ) { + return false; + } + } + return true; +} + +function identifierPrefixMatches( + candidate: string, + prefix: string, + foldCandidate: boolean, + foldPrefix: boolean, +): boolean { + if (prefix.length > candidate.length) { + return false; + } + for (let index = 0; index < prefix.length; index += 1) { + const candidateCode = candidate.charCodeAt(index); + const prefixCode = prefix.charCodeAt(index); + if ( + (foldCandidate ? asciiFoldCode(candidateCode) : candidateCode) !== + (foldPrefix ? asciiFoldCode(prefixCode) : prefixCode) + ) { + return false; + } + } + return true; +} + +function isAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) > 0x7f) { + return false; + } + } + return true; +} + +function isControlWord( + value: string, + words: readonly string[], +): boolean { + if (!isAscii(value)) { + return false; + } + for (const word of words) { + if (identifierCodeEquals(value, word, true, false)) { + return true; + } + } + return false; +} + +function isWellFormed(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const trailing = value.charCodeAt(index + 1); + if (!(trailing >= 0xdc00 && trailing <= 0xdfff)) { + return false; + } + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function isBareIdentifier( + value: string, + allowDollar: boolean, +): boolean { + if ( + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + !isWellFormed(value) + ) { + return false; + } + let cursor = sqlIdentifierStartLengthAt(value, 0); + if (cursor === 0) { + return false; + } + while (cursor < value.length) { + if (allowDollar && value.charCodeAt(cursor) === 36) { + cursor += 1; + continue; + } + const length = sqlIdentifierContinueLengthAt(value, cursor); + if (length === 0) { + return false; + } + cursor += length; + } + return true; +} + +function isBigQueryDashedIdentifier(value: string): boolean { + if (!isAscii(value)) { + return false; + } + const parts = value.split("-"); + const first = parts[0]; + if (!first || !isBareIdentifier(first, false)) { + return false; + } + for (let index = 1; index < parts.length; index += 1) { + const part = parts[index]; + if ( + !part || + (!/^\d+$/.test(part) && !isBareIdentifier(part, false)) + ) { + return false; + } + } + return parts.length > 1; +} + +function isDremioBareIdentifier(value: string): boolean { + return ( + isBareIdentifier(value, false) && + DREMIO_BARE_IDENTIFIER.test(value) + ); +} + +function freezeComponent( + value: string, + quoted: boolean, +): SqlIdentifierComponent { + return Object.freeze({ quoted, value }); +} + +function decodedIdentifier( + value: string, + quoted: boolean, + quality: "exact" | "recovered", +): SqlIdentifierDecodeResult { + return Object.freeze({ + component: freezeComponent(value, quoted), + quality, + status: "decoded", + }); +} + +function decodeDoubleQuoted( + token: string, + mode: "complete" | "completion-prefix", + kind: Exclude, +): SqlIdentifierDecodeResult { + if (token.length > MAX_STANDARD_QUOTED_IDENTIFIER_RAW_LENGTH) { + return INVALID_IDENTIFIER; + } + if (token.length === 0 && mode === "completion-prefix") { + return decodedIdentifier("", false, "exact"); + } + if (!token.startsWith("\"")) { + if ( + !(kind === "dremio" + ? isDremioBareIdentifier(token) + : isBareIdentifier(token, kind === "postgresql")) || + isSqlRelationReservedWord(kind, token) + ) { + return INVALID_IDENTIFIER; + } + return decodedIdentifier(token, false, "exact"); + } + + const closed = token.length > 1 && token.endsWith("\""); + if (!closed && mode === "complete") { + return INVALID_IDENTIFIER; + } + const contentTo = closed ? token.length - 1 : token.length; + let output = ""; + for (let cursor = 1; cursor < contentTo; cursor += 1) { + const code = token.charCodeAt(cursor); + if (code === 0) { + return INVALID_IDENTIFIER; + } + if (code !== 34) { + output += token[cursor]; + if (output.length > MAX_IDENTIFIER_LENGTH) { + return INVALID_IDENTIFIER; + } + continue; + } + if ( + cursor + 1 >= contentTo || + token.charCodeAt(cursor + 1) !== 34 + ) { + return INVALID_IDENTIFIER; + } + output += "\""; + if (output.length > MAX_IDENTIFIER_LENGTH) { + return INVALID_IDENTIFIER; + } + cursor += 1; + } + if ( + (output.length === 0 && + !(mode === "completion-prefix" && !closed)) || + output.length > MAX_IDENTIFIER_LENGTH || + !isWellFormed(output) + ) { + return INVALID_IDENTIFIER; + } + return decodedIdentifier( + output, + true, + closed ? "exact" : "recovered", + ); +} + +function digitValue(code: number, radix: number): number { + if (code >= 48 && code <= 57) { + const value = code - 48; + return value < radix ? value : -1; + } + if (radix === 16 && code >= 65 && code <= 70) { + return code - 55; + } + if (radix === 16 && code >= 97 && code <= 102) { + return code - 87; + } + return -1; +} + +function decodeDigits( + text: string, + from: number, + length: number, + radix: number, +): number | null { + if (from + length > text.length) { + return null; + } + let value = 0; + for (let index = 0; index < length; index += 1) { + const digit = digitValue(text.charCodeAt(from + index), radix); + if (digit < 0) { + return null; + } + value = value * radix + digit; + } + return value; +} + +const BIGQUERY_SIMPLE_ESCAPES: Readonly> = + Object.freeze({ + "\"": "\"", + "'": "'", + "?": "?", + "\\": "\\", + "`": "`", + a: "\u0007", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", + }); + +function decodeBigQueryContent(content: string): string | null { + let output = ""; + for (let cursor = 0; cursor < content.length; cursor += 1) { + const code = content.charCodeAt(cursor); + if (code === 0 || code === 10 || code === 13) { + return null; + } + if (code !== 92) { + output += content[cursor]; + if (output.length > MAX_IDENTIFIER_LENGTH) { + return null; + } + continue; + } + const escape = content[cursor + 1]; + if (escape === undefined) { + return null; + } + const simple = BIGQUERY_SIMPLE_ESCAPES[escape]; + if (simple !== undefined) { + output += simple; + if (output.length > MAX_IDENTIFIER_LENGTH) { + return null; + } + cursor += 1; + continue; + } + let digitsFrom = cursor + 1; + let digitsLength = 0; + let radix = 16; + if (escape >= "0" && escape <= "7") { + digitsLength = 3; + radix = 8; + } else if (escape === "x" || escape === "X") { + digitsFrom += 1; + digitsLength = 2; + } else if (escape === "u") { + digitsFrom += 1; + digitsLength = 4; + } else if (escape === "U") { + digitsFrom += 1; + digitsLength = 8; + } else { + return null; + } + const value = decodeDigits( + content, + digitsFrom, + digitsLength, + radix, + ); + if ( + value === null || + value === 0 || + value > 0x10ffff || + (value >= 0xd800 && value <= 0xdfff) + ) { + return null; + } + output += String.fromCodePoint(value); + if (output.length > MAX_IDENTIFIER_LENGTH) { + return null; + } + cursor = digitsFrom + digitsLength - 1; + } + return output.length <= MAX_IDENTIFIER_LENGTH && + isWellFormed(output) + ? output + : null; +} + +function decodeBigQueryIdentifier( + token: string, + mode: "complete" | "completion-prefix", +): SqlIdentifierDecodeResult { + if (token.length > MAX_BIGQUERY_QUOTED_IDENTIFIER_RAW_LENGTH) { + return INVALID_IDENTIFIER; + } + if (token.length === 0 && mode === "completion-prefix") { + return decodedIdentifier("", false, "exact"); + } + if (!token.startsWith("`")) { + if ( + !isAscii(token) || + !isBareIdentifier(token, false) || + isSqlRelationReservedWord("bigquery", token) + ) { + return INVALID_IDENTIFIER; + } + return decodedIdentifier(token, false, "exact"); + } + const closed = + token.length > 1 && + token.endsWith("`") && + !isEscapedAt(token, token.length - 1); + if (!closed && mode === "complete") { + return INVALID_IDENTIFIER; + } + const value = decodeBigQueryContent( + token.slice(1, closed ? -1 : undefined), + ); + if ( + value === null || + (value.length === 0 && + !(mode === "completion-prefix" && !closed)) + ) { + return INVALID_IDENTIFIER; + } + return decodedIdentifier( + value, + true, + closed ? "exact" : "recovered", + ); +} + +function isEscapedAt(text: string, index: number): boolean { + let slashes = 0; + for ( + let cursor = index - 1; + cursor >= 0 && text.charCodeAt(cursor) === 92; + cursor -= 1 + ) { + slashes += 1; + } + return slashes % 2 === 1; +} + +function splitQuotedPath( + rawPath: string, + quote: "\"" | "`", +): readonly RawSegment[] | null { + const output: RawSegment[] = []; + let segmentFrom = 0; + let cursor = 0; + while (cursor <= rawPath.length) { + if (cursor === rawPath.length) { + output.push({ + closed: true, + from: segmentFrom, + quoted: false, + to: cursor, + }); + break; + } + if (rawPath[cursor] === quote && cursor === segmentFrom) { + const quotedFrom = cursor; + cursor += 1; + let closed = false; + while (cursor < rawPath.length) { + if (rawPath[cursor] !== quote) { + if (quote === "`" && rawPath[cursor] === "\\") { + cursor += Math.min(2, rawPath.length - cursor); + } else { + cursor += 1; + } + continue; + } + if ( + quote === "\"" && + rawPath[cursor + 1] === "\"" + ) { + cursor += 2; + continue; + } + closed = true; + cursor += 1; + break; + } + if (cursor < rawPath.length && rawPath[cursor] !== ".") { + return null; + } + output.push({ + closed, + from: quotedFrom, + quoted: true, + to: cursor, + }); + if (cursor === rawPath.length) { + break; + } + segmentFrom = cursor + 1; + cursor = segmentFrom; + continue; + } + if (rawPath[cursor] === quote) { + return null; + } + if (rawPath[cursor] === ".") { + output.push({ + closed: true, + from: segmentFrom, + quoted: false, + to: cursor, + }); + segmentFrom = cursor + 1; + } + cursor += 1; + } + return output; +} + +function decodePathSegment( + rawPath: string, + segment: RawSegment, + decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], +): SqlIdentifierComponent | null { + if (!segment.closed || segment.from === segment.to) { + return null; + } + const result = decodeIdentifier( + rawPath.slice(segment.from, segment.to), + "complete", + ); + return result.status === "decoded" ? result.component : null; +} + +function freezeDecodedPath( + qualifier: SqlIdentifierComponent[], + prefix: SqlIdentifierComponent, + finalFrom: number, + finalTo: number, + quality: "exact" | "recovered", +): SqlDecodedQueryPath { + return Object.freeze({ + finalSegment: Object.freeze({ from: finalFrom, to: finalTo }), + prefix, + qualifier: Object.freeze(qualifier), + quality, + status: "decoded", + }); +} + +function decodeSegmentedPath( + rawPath: string, + cursorOffset: number, + maximumPathDepth: number, + quote: "\"" | "`", + decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], + kind: DialectKind, +): SqlDecodedQueryPath { + if ( + typeof rawPath !== "string" || + !Number.isSafeInteger(cursorOffset) || + cursorOffset < 0 || + cursorOffset > rawPath.length + ) { + return INVALID_IDENTIFIER; + } + const segments = splitQuotedPath(rawPath, quote); + if ( + !segments || + segments.length === 0 || + segments.length > maximumPathDepth + ) { + return INVALID_IDENTIFIER; + } + const final = segments[segments.length - 1]; + if ( + !final || + cursorOffset < final.from || + cursorOffset > final.to + ) { + return INVALID_IDENTIFIER; + } + const qualifier: SqlIdentifierComponent[] = []; + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index]; + if (!segment) { + return INVALID_IDENTIFIER; + } + const component = + kind === "bigquery" && !segment.quoted + ? decodeBigQueryBarePathSegment( + rawPath.slice(segment.from, segment.to), + index, + ) + : decodePathSegment(rawPath, segment, decodeIdentifier); + if (!component) { + return INVALID_IDENTIFIER; + } + qualifier.push(component); + } + + let prefixToken = rawPath.slice(final.from, cursorOffset); + if (final.quoted) { + if (prefixToken.length === 0) { + return freezeDecodedPath( + qualifier, + freezeComponent("", true), + final.from, + final.to, + "exact", + ); + } + if (final.closed && !prefixToken.endsWith(quote)) { + prefixToken += quote; + } + } + let prefix: SqlIdentifierComponent | null = null; + let quality: "exact" | "recovered" = + final.quoted && !final.closed ? "recovered" : "exact"; + if ( + kind === "bigquery" && + !final.quoted && + prefixToken.length > 0 + ) { + prefix = decodeBigQueryBarePathSegment( + prefixToken, + segments.length - 1, + ); + } else { + const decoded = decodeIdentifier( + prefixToken, + "completion-prefix", + ); + if (decoded.status === "decoded") { + prefix = decoded.component; + if (decoded.quality === "recovered") { + quality = "recovered"; + } + } + } + return prefix + ? freezeDecodedPath( + qualifier, + prefix, + final.from, + final.to, + quality, + ) + : INVALID_IDENTIFIER; +} + +function decodeBigQueryBarePathSegment( + value: string, + index: number, +): SqlIdentifierComponent | null { + if ( + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + !isAscii(value) || + !isWellFormed(value) + ) { + return null; + } + if (value.includes("-")) { + return index === 0 && isBigQueryDashedIdentifier(value) + ? freezeComponent(value, false) + : null; + } + if ( + !isBareIdentifier(value, false) || + (index === 0 && + isSqlRelationReservedWord("bigquery", value)) + ) { + return null; + } + return freezeComponent(value, false); +} + +function splitBigQueryWholePath( + content: string, +): readonly { readonly from: number; readonly to: number }[] | null { + const output: { from: number; to: number }[] = []; + let from = 0; + for (let cursor = 0; cursor < content.length; cursor += 1) { + if (content[cursor] === "\\") { + const escape = content[cursor + 1]; + if (escape === undefined) { + return null; + } + if (escape >= "0" && escape <= "7") { + cursor += 3; + } else if (escape === "x" || escape === "X") { + cursor += 3; + } else if (escape === "u") { + cursor += 5; + } else if (escape === "U") { + cursor += 9; + } else { + cursor += 1; + } + if (cursor >= content.length) { + return null; + } + continue; + } + if (content[cursor] === ".") { + if (cursor === from) { + return null; + } + output.push({ from, to: cursor }); + from = cursor + 1; + } + } + if (from === content.length) { + return null; + } + output.push({ from, to: content.length }); + return output; +} + +function decodeBigQueryWholePath( + rawPath: string, + cursorOffset: number, +): SqlDecodedQueryPath | null { + if (!rawPath.startsWith("`")) { + return null; + } + let closingAt = -1; + for (let cursor = 1; cursor < rawPath.length; cursor += 1) { + if (rawPath[cursor] === "\\") { + cursor += 1; + } else if (rawPath[cursor] === "`") { + closingAt = cursor; + break; + } + } + if (closingAt >= 0 && closingAt !== rawPath.length - 1) { + return null; + } + const closed = closingAt === rawPath.length - 1; + const contentTo = closed ? rawPath.length - 1 : rawPath.length; + const content = rawPath.slice(1, contentTo); + if (content.length === 0 && !closed) { + return freezeDecodedPath( + [], + freezeComponent("", true), + 1, + rawPath.length, + "recovered", + ); + } + const parts = splitBigQueryWholePath(content); + if ( + !parts || + parts.length > 3 || + !Number.isSafeInteger(cursorOffset) || + cursorOffset < 1 || + cursorOffset > rawPath.length + ) { + return INVALID_IDENTIFIER; + } + const final = parts[parts.length - 1]; + if (!final) { + return INVALID_IDENTIFIER; + } + const finalFrom = final.from + 1; + const finalContentTo = final.to + 1; + const maximumCursor = closed ? rawPath.length - 1 : rawPath.length; + const contentCursor = Math.min(cursorOffset, maximumCursor); + if (contentCursor < finalFrom || contentCursor > finalContentTo) { + return INVALID_IDENTIFIER; + } + const qualifier: SqlIdentifierComponent[] = []; + for (let index = 0; index < parts.length - 1; index += 1) { + const part = parts[index]; + if (!part) { + return INVALID_IDENTIFIER; + } + const value = decodeBigQueryContent( + content.slice(part.from, part.to), + ); + if (!value) { + return INVALID_IDENTIFIER; + } + qualifier.push(freezeComponent(value, true)); + } + const prefixValue = decodeBigQueryContent( + rawPath.slice(finalFrom, contentCursor), + ); + if (prefixValue === null) { + return INVALID_IDENTIFIER; + } + return freezeDecodedPath( + qualifier, + freezeComponent(prefixValue, true), + finalFrom, + rawPath.length, + closed ? "exact" : "recovered", + ); +} + +function createPathDecoder( + spec: RelationDialectSpec, + decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], +): SqlQuerySiteDialect["decodeRelationPath"] { + const maximumRawIdentifierLength = + spec.kind === "bigquery" + ? MAX_BIGQUERY_QUOTED_IDENTIFIER_RAW_LENGTH + : MAX_STANDARD_QUOTED_IDENTIFIER_RAW_LENGTH; + const maximumRawPathLength = + spec.maximumPathDepth * (maximumRawIdentifierLength + 1); + return (rawPath, cursorOffset) => { + try { + if ( + typeof rawPath !== "string" || + rawPath.length > maximumRawPathLength || + !Number.isSafeInteger(cursorOffset) + ) { + return INVALID_IDENTIFIER; + } + if (spec.kind === "bigquery") { + const whole = decodeBigQueryWholePath(rawPath, cursorOffset); + if (whole) { + return whole; + } + } + return decodeSegmentedPath( + rawPath, + cursorOffset, + spec.maximumPathDepth, + spec.kind === "bigquery" ? "`" : "\"", + decodeIdentifier, + spec.kind, + ); + } catch { + return INVALID_IDENTIFIER; + } + }; +} + +function createQueryClassifier( + kind: DialectKind, + decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], +): SqlQuerySiteDialect["classifyIdentifierToken"] { + return (rawIdentifier, quoted, role) => { + if ( + typeof rawIdentifier !== "string" || + typeof quoted !== "boolean" || + (role !== "explicit-alias" && + role !== "implicit-alias" && + role !== "using-column") || + (kind === "bigquery" && + role === "implicit-alias" && + !quoted && + isControlWord( + rawIdentifier, + BIGQUERY_IMPLICIT_ALIAS_CONTROL_WORDS, + )) + ) { + return UNSUPPORTED_IDENTIFIER; + } + const result = decodeIdentifier(rawIdentifier, "complete"); + if ( + result.status !== "decoded" || + result.component.quoted !== quoted || + result.component.value.length === 0 + ) { + return UNSUPPORTED_IDENTIFIER; + } + return Object.freeze({ + status: "identifier" as const, + value: result.component.value, + }); + }; +} + +function createCteClassifier( + decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], +): SqlCteLayoutDialect["classifyIdentifierToken"] { + return (rawIdentifier, quoted, role): SqlCteIdentifierResult => { + if ( + typeof rawIdentifier !== "string" || + typeof quoted !== "boolean" || + (role !== "cte-column" && role !== "cte-name") + ) { + return UNSUPPORTED_IDENTIFIER; + } + const result = decodeIdentifier(rawIdentifier, "complete"); + if ( + result.status !== "decoded" || + result.component.quoted !== quoted || + result.component.value.length === 0 + ) { + return UNSUPPORTED_IDENTIFIER; + } + return Object.freeze({ + status: "identifier" as const, + value: Object.freeze({ component: result.component }), + }); + }; +} + +function postgresComparison( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): SqlCteIdentifierComparison { + if ( + left === null || + typeof left !== "object" || + right === null || + typeof right !== "object" + ) { + return "unknown"; + } + const leftQuoted = left.quoted; + const leftValue = left.value; + const rightQuoted = right.quoted; + const rightValue = right.value; + if ( + !validComparisonValue(leftValue, leftQuoted) || + !validComparisonValue(rightValue, rightQuoted) + ) { + return "unknown"; + } + if (leftQuoted === rightQuoted && leftValue === rightValue) { + return "equal"; + } + if (isAscii(leftValue) && isAscii(rightValue)) { + return identifierCodeEquals( + leftValue, + rightValue, + !leftQuoted, + !rightQuoted, + ) + ? "equal" + : "distinct"; + } + return "unknown"; +} + +function asciiInsensitiveComparison( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, + fullyDecidable: boolean, +): SqlCteIdentifierComparison { + if ( + left === null || + typeof left !== "object" || + right === null || + typeof right !== "object" + ) { + return "unknown"; + } + const leftQuoted = left.quoted; + const leftValue = left.value; + const rightQuoted = right.quoted; + const rightValue = right.value; + if ( + !validComparisonValue(leftValue, leftQuoted) || + !validComparisonValue(rightValue, rightQuoted) + ) { + return "unknown"; + } + if (leftValue === rightValue) { + return "equal"; + } + if ( + fullyDecidable || + (isAscii(leftValue) && isAscii(rightValue)) + ) { + return identifierCodeEquals( + leftValue, + rightValue, + true, + true, + ) + ? "equal" + : "distinct"; + } + return "unknown"; +} + +function postgresPrefixMatch( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, +): SqlCteIdentifierPrefixMatch { + if ( + candidate === null || + typeof candidate !== "object" || + prefix === null || + typeof prefix !== "object" + ) { + return "unknown"; + } + const candidateQuoted = candidate.quoted; + const candidateValue = candidate.value; + const prefixQuoted = prefix.quoted; + const prefixValue = prefix.value; + if ( + !validComparisonValue(candidateValue, candidateQuoted) || + !validComparisonValue(prefixValue, prefixQuoted, true) + ) { + return "unknown"; + } + if (prefixValue.length === 0) { + return "match"; + } + if ( + candidateQuoted === prefixQuoted && + candidateValue.startsWith(prefixValue) + ) { + return "match"; + } + if (isAscii(candidateValue) && isAscii(prefixValue)) { + return identifierPrefixMatches( + candidateValue, + prefixValue, + !candidateQuoted, + !prefixQuoted, + ) + ? "match" + : "no-match"; + } + return candidateQuoted === prefixQuoted && + candidateValue.length < prefixValue.length + ? "no-match" + : "unknown"; +} + +function asciiInsensitivePrefixMatch( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, + fullyDecidable: boolean, +): SqlCteIdentifierPrefixMatch { + if ( + candidate === null || + typeof candidate !== "object" || + prefix === null || + typeof prefix !== "object" + ) { + return "unknown"; + } + const candidateQuoted = candidate.quoted; + const candidateValue = candidate.value; + const prefixQuoted = prefix.quoted; + const prefixValue = prefix.value; + if ( + !validComparisonValue(candidateValue, candidateQuoted) || + !validComparisonValue(prefixValue, prefixQuoted, true) + ) { + return "unknown"; + } + if (prefixValue.length === 0) { + return "match"; + } + if (candidateValue.startsWith(prefixValue)) { + return "match"; + } + if ( + fullyDecidable || + (isAscii(candidateValue) && isAscii(prefixValue)) + ) { + return identifierPrefixMatches( + candidateValue, + prefixValue, + true, + true, + ) + ? "match" + : "no-match"; + } + return "unknown"; +} + +function validComparisonValue( + value: unknown, + quoted: unknown, + allowEmpty = false, +): value is string { + return ( + typeof quoted === "boolean" && + typeof value === "string" && + (allowEmpty || value.length > 0) && + value.length <= MAX_IDENTIFIER_LENGTH && + !value.includes("\0") && + isWellFormed(value) + ); +} + +function quoteDouble(value: string): string { + return `"${value.replaceAll("\"", "\"\"")}"`; +} + +function quoteBigQuery(value: string): string { + let output = "`"; + for (let cursor = 0; cursor < value.length; cursor += 1) { + const code = value.charCodeAt(cursor); + if (code === 96) { + output += "\\`"; + } else if (code === 92) { + output += "\\\\"; + } else if (code === 7) { + output += "\\a"; + } else if (code === 8) { + output += "\\b"; + } else if (code === 9) { + output += "\\t"; + } else if (code === 10) { + output += "\\n"; + } else if (code === 11) { + output += "\\v"; + } else if (code === 12) { + output += "\\f"; + } else if (code === 13) { + output += "\\r"; + } else if (code < 32 || code === 127) { + output += `\\x${code.toString(16).padStart(2, "0")}`; + } else { + output += value[cursor]; + } + } + return `${output}\``; +} + +function legalRoleSequence( + kind: DialectKind, + roles: readonly string[], +): boolean { + if (roles.length === 0 || roles.at(-1) !== "relation") { + return false; + } + const containers = roles.slice(0, -1); + if (kind === "postgresql") { + return ( + containers.length === 0 || + (containers.length === 1 && containers[0] === "schema") + ); + } + if (kind === "duckdb") { + return ( + containers.length === 0 || + (containers.length === 1 && + (containers[0] === "catalog" || + containers[0] === "schema")) || + (containers.length === 2 && + containers[0] === "catalog" && + containers[1] === "schema") + ); + } + if (kind === "bigquery") { + return ( + containers.length === 0 || + (containers.length === 1 && containers[0] === "dataset") || + (containers.length === 2 && + containers[0] === "project" && + containers[1] === "dataset") + ); + } + let cursor = 0; + if (containers[0] === "catalog") { + cursor = 1; + } + for (; cursor < containers.length; cursor += 1) { + if (containers[cursor] !== "schema") { + return false; + } + } + return containers.length < MAX_QUERY_SITE_PATH_COMPONENTS; +} + +function canRenderBare( + spec: RelationDialectSpec, + value: string, + role: string, + pathLength: number, +): boolean { + if (isSqlRelationReservedWord(spec.kind, value)) { + return false; + } + if (spec.kind !== "bigquery") { + return spec.kind === "dremio" + ? isDremioBareIdentifier(value) + : isBareIdentifier(value, spec.kind === "postgresql"); + } + if ( + (role === "project" || + (role === "relation" && pathLength === 1)) && + isBigQueryDashedIdentifier(value) + ) { + return true; + } + return isAscii(value) && isBareIdentifier(value, false); +} + +function createRenderer( + spec: RelationDialectSpec, +): SqlRelationCompletionDialectRuntime["renderRelationPath"] { + return (path): SqlRenderedRelationPath => { + try { + if (!Array.isArray(path)) { + return unsupportedPath(); + } + const pathLength = path.length; + if (pathLength === 0 || pathLength > spec.maximumPathDepth) { + return unsupportedPath(); + } + const roles: string[] = []; + const components: { + readonly quoted: boolean; + readonly role: string; + readonly value: string; + }[] = []; + for (let index = 0; index < pathLength; index += 1) { + const component = path[index]; + if ( + component === null || + typeof component !== "object" + ) { + return unsupportedPath(); + } + const role = component.role; + const quoted = component.quoted; + const value = component.value; + if ( + typeof role !== "string" || + typeof quoted !== "boolean" || + typeof value !== "string" || + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value.includes("\0") || + !isWellFormed(value) + ) { + return unsupportedPath(); + } + roles.push(role); + components.push({ quoted, role, value }); + } + if (!legalRoleSequence(spec.kind, roles)) { + return unsupportedPath(); + } + const rendered = components.map((component) => { + if ( + !component.quoted && + canRenderBare( + spec, + component.value, + component.role, + pathLength, + ) + ) { + return component.value; + } + return spec.kind === "bigquery" + ? quoteBigQuery(component.value) + : quoteDouble(component.value); + }); + return Object.freeze({ + status: "rendered" as const, + text: rendered.join("."), + }); + } catch { + return unsupportedPath(); + } + }; +} + +function unsupportedPath(): SqlRenderedRelationPath { + return Object.freeze({ + reason: "illegal-role-sequence" as const, + status: "unsupported" as const, + }); +} + +function createRuntime(spec: RelationDialectSpec): SqlRelationDialectRuntime { + let decodeIdentifierImplementation: SqlRelationCompletionDialectRuntime["decodeIdentifier"]; + switch (spec.kind) { + case "bigquery": + decodeIdentifierImplementation = decodeBigQueryIdentifier; + break; + case "dremio": + decodeIdentifierImplementation = (token, mode) => + decodeDoubleQuoted(token, mode, "dremio"); + break; + case "duckdb": + decodeIdentifierImplementation = (token, mode) => + decodeDoubleQuoted(token, mode, "duckdb"); + break; + case "postgresql": + decodeIdentifierImplementation = (token, mode) => + decodeDoubleQuoted(token, mode, "postgresql"); + break; + } + const decodeIdentifier: + SqlRelationCompletionDialectRuntime["decodeIdentifier"] = ( + token, + mode, + ) => { + try { + return typeof token === "string" && + (mode === "complete" || mode === "completion-prefix") + ? decodeIdentifierImplementation(token, mode) + : INVALID_IDENTIFIER; + } catch { + return INVALID_IDENTIFIER; + } + }; + const compareCteIdentifiersImplementation = + spec.kind === "postgresql" + ? postgresComparison + : (left: SqlIdentifierComponent, right: SqlIdentifierComponent) => + asciiInsensitiveComparison( + left, + right, + spec.kind === "duckdb", + ); + const compareCteIdentifiers: + SqlRelationCompletionDialectRuntime["compareCteIdentifiers"] = ( + left, + right, + ) => { + try { + return compareCteIdentifiersImplementation(left, right); + } catch { + return "unknown"; + } + }; + const cteIdentifierMatchesPrefixImplementation = + spec.kind === "postgresql" + ? postgresPrefixMatch + : ( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, + ) => + asciiInsensitivePrefixMatch( + candidate, + prefix, + spec.kind === "duckdb", + ); + const cteIdentifierMatchesPrefix: + SqlRelationCompletionDialectRuntime["cteIdentifierMatchesPrefix"] = ( + candidate, + prefix, + ) => { + try { + return cteIdentifierMatchesPrefixImplementation( + candidate, + prefix, + ); + } catch { + return "unknown"; + } + }; + const completion: SqlRelationCompletionDialectRuntime = Object.freeze({ + compareCteIdentifiers, + cteIdentifierMatchesPrefix, + decodeIdentifier, + renderRelationPath: createRenderer(spec), + }); + const cteLayout: SqlCteLayoutDialect = Object.freeze({ + classifyIdentifierToken: createCteClassifier(decodeIdentifier), + compareCteIdentifiers, + grammar: spec.cteGrammar, + lexicalProfile: spec.lexicalProfile, + }); + const querySite: SqlQuerySiteDialect = Object.freeze({ + classifyIdentifierToken: createQueryClassifier( + spec.kind, + decodeIdentifier, + ), + decodeRelationPath: createPathDecoder(spec, decodeIdentifier), + lexicalProfile: spec.lexicalProfile, + maximumPathDepth: spec.maximumPathDepth, + supportsNaturalJoin: spec.supportsNaturalJoin, + }); + return Object.freeze({ completion, cteLayout, querySite }); +} + +function createGrammar( + declaredColumns: boolean, + materialization: boolean, + maximumDeclarationsPerFrame: number, + recursive: boolean, +): SqlCteLayoutDialect["grammar"] { + return Object.freeze({ + declaredColumns, + materialization, + maximumDeclarationsPerFrame, + recursive, + }); +} + +export const POSTGRESQL_SQL_RELATION_DIALECT = + createRuntime({ + cteGrammar: createGrammar( + true, + true, + MAX_CTE_DECLARATIONS, + true, + ), + kind: "postgresql", + lexicalProfile: POSTGRESQL_SQL_LEXICAL_PROFILE, + maximumPathDepth: 2, + supportsNaturalJoin: true, + }); + +export const DUCKDB_SQL_RELATION_DIALECT = + createRuntime({ + cteGrammar: createGrammar( + true, + true, + MAX_CTE_DECLARATIONS, + true, + ), + kind: "duckdb", + lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, + maximumPathDepth: 3, + supportsNaturalJoin: true, + }); + +export const BIGQUERY_SQL_RELATION_DIALECT = + createRuntime({ + cteGrammar: createGrammar( + false, + false, + MAX_CTE_DECLARATIONS, + true, + ), + kind: "bigquery", + lexicalProfile: BIGQUERY_SQL_LEXICAL_PROFILE, + maximumPathDepth: 3, + supportsNaturalJoin: false, + }); + +export const DREMIO_SQL_RELATION_DIALECT = + createRuntime({ + cteGrammar: createGrammar(true, false, 1, false), + kind: "dremio", + lexicalProfile: DREMIO_SQL_LEXICAL_PROFILE, + maximumPathDepth: MAX_QUERY_SITE_PATH_COMPONENTS, + supportsNaturalJoin: false, + }); diff --git a/src/vnext/relation-reserved-words.ts b/src/vnext/relation-reserved-words.ts new file mode 100644 index 0000000..9f820a7 --- /dev/null +++ b/src/vnext/relation-reserved-words.ts @@ -0,0 +1,99 @@ +type SqlRelationReservedWordKind = + | "bigquery" + | "dremio" + | "duckdb" + | "postgresql"; + +const MAX_RESERVED_WORD_LENGTH = 64; + +// Source: https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#reserved_keywords +const BIGQUERY_RESERVED_WORDS: ReadonlySet = new Set( + "all and any array as asc assert_rows_modified at between by case cast collate contains create cross cube current default define desc distinct else end enum escape except exclude exists extract false fetch following for from full graph_table group grouping groups hash having if ignore in inner intersect interval into is join lateral left like limit lookup merge natural new no not null nulls of on or order outer over partition preceding proto qualify range recursive respect right rollup rows select set some struct tablesample then to treat true unbounded union unnest using when where window with within".split( + " ", + ), +); + +// Source: https://docs.dremio.com/current/reference/sql/reserved-keywords/ +// Kept local so this SSR-safe module does not import the CodeMirror dialect. +const DREMIO_RESERVED_WORDS: ReadonlySet = new Set( + "abs access acos aes_decrypt aggregate all allocate allow alter analyze and any approx_count_distinct approx_percentile are array array_avg array_cat array_compact array_contains array_generate_range array_max array_max_cardinality array_min array_position array_remove array_remove_at array_size array_sum array_to_string arrow as ascii asensitive asin assign asymmetric at atan atan2 atomic authorization auto avg avoid base64 batch begin begin_frame begin_partition between bigint bin bin_pack binary binary_string bit bit_and bit_length bit_or bitwise_and bitwise_not bitwise_or bitwise_xor blob bool_and bool_or boolean both branch bround btrim by cache call called cardinality cascaded case cast catalog cbrt ceil ceiling change char char_length character character_length check chr classifier clob close cloud coalesce col_like collate collect column columns commit commits_older_than compute concat concat_ws condition connect constraint contains convert convert_from convert_replaceutf8 convert_timezone convert_to copy corr corresponding cos cosh cot count covar_pop covar_samp crc32 create cross cube cume_dist current current_catalog current_date current_date_utc current_default_transform_group current_path current_role current_row current_schema current_time current_timestamp current_transform_group_for_type current_user cursor cycle data databases datasets date date_add date_diff date_format date_part date_sub date_trunc datediff datetype day dayofmonth dayofweek dayofyear deallocate dec decimal declare dedupe_lookback_period default define degrees delete dense_rank deref describe deterministic dimensions disallow disconnect display distinct double drop dynamic e each element else empty empty_as_null encode end end-exec end_frame end_partition ends_with engine equals escape escape_char every except exec execute exists exp expire explain extend external extract factorial false fetch field field_delimiter file_format files filter first_value flatten float floor folder for foreign frame_row free from from_hex full function fusion geo_beyond geo_distance geo_nearby get global grant grants greatest group grouping groups hash hash64 having hex history hold hour identity if ilike imindir import in include indicator initcap initial inner inout insensitive insert instr int integer intersect intersection interval into is is_bigint is_int is_member is_substr is_utf8 is_varchar isdate isnumeric job join json_array json_arrayagg json_exists json_object json_objectagg json_query json_value lag language large last_day last_query_id last_value lateral lazy lcase lead leading least left length levenshtein like like_regex limit listagg ln local localsort localtime localtimestamp locate log log10 logs lower lpad lshift ltrim manifests map_keys map_values mask mask_first_n mask_hash mask_last_n mask_show_first_n mask_show_last_n masking match match_number match_recognize matches max max_file_size_mb maxdir md5 measures median member merge metadata method min min_file_size_mb min_input_files mindir minus minute missing mod modifies module monitor month months_between more multiset national natural nchar nclob ndv new next next_day no none normalize normalize_string not notification_provider notification_queue_reference now nth_value ntile null null_if nullif numeric nvl occurrences_regex octet_length of offset old older_than omit on one only open operate optimize or order orphan out outer over overlaps overlay ownership parameter parse_url partition partitions pattern per percent percent_rank percentile_cont percentile_disc period permute pi pivot pmod policy portion position position_regex pow power precedes precision prepare prev primary procedure project promotion qualify quarter query query_user quote quote_char radians random range rank raw reads real record_delimiter recursive ref reference references referencing reflection reflections refresh regex regexp_col_like regexp_extract regexp_like regexp_matches regexp_replace regexp_split regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxy regr_syy release remove rename repeat repeatstr replace reset result retain_last retain_last_commits retain_last_snapshots return returns reverse revoke rewrite right role rollback rollup round route row row_number rows rpad rshift rtrim running savepoint schemas scope scroll search second seek select sensitive session_user set sha sha1 sha256 sha512 show sign similar similar_to sin sinh size skip smallint snapshot snapshots snapshots_older_than some soundex specific specifictype split_part sql sqlexception sqlstate sqlwarning sqrt st_fromgeohash st_geohash start starts_with static statistics stddev stddev_pop stddev_samp stream string_binary strpos submultiset subset substr substring substring_index substring_regex succeeds sum symmetric system system_time system_user table tables tablesample tag tan tanh target_file_size_mb tblproperties then time time_format timestamp timestamp_format timestampadd timestampdiff timestamptype timezone_hour timezone_minute tinyint to to_char to_date to_hex to_number to_time to_timestamp toascii trailing transaction_timestamp translate translate_regex translation treat trigger trim trim_array trim_space true truncate typeof ucase uescape unbase64 unhex union unique unix_timestamp unknown unnest unpivot unset update upper upsert usage use user using vacuum value value_of values var_pop var_samp varbinary varchar varying versioning view views week weekofyear when whenever where width_bucket window with within without write xor year".split( + " ", + ), +); + +// Source: DuckDB 1.4.5 +// SELECT keyword_name FROM duckdb_keywords() +// WHERE keyword_category = 'reserved' ORDER BY keyword_name; +const DUCKDB_RESERVED_WORDS: ReadonlySet = new Set( + "all analyse analyze and any array as asc asymmetric both case cast check collate column constraint create default deferrable desc describe distinct do else end except false fetch for foreign from group having in initially intersect into lambda lateral leading limit not null offset on only or order pivot pivot_longer pivot_wider placing primary qualify references returning select show some summarize symmetric table then to trailing true union unique unpivot using variadic when where window with".split( + " ", + ), +); + +// Source: https://github.com/postgres/postgres/blob/REL_18_STABLE/src/include/parser/kwlist.h +// RESERVED_KEYWORD and TYPE_FUNC_NAME_KEYWORD entries: both categories are +// disallowed as unquoted column or relation names. +const POSTGRESQL_RESERVED_WORDS: ReadonlySet = new Set( + "all analyse analyze and any array as asc asymmetric authorization binary both case cast check collate collation column concurrently constraint create cross current_catalog current_date current_role current_schema current_time current_timestamp current_user default deferrable desc distinct do else end except false fetch for foreign freeze from full grant group having ilike in initially inner intersect into is isnull join lateral leading left like limit localtime localtimestamp natural not notnull null offset on only or order outer overlaps placing primary references returning right select session_user similar some symmetric system_user table tablesample then to trailing true union unique user using variadic verbose when where window with".split( + " ", + ), +); + +function asciiLowercase(value: string): string | null { + if ( + value.length === 0 || + value.length > MAX_RESERVED_WORD_LENGTH + ) { + return null; + } + let hasUppercase = false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 65 && code <= 90) { + hasUppercase = true; + continue; + } + if ( + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 45 || + code === 95 + ) { + continue; + } + return null; + } + if (!hasUppercase) { + return value; + } + let output = ""; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + output += String.fromCharCode( + code >= 65 && code <= 90 ? code + 32 : code, + ); + } + return output; +} + +export function isSqlRelationReservedWord( + kind: SqlRelationReservedWordKind, + value: string, +): boolean { + const word = asciiLowercase(value); + if (word === null) { + return false; + } + switch (kind) { + case "bigquery": + return BIGQUERY_RESERVED_WORDS.has(word); + case "dremio": + return DREMIO_RESERVED_WORDS.has(word); + case "duckdb": + return DUCKDB_RESERVED_WORDS.has(word); + case "postgresql": + return POSTGRESQL_RESERVED_WORDS.has(word); + default: + return false; + } +} diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 4d1dfbe..7306d16 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -10,15 +10,18 @@ import type { SqlTextRange, } from "./types.js"; import { - BIGQUERY_SQL_LEXICAL_PROFILE, buildSqlStatementIndex, - DREMIO_SQL_LEXICAL_PROFILE, - DUCKDB_SQL_LEXICAL_PROFILE, - POSTGRESQL_SQL_LEXICAL_PROFILE, type SqlLexicalProfile, type SqlStatementIndex, updateSqlStatementIndex, } from "./statement-index.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "./relation-dialect.js"; import { createIdentitySqlSource, createMaskedSqlSource, @@ -46,6 +49,7 @@ const MAX_DIALECTS = 1_000; interface SqlDialectRuntime { readonly dialect: SqlDialect; readonly lexicalProfile: SqlLexicalProfile; + readonly relationDialect: SqlRelationDialectRuntime; } const sqlDialectRuntimes = new WeakMap(); @@ -53,12 +57,22 @@ const sqlDialectRuntimes = new WeakMap(); function createBuiltinSqlDialect( id: string, displayName: string, - lexicalProfile: SqlLexicalProfile, + relationDialect: SqlRelationDialectRuntime, ): SqlDialect { + if ( + relationDialect.querySite.lexicalProfile !== + relationDialect.cteLayout.lexicalProfile + ) { + throw new Error("Built-in SQL dialect lexical profiles must match"); + } const dialect = createSqlDialect(id, displayName); sqlDialectRuntimes.set( dialect, - Object.freeze({ dialect, lexicalProfile }), + Object.freeze({ + dialect, + lexicalProfile: relationDialect.querySite.lexicalProfile, + relationDialect, + }), ); return dialect; } @@ -66,22 +80,22 @@ function createBuiltinSqlDialect( const BIGQUERY_DIALECT = createBuiltinSqlDialect( "bigquery", "BigQuery", - BIGQUERY_SQL_LEXICAL_PROFILE, + BIGQUERY_SQL_RELATION_DIALECT, ); const DREMIO_DIALECT = createBuiltinSqlDialect( "dremio", "Dremio", - DREMIO_SQL_LEXICAL_PROFILE, + DREMIO_SQL_RELATION_DIALECT, ); const DUCKDB_DIALECT = createBuiltinSqlDialect( "duckdb", "DuckDB", - DUCKDB_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_RELATION_DIALECT, ); const POSTGRES_DIALECT = createBuiltinSqlDialect( "postgresql", "PostgreSQL", - POSTGRESQL_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_RELATION_DIALECT, ); /** Returns the package-owned BigQuery dialect handle. */ @@ -111,6 +125,12 @@ function getSqlDialectRuntime(candidate: unknown): SqlDialectRuntime | null { return sqlDialectRuntimes.get(candidate) ?? null; } +export function getSqlRelationDialectRuntime( + candidate: unknown, +): SqlRelationDialectRuntime | null { + return getSqlDialectRuntime(candidate)?.relationDialect ?? null; +} + interface PendingContextValue { readonly depth: number; readonly value: unknown; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 9847138..002f997 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -19,7 +19,6 @@ import type { SqlRelationCompletionItem, SqlRelationCompletionList, SqlRelationCatalogProvider, - SqlRelationCompletionDialectRuntime, SqlRelationCompletionSession, } from "../../src/vnext/relation-completion-types.js"; @@ -145,25 +144,6 @@ const relation: SqlCatalogRelation = { }; void relation; -const dialectRuntime: SqlRelationCompletionDialectRuntime = { - compareCteIdentifiers: (left, right) => - left.quoted === right.quoted && left.value === right.value - ? "equal" - : "distinct", - cteIdentifierMatchesPrefix: (candidate, prefix) => - candidate.value.startsWith(prefix.value) ? "match" : "no-match", - decodeIdentifier: (token) => ({ - component: { quoted: false, value: token }, - quality: "exact", - status: "decoded", - }), - renderRelationPath: (path) => ({ - status: "rendered", - text: path.map((component) => component.value).join("."), - }), -}; -void dialectRuntime; - // @ts-expect-error semantic catalog roles are a closed set const invalidRole: SqlCatalogRelation["canonicalPath"][number]["role"] = "database"; diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 7d62a5f..13c7020 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -26,6 +26,12 @@ interface HostEmbeddedRegion extends SqlEmbeddedRegion { const dialect = duckdbDialect(); const typedDialect: SqlDialect = dialect; +// @ts-expect-error dialect implementation callbacks are package-private +void typedDialect.decodeIdentifier; +// @ts-expect-error dialect grammar is package-private +void typedDialect.grammar; +// @ts-expect-error dialect rendering policy is package-private +void typedDialect.renderRelationPath; const service = createSqlLanguageService({ dialects: [dialect] }); const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index 3916cb0..b1dbf6c 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -9,17 +9,23 @@ package's dependency. The fixture workspace pins Vite's floating transitive versions to the exact versions in the root lock, so the nested frozen install can run offline after a clean root CI install. -The minified Vite 8 single-worker baseline is 67,396 gzip bytes for the -PostgreSQL transitive graph, 50,389 gzip bytes for the BigQuery transitive -graph, and 118,170 gzip/549,893 raw bytes for the complete worker build output. -The PostgreSQL and BigQuery figures each include their transitive shared -chunks; the report also identifies those shared chunks explicitly. The -fail-closed ceilings include small explicit headroom over that measured -packed-consumer baseline: +The minified Vite 8 packed-consumer baseline is 15,028 gzip/47,141 raw +bytes for the complete parser-free core, 67,573 gzip bytes for the PostgreSQL +transitive graph, 50,470 gzip bytes for the BigQuery transitive graph, and +125,937 gzip/572,982 raw bytes for the complete worker build output. The core +measurement includes the four authenticated relation-dialect runtimes and +their reserved-word tables. The PostgreSQL and BigQuery figures each include +their transitive shared chunks; the report also identifies those shared chunks +explicitly. +The fail-closed ceilings retain approximately 9% gzip and 13% raw headroom for +the core, 4% gzip and 2% raw headroom for the complete worker output, and the +existing tight dialect-graph headroom: + +- Complete parser-free core: 16 KiB gzip and 52 KiB raw - PostgreSQL transitive graph: 68 KiB gzip - BigQuery transitive graph: 50 KiB gzip -- Complete worker build output: 120 KiB gzip and 570 KiB raw +- Complete worker build output: 128 KiB gzip and 570 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no From 1dc15d8791e30543d2667d9a7ba10c7092ea9cdf Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 11:50:49 +0800 Subject: [PATCH 21/42] feat(vnext): authenticate CTE main query sites (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - authenticate bounded CTE main-query entrypoints before the relation query-site recognizer consumes them - unify source, statement-index, slot, layout, and built-in runtime provenance so mixed or stale evidence fails closed - keep the low-level query-site recognizer independent of CTE and aggregate runtime modules through a one-way orchestration layer - preserve independent old/new statement contexts when incremental indexes reuse immutable slot identities - validate real ready paths in the CTE/query-site benchmarks ## Correctness and trust boundaries - package-created sources, statement indexes, exact slots, CTE layouts, and coherent dialect runtimes are authenticated by identity - each statement index privately owns its exact analysis text, frozen lexical-profile scalar snapshot, and bounded slot membership - foreign sources, indexes, slots, profiles, layouts, mixed runtimes, mutable structural copies, and hostile proxies return unavailable - incomplete and invalid CTE headers remain fail-closed while valid main queries work across PostgreSQL, DuckDB, BigQuery, and Dremio ## Verification - 1,671 tests passed and 1 expected failure remained expected - changed coverage: 96.99% statements, 95.95% branches, 100% functions, 96.96% lines - all five TypeScript configurations passed - oxlint passed with zero warnings or errors - test-integrity and diff checks passed - exact tarball package smoke, SSR import, strict CSP, worker placement, and bundle budgets passed - Chromium browser suite passed: 4 files / 7 tests - statement-index, CTE-layout, and query-site benchmarks passed - exact SHA c096a1616a2687f29c58f1617ad0833a43078e1e received three independent adversarial approvals after five review/fix loops Part of #169. --- ## Summary by cubic Authenticate CTE main‑query entrypoints and only recognize relation query sites when the source, statement index, slot, and dialect all match. Ambiguous evidence fails closed, keeping the core recognizer independent; part of #169. - **New Features** - Authenticated CTE layouts: `analyzeSqlCteLayout(source, index, slot, dialect)` records provenance and binds main‑query entrypoints to the exact source/index/slot/dialect; `resolveAuthenticatedSqlCteEntrypoints(...)` exposes them safely. - Orchestration: `recognizeSqlRelationQuerySiteWithCteLayout(...)` validates runtime/source/slot via `isSqlRelationDialectRuntime`, `isSqlSourceSnapshot`, and `isSqlStatementSlotSnapshot`, then feeds authenticated entrypoints into the query‑site recognizer; runtimes are registered with `registerSqlRelationDialectRuntime`. - Entrypoint‑aware query‑site: `recognizeSqlRelationQuerySiteWithEntrypoints(...)` matches SELECTs at the exact depth/offset for nested CTEs. - Statement index provenance: `buildSqlStatementIndex` snapshots the lexical profile; `isExactSqlStatementSlotSnapshot(For)` ensures slots belong to the index and text/profile; `updateSqlStatementIndex` rebuilds when text or lexical profile changes. - **Migration** - Pass the statement index into `analyzeSqlCteLayout` (new signature: `(source, index, slot, dialect)`). - Use `recognizeSqlRelationQuerySiteWithCteLayout` when combining CTE layout with relation query‑site; keep `recognizeSqlRelationQuerySite` for non‑CTE cases. - Use built‑in registered runtimes from `relation-dialect` and authentic sources/slots produced by our builders. Written for commit 9ca185f4b452d6fa8d3c160fcda540108cb692d1. Summary will update on new commits. Review in cubic --- src/vnext/__tests__/cte-layout.bench.ts | 14 +- src/vnext/__tests__/cte-layout.test.ts | 61 +- .../incremental-statement-index.test.ts | 92 ++- src/vnext/__tests__/query-site.bench.ts | 136 ++++ src/vnext/__tests__/query-site.test.ts | 626 ++++++++++++++++++ src/vnext/__tests__/relation-dialect.test.ts | 29 +- src/vnext/__tests__/source.test.ts | 4 + src/vnext/__tests__/statement-index.test.ts | 44 ++ src/vnext/cte-layout.ts | 82 ++- src/vnext/query-site.ts | 57 +- src/vnext/relation-dialect.ts | 11 +- src/vnext/relation-query-site.ts | 59 ++ src/vnext/relation-runtime-auth.ts | 18 + src/vnext/source.ts | 19 +- src/vnext/statement-index.ts | 170 ++++- 15 files changed, 1379 insertions(+), 43 deletions(-) create mode 100644 src/vnext/relation-query-site.ts create mode 100644 src/vnext/relation-runtime-auth.ts diff --git a/src/vnext/__tests__/cte-layout.bench.ts b/src/vnext/__tests__/cte-layout.bench.ts index 0d179b5..945223b 100644 --- a/src/vnext/__tests__/cte-layout.bench.ts +++ b/src/vnext/__tests__/cte-layout.bench.ts @@ -17,17 +17,19 @@ const dialect = DUCKDB_SQL_RELATION_DIALECT.cteLayout; function fixture(text: string): { readonly source: ReturnType; + readonly index: ReturnType; readonly slot: ExactSqlStatementSlot; } { const source = createIdentitySqlSource(text); - const slot = buildSqlStatementIndex( + const index = buildSqlStatementIndex( source.analysisText, dialect.lexicalProfile, - ).slots[0]; + ); + const slot = index.slots[0]; if (!slot || slot.boundaryQuality !== "exact") { throw new Error("CTE benchmark fixture requires an exact statement"); } - return { slot, source }; + return { index, slot, source }; } const tenKibibytes = 10 * 1_024; @@ -62,6 +64,7 @@ const bareFrameHeavyText = `SELECT ${Array.from( const bareFrameHeavyFixture = fixture(bareFrameHeavyText); const depthLayout = analyzeSqlCteLayout( depthHeavyFixture.source, + depthHeavyFixture.index, depthHeavyFixture.slot, dialect, ); @@ -70,6 +73,7 @@ if (depthLayout.status !== "ready") { } const projectedLayout = analyzeSqlCteLayout( declarationHeavyFixture.source, + declarationHeavyFixture.index, declarationHeavyFixture.slot, dialect, ); @@ -81,6 +85,7 @@ describe("CTE layout", () => { bench("ordinary 10 KiB statement", () => { analyzeSqlCteLayout( ordinaryFixture.source, + ordinaryFixture.index, ordinaryFixture.slot, dialect, ); @@ -89,6 +94,7 @@ describe("CTE layout", () => { bench("256 declarations", () => { analyzeSqlCteLayout( declarationHeavyFixture.source, + declarationHeavyFixture.index, declarationHeavyFixture.slot, dialect, ); @@ -97,6 +103,7 @@ describe("CTE layout", () => { bench("128-depth nested CTE", () => { analyzeSqlCteLayout( depthHeavyFixture.source, + depthHeavyFixture.index, depthHeavyFixture.slot, dialect, ); @@ -105,6 +112,7 @@ describe("CTE layout", () => { bench("256 sequential incomplete frames", () => { analyzeSqlCteLayout( bareFrameHeavyFixture.source, + bareFrameHeavyFixture.index, bareFrameHeavyFixture.slot, dialect, ); diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/vnext/__tests__/cte-layout.test.ts index 97ce704..213e861 100644 --- a/src/vnext/__tests__/cte-layout.test.ts +++ b/src/vnext/__tests__/cte-layout.test.ts @@ -7,6 +7,7 @@ import { MAX_CTE_IDENTIFIER_LENGTH, MAX_CTE_QUOTED_IDENTIFIER_LENGTH, MAX_CTE_STATEMENT_LENGTH, + resolveAuthenticatedSqlCteEntrypoints, type SqlCteLayout, type SqlCteLayoutDialect, type SqlCteLayoutIssue, @@ -55,6 +56,7 @@ function analyze( expect(slot?.boundaryQuality).toBe("exact"); const result = analyzeSqlCteLayout( source, + index, slot as ExactSqlStatementSlot, dialect, ); @@ -68,15 +70,18 @@ function analyze( function analyzeRaw( text: string, dialect: SqlCteLayoutDialect = postgres, + indexDialect: SqlCteLayoutDialect = dialect, ): SqlCteLayout { const source = createIdentitySqlSource(text); - const slot = buildSqlStatementIndex( + const index = buildSqlStatementIndex( source.analysisText, - dialect.lexicalProfile, - ).slots[0]; + indexDialect.lexicalProfile, + ); + const slot = index.slots[0]; expect(slot?.boundaryQuality).toBe("exact"); return analyzeSqlCteLayout( source, + index, slot as ExactSqlStatementSlot, dialect, ); @@ -400,6 +405,7 @@ describe("bounded CTE layout", () => { } const layout = analyzeSqlCteLayout( source, + index, slot, postgres, ); @@ -1022,13 +1028,60 @@ describe("bounded CTE layout", () => { }, }, }; - expect(analyzeRaw("SELECT 1", getterLexicalProfile)).toEqual({ + expect( + analyzeRaw("SELECT 1", getterLexicalProfile, postgres), + ).toEqual({ reason: "resource-limit", status: "unavailable", }); expect(getterInvoked).toBe(false); }); + it("never reads a raw dialect after validating its data descriptors", () => { + const text = "WITH local AS (SELECT 1) SELECT * FROM local"; + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + postgres.lexicalProfile, + ); + const slot = index.slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + if (!slot || slot.boundaryQuality !== "exact") { + throw new Error("Expected one exact statement slot"); + } + + let rawRead = false; + const descriptorOnlyDialect = new Proxy(postgres, { + get(target, property, receiver) { + if (property === "lexicalProfile") { + rawRead = true; + throw new Error("hostile"); + } + return Reflect.get(target, property, receiver); + }, + }); + const layout = analyzeSqlCteLayout( + source, + index, + slot, + descriptorOnlyDialect, + ); + + expect(layout.status).toBe("ready"); + if (layout.status === "unavailable") { + throw new Error("Expected an authenticated CTE layout"); + } + expect( + resolveAuthenticatedSqlCteEntrypoints( + layout, + source, + slot, + descriptorOnlyDialect, + ), + ).toEqual(layout.mainQueryEntrypoints); + expect(rawRead).toBe(false); + }); + it("projects only proven frame phases and validates positions", () => { const text = "WITH a AS (SELECT 1) SELECT * FROM a"; diff --git a/src/vnext/__tests__/incremental-statement-index.test.ts b/src/vnext/__tests__/incremental-statement-index.test.ts index a0dbfae..4e7e879 100644 --- a/src/vnext/__tests__/incremental-statement-index.test.ts +++ b/src/vnext/__tests__/incremental-statement-index.test.ts @@ -4,6 +4,7 @@ import { buildSqlStatementIndex, DREMIO_SQL_LEXICAL_PROFILE, DUCKDB_SQL_LEXICAL_PROFILE, + isExactSqlStatementSlotSnapshotFor, MAX_SQL_STATEMENT_SLOTS, POSTGRESQL_SQL_LEXICAL_PROFILE, type SqlLexicalProfile, @@ -358,18 +359,93 @@ describe("incremental statement index reuse", () => { }, ); + it("rebuilds when empty change metadata does not match the text", () => { + const previousIndex = buildSqlStatementIndex( + "SELECT 1", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const nextIndex = updateSqlStatementIndex( + previousIndex, + "SELECT 2", + [], + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(nextIndex).not.toBe(previousIndex); + expect(nextIndex).toEqual( + buildSqlStatementIndex( + "SELECT 2", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ), + ); + }); + + it("rebuilds when the lexical profile changes", () => { + const text = "SELECT 1"; + const previousIndex = buildSqlStatementIndex( + text, + DUCKDB_SQL_LEXICAL_PROFILE, + ); + const nextIndex = updateSqlStatementIndex( + previousIndex, + text, + [], + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(nextIndex).not.toBe(previousIndex); + expect(nextIndex).toEqual( + buildSqlStatementIndex( + text, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ), + ); + }); + + it("rebuilds when one lexical profile object mutates", () => { + const profile = { ...POSTGRESQL_SQL_LEXICAL_PROFILE }; + const text = "SELECT # ;\n SELECT 2"; + const previousIndex = buildSqlStatementIndex(text, profile); + expect(previousIndex.slots).toHaveLength(2); + profile.hashLineComments = true; + const nextIndex = updateSqlStatementIndex( + previousIndex, + text, + [], + profile, + ); + expect(nextIndex).not.toBe(previousIndex); + expect(nextIndex).toEqual(buildSqlStatementIndex(text, profile)); + expect(nextIndex.slots).toHaveLength(1); + }); + it("reuses unchanged prefix and zero-delta suffix slot identities", () => { const profile = DUCKDB_SQL_LEXICAL_PROFILE; const text = "SELECT 1; SELECT 2; SELECT 3"; const change = replaceFirst(text, "2", "9"); - const { nextIndex, previousIndex } = expectIncrementalMatchesOracle( - text, - [change], - profile, - ); - expect(expectSlot(nextIndex.slots, 0)).toBe( - expectSlot(previousIndex.slots, 0), - ); + const { nextIndex, nextText, previousIndex } = + expectIncrementalMatchesOracle( + text, + [change], + profile, + ); + const sharedPrefix = expectSlot(nextIndex.slots, 0); + expect(sharedPrefix).toBe(expectSlot(previousIndex.slots, 0)); + expect(sharedPrefix.boundaryQuality).toBe("exact"); + expect( + isExactSqlStatementSlotSnapshotFor( + previousIndex, + sharedPrefix, + text, + profile, + ), + ).toBe(true); + expect( + isExactSqlStatementSlotSnapshotFor( + nextIndex, + sharedPrefix, + nextText, + profile, + ), + ).toBe(true); expect(expectSlot(nextIndex.slots, 1)).not.toBe( expectSlot(previousIndex.slots, 1), ); diff --git a/src/vnext/__tests__/query-site.bench.ts b/src/vnext/__tests__/query-site.bench.ts index ca19f79..6a8193d 100644 --- a/src/vnext/__tests__/query-site.bench.ts +++ b/src/vnext/__tests__/query-site.bench.ts @@ -1,8 +1,15 @@ import { bench, describe } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, +} from "../cte-layout.js"; import { recognizeSqlRelationQuerySite, } from "../query-site.js"; import { DUCKDB_SQL_RELATION_DIALECT } from "../relation-dialect.js"; +import { + recognizeSqlRelationQuerySiteWithCteLayout, +} from "../relation-query-site.js"; import { createIdentitySqlSource } from "../source.js"; import { buildSqlStatementIndex, @@ -61,6 +68,103 @@ const usingHeavySlot = findSqlStatementSlot( usingHeavyPosition, "left", ); +const cteQueryPrefix = + "WITH cte_name AS (SELECT 1) SELECT "; +const cteQuerySuffix = " FROM schema_prefix"; +const cteProjectedListLength = + TEN_KIBIBYTES - + cteQueryPrefix.length - + cteQuerySuffix.length; +const cteProjectedList = `${"x,".repeat( + Math.floor((cteProjectedListLength - 1) / 2), +)}x`; +const tenKilobyteCteQuery = + `${cteQueryPrefix}${cteProjectedList}` + + `${" ".repeat( + cteProjectedListLength - cteProjectedList.length, + )}${cteQuerySuffix}`; +if (tenKilobyteCteQuery.length !== TEN_KIBIBYTES) { + throw new Error("CTE query benchmark fixture must be exactly 10 KiB"); +} +const cteSource = createIdentitySqlSource(tenKilobyteCteQuery); +const cteIndex = buildSqlStatementIndex( + cteSource.analysisText, + dialect.lexicalProfile, +); +const ctePosition = tenKilobyteCteQuery.length; +const cteSlot = findSqlStatementSlot( + cteIndex, + ctePosition, + "left", +); +if (cteSlot.boundaryQuality === "opaque") { + throw new Error("CTE query benchmark requires an exact statement"); +} +const maximumCteQuery = + `WITH ${Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `cte_${index} AS (SELECT ${index})`, + ).join(", ")} SELECT * FROM target`; +const maximumCteSource = + createIdentitySqlSource(maximumCteQuery); +const maximumCteIndex = buildSqlStatementIndex( + maximumCteSource.analysisText, + dialect.lexicalProfile, +); +const maximumCtePosition = maximumCteQuery.length; +const maximumCteSlot = findSqlStatementSlot( + maximumCteIndex, + maximumCtePosition, + "left", +); +if (maximumCteSlot.boundaryQuality === "opaque") { + throw new Error("Maximum CTE benchmark requires an exact statement"); +} +const ctePreflightLayout = analyzeSqlCteLayout( + cteSource, + cteIndex, + cteSlot, + DUCKDB_SQL_RELATION_DIALECT.cteLayout, +); +const ctePreflightResult = + recognizeSqlRelationQuerySiteWithCteLayout( + cteSource, + cteSlot, + ctePosition, + DUCKDB_SQL_RELATION_DIALECT, + ctePreflightLayout, + ); +if ( + ctePreflightLayout.status !== "ready" || + ctePreflightResult.status !== "ready" || + ctePreflightResult.anchor !== "from" +) { + throw new Error("CTE query benchmark must exercise a ready FROM site"); +} +const maximumCtePreflightLayout = analyzeSqlCteLayout( + maximumCteSource, + maximumCteIndex, + maximumCteSlot, + DUCKDB_SQL_RELATION_DIALECT.cteLayout, +); +const maximumCtePreflightResult = + recognizeSqlRelationQuerySiteWithCteLayout( + maximumCteSource, + maximumCteSlot, + maximumCtePosition, + DUCKDB_SQL_RELATION_DIALECT, + maximumCtePreflightLayout, + ); +if ( + maximumCtePreflightLayout.status !== "ready" || + maximumCtePreflightLayout.declarations.length !== + MAX_CTE_DECLARATIONS || + maximumCtePreflightLayout.mainQueryEntrypoints.length !== 1 || + maximumCtePreflightResult.status !== "ready" || + maximumCtePreflightResult.prefix.value !== "target" +) { + throw new Error("Maximum CTE benchmark must exercise all declarations"); +} describe("query-site recognizer", () => { bench("10 KiB active statement", () => { @@ -84,4 +188,36 @@ describe("query-site recognizer", () => { dialect, ); }); + + bench("10 KiB WITH main-query double scan", () => { + const layout = analyzeSqlCteLayout( + cteSource, + cteIndex, + cteSlot, + DUCKDB_SQL_RELATION_DIALECT.cteLayout, + ); + recognizeSqlRelationQuerySiteWithCteLayout( + cteSource, + cteSlot, + ctePosition, + DUCKDB_SQL_RELATION_DIALECT, + layout, + ); + }); + + bench("256 CTE main-query double scan", () => { + const layout = analyzeSqlCteLayout( + maximumCteSource, + maximumCteIndex, + maximumCteSlot, + DUCKDB_SQL_RELATION_DIALECT.cteLayout, + ); + recognizeSqlRelationQuerySiteWithCteLayout( + maximumCteSource, + maximumCteSlot, + maximumCtePosition, + DUCKDB_SQL_RELATION_DIALECT, + layout, + ); + }); }); diff --git a/src/vnext/__tests__/query-site.test.ts b/src/vnext/__tests__/query-site.test.ts index 10f268b..089b52a 100644 --- a/src/vnext/__tests__/query-site.test.ts +++ b/src/vnext/__tests__/query-site.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + type SqlCteLayout, +} from "../cte-layout.js"; import { MAX_QUERY_SITE_DEPTH, MAX_QUERY_SITE_IDENTIFIER_LENGTH, @@ -10,10 +14,15 @@ import { type SqlQuerySiteDialect, type SqlQuerySiteResult, } from "../query-site.js"; +import { + recognizeSqlRelationQuerySiteWithCteLayout, +} from "../relation-query-site.js"; import { BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, DUCKDB_SQL_RELATION_DIALECT, POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, } from "../relation-dialect.js"; import { createIdentitySqlSource, @@ -24,6 +33,8 @@ import { buildSqlStatementIndex, findSqlStatementSlot, POSTGRESQL_SQL_LEXICAL_PROFILE, + type ExactSqlStatementSlot, + updateSqlStatementIndex, } from "../statement-index.js"; const postgresDialect = POSTGRESQL_SQL_RELATION_DIALECT.querySite; @@ -84,6 +95,54 @@ function expectReady( return result; } +function cteFixture( + marked: string, + dialect: SqlRelationDialectRuntime = + POSTGRESQL_SQL_RELATION_DIALECT, +): { + readonly layout: Exclude< + SqlCteLayout, + { readonly status: "unavailable" } + >; + readonly position: number; + readonly result: SqlQuerySiteResult; + readonly slot: ExactSqlStatementSlot; + readonly source: SqlSourceSnapshot; +} { + const { position, text } = markedSource(marked); + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + if (slot.boundaryQuality === "opaque") { + throw new Error("CTE query fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + dialect.cteLayout, + ); + if (layout.status === "unavailable") { + throw new Error("CTE query fixture requires an available layout"); + } + return { + layout, + position, + result: recognizeSqlRelationQuerySiteWithCteLayout( + source, + slot, + position, + dialect, + layout, + ), + slot, + source, + }; +} + describe("partial SELECT relation query sites", () => { it.each([ ["SELECT * FROM |", "from"], @@ -306,6 +365,573 @@ describe("partial SELECT relation query sites", () => { }); }); +describe("authenticated CTE main-query entrypoints", () => { + it.each([ + [ + "WITH cte_name AS (SELECT 1) SELECT * FROM |", + POSTGRESQL_SQL_RELATION_DIALECT, + ], + [ + "WITH cte_name AS (SELECT 1) SELECT * FROM |", + DUCKDB_SQL_RELATION_DIALECT, + ], + [ + "WITH cte_name AS (SELECT 1) SELECT * FROM |", + BIGQUERY_SQL_RELATION_DIALECT, + ], + [ + "WITH cte_name(id) AS (SELECT 1) SELECT * FROM |", + DREMIO_SQL_RELATION_DIALECT, + ], + ] as const)("recognizes a built-in main query in %s", (marked, dialect) => { + const result = expectReady(cteFixture(marked, dialect).result); + expect(result.anchor).toBe("from"); + expect(result.recognition).toEqual({ + issues: [], + quality: "exact", + }); + }); + + it("recognizes nested CTE main queries by exact offset and depth", () => { + const result = expectReady( + cteFixture( + "WITH outer_cte AS (WITH inner_cte AS (SELECT 1) SELECT * FROM |) SELECT 1", + ).result, + ); + expect(result.anchor).toBe("from"); + }); + + it("returns to the enclosing entrypoint after a nested CTE closes", () => { + const result = expectReady( + cteFixture( + "WITH outer_cte AS (WITH inner_cte AS (SELECT 1) SELECT * FROM inner_cte) SELECT * FROM |", + ).result, + ); + expect(result.anchor).toBe("from"); + expect(result.recognition.quality).toBe("exact"); + }); + + it("keeps ordinary CTE body recognition independent of entrypoints", () => { + const marked = + "WITH local AS (SELECT * FROM |) SELECT * FROM local"; + expect(recognize(marked).status).toBe("ready"); + expect(cteFixture(marked).result.status).toBe("ready"); + }); + + it("translates statement-relative entrypoints for later statements", () => { + const result = expectReady( + cteFixture( + "SELECT 1; WITH local AS (SELECT 1) SELECT * FROM schema.ta|", + ).result, + ); + expect(result.prefix).toEqual({ quoted: false, value: "ta" }); + expect(result.typedPathRange).toMatchObject({ + from: 40, + to: 49, + }); + }); + + it("rejects copied, proxied, raw, cross-source, and cross-dialect evidence", () => { + const fixture = cteFixture( + "WITH local AS (SELECT 1) SELECT * FROM |", + ); + const copied = { ...fixture.layout } as SqlCteLayout; + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + copied, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + + let invoked = false; + const proxied = new Proxy(fixture.layout, { + get() { + invoked = true; + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + invoked = true; + throw new Error("hostile"); + }, + ownKeys() { + invoked = true; + throw new Error("hostile"); + }, + }); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + proxied, + ).status, + ).toBe("unavailable"); + expect(invoked).toBe(false); + + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + fixture.layout.mainQueryEntrypoints as never, + ).status, + ).toBe("unavailable"); + + const secondSource = createIdentitySqlSource( + fixture.source.originalText, + ); + const secondIndex = buildSqlStatementIndex( + secondSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const secondSlot = findSqlStatementSlot( + secondIndex, + fixture.position, + "left", + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + secondSource, + secondSlot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + fixture.layout, + ).status, + ).toBe("unavailable"); + + const mixedRuntime: SqlRelationDialectRuntime = { + ...POSTGRESQL_SQL_RELATION_DIALECT, + querySite: DUCKDB_SQL_RELATION_DIALECT.querySite, + }; + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + mixedRuntime, + fixture.layout, + ).status, + ).toBe("unavailable"); + + let runtimeInvoked = false; + const proxiedRuntime = new Proxy( + POSTGRESQL_SQL_RELATION_DIALECT, + { + get() { + runtimeInvoked = true; + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + runtimeInvoked = true; + throw new Error("hostile"); + }, + ownKeys() { + runtimeInvoked = true; + throw new Error("hostile"); + }, + }, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + proxiedRuntime, + fixture.layout, + ).status, + ).toBe("unavailable"); + expect(runtimeInvoked).toBe(false); + + let slotInvoked = false; + const proxiedSlot = new Proxy(fixture.slot, { + get() { + slotInvoked = true; + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + slotInvoked = true; + throw new Error("hostile"); + }, + ownKeys() { + slotInvoked = true; + throw new Error("hostile"); + }, + }); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + proxiedSlot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + fixture.layout, + ).status, + ).toBe("unavailable"); + expect(slotInvoked).toBe(false); + + const rebuiltIndex = buildSqlStatementIndex( + fixture.source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const rebuiltSlot = findSqlStatementSlot( + rebuiltIndex, + fixture.position, + "left", + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + rebuiltSlot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + fixture.layout, + ).status, + ).toBe("unavailable"); + const mismatchedLayout = analyzeSqlCteLayout( + fixture.source, + rebuiltIndex, + fixture.slot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + mismatchedLayout, + ).status, + ).toBe("unavailable"); + + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + POSTGRESQL_SQL_RELATION_DIALECT, + null as never, + ).status, + ).toBe("unavailable"); + + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + fixture.source, + fixture.slot, + fixture.position, + DUCKDB_SQL_RELATION_DIALECT, + fixture.layout, + ).status, + ).toBe("unavailable"); + }); + + it("does not authenticate mutable statement slots", () => { + const first = + "WITH cte_name AS (SELECT 1) SELECT * FROM target"; + const second = + "XXXX cte_name AS (SELECT 1) SELECT * FROM target"; + const source = createIdentitySqlSource(`${first};${second}`); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const firstSlot = findSqlStatementSlot(index, first.length, "left"); + const secondSlot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + if ( + firstSlot.boundaryQuality === "opaque" || + secondSlot.boundaryQuality === "opaque" + ) { + throw new Error("Mutable slot fixture requires exact statements"); + } + const mutableSlot: ExactSqlStatementSlot = { + ...firstSlot, + extent: { ...firstSlot.extent }, + source: { ...firstSlot.source }, + terminator: firstSlot.terminator + ? { ...firstSlot.terminator } + : null, + }; + const layout = analyzeSqlCteLayout( + source, + index, + mutableSlot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + Object.assign(mutableSlot, { + endState: secondSlot.endState, + extent: { ...secondSlot.extent }, + hasCode: secondSlot.hasCode, + source: { ...secondSlot.source }, + terminator: secondSlot.terminator, + }); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + mutableSlot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("binds authentic slots to their analysis text", () => { + const source = createIdentitySqlSource( + "DELIMITER $$;SELECT * FROM ", + ); + const foreignText = "xxxxxxxxxxxx;SELECT * FROM "; + expect(foreignText.length).toBe(source.analysisText.length); + const foreignIndex = buildSqlStatementIndex( + foreignText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const foreignSlot = findSqlStatementSlot( + foreignIndex, + foreignText.length, + "left", + ); + if (foreignSlot.boundaryQuality === "opaque") { + throw new Error("Foreign slot fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + source, + foreignIndex, + foreignSlot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + foreignSlot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("binds authentic slots to their lexical profile", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + DUCKDB_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + if (slot.boundaryQuality === "opaque") { + throw new Error("Wrong-profile fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + slot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("keeps old and new incremental statement contexts valid", () => { + const first = + "WITH cte_name AS (SELECT 1) SELECT * FROM target"; + const oldText = `${first}; SELECT 2; SELECT 3`; + const oldSource = createIdentitySqlSource(oldText); + const profile = + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile; + const oldIndex = buildSqlStatementIndex(oldText, profile); + const oldSlot = findSqlStatementSlot( + oldIndex, + first.length, + "left", + ); + if (oldSlot.boundaryQuality === "opaque") { + throw new Error("Incremental fixture requires exact statements"); + } + const oldLayout = analyzeSqlCteLayout( + oldSource, + oldIndex, + oldSlot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + const editFrom = oldText.indexOf("SELECT 2") + "SELECT ".length; + const newText = `${oldText.slice(0, editFrom)}4${oldText.slice( + editFrom + 1, + )}`; + const newIndex = updateSqlStatementIndex( + oldIndex, + newText, + [{ from: editFrom, insert: "4", to: editFrom + 1 }], + profile, + ); + const newSource = createIdentitySqlSource(newText); + const newSlot = findSqlStatementSlot( + newIndex, + first.length, + "left", + ); + if (newSlot.boundaryQuality === "opaque") { + throw new Error("Incremental fixture requires exact statements"); + } + expect(newSlot).toBe(oldSlot); + const newLayout = analyzeSqlCteLayout( + newSource, + newIndex, + newSlot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + oldSource, + oldSlot, + first.length, + POSTGRESQL_SQL_RELATION_DIALECT, + oldLayout, + ).status, + ).toBe("ready"); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + newSource, + newSlot, + first.length, + POSTGRESQL_SQL_RELATION_DIALECT, + newLayout, + ).status, + ).toBe("ready"); + }); + + it("preserves opaque statement failures for authentic slots", () => { + const source = createIdentitySqlSource("DELIMITER $$"); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + const fixture = cteFixture( + "WITH local AS (SELECT 1) SELECT * FROM |", + ); + expect(slot.boundaryQuality).toBe("opaque"); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + slot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + fixture.layout, + ), + ).toEqual({ + reason: "opaque-statement", + status: "unavailable", + }); + }); + + it.each([ + ["invalid-header", "header"], + ["skipped", "skipped"], + ["wrong-depth", "depth"], + ["wrong-token", "token"], + ] as const)( + "fails closed when authenticated source evidence becomes %s", + (_, mutation) => { + const text = + "WITH cte_name AS (SELECT 1) SELECT * FROM target"; + const mutableSource: SqlSourceSnapshot = { + ...createIdentitySqlSource(text), + }; + const position = text.length; + const index = buildSqlStatementIndex( + mutableSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + if (slot.boundaryQuality === "opaque") { + throw new Error("Mutable fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + mutableSource, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + const mainQueryStart = text.lastIndexOf("SELECT"); + const nextAnalysisText = + mutation === "header" + ? `XXXX${text.slice(4)}` + : mutation === "depth" + ? `${text.slice(0, mainQueryStart - 2)} ${text.slice( + mainQueryStart - 1, + )}` + : `${text.slice(0, mainQueryStart)}${ + mutation === "skipped" ? "/*x*/ " : "DELETE" + }${text.slice(mainQueryStart + 6)}`; + (mutableSource as { analysisText: string }).analysisText = + nextAnalysisText; + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + mutableSource, + slot, + position, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }, + ); + + it("does not make raw WITH statements query candidates", () => { + const marked = + "WITH local AS (SELECT 1) SELECT * FROM |"; + expect(recognize(marked)).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + }); + + it("does not invent entrypoints for incomplete CTE headers", () => { + const fixture = cteFixture( + "WITH cte_name AS (SELECT 1), SELECT * FROM |", + ); + expect(fixture.layout.status).toBe("partial"); + expect(fixture.layout.mainQueryEntrypoints).toEqual([]); + expect(fixture.result.status).not.toBe("ready"); + }); +}); + describe("fail-closed query-site behavior", () => { it.each([ ["SELECT x IS DISTINCT FROM |", "inactive"], diff --git a/src/vnext/__tests__/relation-dialect.test.ts b/src/vnext/__tests__/relation-dialect.test.ts index 8dca8d5..38a4c9f 100644 --- a/src/vnext/__tests__/relation-dialect.test.ts +++ b/src/vnext/__tests__/relation-dialect.test.ts @@ -10,6 +10,9 @@ import { POSTGRESQL_SQL_RELATION_DIALECT, type SqlRelationDialectRuntime, } from "../relation-dialect.js"; +import { + isSqlRelationDialectRuntime, +} from "../relation-runtime-auth.js"; import type { SqlCanonicalRelationPath, SqlIdentifierDecodeResult, @@ -64,15 +67,21 @@ function analyze( text: string, ): SqlCteLayout { const source = createIdentitySqlSource(text); - const slot = buildSqlStatementIndex( + const index = buildSqlStatementIndex( source.analysisText, runtime.querySite.lexicalProfile, - ).slots[0]; + ); + const slot = index.slots[0]; expect(slot?.boundaryQuality).toBe("exact"); if (!slot || slot.boundaryQuality !== "exact") { throw new Error("Expected one exact SQL statement"); } - return analyzeSqlCteLayout(source, slot, runtime.cteLayout); + return analyzeSqlCteLayout( + source, + index, + slot, + runtime.cteLayout, + ); } function expectDeepFrozenRuntime( @@ -86,6 +95,20 @@ function expectDeepFrozenRuntime( } describe("built-in relation dialect runtime", () => { + it("authenticates only package-owned coherent runtime aggregates", () => { + expect( + isSqlRelationDialectRuntime( + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toBe(true); + expect( + isSqlRelationDialectRuntime({ + ...POSTGRESQL_SQL_RELATION_DIALECT, + }), + ).toBe(false); + expect(isSqlRelationDialectRuntime(null)).toBe(false); + }); + it("owns stable, deeply frozen, coherent views", () => { for (const runtime of Object.values(RUNTIMES)) { expectDeepFrozenRuntime(runtime); diff --git a/src/vnext/__tests__/source.test.ts b/src/vnext/__tests__/source.test.ts index c98bf7e..62178d9 100644 --- a/src/vnext/__tests__/source.test.ts +++ b/src/vnext/__tests__/source.test.ts @@ -3,6 +3,7 @@ import { createIdentitySqlSource, createMaskedSqlSource, findSqlEmbeddedRegionAtOrAfter, + isSqlSourceSnapshot, mapAnalysisRangeToOriginal, mapOriginalRangeToAnalysis, MAX_SQL_EMBEDDED_REGIONS, @@ -147,6 +148,9 @@ describe("SQL source snapshots", () => { expect(source.embeddedRegions).toEqual([]); expect(Object.isFrozen(source)).toBe(true); expect(Object.isFrozen(source.embeddedRegions)).toBe(true); + expect(isSqlSourceSnapshot(source)).toBe(true); + expect(isSqlSourceSnapshot({ ...source })).toBe(false); + expect(isSqlSourceSnapshot(null)).toBe(false); }); it("bounds and validates source text", () => { diff --git a/src/vnext/__tests__/statement-index.test.ts b/src/vnext/__tests__/statement-index.test.ts index 0a17bd1..26bc41f 100644 --- a/src/vnext/__tests__/statement-index.test.ts +++ b/src/vnext/__tests__/statement-index.test.ts @@ -5,6 +5,9 @@ import { DREMIO_SQL_LEXICAL_PROFILE, DUCKDB_SQL_LEXICAL_PROFILE, findSqlStatementSlot, + isExactSqlStatementSlotSnapshot, + isExactSqlStatementSlotSnapshotFor, + isSqlStatementSlotSnapshot, MAX_SQL_STATEMENT_SLOTS, POSTGRESQL_SQL_LEXICAL_PROFILE, type SqlLexicalProfile, @@ -78,6 +81,47 @@ function expectPartition(text: string, index: SqlStatementIndex): void { } describe("statement partition", () => { + it("authenticates only package-created immutable slot snapshots", () => { + const exactIndex = buildSqlStatementIndex( + "SELECT 1", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const exact = itemAt( + exactIndex.slots, + 0, + ); + const opaqueIndex = buildSqlStatementIndex( + "DELIMITER $$", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const opaque = itemAt( + opaqueIndex.slots, + 0, + ); + expect(isSqlStatementSlotSnapshot(exact)).toBe(true); + expect(isExactSqlStatementSlotSnapshot(exact)).toBe(true); + expect( + isExactSqlStatementSlotSnapshotFor( + exactIndex, + exact, + "SELECT 1", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ), + ).toBe(true); + expect( + isExactSqlStatementSlotSnapshotFor( + exactIndex, + exact, + "SELECT 2", + POSTGRESQL_SQL_LEXICAL_PROFILE, + ), + ).toBe(false); + expect(isSqlStatementSlotSnapshot(opaque)).toBe(true); + expect(isExactSqlStatementSlotSnapshot(opaque)).toBe(false); + expect(isSqlStatementSlotSnapshot({ ...exact })).toBe(false); + expect(isExactSqlStatementSlotSnapshot(null)).toBe(false); + }); + it.each([ [ "", diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts index d5c9bb5..820fefc 100644 --- a/src/vnext/cte-layout.ts +++ b/src/vnext/cte-layout.ts @@ -4,8 +4,15 @@ import { type BoundedSqlLexerResource, } from "./bounded-sql-lexer.js"; import type { SqlLexicalProfile } from "./lexical.js"; -import type { SqlSourceSnapshot } from "./source.js"; -import type { ExactSqlStatementSlot } from "./statement-index.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isExactSqlStatementSlotSnapshotFor, + type ExactSqlStatementSlot, + type SqlStatementIndex, +} from "./statement-index.js"; import type { SqlIdentifierComponent } from "./types.js"; const cteRangeBrand: unique symbol = Symbol("SqlCteRange"); @@ -166,6 +173,20 @@ export interface SqlCteVisibility { readonly shadowing: SqlCteShadowing; } +interface SqlCteLayoutProvenance { + readonly dialect: SqlCteLayoutDialect; + readonly entrypoints: readonly SqlCteMainQueryEntrypoint[]; + readonly index: SqlStatementIndex; + readonly lexicalProfile: SqlLexicalProfile; + readonly slot: ExactSqlStatementSlot; + readonly source: SqlSourceSnapshot; +} + +const sqlCteLayoutProvenance = new WeakMap< + object, + SqlCteLayoutProvenance +>(); + type HeaderState = | "after-as" | "after-body" @@ -872,7 +893,7 @@ function freezeLayout( exactThrough: number, issues: Set, resource?: SqlCteLayoutResource, -): SqlCteLayout { +): Exclude { const unknownClasses = (identityIndex: number): readonly number[] => Object.freeze( [ @@ -1012,6 +1033,7 @@ function freezeLayout( export function analyzeSqlCteLayout( source: SqlSourceSnapshot, + index: SqlStatementIndex, slot: ExactSqlStatementSlot, dialect: SqlCteLayoutDialect, ): SqlCteLayout { @@ -1551,7 +1573,7 @@ export function analyzeSqlCteLayout( relations, exactThrough, ); - return freezeLayout( + const layout = freezeLayout( frames, declarations, draftDeclarations, @@ -1561,6 +1583,58 @@ export function analyzeSqlCteLayout( issues, resource, ); + if ( + isSqlSourceSnapshot(source) && + isExactSqlStatementSlotSnapshotFor( + index, + slot, + source.analysisText, + lexicalProfile, + ) + ) { + sqlCteLayoutProvenance.set( + layout, + Object.freeze({ + dialect, + entrypoints: layout.mainQueryEntrypoints, + index, + lexicalProfile, + slot, + source, + }), + ); + } + return layout; +} + +export function resolveAuthenticatedSqlCteEntrypoints( + candidate: unknown, + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): readonly SqlCteMainQueryEntrypoint[] | null { + if ( + candidate === null || + typeof candidate !== "object" || + !isSqlSourceSnapshot(source) + ) { + return null; + } + const provenance = sqlCteLayoutProvenance.get(candidate); + if ( + provenance?.source !== source || + provenance.slot !== slot || + provenance.dialect !== dialect || + !isExactSqlStatementSlotSnapshotFor( + provenance.index, + slot, + source.analysisText, + provenance.lexicalProfile, + ) + ) { + return null; + } + return provenance.entrypoints; } function framePhase( diff --git a/src/vnext/query-site.ts b/src/vnext/query-site.ts index f08321e..33c9480 100644 --- a/src/vnext/query-site.ts +++ b/src/vnext/query-site.ts @@ -32,6 +32,11 @@ export interface SqlQuerySiteRange { readonly to: number; } +export interface SqlQuerySiteEntrypoint { + readonly depth: number; + readonly from: number; +} + export type SqlQuerySiteResource = | "active-statement" | "identifier-path" @@ -1068,11 +1073,14 @@ function resultAtGap( return inactive("not-relation-position"); } -export function recognizeSqlRelationQuerySite( +function recognizeSqlRelationQuerySiteInternal( source: SqlSourceSnapshot, slot: SqlStatementSlot, position: number, dialect: SqlQuerySiteDialect, + authenticatedEntrypoints: + | readonly SqlQuerySiteEntrypoint[] + | null, ): SqlQuerySiteResult { if (slot.boundaryQuality === "opaque") { return unavailable("opaque-statement"); @@ -1120,6 +1128,7 @@ export function recognizeSqlRelationQuerySite( ); const frames: QueryFrame[] = []; const queryCandidates = new Set([0]); + let entrypointIndex = 0; let depth = 0; let sawSelect = false; let statementTainted = false; @@ -1422,7 +1431,20 @@ export function recognizeSqlRelationQuerySite( if (token.kind === "word") { const isSelect = wordEquals(source.analysisText, token, "select"); - if (queryCandidates.has(depth)) { + const entrypoint = + authenticatedEntrypoints?.[entrypointIndex]; + const relativeTokenFrom = token.from - slot.source.from; + const isAuthenticatedEntrypoint = + entrypoint?.from === relativeTokenFrom && + entrypoint.depth === depth && + isSelect; + if (isAuthenticatedEntrypoint) { + entrypointIndex += 1; + } + if ( + queryCandidates.has(depth) || + isAuthenticatedEntrypoint + ) { queryCandidates.delete(depth); if ( isSelect && @@ -1525,3 +1547,34 @@ export function recognizeSqlRelationQuerySite( } } } + +export function recognizeSqlRelationQuerySite( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, + position: number, + dialect: SqlQuerySiteDialect, +): SqlQuerySiteResult { + return recognizeSqlRelationQuerySiteInternal( + source, + slot, + position, + dialect, + null, + ); +} + +export function recognizeSqlRelationQuerySiteWithEntrypoints( + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + position: number, + dialect: SqlQuerySiteDialect, + entrypoints: readonly SqlQuerySiteEntrypoint[], +): SqlQuerySiteResult { + return recognizeSqlRelationQuerySiteInternal( + source, + slot, + position, + dialect, + entrypoints, + ); +} diff --git a/src/vnext/relation-dialect.ts b/src/vnext/relation-dialect.ts index fe01d17..76bbf50 100644 --- a/src/vnext/relation-dialect.ts +++ b/src/vnext/relation-dialect.ts @@ -20,6 +20,9 @@ import { type SqlQuerySiteDialect, } from "./query-site.js"; import { isSqlRelationReservedWord } from "./relation-reserved-words.js"; +import { + registerSqlRelationDialectRuntime, +} from "./relation-runtime-auth.js"; import type { SqlCteIdentifierComparison, SqlCteIdentifierPrefixMatch, @@ -1413,7 +1416,13 @@ function createRuntime(spec: RelationDialectSpec): SqlRelationDialectRuntime { maximumPathDepth: spec.maximumPathDepth, supportsNaturalJoin: spec.supportsNaturalJoin, }); - return Object.freeze({ completion, cteLayout, querySite }); + return registerSqlRelationDialectRuntime( + Object.freeze({ + completion, + cteLayout, + querySite, + }), + ); } function createGrammar( diff --git a/src/vnext/relation-query-site.ts b/src/vnext/relation-query-site.ts new file mode 100644 index 0000000..f324acb --- /dev/null +++ b/src/vnext/relation-query-site.ts @@ -0,0 +1,59 @@ +import { + resolveAuthenticatedSqlCteEntrypoints, + type SqlCteLayout, +} from "./cte-layout.js"; +import { + recognizeSqlRelationQuerySiteWithEntrypoints, + type SqlQuerySiteResult, +} from "./query-site.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isSqlStatementSlotSnapshot, + type SqlStatementSlot, +} from "./statement-index.js"; + +function unavailable( + reason: "ambiguous-query-site" | "opaque-statement", +): SqlQuerySiteResult { + return Object.freeze({ reason, status: "unavailable" }); +} + +export function recognizeSqlRelationQuerySiteWithCteLayout( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, + position: number, + dialect: SqlRelationDialectRuntime, + layout: SqlCteLayout, +): SqlQuerySiteResult { + if ( + !isSqlRelationDialectRuntime(dialect) || + !isSqlSourceSnapshot(source) || + !isSqlStatementSlotSnapshot(slot) + ) { + return unavailable("ambiguous-query-site"); + } + if (slot.boundaryQuality === "opaque") { + return unavailable("opaque-statement"); + } + const entrypoints = resolveAuthenticatedSqlCteEntrypoints( + layout, + source, + slot, + dialect.cteLayout, + ); + if (!entrypoints) { + return unavailable("ambiguous-query-site"); + } + return recognizeSqlRelationQuerySiteWithEntrypoints( + source, + slot, + position, + dialect.querySite, + entrypoints, + ); +} diff --git a/src/vnext/relation-runtime-auth.ts b/src/vnext/relation-runtime-auth.ts new file mode 100644 index 0000000..0bcf73b --- /dev/null +++ b/src/vnext/relation-runtime-auth.ts @@ -0,0 +1,18 @@ +const sqlRelationDialectRuntimes = new WeakSet(); + +export function registerSqlRelationDialectRuntime< + Runtime extends object, +>(runtime: Runtime): Runtime { + sqlRelationDialectRuntimes.add(runtime); + return runtime; +} + +export function isSqlRelationDialectRuntime( + candidate: unknown, +): boolean { + return ( + candidate !== null && + typeof candidate === "object" && + sqlRelationDialectRuntimes.has(candidate) + ); +} diff --git a/src/vnext/source.ts b/src/vnext/source.ts index 5da81ba..24760ef 100644 --- a/src/vnext/source.ts +++ b/src/vnext/source.ts @@ -7,6 +7,7 @@ const MAX_EMBEDDED_LANGUAGE_LENGTH = 256; const MASK_CHUNK_LENGTH = 64 * 1024; const EMPTY_EMBEDDED_REGIONS: readonly SqlEmbeddedRegion[] = Object.freeze([]); const sourceErrors = new WeakSet(); +const sqlSourceSnapshots = new WeakSet(); export type SqlSourceErrorCode = | "invalid-source" @@ -38,6 +39,16 @@ export interface SqlSourceSnapshot { readonly originalText: string; } +export function isSqlSourceSnapshot( + candidate: unknown, +): candidate is SqlSourceSnapshot { + return ( + candidate !== null && + typeof candidate === "object" && + sqlSourceSnapshots.has(candidate) + ); +} + export function findSqlEmbeddedRegionAtOrAfter( source: SqlSourceSnapshot, position: number, @@ -292,11 +303,13 @@ function maskRegion(text: string, from: number, to: number): string { export function createIdentitySqlSource(text: unknown): SqlSourceSnapshot { const originalText = normalizeSourceText(text); - return Object.freeze({ + const source = Object.freeze({ analysisText: originalText, embeddedRegions: EMPTY_EMBEDDED_REGIONS, originalText, }); + sqlSourceSnapshots.add(source); + return source; } export function createMaskedSqlSource( @@ -323,11 +336,13 @@ export function createMaskedSqlSource( } output.push(originalText.slice(cursor)); const analysisText = output.join(""); - return Object.freeze({ + const source = Object.freeze({ analysisText, embeddedRegions, originalText, }); + sqlSourceSnapshots.add(source); + return source; } export function mapAnalysisRangeToOriginal( diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index a110725..fd7f7c5 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -20,6 +20,12 @@ export { export type { SqlLexicalProfile } from "./lexical.js"; const analysisRangeBrand: unique symbol = Symbol("SqlAnalysisRange"); +const exactSqlStatementSlots = new WeakSet(); +const sqlStatementIndexProvenance = new WeakMap< + object, + SqlStatementProvenance +>(); +const sqlStatementSlots = new WeakSet(); export const MAX_SQL_STATEMENT_SLOTS = 10_000; const MAX_PREFIX_TOKENS = 6; @@ -87,6 +93,82 @@ export interface SqlStatementIndex { readonly slots: readonly SqlStatementSlot[]; } +interface SqlStatementProvenance { + readonly analysisText: string; + readonly profile: SqlLexicalProfile; + readonly slots: WeakSet; +} + +function snapshotLexicalProfile( + profile: SqlLexicalProfile, +): SqlLexicalProfile { + return Object.freeze({ + backtickQuotedIdentifiers: profile.backtickQuotedIdentifiers, + bigQueryStrings: profile.bigQueryStrings, + dollarQuotedStrings: profile.dollarQuotedStrings, + hashLineComments: profile.hashLineComments, + nestedBlockComments: profile.nestedBlockComments, + proceduralGuards: profile.proceduralGuards, + singleQuoteBackslash: profile.singleQuoteBackslash, + }); +} + +function hasSameLexicalProfile( + left: SqlLexicalProfile, + right: SqlLexicalProfile, +): boolean { + return ( + left.backtickQuotedIdentifiers === right.backtickQuotedIdentifiers && + left.bigQueryStrings === right.bigQueryStrings && + left.dollarQuotedStrings === right.dollarQuotedStrings && + left.hashLineComments === right.hashLineComments && + left.nestedBlockComments === right.nestedBlockComments && + left.proceduralGuards === right.proceduralGuards && + left.singleQuoteBackslash === right.singleQuoteBackslash + ); +} + +export function isExactSqlStatementSlotSnapshot( + candidate: unknown, +): candidate is ExactSqlStatementSlot { + return ( + candidate !== null && + typeof candidate === "object" && + exactSqlStatementSlots.has(candidate) + ); +} + +export function isExactSqlStatementSlotSnapshotFor( + index: unknown, + candidate: unknown, + analysisText: string, + profile: SqlLexicalProfile, +): candidate is ExactSqlStatementSlot { + if ( + index === null || + typeof index !== "object" || + !isExactSqlStatementSlotSnapshot(candidate) + ) { + return false; + } + const provenance = sqlStatementIndexProvenance.get(index); + return ( + provenance?.analysisText === analysisText && + hasSameLexicalProfile(provenance.profile, profile) && + provenance.slots.has(candidate) + ); +} + +export function isSqlStatementSlotSnapshot( + candidate: unknown, +): candidate is SqlStatementSlot { + return ( + candidate !== null && + typeof candidate === "object" && + sqlStatementSlots.has(candidate) + ); +} + const NORMAL_END_STATE: Extract< SqlLexicalEndState, { readonly kind: "normal" } @@ -122,7 +204,7 @@ function createExactSlot( hasCode: boolean, endState: ExactSqlStatementSlot["endState"], ): ExactSqlStatementSlot { - return Object.freeze({ + const slot = Object.freeze({ boundaryQuality: "exact", endState, extent: createAnalysisRange(from, extentTo), @@ -131,6 +213,9 @@ function createExactSlot( terminator: sourceTo === extentTo ? null : createAnalysisRange(sourceTo, extentTo), }); + exactSqlStatementSlots.add(slot); + sqlStatementSlots.add(slot); + return slot; } function createOpaqueSlot( @@ -139,11 +224,13 @@ function createOpaqueSlot( reason: SqlOpaqueBoundaryReason, detectedAt: number, ): OpaqueSqlStatementSlot { - return Object.freeze({ + const slot = Object.freeze({ boundaryQuality: "opaque", endState: createOpaqueEndState(reason, detectedAt), extent: createAnalysisRange(from, to), }); + sqlStatementSlots.add(slot); + return slot; } class SqlPrefixGuard { @@ -360,14 +447,27 @@ interface SqlStatementScanOptions { function createStatementIndex( slots: SqlStatementSlot[], + analysisText: string, + profile: SqlLexicalProfile, ): SqlStatementIndex { const finalSlot = getStatementSlot(slots, slots.length - 1); - return Object.freeze({ + const membership = new WeakSet(); + for (const slot of slots) { + membership.add(slot); + } + const provenance = Object.freeze({ + analysisText, + profile, + slots: membership, + }); + const index = Object.freeze({ endState: finalSlot.endState, quality: finalSlot.boundaryQuality === "opaque" ? "opaque" : "exact", slots: Object.freeze(slots), }); + sqlStatementIndexProvenance.set(index, provenance); + return index; } function scanSqlStatementIndex( @@ -393,7 +493,7 @@ function scanSqlStatementIndex( detectedAt, ); slots.push(slot); - return createStatementIndex(slots); + return createStatementIndex(slots, analysisText, profile); }; while (cursor < analysisText.length) { @@ -611,7 +711,7 @@ function scanSqlStatementIndex( finalEndState, ), ); - return createStatementIndex(slots); + return createStatementIndex(slots, analysisText, profile); } /** Builds the bounded, parser-free statement partition for one analysis text. */ @@ -619,10 +719,14 @@ export function buildSqlStatementIndex( analysisText: string, profile: SqlLexicalProfile, ): SqlStatementIndex { - return scanSqlStatementIndex(analysisText, profile, { - from: 0, - prefixSlots: [], - }); + return scanSqlStatementIndex( + analysisText, + snapshotLexicalProfile(profile), + { + from: 0, + prefixSlots: [], + }, + ); } function statementSlotIndexAt( @@ -697,7 +801,7 @@ function shiftStatementSlot( delta: number, ): SqlStatementSlot { if (slot.boundaryQuality === "opaque") { - return Object.freeze({ + const shifted = Object.freeze({ boundaryQuality: "opaque", endState: shiftEndState(slot.endState, delta), extent: createAnalysisRange( @@ -705,8 +809,10 @@ function shiftStatementSlot( slot.extent.to + delta, ), }); + sqlStatementSlots.add(shifted); + return shifted; } - return Object.freeze({ + const shifted = Object.freeze({ boundaryQuality: "exact", endState: shiftEndState(slot.endState, delta), extent: createAnalysisRange( @@ -725,6 +831,9 @@ function shiftStatementSlot( ) : null, }); + exactSqlStatementSlots.add(shifted); + sqlStatementSlots.add(shifted); + return shifted; } function normalizeTrustedChanges( @@ -774,15 +883,37 @@ export function updateSqlStatementIndex( changes: readonly SqlTextChange[], profile: SqlLexicalProfile, ): SqlStatementIndex { + const previousProvenance = + sqlStatementIndexProvenance.get(previousIndex); + if ( + !previousProvenance || + !hasSameLexicalProfile(previousProvenance.profile, profile) + ) { + return scanSqlStatementIndex( + nextAnalysisText, + snapshotLexicalProfile(profile), + { + from: 0, + prefixSlots: [], + }, + ); + } + const nextProfile = previousProvenance.profile; const previousSlots = previousIndex.slots; const previousLength = getStatementSlot( previousSlots, previousSlots.length - 1, ).extent.to; if (changes.length === 0) { - return previousLength === nextAnalysisText.length + const unchanged = + previousLength === nextAnalysisText.length && + previousProvenance.analysisText === nextAnalysisText; + return unchanged ? previousIndex - : buildSqlStatementIndex(nextAnalysisText, profile); + : scanSqlStatementIndex(nextAnalysisText, nextProfile, { + from: 0, + prefixSlots: [], + }); } const normalized = normalizeTrustedChanges( changes, @@ -790,7 +921,10 @@ export function updateSqlStatementIndex( nextAnalysisText.length, ); if (!normalized) { - return buildSqlStatementIndex(nextAnalysisText, profile); + return scanSqlStatementIndex(nextAnalysisText, nextProfile, { + from: 0, + prefixSlots: [], + }); } const restartIndex = statementSlotIndexAt( @@ -816,7 +950,7 @@ export function updateSqlStatementIndex( previousFinalSlot.boundaryQuality === "opaque" && previousFinalSlot.endState.reason === "resource-limit"; - return scanSqlStatementIndex(nextAnalysisText, profile, { + return scanSqlStatementIndex(nextAnalysisText, nextProfile, { from: restartFrom, prefixSlots, tryReuseSuffix: (newBoundary, scannedSlots) => { @@ -862,7 +996,11 @@ export function updateSqlStatementIndex( : oldSuffix.map((slot) => shiftStatementSlot(slot, normalized.delta), ); - return createStatementIndex([...scannedSlots, ...suffix]); + return createStatementIndex( + [...scannedSlots, ...suffix], + nextAnalysisText, + nextProfile, + ); }, }); } From 9b7851c96ae3cc969c11f4f4b2dafe1a4e59f996 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 12:05:03 +0800 Subject: [PATCH 22/42] feat(vnext): prepare local relation evidence (#191) ## Summary - add a package-private prepared statement handle that composes authenticated query-site and CTE-visibility evidence - preserve qualified, inactive, ambiguous, opaque, and resource-limited states without inventing local candidates - authenticate complete CTE layout results so active-statement resource failures survive orchestration - add four-dialect integration coverage and cold/warm performance baselines ## Why The relation-completion session needs one immutable artifact it can cache per source, statement slot, and dialect. Keeping preparation separate from position projection avoids rebuilding CTE layout state as the cursor moves, while the opaque handle prevents stale or independently mixable evidence from crossing sessions. This remains package-private until the catalog and end-to-end completion slice prove the public API described by ADR 0005. ## Evidence - 1,691 passing unit tests plus one governed expected failure - all five TypeScript configurations, oxlint, integrity, package smoke, worker placement, and browser suites pass - changed coverage: 97.05% statements, 95.77% branches, 100% functions, 97.04% lines - warm projection: approximately 0.65 ms for a 10 KiB statement and 0.32 ms for 256 CTEs Roadmap: #169 --- ## Summary by cubic Introduce a package-private prepared statement that authenticates query-site and CTE visibility to power local relation completion. It preserves qualified/inactive/opaque/ambiguous/resource-limited states and adds four-dialect coverage; supports #169. - **New Features** - Added `prepareSqlLocalRelationStatement` and `analyzeSqlLocalRelationSite` for deterministic, cacheable local evidence (results are frozen). - Propagated authenticated CTE layout through `relation-query-site`, including active-statement resource failures. - Added `isSqlStatementSlotSnapshotFor` and authenticated layout registration/lookup to validate provenance. - Added `bench:local-relation-site` plus tests and benches across PostgreSQL, DuckDB, BigQuery, and Dremio. Written for commit 74e9edff76dada81c9b6075ce613c77b89fdbd0e. Summary will update on new commits. Review in cubic --- package.json | 1 + .../__tests__/local-relation-site.bench.ts | 134 +++++ .../__tests__/local-relation-site.test.ts | 543 ++++++++++++++++++ src/vnext/__tests__/query-site.test.ts | 37 ++ src/vnext/cte-layout.ts | 129 +++-- src/vnext/local-relation-site.ts | 200 +++++++ src/vnext/relation-query-site.ts | 26 +- src/vnext/statement-index.ts | 19 +- 8 files changed, 1047 insertions(+), 42 deletions(-) create mode 100644 src/vnext/__tests__/local-relation-site.bench.ts create mode 100644 src/vnext/__tests__/local-relation-site.test.ts create mode 100644 src/vnext/local-relation-site.ts diff --git a/package.json b/package.json index 64b9b9a..b57fcc8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", diff --git a/src/vnext/__tests__/local-relation-site.bench.ts b/src/vnext/__tests__/local-relation-site.bench.ts new file mode 100644 index 0000000..9c10844 --- /dev/null +++ b/src/vnext/__tests__/local-relation-site.bench.ts @@ -0,0 +1,134 @@ +import { bench, describe } from "vitest"; +import { MAX_CTE_DECLARATIONS } from "../cte-layout.js"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationStatement, +} from "../local-relation-site.js"; +import { DUCKDB_SQL_RELATION_DIALECT } from "../relation-dialect.js"; +import { createIdentitySqlSource } from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, +} from "../statement-index.js"; + +interface Fixture { + readonly index: ReturnType; + readonly position: number; + readonly slot: ReturnType; + readonly source: ReturnType; + readonly statement: SqlLocalRelationStatement; +} + +function fixture(text: string): Fixture { + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + DUCKDB_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const position = text.length; + const slot = findSqlStatementSlot(index, position, "left"); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + DUCKDB_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Local relation benchmark must prepare"); + } + return { + index, + position, + slot, + source, + statement: preparation.statement, + }; +} + +function coldAnalyze(value: Fixture) { + const preparation = prepareSqlLocalRelationStatement( + value.source, + value.index, + value.slot, + DUCKDB_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Cold local relation benchmark must prepare"); + } + return analyzeSqlLocalRelationSite( + preparation.statement, + value.position, + ); +} + +const tenKibibytes = 10 * 1_024; +const withPrefix = "WITH local_cte AS (SELECT 1) SELECT "; +const withSuffix = " FROM target"; +const projectionLength = + tenKibibytes - withPrefix.length - withSuffix.length; +const withText = `${withPrefix}${"x,".repeat( + Math.floor((projectionLength - 1) / 2), +)}x${withSuffix}`; +const paddedWithText = `${withText.slice( + 0, + -withSuffix.length, +)}${" ".repeat(tenKibibytes - withText.length)}${withSuffix}`; +if (paddedWithText.length !== tenKibibytes) { + throw new Error("Local relation benchmark must be exactly 10 KiB"); +} +const withFixture = fixture(paddedWithText); + +const maximumCteText = `WITH ${Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, +).join(", ")} SELECT * FROM target`; +const maximumCteFixture = fixture(maximumCteText); + +const withPreflight = analyzeSqlLocalRelationSite( + withFixture.statement, + withFixture.position, +); +const maximumCtePreflight = analyzeSqlLocalRelationSite( + maximumCteFixture.statement, + maximumCteFixture.position, +); +if ( + withPreflight.status !== "ready" || + withPreflight.local.kind !== "unqualified" || + withPreflight.local.cteVisibility.ctes.length !== 1 +) { + throw new Error("10 KiB benchmark must expose one visible CTE"); +} +if ( + maximumCtePreflight.status !== "ready" || + maximumCtePreflight.local.kind !== "unqualified" || + maximumCtePreflight.local.cteVisibility.ctes.length !== + MAX_CTE_DECLARATIONS +) { + throw new Error("Maximum benchmark must expose every CTE"); +} + +describe("local relation site", () => { + bench("cold 10 KiB WITH statement", () => { + coldAnalyze(withFixture); + }); + + bench("warm 10 KiB WITH statement", () => { + analyzeSqlLocalRelationSite( + withFixture.statement, + withFixture.position, + ); + }); + + bench("cold 256-CTE statement", () => { + coldAnalyze(maximumCteFixture); + }); + + bench("warm 256-CTE statement", () => { + analyzeSqlLocalRelationSite( + maximumCteFixture.statement, + maximumCteFixture.position, + ); + }); +}); diff --git a/src/vnext/__tests__/local-relation-site.test.ts b/src/vnext/__tests__/local-relation-site.test.ts new file mode 100644 index 0000000..899e6c1 --- /dev/null +++ b/src/vnext/__tests__/local-relation-site.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationSiteResult, +} from "../local-relation-site.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, + type SqlSourceSnapshot, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, + type SqlStatementIndex, + type SqlStatementSlot, +} from "../statement-index.js"; + +const RUNTIMES = [ + { + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + name: "PostgreSQL", + }, + { dialect: DUCKDB_SQL_RELATION_DIALECT, name: "DuckDB" }, + { dialect: BIGQUERY_SQL_RELATION_DIALECT, name: "BigQuery" }, + { dialect: DREMIO_SQL_RELATION_DIALECT, name: "Dremio" }, +] as const; + +function markedText(marked: string): { + readonly position: number; + readonly text: string; +} { + const position = marked.indexOf("|"); + if (position < 0 || marked.indexOf("|", position + 1) >= 0) { + throw new Error("Fixture requires exactly one cursor marker"); + } + return { + position, + text: `${marked.slice(0, position)}${marked.slice(position + 1)}`, + }; +} + +function hostileProxy( + target: Value, + onInvoke: () => void, +): Value { + return new Proxy(target, { + get() { + onInvoke(); + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + onInvoke(); + throw new Error("hostile"); + }, + ownKeys() { + onInvoke(); + throw new Error("hostile"); + }, + }); +} + +function analyzeMarked( + marked: string, + dialect: SqlRelationDialectRuntime = + POSTGRESQL_SQL_RELATION_DIALECT, +): { + readonly index: SqlStatementIndex; + readonly position: number; + readonly result: SqlLocalRelationSiteResult; + readonly slot: SqlStatementSlot; + readonly source: SqlSourceSnapshot; +} { + const { position, text } = markedText(marked); + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + dialect, + ); + if (preparation.status === "unavailable") { + return { + index, + position, + result: preparation, + slot, + source, + }; + } + return { + index, + position, + result: analyzeSqlLocalRelationSite( + preparation.statement, + position, + ), + slot, + source, + }; +} + +function expectReady( + result: SqlLocalRelationSiteResult, +): Extract { + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + throw new Error("Expected a ready local relation site"); + } + return result; +} + +function visibleNames( + result: SqlLocalRelationSiteResult, +): string[] { + const ready = expectReady(result); + expect(ready.local.kind).toBe("unqualified"); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + return ready.local.cteVisibility.ctes.map( + (cte) => cte.sourceSpelling, + ); +} + +describe("local relation statement preparation", () => { + it("fails closed for mixed runtime and source/index/slot evidence", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + const mixedRuntime: SqlRelationDialectRuntime = { + ...POSTGRESQL_SQL_RELATION_DIALECT, + querySite: DUCKDB_SQL_RELATION_DIALECT.querySite, + }; + expect( + prepareSqlLocalRelationStatement( + source, + index, + slot, + mixedRuntime, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + { ...source }, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + { ...index }, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + { ...slot }, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + + const otherSource = createIdentitySqlSource(source.analysisText); + const otherIndex = buildSqlStatementIndex( + otherSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const otherSlot = findSqlStatementSlot( + otherIndex, + otherSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + source, + otherIndex, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + otherSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + }); + + it("rejects hostile proxies without invoking traps", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + let invoked = false; + const onInvoke = () => { + invoked = true; + }; + expect( + prepareSqlLocalRelationStatement( + hostileProxy(source, onInvoke), + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + hostileProxy(index, onInvoke), + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + hostileProxy(slot, onInvoke), + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + slot, + hostileProxy( + POSTGRESQL_SQL_RELATION_DIALECT, + onInvoke, + ), + ).status, + ).toBe("unavailable"); + expect(invoked).toBe(false); + }); + + it("preserves explicit opaque and resource failures", () => { + const opaqueSource = createIdentitySqlSource("DELIMITER $$"); + const opaqueIndex = buildSqlStatementIndex( + opaqueSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const opaqueSlot = findSqlStatementSlot( + opaqueIndex, + opaqueSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + opaqueSource, + opaqueIndex, + opaqueSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "opaque-statement", + status: "unavailable", + }); + + const longSource = createIdentitySqlSource( + `SELECT * FROM ${" ".repeat(65_537)}`, + ); + const longIndex = buildSqlStatementIndex( + longSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const longSlot = findSqlStatementSlot( + longIndex, + longSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + longSource, + longIndex, + longSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + }); +}); + +describe("local relation-site evidence", () => { + it.each(RUNTIMES)( + "projects visible main-query CTEs for $name", + ({ dialect }) => { + const declarations = + dialect === DREMIO_SQL_RELATION_DIALECT + ? "first(id) AS (SELECT 1)" + : "first AS (SELECT 1), second AS (SELECT 2)"; + const result = analyzeMarked( + `WITH ${declarations} SELECT * FROM |`, + dialect, + ).result; + expect(visibleNames(result)).toEqual( + dialect === DREMIO_SQL_RELATION_DIALECT + ? ["first"] + : ["first", "second"], + ); + const ready = expectReady(result); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + expect(ready.querySite.recognition.quality).toBe("exact"); + expect(ready.local.cteVisibility).toMatchObject({ + issues: [], + quality: "exact", + shadowing: { coverage: "complete" }, + }); + }, + ); + + it.each(RUNTIMES)( + "keeps ordinary $name sites locally empty", + ({ dialect }) => { + const result = analyzeMarked( + "SELECT * FROM |", + dialect, + ).result; + expect(visibleNames(result)).toEqual([]); + }, + ); + + it("projects non-recursive sibling visibility", () => { + expect( + visibleNames( + analyzeMarked( + "WITH first AS (SELECT * FROM |), second AS (SELECT 2) SELECT 1", + ).result, + ), + ).toEqual([]); + expect( + visibleNames( + analyzeMarked( + "WITH first AS (SELECT 1), second AS (SELECT * FROM |) SELECT 1", + ).result, + ), + ).toEqual(["first"]); + }); + + it("preserves recursive uncertainty without inventing candidates", () => { + const ready = expectReady( + analyzeMarked( + "WITH RECURSIVE first AS (SELECT * FROM |) SELECT 1", + ).result, + ); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + expect(ready.local.cteVisibility).toMatchObject({ + ctes: [], + issues: ["recursive-cte-position"], + quality: "recovered", + shadowing: { coverage: "complete" }, + }); + }); + + it("uses statement-relative coordinates in later statements", () => { + const fixture = analyzeMarked( + "SELECT 0; WITH later_cte AS (SELECT 1) SELECT * FROM |", + ); + const ready = expectReady(fixture.result); + if ( + ready.local.kind !== "unqualified" || + fixture.slot.boundaryQuality === "opaque" + ) { + throw new Error("Expected exact unqualified later-statement evidence"); + } + const cte = ready.local.cteVisibility.ctes[0]; + expect(cte?.sourceSpelling).toBe("later_cte"); + expect(cte?.declarationPosition).toBe( + fixture.source.analysisText.indexOf("later_cte") - + fixture.slot.source.from, + ); + expect(ready.querySite.finalSegmentRange.from).toBe( + fixture.position - fixture.slot.source.from, + ); + }); + + it("separates qualified sites from irrelevant CTE uncertainty", () => { + const ready = expectReady( + analyzeMarked( + "WITH RECURSIVE first AS (SELECT 1) SELECT * FROM schema.|", + ).result, + ); + expect(ready.local).toEqual({ kind: "qualified" }); + expect(ready.querySite.qualifier).toEqual([ + { quoted: false, value: "schema" }, + ]); + }); + + it("passes inactive query-site states through without local evidence", () => { + expect(analyzeMarked("SELECT |").result).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect(analyzeMarked("SELECT * FROM '|'").result).toEqual({ + reason: "cursor-in-string", + status: "inactive", + }); + }); + + it("rejects copied and proxied prepared statements without traps", () => { + const fixture = markedText("SELECT * FROM |"); + const source = createIdentitySqlSource(fixture.text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + fixture.position, + "left", + ); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Prepared statement fixture must be ready"); + } + expect( + analyzeSqlLocalRelationSite( + { ...preparation.statement }, + fixture.position, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + let invoked = false; + const proxied = new Proxy(preparation.statement, { + get() { + invoked = true; + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + invoked = true; + throw new Error("hostile"); + }, + ownKeys() { + invoked = true; + throw new Error("hostile"); + }, + }); + expect( + analyzeSqlLocalRelationSite(proxied, fixture.position), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect(invoked).toBe(false); + expect( + Reflect.apply(analyzeSqlLocalRelationSite, undefined, [ + null, + fixture.position, + ]), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("is deterministic and freezes every exposed wrapper", () => { + const fixture = markedText( + "WITH first AS (SELECT 1) SELECT * FROM |", + ); + const source = createIdentitySqlSource(fixture.text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + fixture.position, + "left", + ); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Prepared statement fixture must be ready"); + } + const first = analyzeSqlLocalRelationSite( + preparation.statement, + fixture.position, + ); + const second = analyzeSqlLocalRelationSite( + preparation.statement, + fixture.position, + ); + expect(first).toEqual(second); + expect(Object.isFrozen(preparation)).toBe(true); + expect(Object.isFrozen(preparation.statement)).toBe(true); + expect(Object.isFrozen(first)).toBe(true); + const ready = expectReady(first); + expect(Object.isFrozen(ready.local)).toBe(true); + expect(Object.isFrozen(ready.querySite)).toBe(true); + if (ready.local.kind === "unqualified") { + expect(Object.isFrozen(ready.local.cteVisibility)).toBe(true); + expect(Object.isFrozen(ready.local.cteVisibility.ctes)).toBe(true); + } + }); +}); diff --git a/src/vnext/__tests__/query-site.test.ts b/src/vnext/__tests__/query-site.test.ts index 089b52a..9f58b02 100644 --- a/src/vnext/__tests__/query-site.test.ts +++ b/src/vnext/__tests__/query-site.test.ts @@ -857,6 +857,43 @@ describe("authenticated CTE main-query entrypoints", () => { }); }); + it("preserves authenticated CTE resource failures", () => { + const source = createIdentitySqlSource( + `SELECT * FROM ${" ".repeat(65_537)}`, + ); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + if (slot.boundaryQuality === "opaque") { + throw new Error("Resource fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + slot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + }); + it.each([ ["invalid-header", "header"], ["skipped", "skipped"], diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts index 820fefc..ac11cdf 100644 --- a/src/vnext/cte-layout.ts +++ b/src/vnext/cte-layout.ts @@ -173,11 +173,19 @@ export interface SqlCteVisibility { readonly shadowing: SqlCteShadowing; } +type AuthenticatedSqlCteLayout = + | Exclude + | { + readonly reason: "resource-limit"; + readonly resource: "active-statement"; + readonly status: "unavailable"; + }; + interface SqlCteLayoutProvenance { readonly dialect: SqlCteLayoutDialect; - readonly entrypoints: readonly SqlCteMainQueryEntrypoint[]; readonly index: SqlStatementIndex; readonly lexicalProfile: SqlLexicalProfile; + readonly layout: AuthenticatedSqlCteLayout; readonly slot: ExactSqlStatementSlot; readonly source: SqlSourceSnapshot; } @@ -187,6 +195,40 @@ const sqlCteLayoutProvenance = new WeakMap< SqlCteLayoutProvenance >(); +function registerSqlCteLayout< + Layout extends AuthenticatedSqlCteLayout, +>( + layout: Layout, + source: SqlSourceSnapshot, + index: SqlStatementIndex, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, + lexicalProfile: SqlLexicalProfile, +): Layout { + if ( + isSqlSourceSnapshot(source) && + isExactSqlStatementSlotSnapshotFor( + index, + slot, + source.analysisText, + lexicalProfile, + ) + ) { + sqlCteLayoutProvenance.set( + layout, + Object.freeze({ + dialect, + index, + layout, + lexicalProfile, + slot, + source, + }), + ); + } + return layout; +} + type HeaderState = | "after-as" | "after-body" @@ -650,13 +692,19 @@ function freezeIssues( function layoutUnavailable( reason: "opaque-statement" | "resource-limit", - resource?: SqlCteLayoutResource, ): SqlCteLayout { - return Object.freeze( - resource === undefined - ? { reason, status: "unavailable" } - : { reason, resource, status: "unavailable" }, - ); + return Object.freeze({ reason, status: "unavailable" }); +} + +function activeStatementUnavailable(): Extract< + AuthenticatedSqlCteLayout, + { readonly status: "unavailable" } +> { + return Object.freeze({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); } function findOpenParentFrame( @@ -1038,14 +1086,21 @@ export function analyzeSqlCteLayout( dialect: SqlCteLayoutDialect, ): SqlCteLayout { const statementLength = slot.source.to - slot.source.from; - if (statementLength > MAX_CTE_STATEMENT_LENGTH) { - return layoutUnavailable("resource-limit", "active-statement"); - } const validatedDialect = validateDialect(dialect); if (!validatedDialect) { return layoutUnavailable("resource-limit"); } const { grammar, lexicalProfile } = validatedDialect; + if (statementLength > MAX_CTE_STATEMENT_LENGTH) { + return registerSqlCteLayout( + activeStatementUnavailable(), + source, + index, + slot, + dialect, + lexicalProfile, + ); + } const text = source.analysisText; const statementFrom = slot.source.from; const lexer = new BoundedSqlLexer( @@ -1583,36 +1638,22 @@ export function analyzeSqlCteLayout( issues, resource, ); - if ( - isSqlSourceSnapshot(source) && - isExactSqlStatementSlotSnapshotFor( - index, - slot, - source.analysisText, - lexicalProfile, - ) - ) { - sqlCteLayoutProvenance.set( - layout, - Object.freeze({ - dialect, - entrypoints: layout.mainQueryEntrypoints, - index, - lexicalProfile, - slot, - source, - }), - ); - } - return layout; + return registerSqlCteLayout( + layout, + source, + index, + slot, + dialect, + lexicalProfile, + ); } -export function resolveAuthenticatedSqlCteEntrypoints( +export function resolveAuthenticatedSqlCteLayout( candidate: unknown, source: SqlSourceSnapshot, slot: ExactSqlStatementSlot, dialect: SqlCteLayoutDialect, -): readonly SqlCteMainQueryEntrypoint[] | null { +): AuthenticatedSqlCteLayout | null { if ( candidate === null || typeof candidate !== "object" || @@ -1634,7 +1675,25 @@ export function resolveAuthenticatedSqlCteEntrypoints( ) { return null; } - return provenance.entrypoints; + return provenance.layout; +} + +export function resolveAuthenticatedSqlCteEntrypoints( + candidate: unknown, + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): readonly SqlCteMainQueryEntrypoint[] | null { + const layout = resolveAuthenticatedSqlCteLayout( + candidate, + source, + slot, + dialect, + ); + if (!layout || layout.status === "unavailable") { + return null; + } + return layout.mainQueryEntrypoints; } function framePhase( diff --git a/src/vnext/local-relation-site.ts b/src/vnext/local-relation-site.ts new file mode 100644 index 0000000..5199eb0 --- /dev/null +++ b/src/vnext/local-relation-site.ts @@ -0,0 +1,200 @@ +import { + analyzeSqlCteLayout, + type SqlCteLayoutResource, + type SqlCteVisibility, + visibleSqlCtesAt, +} from "./cte-layout.js"; +import { + recognizeSqlRelationQuerySiteWithEntrypoints, + type SqlQuerySiteResult, +} from "./query-site.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isSqlStatementSlotSnapshotFor, + type ExactSqlStatementSlot, + type SqlStatementIndex, + type SqlStatementSlot, +} from "./statement-index.js"; + +const localRelationStatementBrand: unique symbol = Symbol( + "SqlLocalRelationStatement", +); + +export interface SqlLocalRelationStatement { + readonly [localRelationStatementBrand]: "SqlLocalRelationStatement"; +} + +export type SqlLocalRelationStatementPreparation = + | { + readonly status: "ready"; + readonly statement: SqlLocalRelationStatement; + } + | { + readonly status: "unavailable"; + readonly reason: + | "ambiguous-query-site" + | "opaque-statement" + | "resource-limit"; + readonly resource?: SqlCteLayoutResource; + }; + +type SqlReadyQuerySite = Extract< + SqlQuerySiteResult, + { readonly status: "ready" } +>; + +export type SqlLocalRelationSiteResult = + | Exclude + | Extract< + SqlLocalRelationStatementPreparation, + { readonly status: "unavailable" } + > + | { + readonly status: "ready"; + readonly querySite: SqlReadyQuerySite; + readonly local: + | { + readonly kind: "qualified"; + } + | { + readonly kind: "unqualified"; + readonly cteVisibility: SqlCteVisibility; + }; + }; + +interface SqlLocalRelationStatementContext { + readonly dialect: SqlRelationDialectRuntime; + readonly layout: Exclude< + ReturnType, + { readonly status: "unavailable" } + >; + readonly slot: ExactSqlStatementSlot; + readonly source: SqlSourceSnapshot; +} + +const localRelationStatements = new WeakMap< + object, + SqlLocalRelationStatementContext +>(); + +const QUALIFIED_LOCAL = Object.freeze({ + kind: "qualified" as const, +}); + +function unavailablePreparation( + reason: Extract< + SqlLocalRelationStatementPreparation, + { readonly status: "unavailable" } + >["reason"], + resource?: SqlCteLayoutResource, +): SqlLocalRelationStatementPreparation { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); +} + +function unavailableSite(): SqlLocalRelationSiteResult { + return Object.freeze({ + reason: "ambiguous-query-site", + status: "unavailable", + }); +} + +export function prepareSqlLocalRelationStatement( + source: SqlSourceSnapshot, + index: SqlStatementIndex, + slot: SqlStatementSlot, + dialect: SqlRelationDialectRuntime, +): SqlLocalRelationStatementPreparation { + if ( + !isSqlRelationDialectRuntime(dialect) || + !isSqlSourceSnapshot(source) || + !isSqlStatementSlotSnapshotFor( + index, + slot, + source.analysisText, + dialect.querySite.lexicalProfile, + ) + ) { + return unavailablePreparation("ambiguous-query-site"); + } + if (slot.boundaryQuality === "opaque") { + return unavailablePreparation("opaque-statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + dialect.cteLayout, + ); + if (layout.status === "unavailable") { + return unavailablePreparation(layout.reason, layout.resource); + } + const statement: SqlLocalRelationStatement = Object.freeze({ + [localRelationStatementBrand]: "SqlLocalRelationStatement" as const, + }); + localRelationStatements.set( + statement, + Object.freeze({ dialect, layout, slot, source }), + ); + return Object.freeze({ statement, status: "ready" }); +} + +export function analyzeSqlLocalRelationSite( + statement: SqlLocalRelationStatement, + position: number, +): SqlLocalRelationSiteResult { + if ( + statement === null || + typeof statement !== "object" + ) { + return unavailableSite(); + } + const context = localRelationStatements.get(statement); + if (!context) { + return unavailableSite(); + } + const querySite = recognizeSqlRelationQuerySiteWithEntrypoints( + context.source, + context.slot, + position, + context.dialect.querySite, + context.layout.mainQueryEntrypoints, + ); + if (querySite.status !== "ready") { + return querySite; + } + if (querySite.qualifier.length > 0) { + return Object.freeze({ + local: QUALIFIED_LOCAL, + querySite, + status: "ready", + }); + } + const relativePosition = position - context.slot.source.from; + if ( + !Number.isSafeInteger(relativePosition) || + relativePosition < 0 || + relativePosition > context.layout.statementLength + ) { + return unavailableSite(); + } + return Object.freeze({ + local: Object.freeze({ + cteVisibility: visibleSqlCtesAt( + context.layout, + relativePosition, + ), + kind: "unqualified" as const, + }), + querySite, + status: "ready", + }); +} diff --git a/src/vnext/relation-query-site.ts b/src/vnext/relation-query-site.ts index f324acb..f739715 100644 --- a/src/vnext/relation-query-site.ts +++ b/src/vnext/relation-query-site.ts @@ -1,5 +1,5 @@ import { - resolveAuthenticatedSqlCteEntrypoints, + resolveAuthenticatedSqlCteLayout, type SqlCteLayout, } from "./cte-layout.js"; import { @@ -18,9 +18,17 @@ import { } from "./statement-index.js"; function unavailable( - reason: "ambiguous-query-site" | "opaque-statement", + reason: + | "ambiguous-query-site" + | "opaque-statement" + | "resource-limit", + resource?: "active-statement", ): SqlQuerySiteResult { - return Object.freeze({ reason, status: "unavailable" }); + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); } export function recognizeSqlRelationQuerySiteWithCteLayout( @@ -40,20 +48,26 @@ export function recognizeSqlRelationQuerySiteWithCteLayout( if (slot.boundaryQuality === "opaque") { return unavailable("opaque-statement"); } - const entrypoints = resolveAuthenticatedSqlCteEntrypoints( + const authenticatedLayout = resolveAuthenticatedSqlCteLayout( layout, source, slot, dialect.cteLayout, ); - if (!entrypoints) { + if (!authenticatedLayout) { return unavailable("ambiguous-query-site"); } + if (authenticatedLayout.status === "unavailable") { + return unavailable( + authenticatedLayout.reason, + authenticatedLayout.resource, + ); + } return recognizeSqlRelationQuerySiteWithEntrypoints( source, slot, position, dialect.querySite, - entrypoints, + authenticatedLayout.mainQueryEntrypoints, ); } diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index fd7f7c5..bd5c0c0 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -144,10 +144,27 @@ export function isExactSqlStatementSlotSnapshotFor( analysisText: string, profile: SqlLexicalProfile, ): candidate is ExactSqlStatementSlot { + return ( + isExactSqlStatementSlotSnapshot(candidate) && + isSqlStatementSlotSnapshotFor( + index, + candidate, + analysisText, + profile, + ) + ); +} + +export function isSqlStatementSlotSnapshotFor( + index: unknown, + candidate: unknown, + analysisText: string, + profile: SqlLexicalProfile, +): candidate is SqlStatementSlot { if ( index === null || typeof index !== "object" || - !isExactSqlStatementSlotSnapshot(candidate) + !isSqlStatementSlotSnapshot(candidate) ) { return false; } From b77db7c251b1ca0c0d64502c8663589a5e774466 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 12:51:49 +0800 Subject: [PATCH 23/42] feat(vnext): validate relation catalog data (#192) ## Summary - add an authenticated, package-private catalog-provider boundary - decode hostile request, response, invalidation, and epoch data into bounded frozen values - validate canonical and completion paths through authenticated dialect runtimes - define provider closure ownership, alias copying, page atomicity, and epoch semantics in ADR 0005 - add boundary contracts and worst-case performance benchmarks ## Why Catalog metadata crosses an asynchronous host boundary and cannot be trusted as typed data at runtime. This slice gives the upcoming coordinator a small, deterministic input surface without coupling catalog providers to sessions, CodeMirror, DOM state, or provider-supplied SQL. ## Validation - 1,727 unit tests passed plus one governed expected failure - changed coverage: 95.61% statements, 95.10% branches, 100% functions, 95.55% lines - strict and loose optional type configurations, lint, integrity, package smoke, browser, and worker-placement checks passed - three independent adversarial reviews approved exact SHA b9f6d3eb4366d1577345b6879b5bb684641b8d8f - million-unit hostile text rejects in about 0.003 ms; near-limit Dremio response decodes in about 0.27 ms Part of #169. --- ...-parser-independent-relation-completion.md | 48 +- package.json | 1 + .../relation-catalog-boundary.bench.ts | 153 ++ .../relation-catalog-boundary.test.ts | 1388 +++++++++++++++++ src/vnext/relation-catalog-boundary.ts | 1269 +++++++++++++++ src/vnext/relation-completion-types.ts | 2 + .../marimo-relation-completion.test-d.ts | 19 + 7 files changed, 2876 insertions(+), 4 deletions(-) create mode 100644 src/vnext/__tests__/relation-catalog-boundary.bench.ts create mode 100644 src/vnext/__tests__/relation-catalog-boundary.test.ts create mode 100644 src/vnext/relation-catalog-boundary.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 87d9ab2..f6266dd 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -347,7 +347,14 @@ search request contains only copied, recursively frozen plain data: The `AbortSignal` is passed separately. Providers never receive a session, revision, `EditorState`, DOM object, credential object, or arbitrary live host context. Connection identity belongs in the catalog scope; live resources stay -inside the provider closure. +inside the provider closure. The provider object is caller-owned. The service +captures its own-data callbacks once and owns searches, subscriptions, and +their cleanup, but does not imply provider-wide disposal. Provider callbacks +are closure functions with a declared `this: void` contract and are invoked +with `this === undefined`; mutable receiver state is not part of the provider +API. Unrelated own configuration fields are ignored, while inherited callbacks +and accessors are rejected. An own optional subscription value of `undefined` +is normalized as omitted. A returned relation contains: @@ -363,7 +370,10 @@ A returned relation contains: The initial closed component roles are `catalog`, `schema`, `project`, `dataset`, and `relation`. Each dialect accepts only its documented role -sequences, and the final component is always `relation`. +sequences, and the final component is always `relation`. The provider's +`quoted` bit is positive catalog evidence that the canonical decoded spelling +is quoted; it is never insertion SQL. The dialect still owns delimiters, +escaping, reserved-word quoting, and rendering. The service never invents an unqualified candidate from an absolute path. The provider proves matching and addressability through `matchQuality` and @@ -397,7 +407,25 @@ as an unknown-object diagnostic. Provider responses are decoded from `unknown` using bounded own enumerable data properties and copied into fresh frozen current-realm objects. Accessors, throwing proxies, unexpected keys, oversized strings or arrays, and malformed -closed values fail without exposing raw errors. +closed values fail without exposing raw errors. One malformed relation rejects +the whole page: silently retaining other entities while preserving the +provider's coverage claim would create false authority. Entity IDs must be +unique within a page. Their stability across searches and epochs is a provider +promise used for provenance, not authority the service can infer by generic +deduplication. Shared input identities are allowed, but each occurrence is +decoded, budgeted, and copied independently, so aliasing cannot retain mutable +provider state or bypass aggregate limits. + +Both the complete canonical role sequence and the suffix selected by +`completionPathStart` must be legal for the registered dialect. The boundary +pre-renders the selected suffix from fresh decoded data so later scheduling and +composition never touch raw provider objects or provider-supplied SQL. + +The initial vertical slice validates one bounded page and reports paginated +coverage as incomplete; it does not automatically follow or merge continuation +tokens. Continuation tokens are opaque provider state. They are bounded, +retained only with their page/work state, and never included in completion +items, logs, errors, or revision events. The core also provides a provisional `createInMemoryRelationCatalog` helper for bounded readonly relation data. It indexes stable IDs, kinds, role-bearing @@ -439,8 +467,20 @@ A search that discovers a higher epoch supersedes itself instead of publishing against its older captured revision. Pages and cache entries from different epochs are never merged. +Any successfully decoded response status (`ready`, `loading`, or `failed`) may +establish the first epoch baseline. Equal-epoch responses for different exact +searches remain usable; only the epoch observation is a duplicate. A terminal +`loading` response requires a higher epoch before that exact search can become +ready. Providers without subscriptions may return `loading`, but they cannot +cause automatic refresh; a later explicit request must observe the higher +epoch. Failed responses are attempt evidence: `next-request` may recover at +the same epoch, while `after-invalidation` and `never` create bounded retry +gates rather than cached search results. + The service reference-counts one provider subscription per provider configuration and scope, then fans invalidation out to subscribed sessions. +The installed callback closure supplies authenticated provider and scope +identity; the untrusted invalidation payload therefore contains only an epoch. Subscription membership is installed atomically before a provider can call back synchronously. A newly joining session captures an already-observed epoch without a synthetic revision bump. Disposal removes membership before @@ -639,7 +679,7 @@ The initial checked limits are: | Plain-text detail | 1,024 UTF-16 units | | Continuation token | 2,048 UTF-16 units | | Decoded response aggregate | 65,536 UTF-16 units | -| Decoded response own keys | 1,024 | +| Decoded response own keys | 16,384 aggregate traversal occurrences | | Decoded response nesting depth | 8 | | Service catalog cache | 256 entries and 2 MiB estimated retained bytes | | Service-wide active catalog searches | 8 | diff --git a/package.json b/package.json index b57fcc8..8832267 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:catalog-boundary": "vitest bench --run src/vnext/__tests__/relation-catalog-boundary.bench.ts", "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", diff --git a/src/vnext/__tests__/relation-catalog-boundary.bench.ts b/src/vnext/__tests__/relation-catalog-boundary.bench.ts new file mode 100644 index 0000000..f8acb10 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-boundary.bench.ts @@ -0,0 +1,153 @@ +import { bench, describe } from "vitest"; +import { decodeSqlCatalogSearchResponse } from "../relation-catalog-boundary.js"; +import { + DREMIO_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; + +function relation(index: number) { + return { + canonicalPath: [ + { quoted: false, role: "schema", value: "public" }, + { + quoted: false, + role: "relation", + value: `relation_${index}`, + }, + ], + completionPathStart: 0, + detail: `Relation ${index}`, + entityId: `relation-${index}`, + matchQuality: "exact", + relationKind: "table", + }; +} + +function response(relations: readonly unknown[]) { + return { + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "snapshot-1" }, + relations, + status: "ready", + }; +} + +const emptyResponse = response([]); +const oneResponse = response([relation(0)]); +const hundredResponse = response( + Array.from({ length: 100 }, (_, index) => relation(index)), +); +const malformedLastResponse = response([ + ...Array.from({ length: 99 }, (_, index) => relation(index)), + { ...relation(99), completionPathStart: 9 }, +]); +const oversizedTextResponse = response([ + { ...relation(0), detail: "x".repeat(1_000_000) }, +]); +const maximumDepthResponse = response( + Array.from({ length: 7 }, (_, relationIndex) => ({ + ...relation(relationIndex), + canonicalPath: Array.from({ length: 32 }, (_, pathIndex) => ({ + quoted: true, + role: pathIndex === 31 ? "relation" : "schema", + value: String(relationIndex).repeat(256), + })), + detail: "d".repeat(1_024), + entityId: `maximum-depth-${relationIndex}`, + })), +); +const wideInvalidResponse = Object.fromEntries( + Array.from({ length: 10_000 }, (_, index) => [ + `unexpected-${index}`, + index, + ]), +); + +for (const [candidate, expectedStatus] of [ + [emptyResponse, "accepted"], + [oneResponse, "accepted"], + [hundredResponse, "accepted"], + [malformedLastResponse, "malformed"], + [oversizedTextResponse, "malformed"], +] as const) { + const result = decodeSqlCatalogSearchResponse( + candidate, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (result.status !== expectedStatus) { + throw new Error("Catalog boundary benchmark preflight failed"); + } +} +if ( + decodeSqlCatalogSearchResponse( + maximumDepthResponse, + 100, + DREMIO_SQL_RELATION_DIALECT, + ).status !== "accepted" || + decodeSqlCatalogSearchResponse( + wideInvalidResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status !== "malformed" +) { + throw new Error("Catalog boundary benchmark preflight failed"); +} + +describe("relation catalog boundary", () => { + bench("decode empty ready response", () => { + decodeSqlCatalogSearchResponse( + emptyResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode one relation", () => { + decodeSqlCatalogSearchResponse( + oneResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode 100 relations", () => { + decodeSqlCatalogSearchResponse( + hundredResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("reject malformed final relation", () => { + decodeSqlCatalogSearchResponse( + malformedLastResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("reject a million-unit detail before scanning it", () => { + decodeSqlCatalogSearchResponse( + oversizedTextResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); + + bench("decode near-limit maximum-depth relations", () => { + decodeSqlCatalogSearchResponse( + maximumDepthResponse, + 100, + DREMIO_SQL_RELATION_DIALECT, + ); + }); + + bench("reject a wide unexpected record", () => { + decodeSqlCatalogSearchResponse( + wideInvalidResponse, + 100, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-boundary.test.ts b/src/vnext/__tests__/relation-catalog-boundary.test.ts new file mode 100644 index 0000000..93ef31a --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-boundary.test.ts @@ -0,0 +1,1388 @@ +import { describe, expect, it } from "vitest"; +import { + captureSqlRelationCatalogProvider, + compareSqlCatalogEpoch, + createSqlCatalogSearchRequest, + decodeSqlCatalogInvalidation, + decodeSqlCatalogSearchResponse, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + MAX_CATALOG_DETAIL_LENGTH, + MAX_CATALOG_ENTITY_ID_LENGTH, + MAX_CATALOG_EPOCH_TOKEN_LENGTH, + MAX_CATALOG_IDENTIFIER_LENGTH, + MAX_CATALOG_PROVIDER_ID_LENGTH, + MAX_CATALOG_RELATIONS, + MAX_CATALOG_REQUEST_TEXT_LENGTH, + MAX_CATALOG_RESPONSE_TEXT_LENGTH, + MAX_CATALOG_SCOPE_LENGTH, + resolveSqlRelationCatalogProvider, + type SqlCatalogBoundaryResult, +} from "../relation-catalog-boundary.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; + +function accepted( + result: SqlCatalogBoundaryResult, +): Value { + expect(result.status).toBe("accepted"); + if (result.status !== "accepted") { + throw new Error(`Expected accepted, received ${result.reason}`); + } + return result.value; +} + +function epoch( + generation = 1, + token = "snapshot-1", +): { + readonly generation: number; + readonly token: string; +} { + return { generation, token }; +} + +function component( + value: string, + quoted = false, +): { + readonly quoted: boolean; + readonly value: string; +} { + return { quoted, value }; +} + +function pathComponent( + role: "catalog" | "dataset" | "project" | "relation" | "schema", + value: string, + quoted = false, +): { + readonly quoted: boolean; + readonly role: + | "catalog" + | "dataset" + | "project" + | "relation" + | "schema"; + readonly value: string; +} { + return { quoted, role, value }; +} + +function relation( + canonicalPath: readonly unknown[] = [ + pathComponent("schema", "public"), + pathComponent("relation", "users"), + ], + entityId = "relation-1", + completionPathStart = 0, +) { + return { + canonicalPath, + completionPathStart, + detail: "User accounts", + entityId, + matchQuality: "exact", + relationKind: "table", + }; +} + +function readyResponse( + relations: readonly unknown[] = [relation()], +) { + return { + coverage: { kind: "complete" }, + epoch: epoch(), + relations, + status: "ready", + }; +} + +function request( + overrides: Readonly> = {}, +) { + return { + continuationToken: null, + dialectId: "postgresql", + expectedEpoch: null, + limit: 20, + prefix: component("us"), + qualifier: [component("public")], + scope: "connection:primary", + searchPaths: [[component("public")]], + ...overrides, + }; +} + +function requestWithAggregateText( + textLength: number, +): ReturnType { + const fixedTextLength = "s".length + "postgresql".length; + let remaining = textLength - fixedTextLength; + if (remaining < 1) { + throw new Error("Aggregate request text fixture is too small"); + } + const components: ReturnType[] = []; + while (remaining > 0) { + const length = Math.min( + remaining, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + components.push(component("x".repeat(length), true)); + remaining -= length; + } + const searchPaths: ReturnType[][] = []; + for (let index = 0; index < components.length; index += 4) { + searchPaths.push(components.slice(index, index + 4)); + } + return request({ + dialectId: "postgresql", + prefix: component(""), + qualifier: [], + scope: "s", + searchPaths, + }); +} + +function paddedUniqueId(index: number, length: number): string { + const prefix = `id-${index}-`; + if (prefix.length > length) { + throw new Error("Entity ID fixture length is too small"); + } + return prefix + "x".repeat(length - prefix.length); +} + +function responseAtAggregateTextLimit( + extraTextLength = 0, +) { + const relations = Array.from({ length: 50 }, (_, index) => ({ + canonicalPath: [ + pathComponent("schema", "s".repeat(256), true), + pathComponent( + "relation", + "r".repeat(20 + (index === 0 ? extraTextLength : 0)), + true, + ), + ], + completionPathStart: 0, + detail: "d".repeat(MAX_CATALOG_DETAIL_LENGTH), + entityId: paddedUniqueId(index, index < 35 ? 11 : 10), + matchQuality: "exact", + relationKind: "table", + })); + return { + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "e" }, + relations, + status: "ready", + }; +} + +function expectMalformed( + value: SqlCatalogBoundaryResult, + reason?: + | "duplicate-entity-id" + | "illegal-relation-path" + | "invalid-shape" + | "resource-limit", +): void { + expect(value.status).toBe("malformed"); + if (reason !== undefined) { + expect(value).toEqual({ reason, status: "malformed" }); + } +} + +describe("relation catalog provider capture", () => { + it("captures own callbacks once as this-free closures", () => { + const calls: unknown[][] = []; + const provider = { + hostState: { connection: "caller-owned" }, + id: "catalog", + search(this: unknown, ...args: unknown[]) { + calls.push([this, ...args]); + return Promise.resolve(readyResponse()); + }, + subscribe(this: unknown, ...args: unknown[]) { + calls.push([this, ...args]); + return { dispose() {} }; + }, + }; + const captured = accepted( + captureSqlRelationCatalogProvider(provider), + ); + provider.id = "changed"; + provider.search = function replacementSearch() { + throw new Error("replacement search must not run"); + }; + provider.subscribe = function replacementSubscribe() { + throw new Error("replacement subscription must not run"); + }; + const context = resolveSqlRelationCatalogProvider(captured); + expect(context?.id).toBe("catalog"); + const signal = new AbortController().signal; + const normalizedRequest = accepted( + createSqlCatalogSearchRequest(request()), + ); + context?.search(normalizedRequest, signal); + const listener = () => {}; + context?.subscribe?.("scope", listener); + expect(calls).toEqual([ + [undefined, normalizedRequest, signal], + [undefined, "scope", listener], + ]); + expect(Object.isFrozen(captured)).toBe(true); + expect(Object.isFrozen(context)).toBe(true); + }); + + it("supports static providers without subscriptions", () => { + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "static", + search: async () => readyResponse(), + subscribe: undefined, + }), + ); + expect( + resolveSqlRelationCatalogProvider(captured)?.subscribe, + ).toBeNull(); + expect(resolveSqlRelationCatalogProvider(null)).toBeNull(); + }); + + it("ignores extra config while rejecting copied, inherited, accessor, and hostile providers", () => { + const valid = { + id: "catalog", + search: async () => readyResponse(), + }; + const captured = accepted( + captureSqlRelationCatalogProvider(valid), + ); + expect(resolveSqlRelationCatalogProvider({ ...captured })).toBeNull(); + + const inherited = Object.create(valid); + expectMalformed(captureSqlRelationCatalogProvider(inherited)); + expect( + captureSqlRelationCatalogProvider({ ...valid, extra: true }) + .status, + ).toBe("accepted"); + class PrototypeProvider { + readonly id = "prototype"; + + async search() { + return readyResponse(); + } + } + expectMalformed( + captureSqlRelationCatalogProvider(new PrototypeProvider()), + ); + for (const candidate of [ + null, + [], + { id: "catalog", search: 1 }, + { ...valid, subscribe: null }, + new Date(), + ]) { + expectMalformed(captureSqlRelationCatalogProvider(candidate)); + } + + let getterInvoked = false; + const accessor = { + get id() { + getterInvoked = true; + return "catalog"; + }, + search: valid.search, + }; + expectMalformed(captureSqlRelationCatalogProvider(accessor)); + expect(getterInvoked).toBe(false); + + const revoked = Proxy.revocable(valid, {}); + revoked.revoke(); + expectMalformed( + captureSqlRelationCatalogProvider(revoked.proxy), + ); + expectMalformed( + captureSqlRelationCatalogProvider( + new Proxy(valid, { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }), + ), + ); + }); + + it("enforces bounded well-formed provider IDs", () => { + const search = async () => readyResponse(); + expect( + captureSqlRelationCatalogProvider({ + id: "x".repeat(MAX_CATALOG_PROVIDER_ID_LENGTH), + search, + }).status, + ).toBe("accepted"); + expectMalformed( + captureSqlRelationCatalogProvider({ + id: "x".repeat(MAX_CATALOG_PROVIDER_ID_LENGTH + 1), + search, + }), + "resource-limit", + ); + for (const id of ["", "bad\0id", "\ud800", "\udc00"]) { + expectMalformed( + captureSqlRelationCatalogProvider({ id, search }), + "invalid-shape", + ); + } + expect( + captureSqlRelationCatalogProvider({ + id: "catalog-\ud83d\ude80", + search, + }).status, + ).toBe("accepted"); + }); +}); + +describe("catalog search request snapshots", () => { + it("copies and recursively freezes the exact provider request", () => { + const raw = request(); + const normalized = accepted( + createSqlCatalogSearchRequest(raw), + ); + expect(normalized).toEqual(raw); + expect(normalized).not.toBe(raw); + expect(Object.isFrozen(normalized)).toBe(true); + expect(Object.isFrozen(normalized.searchPaths)).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0])).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0]?.[0])).toBe(true); + expect(Object.isFrozen(normalized.qualifier)).toBe(true); + expect(Object.isFrozen(normalized.prefix)).toBe(true); + }); + + it("copies shared request identities into independent frozen data", () => { + const sharedComponent = { + quoted: false, + value: "public", + }; + const sharedPath = [sharedComponent]; + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + qualifier: sharedPath, + searchPaths: [sharedPath, sharedPath], + }), + ), + ); + + expect(normalized.qualifier).not.toBe(sharedPath); + expect(normalized.searchPaths[0]).not.toBe(sharedPath); + expect(normalized.searchPaths[1]).not.toBe(sharedPath); + expect(normalized.qualifier[0]).not.toBe(sharedComponent); + expect(normalized.searchPaths[0]?.[0]).not.toBe( + sharedComponent, + ); + expect(normalized.searchPaths[0]?.[0]).not.toBe( + normalized.searchPaths[1]?.[0], + ); + expect(Object.isFrozen(normalized.qualifier[0])).toBe(true); + expect(Object.isFrozen(normalized.searchPaths[0]?.[0])).toBe( + true, + ); + + sharedComponent.value = "mutated"; + sharedPath.push({ quoted: true, value: "later" }); + expect(normalized.qualifier).toEqual([ + { quoted: false, value: "public" }, + ]); + expect(normalized.searchPaths).toEqual([ + [{ quoted: false, value: "public" }], + [{ quoted: false, value: "public" }], + ]); + }); + + it("accepts empty prefix, qualifier, and search-path list", () => { + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + prefix: component(""), + qualifier: [], + searchPaths: [], + }), + ), + ); + expect(normalized.prefix.value).toBe(""); + expect(normalized.qualifier).toEqual([]); + expect(normalized.searchPaths).toEqual([]); + }); + + it("preserves quoted order, epochs, and continuation tokens", () => { + const normalized = accepted( + createSqlCatalogSearchRequest( + request({ + continuationToken: "page-2", + expectedEpoch: epoch(4, "four"), + searchPaths: [ + [component("first", true), component("second")], + [component("third")], + ], + }), + ), + ); + expect(normalized).toMatchObject({ + continuationToken: "page-2", + expectedEpoch: { generation: 4, token: "four" }, + searchPaths: [ + [ + { quoted: true, value: "first" }, + { quoted: false, value: "second" }, + ], + [{ quoted: false, value: "third" }], + ], + }); + }); + + it("rejects missing, extra, inherited, and sparse data", () => { + const { scope: _scope, ...missing } = request(); + expectMalformed(createSqlCatalogSearchRequest(missing)); + expectMalformed( + createSqlCatalogSearchRequest({ ...request(), extra: true }), + ); + expectMalformed( + createSqlCatalogSearchRequest( + Object.create(request()), + ), + ); + + const sparse: unknown[] = []; + sparse.length = 2; + sparse[1] = component("public"); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: sparse }), + ), + ); + const customArray = [component("public")]; + Object.defineProperty(customArray, "extra", { + enumerable: true, + value: true, + }); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: customArray }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: Object.setPrototypeOf([], null) }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ qualifier: {} }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ searchPaths: [[]] }), + ), + ); + + const withSymbol = request(); + Object.defineProperty(withSymbol, Symbol("extra"), { + enumerable: true, + value: true, + }); + expectMalformed(createSqlCatalogSearchRequest(withSymbol)); + + const nonEnumerable = request(); + Object.defineProperty(nonEnumerable, "scope", { + enumerable: false, + value: "scope", + }); + expectMalformed(createSqlCatalogSearchRequest(nonEnumerable)); + }); + + it("does not invoke accessors or ordinary proxy get traps", () => { + let getterInvoked = false; + const accessor = { + ...request(), + get scope() { + getterInvoked = true; + return "scope"; + }, + }; + expectMalformed(createSqlCatalogSearchRequest(accessor)); + expect(getterInvoked).toBe(false); + + let getInvoked = false; + const proxied = new Proxy(request(), { + get() { + getInvoked = true; + throw new Error("hostile"); + }, + }); + expect( + createSqlCatalogSearchRequest(proxied).status, + ).toBe("accepted"); + expect(getInvoked).toBe(false); + + expectMalformed( + createSqlCatalogSearchRequest( + new Proxy(request(), { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }), + ), + ); + }); + + it("enforces numeric and string resource boundaries", () => { + for (const limit of [ + 0, + MAX_CATALOG_RELATIONS + 1, + 1.5, + Number.NaN, + ]) { + expectMalformed( + createSqlCatalogSearchRequest(request({ limit })), + ); + } + expect( + createSqlCatalogSearchRequest( + request({ + continuationToken: "x".repeat( + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ), + scope: "x".repeat(MAX_CATALOG_SCOPE_LENGTH), + }), + ).status, + ).toBe("accepted"); + expectMalformed( + createSqlCatalogSearchRequest( + request({ + continuationToken: "x".repeat( + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH + 1, + ), + }), + ), + "resource-limit", + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ + scope: "x".repeat(MAX_CATALOG_SCOPE_LENGTH + 1), + }), + ), + "resource-limit", + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ continuationToken: 1 }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ expectedEpoch: { generation: -1, token: "bad" } }), + ), + ); + expectMalformed( + createSqlCatalogSearchRequest( + request({ prefix: { quoted: "no", value: "x" } }), + ), + ); + }); + + it("enforces the aggregate request text boundary", () => { + expect( + createSqlCatalogSearchRequest( + requestWithAggregateText(MAX_CATALOG_REQUEST_TEXT_LENGTH), + ).status, + ).toBe("accepted"); + expectMalformed( + createSqlCatalogSearchRequest( + requestWithAggregateText( + MAX_CATALOG_REQUEST_TEXT_LENGTH + 1, + ), + ), + "resource-limit", + ); + }); +}); + +describe("catalog response decoding", () => { + it.each([ + [ + "PostgreSQL", + POSTGRESQL_SQL_RELATION_DIALECT, + [ + pathComponent("schema", "public"), + pathComponent("relation", "users"), + ], + 0, + "public.users", + ], + [ + "DuckDB", + DUCKDB_SQL_RELATION_DIALECT, + [ + pathComponent("catalog", "memory"), + pathComponent("schema", "main"), + pathComponent("relation", "users"), + ], + 1, + "main.users", + ], + [ + "BigQuery", + BIGQUERY_SQL_RELATION_DIALECT, + [ + pathComponent("project", "my-project"), + pathComponent("dataset", "analytics"), + pathComponent("relation", "users"), + ], + 1, + "analytics.users", + ], + [ + "Dremio", + DREMIO_SQL_RELATION_DIALECT, + [ + pathComponent("catalog", "lake"), + pathComponent("schema", "raw"), + pathComponent("schema", "crm"), + pathComponent("relation", "users"), + ], + 2, + "crm.users", + ], + ] as const)( + "validates and pre-renders a %s canonical suffix", + ( + _name, + dialect, + canonicalPath, + completionPathStart, + completionText, + ) => { + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation( + canonicalPath, + "relation-1", + completionPathStart, + ), + ]), + 20, + dialect, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + expect(decoded.relations[0]).toMatchObject({ + completionPath: canonicalPath.slice(completionPathStart), + completionText, + }); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.relations)).toBe(true); + expect(Object.isFrozen(decoded.relations[0])).toBe(true); + expect( + Object.isFrozen(decoded.relations[0]?.canonicalPath), + ).toBe(true); + }, + ); + + it("renders quoted and reserved relation components with the dialect", () => { + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation( + [ + pathComponent("schema", "select"), + pathComponent("relation", "has space", true), + ], + "quoted", + ), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + expect(decoded.relations[0]?.completionText).toBe( + '"select"."has space"', + ); + }); + + it("accepts relations without optional detail", () => { + const { detail: _detail, ...withoutDetail } = relation(); + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([withoutDetail]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected ready"); + } + expect(decoded.relations[0]).not.toHaveProperty("detail"); + }); + + it("copies shared response identities into independent frozen data", () => { + const sharedSchema = { + quoted: false, + role: "schema" as const, + value: "public", + }; + const sharedRelation = { + quoted: false, + role: "relation" as const, + value: "users", + }; + const sharedPath = [sharedSchema, sharedRelation]; + const decoded = accepted( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(sharedPath, "one"), + relation(sharedPath, "two"), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + if (decoded.status !== "ready") { + throw new Error("Expected a ready catalog page"); + } + + expect(decoded.relations[0]?.canonicalPath).not.toBe(sharedPath); + expect(decoded.relations[1]?.canonicalPath).not.toBe(sharedPath); + expect(decoded.relations[0]?.canonicalPath).not.toBe( + decoded.relations[1]?.canonicalPath, + ); + expect(decoded.relations[0]?.canonicalPath[0]).not.toBe( + sharedSchema, + ); + expect(decoded.relations[0]?.canonicalPath[0]).not.toBe( + decoded.relations[1]?.canonicalPath[0], + ); + expect( + Object.isFrozen(decoded.relations[0]?.canonicalPath[0]), + ).toBe(true); + + sharedSchema.value = "mutated"; + sharedRelation.value = "changed"; + sharedPath.push({ + quoted: false, + role: "relation", + value: "later", + }); + expect(decoded.relations[0]?.canonicalPath).toEqual([ + { quoted: false, role: "schema", value: "public" }, + { quoted: false, role: "relation", value: "users" }, + ]); + expect(decoded.relations[1]?.completionText).toBe( + "public.users", + ); + }); + + it("decodes all coverage and terminal response variants", () => { + for (const coverage of [ + { kind: "complete" }, + { kind: "partial" }, + { continuationToken: "next", kind: "paginated" }, + ]) { + const value = accepted( + decodeSqlCatalogSearchResponse( + { ...readyResponse([]), coverage }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect(value).toMatchObject({ coverage, status: "ready" }); + } + expect( + accepted( + decodeSqlCatalogSearchResponse( + { epoch: epoch(), status: "loading" }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ), + ).toEqual({ epoch: epoch(), status: "loading" }); + for (const code of [ + "authentication", + "authorization", + "invalid-configuration", + "rate-limited", + "unavailable", + "unknown", + ]) { + for (const retry of [ + "after-invalidation", + "never", + "next-request", + ]) { + expect( + accepted( + decodeSqlCatalogSearchResponse( + { code, epoch: epoch(), retry, status: "failed" }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ), + ).toMatchObject({ code, retry, status: "failed" }); + } + } + }); + + it("rejects dialect-illegal canonical paths", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation([ + pathComponent("catalog", "wrong"), + pathComponent("relation", "users"), + ]), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "illegal-relation-path", + ); + }); + + it("rejects malformed paths and completion offsets", () => { + const malformedPaths = [ + [], + [pathComponent("schema", "public")], + [ + pathComponent("relation", "users"), + pathComponent("relation", "again"), + ], + [ + { quoted: false, role: "unknown", value: "x" }, + pathComponent("relation", "users"), + ], + ]; + for (const canonicalPath of malformedPaths) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([relation(canonicalPath)]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + for (const completionPathStart of [-1, 2, 0.5]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "bad-offset", completionPathStart), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + for (const canonicalPath of [ + [ + { quoted: "no", role: "schema", value: "public" }, + pathComponent("relation", "users"), + ], + [ + { quoted: false, role: "schema", value: 1 }, + pathComponent("relation", "users"), + ], + [ + { quoted: false, value: "public" }, + pathComponent("relation", "users"), + ], + [ + pathComponent("schema", ""), + pathComponent("relation", "users"), + ], + ]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([relation(canonicalPath)]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects duplicate IDs and the entire malformed page atomically", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "duplicate"), + relation( + [pathComponent("relation", "other")], + "duplicate", + ), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "duplicate-entity-id", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(), + { ...relation(), completionPathStart: 99 }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("rejects extra/missing/status-specific fields and closed values", () => { + const malformedResponses = [ + { ...readyResponse(), extra: true }, + { ...readyResponse(), status: "unknown" }, + { epoch: epoch(), relations: [], status: "loading" }, + { + code: "wrong", + epoch: epoch(), + retry: "never", + status: "failed", + }, + { + code: "unknown", + epoch: epoch(), + extra: true, + retry: "never", + status: "failed", + }, + { + code: "unknown", + epoch: epoch(), + retry: "wrong", + status: "failed", + }, + { epoch: { generation: -1, token: "bad" }, status: "loading" }, + { epoch: epoch(), status: "failed" }, + { + ...readyResponse(), + coverage: { continuationToken: "", kind: "paginated" }, + }, + { + ...readyResponse(), + coverage: { + continuationToken: 1, + kind: "paginated", + }, + }, + { ...readyResponse(), coverage: null }, + { + ...readyResponse(), + coverage: { continuationToken: "wrong", kind: "complete" }, + }, + ]; + for (const value of malformedResponses) { + expectMalformed( + decodeSqlCatalogSearchResponse( + value, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects malformed relation fields and record shapes", () => { + const malformedRelations = [ + null, + [], + { ...relation(), extra: true }, + { ...relation(), entityId: 1 }, + { ...relation(), matchQuality: "approximate" }, + { ...relation(), relationKind: "sequence" }, + { ...relation(), completionPathStart: "0" }, + new Date(), + ]; + for (const candidate of malformedRelations) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([candidate]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("rejects present undefined detail and enforces relation bounds", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([{ ...relation(), detail: undefined }]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "x".repeat(MAX_CATALOG_DETAIL_LENGTH), + entityId: "x".repeat(MAX_CATALOG_ENTITY_ID_LENGTH), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "x".repeat(MAX_CATALOG_DETAIL_LENGTH + 1), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + detail: "\0".repeat(1_000_000), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + { + ...relation(), + entityId: "x".repeat( + MAX_CATALOG_ENTITY_ID_LENGTH + 1, + ), + }, + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + }); + + it("enforces the aggregate response text boundary", () => { + expect( + decodeSqlCatalogSearchResponse( + responseAtAggregateTextLimit(), + MAX_CATALOG_RELATIONS, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + responseAtAggregateTextLimit(1), + MAX_CATALOG_RELATIONS, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expect(MAX_CATALOG_RESPONSE_TEXT_LENGTH).toBe( + 1 + 50 * (256 + 20 + MAX_CATALOG_DETAIL_LENGTH) + 535, + ); + }); + + it("rejects over-limit pages instead of truncating", () => { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation(undefined, "one"), + relation(undefined, "two"), + ]), + 1, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + const customRelations = [relation()]; + Object.defineProperty(customRelations, "extra", { + enumerable: true, + value: true, + }); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse(customRelations), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("does not invoke accessors or ordinary get traps", () => { + let invoked = false; + const accessor = { + ...readyResponse(), + get status() { + invoked = true; + return "ready"; + }, + }; + expectMalformed( + decodeSqlCatalogSearchResponse( + accessor, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expect(invoked).toBe(false); + + const proxied = new Proxy(readyResponse(), { + get() { + invoked = true; + throw new Error("hostile"); + }, + }); + expect( + decodeSqlCatalogSearchResponse( + proxied, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expect(invoked).toBe(false); + }); + + it("normalizes hostile proxies without leaking thrown values", () => { + const throwing = new Proxy(readyResponse(), { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }); + expectMalformed( + decodeSqlCatalogSearchResponse( + throwing, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + [], + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + new Date(), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + const revoked = Proxy.revocable(readyResponse(), {}); + revoked.revoke(); + expectMalformed( + decodeSqlCatalogSearchResponse( + revoked.proxy, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + }); + + it("rejects foreign dialect aggregates and invalid trusted limits", () => { + const mixed: SqlRelationDialectRuntime = { + ...POSTGRESQL_SQL_RELATION_DIALECT, + completion: DUCKDB_SQL_RELATION_DIALECT.completion, + }; + expectMalformed( + decodeSqlCatalogSearchResponse(readyResponse(), 20, mixed), + ); + for (const limit of [0, 101, 1.5]) { + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse(), + limit, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ); + } + }); + + it("enforces component and epoch token boundaries", () => { + expect( + decodeSqlCatalogSearchResponse( + { + ...readyResponse(), + epoch: epoch( + 1, + "x".repeat(MAX_CATALOG_EPOCH_TOKEN_LENGTH), + ), + relations: [ + relation([ + pathComponent( + "relation", + "x".repeat(MAX_CATALOG_IDENTIFIER_LENGTH), + ), + ]), + ], + }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("accepted"); + expectMalformed( + decodeSqlCatalogSearchResponse( + { + ...readyResponse(), + epoch: epoch( + 1, + "x".repeat(MAX_CATALOG_EPOCH_TOKEN_LENGTH + 1), + ), + }, + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + expectMalformed( + decodeSqlCatalogSearchResponse( + readyResponse([ + relation([ + pathComponent( + "relation", + "x".repeat(MAX_CATALOG_IDENTIFIER_LENGTH + 1), + ), + ]), + ]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + "resource-limit", + ); + }); +}); + +describe("catalog invalidation and epoch comparison", () => { + it("decodes fresh frozen invalidations", () => { + const raw = { epoch: epoch(2, "two") }; + const decoded = accepted( + decodeSqlCatalogInvalidation(raw), + ); + expect(decoded).toEqual(raw); + expect(decoded).not.toBe(raw); + expect(decoded.epoch).not.toBe(raw.epoch); + expect(Object.isFrozen(decoded)).toBe(true); + expect(Object.isFrozen(decoded.epoch)).toBe(true); + }); + + it("rejects malformed invalidations without invoking accessors", () => { + let invoked = false; + const invalidation = { + get epoch() { + invoked = true; + return epoch(); + }, + }; + expectMalformed(decodeSqlCatalogInvalidation(invalidation)); + expect(invoked).toBe(false); + expectMalformed( + decodeSqlCatalogInvalidation({ + epoch: epoch(), + scope: "spoofed", + }), + ); + expectMalformed( + decodeSqlCatalogInvalidation( + new Proxy({ epoch: epoch() }, { + ownKeys() { + throw new Error("hostile"); + }, + }), + ), + ); + expectMalformed(decodeSqlCatalogInvalidation(null)); + }); + + it("classifies baseline, equal, advance, stale, and conflicts", () => { + expect(compareSqlCatalogEpoch(null, epoch(1, "one"))).toEqual({ + epoch: epoch(1, "one"), + kind: "baseline", + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(1, "one")), + ).toEqual({ + epoch: epoch(1, "one"), + kind: "equal", + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(2, "two")), + ).toEqual({ + epoch: epoch(2, "two"), + kind: "advance", + previous: epoch(1, "one"), + }); + expect( + compareSqlCatalogEpoch(epoch(2, "two"), epoch(1, "one")), + ).toEqual({ + kind: "stale", + observed: epoch(2, "two"), + received: epoch(1, "one"), + }); + expect( + compareSqlCatalogEpoch(epoch(1, "one"), epoch(1, "other")), + ).toEqual({ + kind: "token-conflict", + observed: epoch(1, "one"), + received: epoch(1, "other"), + }); + }); + + it("canonicalizes negative zero and rejects invalid epochs", () => { + expect(compareSqlCatalogEpoch(null, epoch(-0, "zero"))).toEqual({ + epoch: epoch(0, "zero"), + kind: "baseline", + }); + for (const generation of [ + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect( + compareSqlCatalogEpoch(null, epoch(generation)), + ).toEqual({ kind: "malformed" }); + } + expect( + compareSqlCatalogEpoch( + { generation: 1, token: "" }, + epoch(), + ), + ).toEqual({ kind: "malformed" }); + expect( + compareSqlCatalogEpoch( + epoch(), + new Proxy(epoch(), { + ownKeys() { + throw new Error("hostile"); + }, + }), + ), + ).toEqual({ kind: "malformed" }); + }); +}); diff --git a/src/vnext/relation-catalog-boundary.ts b/src/vnext/relation-catalog-boundary.ts new file mode 100644 index 0000000..274f5a8 --- /dev/null +++ b/src/vnext/relation-catalog-boundary.ts @@ -0,0 +1,1269 @@ +import type { + SqlCatalogContainerComponent, + SqlCatalogContainerRole, + SqlCatalogEpoch, + SqlCatalogFailureCode, + SqlCatalogInvalidation, + SqlCatalogReadyCoverage, + SqlCatalogRelationKind, + SqlCatalogRetryPolicy, + SqlCatalogSearchRequest, + SqlCanonicalRelationPath, +} from "./relation-completion-types.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export const MAX_CATALOG_PROVIDER_ID_LENGTH = 256; +export const MAX_CATALOG_SCOPE_LENGTH = 512; +export const MAX_CATALOG_DIALECT_ID_LENGTH = 128; +export const MAX_CATALOG_EPOCH_TOKEN_LENGTH = 256; +export const MAX_CATALOG_CONTINUATION_TOKEN_LENGTH = 2_048; +export const MAX_CATALOG_ENTITY_ID_LENGTH = 256; +export const MAX_CATALOG_DETAIL_LENGTH = 1_024; +export const MAX_CATALOG_IDENTIFIER_LENGTH = 256; +export const MAX_CATALOG_SEARCH_PATHS = 32; +export const MAX_CATALOG_SEARCH_PATH_COMPONENTS = 4; +export const MAX_CATALOG_RELATION_PATH_COMPONENTS = 32; +export const MAX_CATALOG_RELATIONS = 100; +export const MAX_CATALOG_REQUEST_TEXT_LENGTH = 16_384; +export const MAX_CATALOG_RESPONSE_TEXT_LENGTH = 65_536; +export const MAX_CATALOG_RESPONSE_OWN_KEYS = 16_384; +export const MAX_CATALOG_DECODE_DEPTH = 8; + +const MAX_CATALOG_REQUEST_OBJECTS = 1_024; +const MAX_CATALOG_REQUEST_OWN_KEYS = 1_024; +const MAX_CATALOG_RESPONSE_OBJECTS = 4_096; + +const capturedProviderBrand: unique symbol = Symbol( + "CapturedSqlRelationCatalogProvider", +); + +export interface CapturedSqlRelationCatalogProvider { + readonly [capturedProviderBrand]: + "CapturedSqlRelationCatalogProvider"; +} + +export interface SqlCapturedRelationCatalogProviderContext { + readonly id: string; + readonly search: ( + this: void, + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) => unknown; + readonly subscribe: + | (( + this: void, + scope: string, + listener: (event: unknown) => void, + ) => unknown) + | null; +} + +export type SqlCatalogBoundaryFailureReason = + | "duplicate-entity-id" + | "illegal-relation-path" + | "invalid-shape" + | "resource-limit"; + +export type SqlCatalogBoundaryResult = + | { + readonly status: "accepted"; + readonly value: Value; + } + | { + readonly status: "malformed"; + readonly reason: SqlCatalogBoundaryFailureReason; + }; + +export interface SqlValidatedCatalogRelation { + readonly canonicalPath: SqlCanonicalRelationPath; + readonly completionPath: SqlCanonicalRelationPath; + readonly completionPathStart: number; + readonly completionText: string; + readonly detail?: string; + readonly entityId: string; + readonly matchQuality: "exact" | "equivalent"; + readonly relationKind: SqlCatalogRelationKind; +} + +export type SqlValidatedCatalogSearchResponse = + | { + readonly status: "ready"; + readonly coverage: SqlCatalogReadyCoverage; + readonly epoch: SqlCatalogEpoch; + readonly relations: readonly SqlValidatedCatalogRelation[]; + } + | { + readonly status: "loading"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly status: "failed"; + readonly code: SqlCatalogFailureCode; + readonly epoch: SqlCatalogEpoch; + readonly retry: SqlCatalogRetryPolicy; + }; + +export type SqlCatalogEpochComparison = + | { + readonly kind: "baseline"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly kind: "equal"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly kind: "advance"; + readonly epoch: SqlCatalogEpoch; + readonly previous: SqlCatalogEpoch; + } + | { + readonly kind: "stale"; + readonly observed: SqlCatalogEpoch; + readonly received: SqlCatalogEpoch; + } + | { + readonly kind: "token-conflict"; + readonly observed: SqlCatalogEpoch; + readonly received: SqlCatalogEpoch; + } + | { + readonly kind: "malformed"; + }; + +interface DecodeBudget { + objects: number; + properties: number; + textLength: number; +} + +interface DecodeLimits { + readonly maximumDepth: number; + readonly maximumObjects: number; + readonly maximumProperties: number; + readonly maximumTextLength: number; +} + +interface DecodeState { + readonly budget: DecodeBudget; + exhausted: boolean; + readonly limits: DecodeLimits; +} + +interface DataRecord { + readonly fields: ReadonlyMap; +} + +interface CapturedProviderFunctions { + readonly id: string; + readonly search: Function; + readonly subscribe: Function | null; +} + +const capturedProviders = new WeakMap< + object, + CapturedProviderFunctions +>(); + +const FAILURE_CODES: ReadonlySet = new Set([ + "authentication", + "authorization", + "invalid-configuration", + "rate-limited", + "unavailable", + "unknown", +]); + +const RETRY_POLICIES: ReadonlySet = new Set([ + "after-invalidation", + "never", + "next-request", +]); + +const RELATION_KINDS: ReadonlySet = new Set([ + "external-relation", + "materialized-view", + "table", + "temporary-table", + "view", +]); + +const CONTAINER_ROLES: ReadonlySet = new Set([ + "catalog", + "dataset", + "project", + "schema", +]); + +function isCatalogContainerRole( + value: string, +): value is SqlCatalogContainerRole { + return CONTAINER_ROLES.has(value); +} + +function isCatalogFailureCode( + value: string, +): value is SqlCatalogFailureCode { + return FAILURE_CODES.has(value); +} + +function isCatalogRetryPolicy( + value: string, +): value is SqlCatalogRetryPolicy { + return RETRY_POLICIES.has(value); +} + +function isCatalogRelationKind( + value: string, +): value is SqlCatalogRelationKind { + return RELATION_KINDS.has(value); +} + +function accepted( + value: Value, +): SqlCatalogBoundaryResult { + return Object.freeze({ status: "accepted", value }); +} + +function malformed( + reason: SqlCatalogBoundaryFailureReason, +): SqlCatalogBoundaryResult { + return Object.freeze({ reason, status: "malformed" }); +} + +function malformedFromState( + state: DecodeState, + reason: SqlCatalogBoundaryFailureReason = "invalid-shape", +): SqlCatalogBoundaryResult { + return malformed(state.exhausted ? "resource-limit" : reason); +} + +const PROVIDER_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: 1, + maximumObjects: 1, + maximumProperties: 3, + maximumTextLength: MAX_CATALOG_PROVIDER_ID_LENGTH, +}); + +const REQUEST_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: MAX_CATALOG_DECODE_DEPTH, + maximumObjects: MAX_CATALOG_REQUEST_OBJECTS, + maximumProperties: MAX_CATALOG_REQUEST_OWN_KEYS, + maximumTextLength: MAX_CATALOG_REQUEST_TEXT_LENGTH, +}); + +const RESPONSE_DECODE_LIMITS: DecodeLimits = Object.freeze({ + maximumDepth: MAX_CATALOG_DECODE_DEPTH, + maximumObjects: MAX_CATALOG_RESPONSE_OBJECTS, + maximumProperties: MAX_CATALOG_RESPONSE_OWN_KEYS, + maximumTextLength: MAX_CATALOG_RESPONSE_TEXT_LENGTH, +}); + +function createDecodeState(limits: DecodeLimits): DecodeState { + return { + budget: { + objects: 0, + properties: 0, + textLength: 0, + }, + exhausted: false, + limits, + }; +} + +function consumeObject( + state: DecodeState, + propertyCount: number, + depth: number, +): boolean { + if (depth > state.limits.maximumDepth) { + state.exhausted = true; + return false; + } + state.budget.objects += 1; + state.budget.properties += propertyCount; + if ( + state.budget.objects > state.limits.maximumObjects || + state.budget.properties > state.limits.maximumProperties + ) { + state.exhausted = true; + return false; + } + return true; +} + +function consumeText( + state: DecodeState, + value: string, + minimumLength: number, + maximumLength: number, +): string | null { + if (value.length > maximumLength) { + state.exhausted = true; + return null; + } + if ( + value.length > + state.limits.maximumTextLength - state.budget.textLength + ) { + state.exhausted = true; + return null; + } + if ( + value.length < minimumLength || + value.includes("\0") || + !isWellFormed(value) + ) { + return null; + } + state.budget.textLength += value.length; + return value; +} + +function isWellFormed(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return false; + } + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function readRecord( + state: DecodeState, + value: unknown, + maximumOwnKeys: number, + depth: number, +): DataRecord | null { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) + ) { + return null; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return null; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length > maximumOwnKeys || + !consumeObject(state, keys.length, depth) + ) { + return null; + } + const fields = new Map(); + for (const key of keys) { + if (typeof key !== "string" || fields.has(key)) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return null; + } + fields.set(key, descriptor.value); + } + return { fields }; +} + +function readArray( + state: DecodeState, + value: unknown, + maximumLength: number, + depth: number, +): readonly unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + if (Object.getPrototypeOf(value) !== Array.prototype) { + return null; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor( + value, + "length", + ); + if ( + !lengthDescriptor || + !("value" in lengthDescriptor) || + typeof lengthDescriptor.value !== "number" || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + return null; + } + const length = lengthDescriptor.value; + if (length > maximumLength) { + state.exhausted = true; + return null; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== length + 1 || + !consumeObject(state, keys.length, depth) + ) { + return null; + } + const output: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ); + if ( + !descriptor || + !descriptor.enumerable || + !("value" in descriptor) + ) { + return null; + } + output.push(descriptor.value); + } + for (const key of keys) { + if ( + typeof key !== "string" || + (key !== "length" && + (!/^(0|[1-9]\d*)$/.test(key) || + Number(key) >= length)) + ) { + return null; + } + } + return output; +} + +function hasExactKeys( + record: DataRecord, + keys: readonly string[], +): boolean { + if (record.fields.size !== keys.length) { + return false; + } + return keys.every((key) => record.fields.has(key)); +} + +function decodeEpoch( + state: DecodeState, + value: unknown, + depth: number, +): SqlCatalogEpoch | null { + const record = readRecord(state, value, 2, depth); + if (!record || !hasExactKeys(record, ["generation", "token"])) { + return null; + } + const generation = record.fields.get("generation"); + const tokenValue = record.fields.get("token"); + if ( + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 0 || + typeof tokenValue !== "string" + ) { + return null; + } + const token = consumeText( + state, + tokenValue, + 1, + MAX_CATALOG_EPOCH_TOKEN_LENGTH, + ); + return token + ? Object.freeze({ + generation: generation === 0 ? 0 : generation, + token, + }) + : null; +} + +function decodeIdentifierComponent( + state: DecodeState, + value: unknown, + allowEmpty: boolean, + depth: number, +): SqlIdentifierComponent | null { + const record = readRecord(state, value, 2, depth); + if (!record || !hasExactKeys(record, ["quoted", "value"])) { + return null; + } + const quoted = record.fields.get("quoted"); + const rawValue = record.fields.get("value"); + if (typeof quoted !== "boolean" || typeof rawValue !== "string") { + return null; + } + const decodedValue = consumeText( + state, + rawValue, + allowEmpty ? 0 : 1, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + return decodedValue === null + ? null + : Object.freeze({ quoted, value: decodedValue }); +} + +function decodeIdentifierPath( + state: DecodeState, + value: unknown, + maximumLength: number, + allowEmpty: boolean, + depth: number, +): SqlIdentifierPath | null { + const values = readArray(state, value, maximumLength, depth); + if (!values || (!allowEmpty && values.length === 0)) { + return null; + } + const components: SqlIdentifierComponent[] = []; + for (const candidate of values) { + const component = decodeIdentifierComponent( + state, + candidate, + false, + depth + 1, + ); + if (!component) { + return null; + } + components.push(component); + } + return Object.freeze(components); +} + +function decodeSearchPaths( + state: DecodeState, + value: unknown, + depth: number, +): readonly SqlIdentifierPath[] | null { + const paths = readArray( + state, + value, + MAX_CATALOG_SEARCH_PATHS, + depth, + ); + if (!paths) { + return null; + } + const output: SqlIdentifierPath[] = []; + for (const path of paths) { + const decoded = decodeIdentifierPath( + state, + path, + MAX_CATALOG_SEARCH_PATH_COMPONENTS, + false, + depth + 1, + ); + if (!decoded) { + return null; + } + output.push(decoded); + } + return Object.freeze(output); +} + +function decodeCoverage( + state: DecodeState, + value: unknown, + depth: number, +): SqlCatalogReadyCoverage | null { + const record = readRecord(state, value, 2, depth); + if (!record) { + return null; + } + const kind = record.fields.get("kind"); + if (kind === "complete" || kind === "partial") { + return hasExactKeys(record, ["kind"]) + ? Object.freeze({ kind }) + : null; + } + if ( + kind !== "paginated" || + !hasExactKeys(record, ["continuationToken", "kind"]) + ) { + return null; + } + const tokenValue = record.fields.get("continuationToken"); + if (typeof tokenValue !== "string") { + return null; + } + const continuationToken = consumeText( + state, + tokenValue, + 1, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ); + return continuationToken + ? Object.freeze({ continuationToken, kind }) + : null; +} + +function decodeCanonicalPath( + state: DecodeState, + value: unknown, + depth: number, +): SqlCanonicalRelationPath | null { + const values = readArray( + state, + value, + MAX_CATALOG_RELATION_PATH_COMPONENTS, + depth, + ); + if (!values || values.length === 0) { + return null; + } + const containers: SqlCatalogContainerComponent[] = []; + let relation: + | { + readonly quoted: boolean; + readonly role: "relation"; + readonly value: string; + } + | null = null; + for (let index = 0; index < values.length; index += 1) { + const record = readRecord( + state, + values[index], + 3, + depth + 1, + ); + if ( + !record || + !hasExactKeys(record, ["quoted", "role", "value"]) + ) { + return null; + } + const quoted = record.fields.get("quoted"); + const role = record.fields.get("role"); + const rawValue = record.fields.get("value"); + if ( + typeof quoted !== "boolean" || + typeof role !== "string" || + typeof rawValue !== "string" + ) { + return null; + } + const decodedValue = consumeText( + state, + rawValue, + 1, + MAX_CATALOG_IDENTIFIER_LENGTH, + ); + if (!decodedValue) { + return null; + } + const final = index === values.length - 1; + if (final) { + if (role !== "relation") { + return null; + } + relation = Object.freeze({ + quoted, + role, + value: decodedValue, + }); + } else { + if (!isCatalogContainerRole(role)) { + return null; + } + const container: SqlCatalogContainerComponent = { + quoted, + role, + value: decodedValue, + }; + containers.push(Object.freeze(container)); + } + } + return relation + ? Object.freeze([...containers, relation]) + : null; +} + +function completionSuffix( + path: SqlCanonicalRelationPath, + start: number, +): SqlCanonicalRelationPath | null { + const relation = path[path.length - 1]; + if (!relation || relation.role !== "relation") { + return null; + } + const containers: SqlCatalogContainerComponent[] = []; + for (let index = start; index < path.length - 1; index += 1) { + const component = path[index]; + if (!component || component.role === "relation") { + return null; + } + containers.push(component); + } + return Object.freeze([...containers, relation]); +} + +function decodeRelation( + state: DecodeState, + value: unknown, + dialect: SqlRelationDialectRuntime, + depth: number, +): SqlCatalogBoundaryResult { + const record = readRecord(state, value, 6, depth); + if (!record) { + return malformedFromState(state); + } + const keys = record.fields.has("detail") + ? [ + "canonicalPath", + "completionPathStart", + "detail", + "entityId", + "matchQuality", + "relationKind", + ] + : [ + "canonicalPath", + "completionPathStart", + "entityId", + "matchQuality", + "relationKind", + ]; + if (!hasExactKeys(record, keys)) { + return malformedFromState(state); + } + const entityIdValue = record.fields.get("entityId"); + const relationKind = record.fields.get("relationKind"); + const matchQuality = record.fields.get("matchQuality"); + const completionPathStart = record.fields.get( + "completionPathStart", + ); + if ( + typeof entityIdValue !== "string" || + typeof relationKind !== "string" || + !isCatalogRelationKind(relationKind) || + (matchQuality !== "exact" && + matchQuality !== "equivalent") || + typeof completionPathStart !== "number" || + !Number.isSafeInteger(completionPathStart) + ) { + return malformedFromState(state); + } + const entityId = consumeText( + state, + entityIdValue, + 1, + MAX_CATALOG_ENTITY_ID_LENGTH, + ); + if (!entityId) { + return malformedFromState(state); + } + const path = decodeCanonicalPath( + state, + record.fields.get("canonicalPath"), + depth + 1, + ); + if ( + !path || + completionPathStart < 0 || + completionPathStart >= path.length + ) { + return malformedFromState(state); + } + const suffix = completionSuffix(path, completionPathStart); + if (!suffix) { + return malformedFromState(state, "illegal-relation-path"); + } + const fullRender = dialect.completion.renderRelationPath(path); + const completionRender = + dialect.completion.renderRelationPath(suffix); + if ( + fullRender.status !== "rendered" || + completionRender.status !== "rendered" + ) { + return malformedFromState(state, "illegal-relation-path"); + } + const detailValue = record.fields.get("detail"); + let detail: string | undefined; + if (record.fields.has("detail")) { + if (typeof detailValue !== "string") { + return malformedFromState(state); + } + const decodedDetail = consumeText( + state, + detailValue, + 0, + MAX_CATALOG_DETAIL_LENGTH, + ); + if (decodedDetail === null) { + return malformedFromState(state); + } + detail = decodedDetail; + } + const base: Omit = { + canonicalPath: path, + completionPath: suffix, + completionPathStart, + completionText: completionRender.text, + entityId, + matchQuality, + relationKind, + }; + return accepted( + Object.freeze( + detail === undefined ? base : { ...base, detail }, + ), + ); +} + +export function captureSqlRelationCatalogProvider( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(PROVIDER_DECODE_LIMITS); + if ( + candidate === null || + typeof candidate !== "object" || + (Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null) + ) { + return malformed("invalid-shape"); + } + const idDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "id", + ); + const searchDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "search", + ); + const subscribeDescriptor = Object.getOwnPropertyDescriptor( + candidate, + "subscribe", + ); + if ( + !idDescriptor || + !idDescriptor.enumerable || + !("value" in idDescriptor) || + !searchDescriptor || + !searchDescriptor.enumerable || + !("value" in searchDescriptor) || + (subscribeDescriptor !== undefined && + (!subscribeDescriptor.enumerable || + !("value" in subscribeDescriptor))) || + !consumeObject( + state, + subscribeDescriptor === undefined ? 2 : 3, + 1, + ) + ) { + return malformedFromState(state); + } + const idValue = idDescriptor.value; + const search = searchDescriptor.value; + const subscribeValue = subscribeDescriptor?.value; + if ( + typeof idValue !== "string" || + typeof search !== "function" || + (subscribeDescriptor !== undefined && + subscribeValue !== undefined && + typeof subscribeValue !== "function") + ) { + return malformedFromState(state); + } + const id = consumeText( + state, + idValue, + 1, + MAX_CATALOG_PROVIDER_ID_LENGTH, + ); + if (!id) { + return malformedFromState(state); + } + const provider: CapturedSqlRelationCatalogProvider = + Object.freeze({ + [capturedProviderBrand]: + "CapturedSqlRelationCatalogProvider" as const, + }); + capturedProviders.set( + provider, + Object.freeze({ + id, + search, + subscribe: + typeof subscribeValue === "function" + ? subscribeValue + : null, + }), + ); + return accepted(provider); + } catch { + return malformed("invalid-shape"); + } +} + +export function resolveSqlRelationCatalogProvider( + candidate: unknown, +): SqlCapturedRelationCatalogProviderContext | null { + if ( + candidate === null || + typeof candidate !== "object" + ) { + return null; + } + const captured = capturedProviders.get(candidate); + if (!captured) { + return null; + } + const subscribe = captured.subscribe; + return Object.freeze({ + id: captured.id, + search: ( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ): unknown => + Reflect.apply(captured.search, undefined, [ + request, + signal, + ]), + subscribe: subscribe + ? ( + scope: string, + listener: (event: unknown) => void, + ): unknown => + Reflect.apply(subscribe, undefined, [ + scope, + listener, + ]) + : null, + }); +} + +export function createSqlCatalogSearchRequest( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(REQUEST_DECODE_LIMITS); + const record = readRecord(state, candidate, 8, 1); + if ( + !record || + !hasExactKeys(record, [ + "continuationToken", + "dialectId", + "expectedEpoch", + "limit", + "prefix", + "qualifier", + "scope", + "searchPaths", + ]) + ) { + return malformedFromState(state); + } + const scopeValue = record.fields.get("scope"); + const dialectIdValue = record.fields.get("dialectId"); + const limit = record.fields.get("limit"); + if ( + typeof scopeValue !== "string" || + typeof dialectIdValue !== "string" || + typeof limit !== "number" || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > MAX_CATALOG_RELATIONS + ) { + return malformedFromState(state); + } + const scope = consumeText( + state, + scopeValue, + 1, + MAX_CATALOG_SCOPE_LENGTH, + ); + const dialectId = consumeText( + state, + dialectIdValue, + 1, + MAX_CATALOG_DIALECT_ID_LENGTH, + ); + const searchPaths = decodeSearchPaths( + state, + record.fields.get("searchPaths"), + 2, + ); + const qualifier = decodeIdentifierPath( + state, + record.fields.get("qualifier"), + MAX_CATALOG_RELATION_PATH_COMPONENTS - 1, + true, + 2, + ); + const prefix = decodeIdentifierComponent( + state, + record.fields.get("prefix"), + true, + 2, + ); + if ( + !scope || + !dialectId || + !searchPaths || + !qualifier || + !prefix + ) { + return malformedFromState(state); + } + const expectedEpochValue = record.fields.get("expectedEpoch"); + const expectedEpoch = + expectedEpochValue === null + ? null + : decodeEpoch(state, expectedEpochValue, 2); + if (expectedEpochValue !== null && !expectedEpoch) { + return malformedFromState(state); + } + const continuationValue = record.fields.get( + "continuationToken", + ); + let continuationToken: string | null = null; + if (continuationValue !== null) { + if (typeof continuationValue !== "string") { + return malformedFromState(state); + } + continuationToken = consumeText( + state, + continuationValue, + 1, + MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, + ); + if (!continuationToken) { + return malformedFromState(state); + } + } + return accepted( + Object.freeze({ + continuationToken, + dialectId, + expectedEpoch, + limit, + prefix, + qualifier, + scope, + searchPaths, + }), + ); + } catch { + return malformed("invalid-shape"); + } +} + +export function decodeSqlCatalogSearchResponse( + candidate: unknown, + requestLimit: number, + dialect: SqlRelationDialectRuntime, +): SqlCatalogBoundaryResult { + try { + if ( + !Number.isSafeInteger(requestLimit) || + requestLimit < 1 || + requestLimit > MAX_CATALOG_RELATIONS || + !isSqlRelationDialectRuntime(dialect) + ) { + return malformed("invalid-shape"); + } + const state = createDecodeState(RESPONSE_DECODE_LIMITS); + const record = readRecord(state, candidate, 4, 1); + if (!record) { + return malformedFromState(state); + } + const status = record.fields.get("status"); + if (status === "loading") { + if (!hasExactKeys(record, ["epoch", "status"])) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + return epoch + ? accepted(Object.freeze({ epoch, status })) + : malformedFromState(state); + } + if (status === "failed") { + if ( + !hasExactKeys(record, [ + "code", + "epoch", + "retry", + "status", + ]) + ) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + const code = record.fields.get("code"); + const retry = record.fields.get("retry"); + if ( + !epoch || + typeof code !== "string" || + !isCatalogFailureCode(code) || + typeof retry !== "string" || + !isCatalogRetryPolicy(retry) + ) { + return malformedFromState(state); + } + const response: Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "failed" } + > = { + code, + epoch, + retry, + status, + }; + return accepted(Object.freeze(response)); + } + if ( + status !== "ready" || + !hasExactKeys(record, [ + "coverage", + "epoch", + "relations", + "status", + ]) + ) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + const coverage = decodeCoverage( + state, + record.fields.get("coverage"), + 2, + ); + const relations = readArray( + state, + record.fields.get("relations"), + requestLimit, + 2, + ); + if (!epoch || !coverage || !relations) { + return malformedFromState(state); + } + const entityIds = new Set(); + const decodedRelations: SqlValidatedCatalogRelation[] = []; + for (const relation of relations) { + const decoded = decodeRelation( + state, + relation, + dialect, + 3, + ); + if (decoded.status === "malformed") { + return decoded; + } + if (entityIds.has(decoded.value.entityId)) { + return malformed("duplicate-entity-id"); + } + entityIds.add(decoded.value.entityId); + decodedRelations.push(decoded.value); + } + return accepted( + Object.freeze({ + coverage, + epoch, + relations: Object.freeze(decodedRelations), + status, + }), + ); + } catch { + return malformed("invalid-shape"); + } +} + +export function decodeSqlCatalogInvalidation( + candidate: unknown, +): SqlCatalogBoundaryResult { + try { + const state = createDecodeState(RESPONSE_DECODE_LIMITS); + const record = readRecord(state, candidate, 1, 1); + if (!record || !hasExactKeys(record, ["epoch"])) { + return malformedFromState(state); + } + const epoch = decodeEpoch( + state, + record.fields.get("epoch"), + 2, + ); + return epoch + ? accepted(Object.freeze({ epoch })) + : malformedFromState(state); + } catch { + return malformed("invalid-shape"); + } +} + +export function compareSqlCatalogEpoch( + observedCandidate: unknown, + receivedCandidate: unknown, +): SqlCatalogEpochComparison { + try { + const received = decodeEpoch( + createDecodeState(RESPONSE_DECODE_LIMITS), + receivedCandidate, + 1, + ); + if (!received) { + return Object.freeze({ kind: "malformed" }); + } + if (observedCandidate === null) { + return Object.freeze({ epoch: received, kind: "baseline" }); + } + const observed = decodeEpoch( + createDecodeState(RESPONSE_DECODE_LIMITS), + observedCandidate, + 1, + ); + if (!observed) { + return Object.freeze({ kind: "malformed" }); + } + if (received.generation < observed.generation) { + return Object.freeze({ + kind: "stale", + observed, + received, + }); + } + if (received.generation > observed.generation) { + return Object.freeze({ + epoch: received, + kind: "advance", + previous: observed, + }); + } + if (received.token !== observed.token) { + return Object.freeze({ + kind: "token-conflict", + observed, + received, + }); + } + return Object.freeze({ epoch: received, kind: "equal" }); + } catch { + return Object.freeze({ kind: "malformed" }); + } +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 0c8ded8..6a7094a 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -117,10 +117,12 @@ export interface SqlCatalogInvalidation { export interface SqlRelationCatalogProvider { readonly id: string; readonly search: ( + this: void, request: SqlCatalogSearchRequest, signal: AbortSignal, ) => Promise; readonly subscribe?: ( + this: void, scope: string, onInvalidation: (event: SqlCatalogInvalidation) => void, ) => SqlDisposable; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 002f997..8656ba1 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -130,6 +130,24 @@ const provider: SqlRelationCatalogProvider = { }; void provider; +const receiverDependentSearch = async function ( + this: { readonly id: string }, + _request: SqlCatalogSearchRequest, + _signal: AbortSignal, +): Promise { + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: this.id }, + relations: [], + status: "ready", + }; +}; +const receiverDependentProvider: SqlRelationCatalogProvider = { + id: "receiver-dependent", + // @ts-expect-error provider callbacks are this-free closures + search: receiverDependentSearch, +}; + const relationPath = [ { quoted: false, role: "catalog", value: "memory" }, { quoted: false, role: "schema", value: "main" }, @@ -309,5 +327,6 @@ void mismatchedItem; void openWithRegions; void openWithoutRegions; void providerRenderedSql; +void receiverDependentProvider; void synchronousProvider; void undefinedContext; From ffa82297ea2db8d873e4effc713400efe88d5288 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 15:00:20 +0800 Subject: [PATCH 24/42] feat(vnext): coordinate catalog epochs (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #169. - Add a package-private provider/scope epoch gate so catalog responses and invalidations cannot publish against mixed catalog state. - Share one subscription per scope with two-phase membership activation, single-use authenticated captures, monotonic arbitration, and exact revision fan-out. - Bound callback storms, FIFO admissions, memberships, scopes, and reentrant provider cleanup with fail-closed quarantine. - Retire failed targets atomically and sever provider/session closure graphs before external cleanup or retained-handle disposal. - Record the lifecycle, ordering, and resource contracts in ADR 0005. --- ## Summary by cubic Introduces a catalog epoch coordinator that gates responses and invalidations per scope to prevent mixed catalog state and ensure deterministic revision fan‑out, and isolates hostile cleanup promises. Part of #169. - **New Features** - One subscription per scope with two‑phase membership and authenticated, single‑use epoch captures. - Monotonic arbitration and exact revision fan‑out across sessions sharing a scope. - Bounds for callback storms and cleanups, FIFO admissions, and fail‑closed quarantine for misbehavior. - Drains async provider/session cleanup settlement, isolates hostile cleanup promises, and atomically retires failed targets with reentrant‑safe cleanup. - Updated ADR‑0005 to document lifecycle and ordering; added extensive tests and a benchmark with `bench:catalog-coordinator`. - **Migration** - `SqlRelationCatalogProvider.subscribe` now returns a this‑free cleanup function `SqlCatalogSubscriptionCleanup` (may be async) instead of a `SqlDisposable`; update providers to return `() => void | PromiseLike`. - `SqlDisposable.dispose` is now this‑free: change to `dispose(this: void): void` and avoid using `this` inside dispose. Written for commit 3d66bd448157a4525f117e1117096e01783a4fa7. Summary will update on new commits. Review in cubic --- ...-parser-independent-relation-completion.md | 125 +- package.json | 1 + ...elation-catalog-epoch-coordinator.bench.ts | 443 ++++ ...relation-catalog-epoch-coordinator.test.ts | 2046 +++++++++++++++++ .../relation-catalog-epoch-coordinator.ts | 1223 ++++++++++ src/vnext/relation-completion-types.ts | 8 +- .../marimo-relation-completion.test-d.ts | 20 +- 7 files changed, 3850 insertions(+), 16 deletions(-) create mode 100644 src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts create mode 100644 src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts create mode 100644 src/vnext/relation-catalog-epoch-coordinator.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index f6266dd..6f21ab5 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -481,11 +481,27 @@ The service reference-counts one provider subscription per provider configuration and scope, then fans invalidation out to subscribed sessions. The installed callback closure supplies authenticated provider and scope identity; the untrusted invalidation payload therefore contains only an epoch. -Subscription membership is installed atomically before a provider can call -back synchronously. A newly joining session captures an already-observed epoch -without a synthetic revision bump. Disposal removes membership before -unsubscribing or running external cleanup. Fifty sessions sharing a scope do -not create fifty provider subscriptions. +Membership uses a two-phase prepare/commit protocol: the session stores its +prepared lease before commit can invoke provider code. Commit installs the +membership atomically before a provider can call back synchronously. Because a +subscription callback is always a change event, a valid callback fired during +`subscribe` advances that committed member after subscription installation +succeeds; it is not reclassified as an initial snapshot. A later joining +session captures an already-observed epoch without a synthetic revision bump. +Disposal removes membership before unsubscribing or running external cleanup. +Retirement also clears the membership's target and coordinator link before +that cleanup, so a consumer retaining a disposed lease retains only inert, +bounded membership metadata. The coordinator stores only the copied provider +ID and captured subscription function; it does not retain the unused search +function. Coordinator disposal revokes that subscription reference before +external cleanup, so a consumer retaining the disposed coordinator handle does +not retain the provider graph. +Fifty sessions sharing a scope do not create fifty provider subscriptions. +The coordinator reserves membership state and capacity before reading the +revision target callback. A throwing or invalid callback getter rolls that +reservation back with `invalid-target` unavailable evidence. Reentrant +coordinator disposal instead reports `disposed` and leaves no reserved +membership or captured callback behind. Each installed subscription has a service-owned incarnation identity captured by its callback. The identity is revoked before external unsubscribe or @@ -493,6 +509,80 @@ cleanup, and every callback checks it before entering the serialized epoch gate. Cleanup from a retired incarnation is keyed to that identity and cannot remove, mutate, or notify a reentrantly installed replacement. +The callback closes over a revocable cell rather than the service, scope state, +or sessions. Retirement clears the cell before external cleanup, so a provider +that retains an old callback retains only an inert bounded object. Synchronous +callbacks are decoded into frozen epochs while `subscribe` is running; raw +payloads and premature audience snapshots are not buffered. After the returned +cleanup closure is validated, the buffered epochs are staged together in FIFO +order. Each accepted baseline or advance snapshots active membership and +installs the epoch as one serialized step with no external call between those +operations. A member joined during one event's dispatch therefore +participates in a later pending event, but not the event already committed. +A thrown subscription or malformed returned cleanup value discards that buffer +and disables automatic invalidation for the live scope; explicit search +remains available. +The returned cleanup closure is itself untrusted: the service captures only a +function, calls it with `this === undefined` at most once, and isolates +malformed values, thrown cleanup, and rejected or hostile thenable results. +Detached cleanup settlement retains no coordinator state. A closure avoids a +structural TypeScript contract that would accept class instances or inherited +methods which the hostile runtime boundary could not safely validate. Dropping +the last owner removes the complete scope incarnation before cleanup. A later +join creates a new unobserved incarnation and may attempt a fresh subscription. + +Malformed, duplicate, stale, or token-conflicting invalidations do not mutate +state and do not tear down an otherwise valid subscription. Every raw callback +still consumes the callback reset-window allowance. Exceeding that allowance +revokes the incarnation and disables automatic invalidation before decoding +further payloads. The conservative window resets from a zero-delay timer +scheduled by the first callback. It intentionally makes no claim about formal +JavaScript task or turn boundaries, while still preventing a provider from +evading the allowance with a microtask chain. If hostile payload inspection +synchronously reenters the same callback, the nested callback consumes that +allowance but is ignored fail-closed; the coordinator does not retain the raw +payload or invert callback order. + +An epoch capture authenticates the exact provider configuration, scope +incarnation, membership, observed epoch, and notification sequence. An +equal-current response is publishable only if no accepted invalidation or +higher response changed that sequence after capture. Otherwise it is +superseded even when its epoch equals the newly current epoch. A capture is a +single-use work token: submission claims it before queue admission, and replay +is discarded even when the first submission was overloaded. + +Epoch commands use one bounded, non-recursive FIFO drain. Its hard admission +limit counts all work accepted during that drain, including commands already +processed, so a one-at-a-time reentrant chain cannot evade the bound. For every +accepted change, active membership is snapshotted and the epoch is immediately +installed before revision preparation or any external listener runs. All +affected session revisions then change before dispatch. Reentrant commands run +only after the current change has reached every still-active member, preserving +event order. The core does not coalesce revision events. The CodeMirror adapter +may coalesce the resulting UI refresh, as described below. Admission fails +before epoch mutation: +an overflowing provider callback revokes automatic invalidation for that +incarnation, while an overflowing response observation settles with closed +overload evidence. +Invalid, retired, replayed, disposed, and overflowing response submissions +return closed settlement evidence without invoking a callback. Only an admitted +FIFO command invokes its completion callback, so rejection cannot recursively +reenter the coordinator's drain. + +A revision target prepares its state change and returns a listener-dispatch +closure. Returning `null`, returning a non-function, or throwing retires that +membership. Retirement detaches state immediately, but hostile provider cleanup +is deferred until every remaining target has prepared. A higher response epoch +installed before preparation remains observed; if its submitting membership is +retired during preparation, its response settles as retired while other +successfully prepared members still dispatch. Coordinator disposal takes +precedence over that retired evidence. The outermost cleanup barrier admits and +invokes at most 1,024 captured cleanups. If reentrant work attempts to admit a +1,025th cleanup, the coordinator quarantines itself immediately: all cells and +memberships become inert and all not-yet-invoked cleanup references are +cleared without calling more provider code. Cleanup is never continued from a +timer. + The session exposes a disposable revision-change subscription for service-originated changes: @@ -671,6 +761,12 @@ The initial checked limits are: | Components per search path | 4 | | Total catalog context | 16,384 UTF-16 units | | Configured relation catalog providers | 1 | +| Live catalog scopes per service | 128 | +| Catalog memberships per service | 1,024 | +| Catalog memberships per scope | 256 | +| Raw subscription callbacks per reset window | 256 | +| Catalog epoch admissions per drain | 1,024 | +| Provider cleanups per outer barrier | 1,024 | | Catalog results per search | 100 | | Completion results after composition | 100 | | Provider ID | 256 UTF-16 units | @@ -815,19 +911,22 @@ Mixed cached assets fail the exact version check and retire the generation. 2. Attach embedded regions to session open/update transactions atomically. 3. Add the bounded partial-`SELECT` query-site recognizer. 4. Add the bounded CTE frame and visibility recognizer. -5. Add the service-owned catalog coordinator, subscriptions, cache, - in-flight sharing, cancellation, and provider contract suite. -6. Add the session completion method and deterministic composition. -7. Add the separate CodeMirror adapter and packed/browser fixtures. -8. Add the pinned packed/browser/runtime marimo fixture and 1/10/50-editor +5. Add the service-owned scoped epoch/subscription coordinator and its hostile + lifecycle contract suite. It does not invoke provider search or retain + result pages. +6. Add catalog search scheduling, cache, in-flight sharing, cancellation, + retry gates, and availability observers over the proven epoch coordinator. +7. Add the session completion method and deterministic composition. +8. Add the separate CodeMirror adapter and packed/browser fixtures. +9. Add the pinned packed/browser/runtime marimo fixture and 1/10/50-editor performance and leak evidence. -9. Validate both the in-memory/notebook and hierarchical remote provider +10. Validate both the in-memory/notebook and hierarchical remote provider shapes, then stabilize the declarations and public export surface. -10. Design scoped parser semantics and protocol v2 against PostgreSQL and +11. Design scoped parser semantics and protocol v2 against PostgreSQL and BigQuery corpora before any additional parser-derived scope feature consumes it. -Breaking refinement remains allowed through step 8. The provider and +Breaking refinement remains allowed through step 9. The provider and completion types become stable only after the working vertical slice, reusable provider contract suite, two materially different provider shapes, marimo packed/browser integration, hostile decoding, cancellation, epoch and diff --git a/package.json b/package.json index 8832267..401581f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "bench:catalog-boundary": "vitest bench --run src/vnext/__tests__/relation-catalog-boundary.bench.ts", + "bench:catalog-coordinator": "vitest bench --run src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts", "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts new file mode 100644 index 0000000..66f0650 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts @@ -0,0 +1,443 @@ +import { bench, describe } from "vitest"; +import { + captureSqlRelationCatalogProvider, +} from "../relation-catalog-boundary.js"; +import { + MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW, + createSqlCatalogEpochCoordinator, +} from "../relation-catalog-epoch-coordinator.js"; +import type { + SqlCatalogEpochCoordinator, + SqlCatalogResponseEpochDecision, + SqlCatalogResponseEpochSubmissionResult, + SqlCatalogRevisionTarget, + SqlCatalogScopeMembership, +} from "../relation-catalog-epoch-coordinator.js"; + +interface RevisionCounter { + dispatched: number; + prepared: number; + readonly target: SqlCatalogRevisionTarget; +} + +interface CoordinatorFixture { + readonly coordinator: SqlCatalogEpochCoordinator; + readonly counters: RevisionCounter[]; + readonly dispose: () => void; + readonly emitInvalidation: (generation: number) => void; + readonly memberships: SqlCatalogScopeMembership[]; + readonly subscriptionCounts: { + disposed: number; + installed: number; + }; +} + +let providerSequence = 0; + +function benchmarkFailure(message: string): never { + throw new Error( + `Catalog epoch coordinator benchmark preflight failed: ${message}`, + ); +} + +function revisionCounter(): RevisionCounter { + const counter: RevisionCounter = { + dispatched: 0, + prepared: 0, + target: { + prepareCatalogChange: (): (() => void) => { + counter.prepared += 1; + return (): void => { + counter.dispatched += 1; + }; + }, + }, + }; + return counter; +} + +function requireMembership( + coordinator: SqlCatalogEpochCoordinator, + scope: string, + counter: RevisionCounter, +): SqlCatalogScopeMembership { + const prepared = coordinator.prepareScopeMembership( + scope, + counter.target, + ); + if (prepared.status !== "prepared") { + return benchmarkFailure("membership preparation was unavailable"); + } + const activated = prepared.membership.activate(); + if (activated.status !== "active") { + return benchmarkFailure("membership activation was unavailable"); + } + return prepared.membership; +} + +function requireMember( + members: readonly SqlCatalogScopeMembership[], + index: number, +): SqlCatalogScopeMembership { + const member = members[index]; + if (!member) { + return benchmarkFailure("membership index was unavailable"); + } + return member; +} + +function requireDecision( + submit: ( + receive: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void, + ) => SqlCatalogResponseEpochSubmissionResult, +): SqlCatalogResponseEpochDecision { + const decisions: SqlCatalogResponseEpochDecision[] = []; + const submission = submit((decision): void => { + decisions.push(decision); + }); + if (submission.status !== "submitted") { + return benchmarkFailure("epoch command was not submitted"); + } + const decision = decisions[0]; + if (!decision || decisions.length !== 1) { + return benchmarkFailure( + "epoch command did not settle exactly once", + ); + } + return decision; +} + +function createFixture(memberCount: number): CoordinatorFixture { + let invalidationListener: + | ((this: void, event: unknown) => void) + | null = null; + const subscriptionCounts = { + disposed: 0, + installed: 0, + }; + providerSequence += 1; + const captured = captureSqlRelationCatalogProvider({ + id: `coordinator-benchmark-${providerSequence}`, + search: (): Promise => + new Promise(() => { + // The epoch coordinator never invokes catalog search. + }), + subscribe: ( + _scope: string, + listener: (this: void, event: unknown) => void, + ) => { + subscriptionCounts.installed += 1; + invalidationListener = listener; + return (): void => { + subscriptionCounts.disposed += 1; + }; + }, + }); + if (captured.status !== "accepted") { + return benchmarkFailure("provider capture was rejected"); + } + const created = createSqlCatalogEpochCoordinator( + captured.value, + ); + if (created.status !== "created") { + return benchmarkFailure("coordinator creation was unavailable"); + } + const counters: RevisionCounter[] = []; + const memberships: SqlCatalogScopeMembership[] = []; + for (let index = 0; index < memberCount; index += 1) { + const counter = revisionCounter(); + counters.push(counter); + memberships.push( + requireMembership( + created.coordinator, + "benchmark-scope", + counter, + ), + ); + } + return { + coordinator: created.coordinator, + counters, + dispose: (): void => { + created.coordinator.dispose(); + }, + emitInvalidation: (generation: number): void => { + const listener = invalidationListener; + if (!listener) { + return benchmarkFailure( + "provider subscription listener was unavailable", + ); + } + listener({ + epoch: { + generation, + token: "benchmark-epoch", + }, + }); + }, + memberships, + subscriptionCounts, + }; +} + +function establishBaseline( + fixture: CoordinatorFixture, +): SqlCatalogScopeMembership { + const member = requireMember(fixture.memberships, 0); + const captured = member.captureEpoch(); + if (captured.status !== "captured") { + return benchmarkFailure("epoch capture was unavailable"); + } + const decision = requireDecision((receive) => + fixture.coordinator.submitResponseEpoch( + captured.capture, + { generation: 0, token: "benchmark-epoch" }, + receive, + ), + ); + if ( + decision.status !== "usable" || + decision.observation !== "baseline" + ) { + return benchmarkFailure("baseline response was not usable"); + } + return member; +} + +function assertFanout(memberCount: number): void { + const fixture = createFixture(memberCount); + const member = establishBaseline(fixture); + const captured = member.captureEpoch(); + if (captured.status !== "captured") { + return benchmarkFailure("fanout capture was unavailable"); + } + const decision = requireDecision((receive) => + fixture.coordinator.submitResponseEpoch( + captured.capture, + { generation: 1, token: "benchmark-epoch" }, + receive, + ), + ); + if (decision.status !== "superseded") { + return benchmarkFailure( + "higher response did not supersede its capture", + ); + } + for (const counter of fixture.counters) { + if (counter.prepared !== 1 || counter.dispatched !== 1) { + return benchmarkFailure( + `higher response did not reach all ${memberCount} members`, + ); + } + } + fixture.dispose(); +} + +function assertRejectedCallbacks(): void { + const fixture = createFixture(1); + fixture.emitInvalidation(2); + fixture.emitInvalidation(2); + fixture.emitInvalidation(1); + if ( + fixture.counters[0]?.prepared !== 1 || + fixture.counters[0]?.dispatched !== 1 + ) { + return benchmarkFailure( + "duplicate or stale callback changed the revision", + ); + } + fixture.dispose(); +} + +function assertBoundedStorm(): void { + const fixture = createFixture(1); + for ( + let generation = 1; + generation <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + generation += 1 + ) { + fixture.emitInvalidation(generation); + } + const counter = fixture.counters[0]; + if ( + !counter || + counter.prepared !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW || + counter.dispatched !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + ) { + return benchmarkFailure("bounded callback storm lost an event"); + } + fixture.emitInvalidation( + MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + 1, + ); + fixture.emitInvalidation( + MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + 2, + ); + if ( + counter.prepared !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW || + counter.dispatched !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW || + fixture.subscriptionCounts.disposed !== 1 + ) { + return benchmarkFailure( + "callback overflow did not retire the subscription", + ); + } + fixture.dispose(); +} + +function assertAttachDetachReuse(): void { + const fixture = createFixture(1); + for (let index = 0; index < 256; index += 1) { + const counter = revisionCounter(); + const membership = requireMembership( + fixture.coordinator, + "benchmark-scope", + counter, + ); + membership.dispose(); + } + const finalMembership = requireMembership( + fixture.coordinator, + "benchmark-scope", + revisionCounter(), + ); + finalMembership.dispose(); + if ( + fixture.subscriptionCounts.installed !== 1 || + fixture.subscriptionCounts.disposed !== 0 + ) { + return benchmarkFailure( + "attach/detach churn changed the shared subscription", + ); + } + fixture.dispose(); + if (Number(fixture.subscriptionCounts.disposed) !== 1) { + return benchmarkFailure( + "shared subscription was not disposed exactly once", + ); + } +} + +for (const memberCount of [1, 10, 50, 256]) { +assertFanout(memberCount); +} +assertRejectedCallbacks(); +assertBoundedStorm(); +assertAttachDetachReuse(); + +const noopDecision = ( + _decision: SqlCatalogResponseEpochDecision, +): void => {}; + +describe("relation catalog epoch coordinator", () => { + for (const memberCount of [1, 10, 50, 256]) { + const lifecycleFixture = createFixture(memberCount); + let lifecycleCursor = 0; + bench( + `join, activate, and dispose within a ${memberCount}-member scope`, + () => { + const previous = requireMember( + lifecycleFixture.memberships, + lifecycleCursor, + ); + const nextCounter = revisionCounter(); + if (memberCount === 1) { + const next = requireMembership( + lifecycleFixture.coordinator, + "benchmark-scope", + nextCounter, + ); + previous.dispose(); + lifecycleFixture.memberships[lifecycleCursor] = next; + } else { + previous.dispose(); + lifecycleFixture.memberships[lifecycleCursor] = + requireMembership( + lifecycleFixture.coordinator, + "benchmark-scope", + nextCounter, + ); + } + lifecycleCursor = + (lifecycleCursor + 1) % memberCount; + }, + ); + + const fanoutFixture = createFixture(memberCount); + const fanoutMember = establishBaseline(fanoutFixture); + let generation = 0; + bench( + `accept higher response and fan out to ${memberCount} members`, + () => { + generation += 1; + const nextCapture = fanoutMember.captureEpoch(); + if (nextCapture.status !== "captured") { + benchmarkFailure( + "fanout benchmark capture was unavailable", + ); + } + const submission = + fanoutFixture.coordinator.submitResponseEpoch( + nextCapture.capture, + { generation, token: "benchmark-epoch" }, + noopDecision, + ); + if (submission.status !== "submitted") { + benchmarkFailure( + "fanout benchmark response was not submitted", + ); + } + }, + ); + } + + bench("reject duplicate and stale provider callbacks", () => { + const fixture = createFixture(1); + fixture.emitInvalidation(2); + fixture.emitInvalidation(2); + fixture.emitInvalidation(1); + fixture.dispose(); + }); + + bench( + "process a bounded 256-event provider callback storm for one member", + () => { + const fixture = createFixture(1); + for ( + let generation = 1; + generation <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + generation += 1 + ) { + fixture.emitInvalidation(generation); + } + fixture.dispose(); + }, + ); + + bench("fan out a bounded 256-event storm to 256 members", () => { + const fixture = createFixture(256); + for ( + let generation = 1; + generation <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + generation += 1 + ) { + fixture.emitInvalidation(generation); + } + fixture.dispose(); + }); + + bench("attach and detach 10,000 members on one scope", () => { + const fixture = createFixture(1); + for (let index = 0; index < 10_000; index += 1) { + const membership = requireMembership( + fixture.coordinator, + "benchmark-scope", + revisionCounter(), + ); + membership.dispose(); + } + fixture.dispose(); + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts new file mode 100644 index 0000000..f0d0a50 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts @@ -0,0 +1,2046 @@ +import { MessageChannel } from "node:worker_threads"; +import { describe, expect, it } from "vitest"; +import { + captureSqlRelationCatalogProvider, + MAX_CATALOG_SCOPE_LENGTH, + type CapturedSqlRelationCatalogProvider, +} from "../relation-catalog-boundary.js"; +import { + createSqlCatalogEpochCoordinator, + MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW, + MAX_CATALOG_CLEANUPS_PER_BARRIER, + MAX_CATALOG_EPOCH_COMMANDS, + MAX_CATALOG_LIVE_SCOPES, + MAX_CATALOG_MEMBERSHIPS, + MAX_CATALOG_MEMBERSHIPS_PER_SCOPE, + type SqlCatalogEpochCapture, + type SqlCatalogEpochCoordinator, + type SqlCatalogResponseEpochDecision, + type SqlCatalogResponseEpochSubmissionResult, + type SqlCatalogRevisionTarget, + type SqlCatalogScopeMembership, +} from "../relation-catalog-epoch-coordinator.js"; + +type RawInvalidationListener = (value: unknown) => void; +type RawSubscribe = ( + scope: string, + listener: RawInvalidationListener, +) => unknown; + +function epoch(generation: number, token = `epoch-${generation}`) { + return { generation, token }; +} + +function invalidation(generation: number, token = `epoch-${generation}`) { + return { epoch: epoch(generation, token) }; +} + +function capturedProvider( + subscribe?: RawSubscribe, + id = "catalog", +): CapturedSqlRelationCatalogProvider { + const candidate = + subscribe === undefined + ? { + id, + search: async () => null, + } + : { + id, + search: async () => null, + subscribe, + }; + const result = captureSqlRelationCatalogProvider(candidate); + expect(result.status).toBe("accepted"); + if (result.status !== "accepted") { + throw new Error("Expected an accepted provider fixture"); + } + return result.value; +} + +function coordinator( + subscribe?: RawSubscribe, + id = "catalog", +): SqlCatalogEpochCoordinator { + const result = createSqlCatalogEpochCoordinator( + capturedProvider(subscribe, id), + ); + expect(result.status).toBe("created"); + if (result.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + return result.coordinator; +} + +function prepared( + owner: SqlCatalogEpochCoordinator, + scope: unknown, + target: SqlCatalogRevisionTarget = { + prepareCatalogChange: () => () => {}, + }, +): SqlCatalogScopeMembership { + const result = owner.prepareScopeMembership(scope, target); + expect(result.status).toBe("prepared"); + if (result.status !== "prepared") { + throw new Error("Expected a prepared membership fixture"); + } + return result.membership; +} + +function active( + owner: SqlCatalogEpochCoordinator, + scope: string, + target?: SqlCatalogRevisionTarget, +): SqlCatalogScopeMembership { + const membership = prepared(owner, scope, target); + expect(membership.activate()).toEqual({ status: "active" }); + return membership; +} + +function capture( + membership: SqlCatalogScopeMembership, +): SqlCatalogEpochCapture { + const result = membership.captureEpoch(); + expect(result.status).toBe("captured"); + if (result.status !== "captured") { + throw new Error("Expected a capture fixture"); + } + return result.capture; +} + +function submit( + owner: SqlCatalogEpochCoordinator, + captured: unknown, + value: unknown, +): { + readonly decisions: readonly SqlCatalogResponseEpochDecision[]; + readonly result: SqlCatalogResponseEpochSubmissionResult; +} { + const decisions: SqlCatalogResponseEpochDecision[] = []; + const result = owner.submitResponseEpoch( + captured, + value, + (decision) => decisions.push(decision), + ); + return { decisions, result }; +} + +function listenerProvider(): { + readonly listeners: RawInvalidationListener[]; + readonly scopes: string[]; + readonly subscribe: RawSubscribe; + cleanupCalls: number; +} { + const listeners: RawInvalidationListener[] = []; + const scopes: string[] = []; + const harness = { + cleanupCalls: 0, + listeners, + scopes, + subscribe: ( + scope: string, + listener: RawInvalidationListener, + ) => { + harness.scopes.push(scope); + harness.listeners.push(listener); + return () => { + harness.cleanupCalls += 1; + }; + }, + }; + return harness; +} + +function counterTarget( + onDispatch?: () => void, +): { + readonly target: SqlCatalogRevisionTarget; + prepared: number; + dispatched: number; +} { + const counter = { + dispatched: 0, + prepared: 0, + target: { + prepareCatalogChange: () => { + counter.prepared += 1; + return () => { + counter.dispatched += 1; + onDispatch?.(); + }; + }, + }, + }; + return counter; +} + +describe("catalog epoch coordinator construction and membership", () => { + it("authenticates providers and freezes its closed API", () => { + expect(createSqlCatalogEpochCoordinator(null)).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + expect( + createSqlCatalogEpochCoordinator({ + ...capturedProvider(undefined, "copied"), + }), + ).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + + const owner = coordinator(undefined, "provider-a"); + expect(owner.providerId).toBe("provider-a"); + expect(Object.isFrozen(owner)).toBe(true); + }); + + it("validates exact bounded well-formed scopes without raw errors", () => { + const owner = coordinator(); + expect( + owner.prepareScopeMembership("", counterTarget().target), + ).toEqual({ + reason: "invalid-scope", + status: "unavailable", + }); + for (const scope of [ + null, + 1, + "bad\0scope", + "\ud800", + "\ud800a", + "\udc00", + "x".repeat(MAX_CATALOG_SCOPE_LENGTH + 1), + ]) { + expect( + owner.prepareScopeMembership(scope, counterTarget().target), + ).toEqual({ + reason: "invalid-scope", + status: "unavailable", + }); + } + expect( + owner.prepareScopeMembership( + `\ud83d\ude80${"x".repeat(MAX_CATALOG_SCOPE_LENGTH - 2)}`, + counterTarget().target, + ).status, + ).toBe("prepared"); + }); + + it("uses two-phase activation and exposes only a frozen expected epoch", () => { + const owner = coordinator(); + const membership = prepared(owner, "scope"); + expect(membership.captureEpoch()).toEqual({ + reason: "inactive", + status: "unavailable", + }); + expect(membership.activate()).toEqual({ status: "active" }); + expect(membership.activate()).toEqual({ status: "active" }); + const first = capture(membership); + expect(first.expectedEpoch).toBeNull(); + expect(Object.keys(first)).toEqual(["expectedEpoch"]); + expect(Object.isFrozen(first)).toBe(true); + membership.dispose(); + membership.dispose(); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(membership.activate()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("does not retain a membership if target capture disposes the coordinator", () => { + const owner = coordinator(); + const target = new Proxy( + { + prepareCatalogChange: () => null, + }, + { + get(value, property, receiver) { + if (property === "prepareCatalogChange") { + owner.dispose(); + return 1; + } + return Reflect.get(value, property, receiver); + }, + }, + ); + const result = owner.prepareScopeMembership("scope", target); + expect(result).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect( + owner.prepareScopeMembership("later", counterTarget().target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + const throwingOwner = coordinator(); + expect( + throwingOwner.prepareScopeMembership("scope", { + get prepareCatalogChange(): never { + throwingOwner.dispose(); + throw new Error("dispose before hostile getter failure"); + }, + }), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("reserves membership capacity before a hostile target getter can reenter", () => { + const owner = coordinator(); + const memberships: SqlCatalogScopeMembership[] = []; + const rejected: string[] = []; + let getterCalls = 0; + let remaining = MAX_CATALOG_MEMBERSHIPS + 16; + const target = { + get prepareCatalogChange(): SqlCatalogRevisionTarget["prepareCatalogChange"] { + getterCalls += 1; + if (remaining > 0) { + remaining -= 1; + const nested = owner.prepareScopeMembership("scope", target); + if (nested.status === "prepared") { + memberships.push(nested.membership); + } else { + rejected.push(nested.reason); + } + } + return () => null; + }, + }; + + const outer = owner.prepareScopeMembership("scope", target); + if (outer.status === "prepared") { + memberships.push(outer.membership); + } else { + rejected.push(outer.reason); + } + expect(memberships).toHaveLength(MAX_CATALOG_MEMBERSHIPS); + expect(getterCalls).toBe(MAX_CATALOG_MEMBERSHIPS); + expect(remaining).toBe(16); + expect(rejected).toEqual(["membership-capacity"]); + expect( + owner.prepareScopeMembership("overflow", counterTarget().target), + ).toEqual({ + reason: "membership-capacity", + status: "unavailable", + }); + + for (const membership of memberships) membership.dispose(); + const reused = Array.from( + { length: MAX_CATALOG_MEMBERSHIPS }, + (_, index) => + owner.prepareScopeMembership( + `reused-${index}`, + counterTarget().target, + ), + ); + expect(reused.every((result) => result.status === "prepared")).toBe( + true, + ); + for (const result of reused) { + if (result.status === "prepared") result.membership.dispose(); + } + }); + + it.each([ + { + name: "a throwing getter", + target: { + get prepareCatalogChange(): never { + throw new Error("hostile target getter"); + }, + }, + }, + { + name: "a non-callable runtime value", + target: new Proxy( + { + prepareCatalogChange: () => null, + }, + { + get(value, property, receiver) { + return property === "prepareCatalogChange" + ? 1 + : Reflect.get(value, property, receiver); + }, + }, + ), + }, + ])( + "rejects $name without consuming the reserved membership slot", + ({ target }) => { + const owner = coordinator(); + const existing = Array.from( + { length: MAX_CATALOG_MEMBERSHIPS - 1 }, + (_, index) => prepared(owner, `existing-${index}`), + ); + expect( + owner.prepareScopeMembership("invalid-target", target), + ).toEqual({ + reason: "invalid-target", + status: "unavailable", + }); + const final = owner.prepareScopeMembership( + "final", + counterTarget().target, + ); + expect(final.status).toBe("prepared"); + expect( + owner.prepareScopeMembership( + "overflow", + counterTarget().target, + ), + ).toEqual({ + reason: "membership-capacity", + status: "unavailable", + }); + + for (const membership of existing) membership.dispose(); + if (final.status === "prepared") final.membership.dispose(); + }, + ); + + it.each([1, 10, 50])( + "shares one subscription across %i same-scope members", + (count) => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const memberships = Array.from({ length: count }, () => + active(owner, "shared"), + ); + expect(harness.scopes).toEqual(["shared"]); + const secondScope = active(owner, "other"); + expect(harness.scopes).toEqual(["shared", "other"]); + for (const membership of memberships.slice(0, -1)) { + membership.dispose(); + } + expect(harness.cleanupCalls).toBe(0); + memberships.at(-1)?.dispose(); + expect(harness.cleanupCalls).toBe(1); + secondScope.dispose(); + expect(harness.cleanupCalls).toBe(2); + }, + ); + + it("supports providers with no subscription", () => { + const owner = coordinator(); + const membership = active(owner, "static"); + const first = capture(membership); + const outcome = submit(owner, first, epoch(3)); + expect(outcome.result).toEqual({ status: "submitted" }); + expect(outcome.decisions).toEqual([ + { + epoch: epoch(3), + observation: "baseline", + status: "usable", + }, + ]); + expect(Object.isFrozen(outcome.result)).toBe(true); + expect(Object.isFrozen(outcome.decisions[0])).toBe(true); + expect(capture(membership).expectedEpoch).toEqual(epoch(3)); + }); + + it("never invokes provider search", () => { + let searchCalls = 0; + const captured = captureSqlRelationCatalogProvider({ + id: "search-is-out-of-scope", + search: async () => { + searchCalls += 1; + throw new Error("search must remain scheduler-owned"); + }, + }); + if (captured.status !== "accepted") { + throw new Error("Expected an accepted provider fixture"); + } + const created = createSqlCatalogEpochCoordinator(captured.value); + if (created.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + const membership = active(created.coordinator, "scope"); + submit(created.coordinator, capture(membership), epoch(1)); + created.coordinator.dispose(); + expect(searchCalls).toBe(0); + }); + + it("enforces exact capacity edges and reuses released capacity", () => { + const owner = coordinator(); + const inactive = Array.from( + { length: MAX_CATALOG_MEMBERSHIPS }, + (_, index) => prepared(owner, `prepared-${index}`), + ); + expect( + owner.prepareScopeMembership("overflow", counterTarget().target), + ).toEqual({ + reason: "membership-capacity", + status: "unavailable", + }); + inactive[0]?.dispose(); + expect( + owner.prepareScopeMembership("reused", counterTarget().target) + .status, + ).toBe("prepared"); + + const scopeOwner = coordinator(); + const scopes = Array.from( + { length: MAX_CATALOG_LIVE_SCOPES }, + (_, index) => active(scopeOwner, `scope-${index}`), + ); + const blocked = prepared(scopeOwner, "scope-overflow"); + expect(blocked.activate()).toEqual({ + reason: "scope-capacity", + status: "unavailable", + }); + scopes[0]?.dispose(); + expect(blocked.activate()).toEqual({ status: "active" }); + + const memberOwner = coordinator(); + const sameScope = Array.from( + { length: MAX_CATALOG_MEMBERSHIPS_PER_SCOPE }, + () => active(memberOwner, "crowded"), + ); + const extra = prepared(memberOwner, "crowded"); + expect(extra.activate()).toEqual({ + reason: "scope-membership-capacity", + status: "unavailable", + }); + sameScope[0]?.dispose(); + expect(extra.activate()).toEqual({ status: "active" }); + }); + + it("reclaims scope structure across ten thousand incarnations", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + for (let index = 0; index < 10_000; index += 1) { + active(owner, `scope-${index}`).dispose(); + } + expect(harness.scopes).toHaveLength(10_000); + expect(harness.cleanupCalls).toBe(10_000); + expect(active(owner, "after-churn").captureEpoch().status).toBe( + "captured", + ); + }); +}); + +describe("response epoch observations and capture authority", () => { + it("classifies baseline, equal, advance, stale, conflicts, and malformed epochs", () => { + const target = counterTarget(); + const owner = coordinator(); + const membership = active(owner, "scope", target.target); + + const baseline = submit(owner, capture(membership), epoch(1)); + expect(baseline.decisions).toEqual([ + { + epoch: epoch(1), + observation: "baseline", + status: "usable", + }, + ]); + expect(target.dispatched).toBe(0); + + const equal = submit(owner, capture(membership), epoch(1)); + expect(equal.decisions[0]).toMatchObject({ + observation: "equal", + status: "usable", + }); + + const advance = submit(owner, capture(membership), epoch(2)); + expect(advance.decisions).toEqual([ + { epoch: epoch(2), status: "superseded" }, + ]); + expect(target.dispatched).toBe(1); + expect(capture(membership).expectedEpoch).toEqual(epoch(2)); + + expect( + submit(owner, capture(membership), epoch(1)).decisions, + ).toEqual([{ reason: "stale", status: "discarded" }]); + expect( + submit(owner, capture(membership), epoch(2, "other")).decisions, + ).toEqual([ + { reason: "token-conflict", status: "discarded" }, + ]); + for (const malformed of [ + null, + {}, + { generation: -1, token: "bad" }, + { generation: 3, token: "\ud800" }, + ]) { + expect( + submit(owner, capture(membership), malformed).decisions, + ).toEqual([{ reason: "malformed", status: "discarded" }]); + } + }); + + it("claims captures once and keeps immediate rejection callback-free", () => { + const owner = coordinator(); + const membership = active(owner, "scope"); + const oneUse = capture(membership); + let callbackCalls = 0; + expect( + owner.submitResponseEpoch(oneUse, epoch(1), () => { + callbackCalls += 1; + const replay = owner.submitResponseEpoch( + oneUse, + epoch(1), + () => { + callbackCalls += 100; + }, + ); + expect(replay).toEqual({ + decision: { reason: "retired", status: "discarded" }, + status: "settled", + }); + }), + ).toEqual({ status: "submitted" }); + expect(callbackCalls).toBe(1); + expect( + owner.submitResponseEpoch(oneUse, epoch(1), () => { + callbackCalls += 100; + }), + ).toEqual({ + decision: { reason: "retired", status: "discarded" }, + status: "settled", + }); + expect(callbackCalls).toBe(1); + + for (const forged of [ + null, + {}, + { ...capture(membership) }, + ]) { + expect( + owner.submitResponseEpoch(forged, epoch(1), () => { + callbackCalls += 100; + }), + ).toEqual({ + decision: { reason: "malformed", status: "discarded" }, + status: "settled", + }); + } + expect(callbackCalls).toBe(1); + }); + + it("supersedes equal-current work captured before an accepted change", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const membership = active(owner, "scope"); + submit(owner, capture(membership), epoch(1)); + const beforeInvalidation = capture(membership); + harness.listeners[0]?.(invalidation(2)); + expect( + submit(owner, beforeInvalidation, epoch(2)).decisions, + ).toEqual([{ epoch: epoch(2), status: "superseded" }]); + + const beforeResponse = capture(membership); + expect( + submit(owner, capture(membership), epoch(3)).decisions, + ).toEqual([{ epoch: epoch(3), status: "superseded" }]); + expect(submit(owner, beforeResponse, epoch(3)).decisions).toEqual([ + { epoch: epoch(3), status: "superseded" }, + ]); + }); + + it("rejects cross-coordinator, retired, and cross-incarnation captures", () => { + const provider = capturedProvider(); + const createdA = createSqlCatalogEpochCoordinator(provider); + const createdB = createSqlCatalogEpochCoordinator(provider); + if (createdA.status !== "created" || createdB.status !== "created") { + throw new Error("Expected coordinator fixtures"); + } + const first = active(createdA.coordinator, "scope"); + const stale = capture(first); + expect( + createdB.coordinator.submitResponseEpoch( + stale, + epoch(1), + () => { + throw new Error("must not run"); + }, + ), + ).toEqual({ + decision: { reason: "malformed", status: "discarded" }, + status: "settled", + }); + first.dispose(); + active(createdA.coordinator, "scope"); + expect( + createdA.coordinator.submitResponseEpoch( + stale, + epoch(1), + () => { + throw new Error("must not run"); + }, + ), + ).toEqual({ + decision: { reason: "retired", status: "discarded" }, + status: "settled", + }); + }); + + it("fails closed when hostile response decoding retires membership or coordinator", () => { + const memberOwner = coordinator(); + const member = active(memberOwner, "scope"); + const memberEpoch = new Proxy(epoch(1), { + ownKeys(value) { + member.dispose(); + return Reflect.ownKeys(value); + }, + }); + const retired = submit( + memberOwner, + capture(member), + memberEpoch, + ); + expect(retired.result).toEqual({ status: "submitted" }); + expect(retired.decisions).toEqual([ + { reason: "retired", status: "discarded" }, + ]); + + const serviceOwner = coordinator(); + const serviceMember = active(serviceOwner, "scope"); + const serviceEpoch = new Proxy(epoch(1), { + ownKeys(value) { + serviceOwner.dispose(); + return Reflect.ownKeys(value); + }, + }); + const disposed = submit( + serviceOwner, + capture(serviceMember), + serviceEpoch, + ); + expect(disposed.result).toEqual({ status: "submitted" }); + expect(disposed.decisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + }); +}); + +describe("subscription invalidation ordering and isolation", () => { + it("classifies baseline/equal/advance while preserving state-before-listener and insertion order", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const order: string[] = []; + let first: SqlCatalogScopeMembership; + const firstTarget: SqlCatalogRevisionTarget = { + prepareCatalogChange: () => { + order.push(`prepare-a:${capture(first).expectedEpoch?.generation}`); + return () => { + order.push( + `dispatch-a:${capture(first).expectedEpoch?.generation}`, + ); + }; + }, + }; + first = active(owner, "scope", firstTarget); + const second = active(owner, "scope", { + prepareCatalogChange: () => { + order.push("prepare-b"); + return () => order.push("dispatch-b"); + }, + }); + const notify = harness.listeners[0]; + notify?.(invalidation(1)); + notify?.(invalidation(1)); + notify?.(invalidation(0)); + notify?.(invalidation(1, "conflict")); + notify?.(invalidation(2)); + expect(order).toEqual([ + "prepare-a:1", + "prepare-b", + "dispatch-a:1", + "dispatch-b", + "prepare-a:2", + "prepare-b", + "dispatch-a:2", + "dispatch-b", + ]); + expect(capture(second).expectedEpoch).toEqual(epoch(2)); + }); + + it("includes members activated after a reentrant invalidation is queued", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const joinedCounter = counterTarget(); + let joined: SqlCatalogScopeMembership | undefined; + let joinedAtActivation: + | SqlCatalogEpochCapture["expectedEpoch"] + | undefined; + let firstPrepared = 0; + let firstDispatched = 0; + const first = active(owner, "scope", { + prepareCatalogChange: () => { + firstPrepared += 1; + return () => { + firstDispatched += 1; + if (firstDispatched !== 1) return; + harness.listeners[0]?.(invalidation(2)); + joined = active(owner, "scope", { + ...joinedCounter.target, + }); + joinedAtActivation = capture(joined).expectedEpoch; + }; + }, + }); + + harness.listeners[0]?.(invalidation(1)); + expect(joinedAtActivation).toEqual(epoch(1)); + expect({ firstDispatched, firstPrepared }).toEqual({ + firstDispatched: 2, + firstPrepared: 2, + }); + expect(joinedCounter).toMatchObject({ + dispatched: 1, + prepared: 1, + }); + expect(capture(first).expectedEpoch).toEqual(epoch(2)); + expect(capture(joined ?? first).expectedEpoch).toEqual(epoch(2)); + }); + + it("isolates listener failure and skips a member disposed by an earlier listener", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const events: string[] = []; + let second: SqlCatalogScopeMembership; + active(owner, "scope", { + prepareCatalogChange: () => () => { + events.push("first"); + second.dispose(); + throw new Error("listener failure"); + }, + }); + second = active(owner, "scope", { + prepareCatalogChange: () => () => events.push("second"), + }); + active(owner, "scope", { + prepareCatalogChange: () => { + throw new Error("prepare failure"); + }, + }); + active(owner, "scope", { + prepareCatalogChange: () => () => events.push("last"), + }); + expect(() => + harness.listeners[0]?.(invalidation(1)), + ).not.toThrow(); + expect(events).toEqual(["first", "last"]); + }); + + it("skips a queued audience member disposed during preparation", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const events: string[] = []; + let second: SqlCatalogScopeMembership; + active(owner, "scope", { + prepareCatalogChange: () => { + events.push("prepare-first"); + second.dispose(); + return () => events.push("dispatch-first"); + }, + }); + second = active(owner, "scope", { + prepareCatalogChange: () => { + events.push("prepare-second"); + return () => events.push("dispatch-second"); + }, + }); + harness.listeners[0]?.(invalidation(1)); + expect(events).toEqual([ + "prepare-first", + "dispatch-first", + ]); + }); + + it("retires failed revision targets without blocking live members or early cleanup", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const nullTarget = active(owner, "scope", { + prepareCatalogChange: () => null, + }); + const throwingTarget = active(owner, "scope", { + prepareCatalogChange: () => { + throw new Error("prepare failed"); + }, + }); + const good = counterTarget(); + const goodMembership = active(owner, "scope", good.target); + + harness.listeners[0]?.(invalidation(1)); + expect(nullTarget.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(throwingTarget.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(good).toMatchObject({ dispatched: 1, prepared: 1 }); + expect(capture(goodMembership).expectedEpoch).toEqual(epoch(1)); + expect(harness.cleanupCalls).toBe(0); + + nullTarget.dispose(); + throwingTarget.dispose(); + expect(harness.cleanupCalls).toBe(0); + goodMembership.dispose(); + expect(harness.cleanupCalls).toBe(1); + }); + + it("fails closed against hostile decode reentrancy without reversing epochs", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const observed: number[] = []; + const membership = active(owner, "scope", { + prepareCatalogChange: () => () => { + const generation = capture(membership).expectedEpoch?.generation; + if (generation !== undefined) observed.push(generation); + }, + }); + const hostileEpoch = new Proxy(epoch(1), { + ownKeys(target) { + harness.listeners[0]?.(invalidation(2)); + return Reflect.ownKeys(target); + }, + }); + harness.listeners[0]?.({ epoch: hostileEpoch }); + expect(observed).toEqual([1]); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + }); + + it("drops decoded work if hostile decoding retires its subscription", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const target = counterTarget(); + const membership = active(owner, "scope", target.target); + const hostileEpoch = new Proxy(epoch(1), { + ownKeys(value) { + membership.dispose(); + return Reflect.ownKeys(value); + }, + }); + expect(() => + harness.listeners[0]?.({ epoch: hostileEpoch }), + ).not.toThrow(); + expect(target.dispatched).toBe(0); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("fans higher responses out atomically and drains reentrant responses FIFO", () => { + const owner = coordinator(); + const events: string[] = []; + let first: SqlCatalogScopeMembership; + let reentered = false; + first = active(owner, "scope", { + prepareCatalogChange: () => { + events.push( + `prepare-a:${capture(first).expectedEpoch?.generation}`, + ); + return () => { + events.push("dispatch-a"); + if (!reentered) { + reentered = true; + const nested = owner.submitResponseEpoch( + capture(first), + epoch(3), + (decision) => events.push(`decision-${decision.status}`), + ); + expect(nested).toEqual({ status: "submitted" }); + } + }; + }, + }); + const second = active(owner, "scope", { + prepareCatalogChange: () => { + events.push( + `prepare-b:${capture(first).expectedEpoch?.generation}`, + ); + return () => events.push("dispatch-b"); + }, + }); + submit(owner, capture(first), epoch(1)); + const result = owner.submitResponseEpoch( + capture(first), + epoch(2), + (decision) => events.push(`decision-${decision.status}`), + ); + expect(result).toEqual({ status: "submitted" }); + expect(events).toEqual([ + "prepare-a:2", + "prepare-b:2", + "decision-superseded", + "dispatch-a", + "dispatch-b", + "prepare-a:3", + "prepare-b:3", + "decision-superseded", + "dispatch-a", + "dispatch-b", + ]); + expect(capture(first).expectedEpoch).toEqual(epoch(3)); + expect(capture(second).expectedEpoch).toEqual(epoch(3)); + }); + + it("includes members activated after a higher response is queued", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const decisions: SqlCatalogResponseEpochDecision[] = []; + const joinedCounter = counterTarget(); + let admission: SqlCatalogResponseEpochSubmissionResult | undefined; + let joined: SqlCatalogScopeMembership | undefined; + let joinedAtActivation: + | SqlCatalogEpochCapture["expectedEpoch"] + | undefined; + let firstPrepared = 0; + let firstDispatched = 0; + let responseCapture: SqlCatalogEpochCapture; + const first = active(owner, "scope", { + prepareCatalogChange: () => { + firstPrepared += 1; + return () => { + firstDispatched += 1; + if (firstDispatched !== 1) return; + admission = owner.submitResponseEpoch( + responseCapture, + epoch(2), + (decision) => decisions.push(decision), + ); + joined = active(owner, "scope", joinedCounter.target); + joinedAtActivation = capture(joined).expectedEpoch; + }; + }, + }); + responseCapture = capture(first); + + harness.listeners[0]?.(invalidation(1)); + expect(admission).toEqual({ status: "submitted" }); + expect(joinedAtActivation).toEqual(epoch(1)); + expect(decisions).toEqual([ + { epoch: epoch(2), status: "superseded" }, + ]); + expect({ firstDispatched, firstPrepared }).toEqual({ + firstDispatched: 2, + firstPrepared: 2, + }); + expect(joinedCounter).toMatchObject({ + dispatched: 1, + prepared: 1, + }); + expect(capture(first).expectedEpoch).toEqual(epoch(2)); + expect(capture(joined ?? first).expectedEpoch).toEqual(epoch(2)); + }); + + it("does not dispatch a higher response after preparation disposes the coordinator", () => { + const owner = coordinator(); + let dispatchCalls = 0; + let disposeDuringPrepare = false; + const membership = active(owner, "scope", { + prepareCatalogChange: () => { + if (disposeDuringPrepare) owner.dispose(); + return () => { + dispatchCalls += 1; + }; + }, + }); + submit(owner, capture(membership), epoch(1)); + disposeDuringPrepare = true; + const outcome = submit(owner, capture(membership), epoch(2)); + expect(outcome.result).toEqual({ status: "submitted" }); + expect(outcome.decisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + expect(dispatchCalls).toBe(0); + }); + + it("discards a higher response whose capture retires during preparation", () => { + const owner = coordinator(); + let retireDuringPrepare = false; + let dispatchCalls = 0; + let membership: SqlCatalogScopeMembership; + membership = active(owner, "scope", { + prepareCatalogChange: () => { + if (retireDuringPrepare) membership.dispose(); + return () => { + dispatchCalls += 1; + }; + }, + }); + submit(owner, capture(membership), epoch(1)); + retireDuringPrepare = true; + + const outcome = submit(owner, capture(membership), epoch(2)); + expect(outcome.result).toEqual({ status: "submitted" }); + expect(outcome.decisions).toEqual([ + { reason: "retired", status: "discarded" }, + ]); + expect(dispatchCalls).toBe(0); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("settles a retired response before cleaning a last membership disposed during preparation", () => { + const events: string[] = []; + let disposeDuringPrepare = false; + let membership: SqlCatalogScopeMembership; + const owner = coordinator( + () => () => { + events.push("cleanup"); + }, + ); + membership = active(owner, "scope", { + prepareCatalogChange: () => { + if (disposeDuringPrepare) { + events.push("prepare-start"); + membership.dispose(); + events.push("prepare-end"); + } + return () => events.push("dispatch"); + }, + }); + submit(owner, capture(membership), epoch(1)); + disposeDuringPrepare = true; + + const result = owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => { + events.push(`decision-${decision.status}`); + }, + ); + expect(result).toEqual({ status: "submitted" }); + expect(events).toEqual([ + "prepare-start", + "prepare-end", + "decision-discarded", + "cleanup", + ]); + }); + + it("discards an admitted response when its membership retires before processing", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const decisions: SqlCatalogResponseEpochDecision[] = []; + let admission: SqlCatalogResponseEpochSubmissionResult | undefined; + let membership: SqlCatalogScopeMembership; + membership = active(owner, "scope", { + prepareCatalogChange: () => () => { + admission = owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => decisions.push(decision), + ); + membership.dispose(); + }, + }); + harness.listeners[0]?.(invalidation(1)); + expect(admission).toEqual({ status: "submitted" }); + expect(decisions).toEqual([ + { reason: "retired", status: "discarded" }, + ]); + }); +}); + +describe("hostile subscription lifecycle", () => { + it("buffers a valid synchronous callback until subscribe succeeds", () => { + let listenerCalls = 0; + const owner = coordinator((_scope, notify) => { + notify(invalidation(1)); + return () => {}; + }); + const membership = prepared(owner, "scope", { + prepareCatalogChange: () => () => { + listenerCalls += 1; + }, + }); + expect(membership.activate()).toEqual({ status: "active" }); + expect(listenerCalls).toBe(1); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + }); + + it("flushes a synchronous callback to every member active when subscribe commits", () => { + let second: SqlCatalogScopeMembership; + const firstCounter = counterTarget(); + const secondCounter = counterTarget(); + const owner = coordinator((_scope, notify) => { + notify(invalidation(1)); + expect(second.activate()).toEqual({ status: "active" }); + return () => {}; + }); + const first = prepared(owner, "scope", firstCounter.target); + second = prepared(owner, "scope", secondCounter.target); + + expect(first.activate()).toEqual({ status: "active" }); + expect(firstCounter).toMatchObject({ dispatched: 1, prepared: 1 }); + expect(secondCounter).toMatchObject({ dispatched: 1, prepared: 1 }); + expect(capture(first).expectedEpoch).toEqual(epoch(1)); + expect(capture(second).expectedEpoch).toEqual(epoch(1)); + }); + + it("includes a member activated between two buffered synchronous invalidations", () => { + const firstCounter = { + dispatched: 0, + prepared: 0, + }; + const secondCounter = counterTarget(); + let second: SqlCatalogScopeMembership; + let secondAtActivation: + | SqlCatalogEpochCapture["expectedEpoch"] + | undefined; + const owner = coordinator((_scope, notify) => { + notify(invalidation(1)); + notify(invalidation(2)); + return () => {}; + }); + const first = prepared(owner, "scope", { + prepareCatalogChange: () => { + firstCounter.prepared += 1; + return () => { + firstCounter.dispatched += 1; + if (firstCounter.dispatched !== 1) return; + expect(second.activate()).toEqual({ status: "active" }); + secondAtActivation = capture(second).expectedEpoch; + }; + }, + }); + second = prepared(owner, "scope", secondCounter.target); + + expect(first.activate()).toEqual({ status: "active" }); + expect(secondAtActivation).toEqual(epoch(1)); + expect(firstCounter).toEqual({ dispatched: 2, prepared: 2 }); + expect(secondCounter).toMatchObject({ + dispatched: 1, + prepared: 1, + }); + expect(capture(first).expectedEpoch).toEqual(epoch(2)); + expect(capture(second).expectedEpoch).toEqual(epoch(2)); + }); + + it("discards malformed buffered callbacks and all buffered work when subscribe fails", () => { + let listenerCalls = 0; + for (const subscribe of [ + (_scope: string, notify: RawInvalidationListener) => { + notify({ epoch: { generation: -1, token: "bad" } }); + return () => {}; + }, + (_scope: string, notify: RawInvalidationListener) => { + notify(invalidation(1)); + throw new Error("subscribe failed"); + }, + (_scope: string, notify: RawInvalidationListener) => { + notify(invalidation(1)); + return null; + }, + ]) { + const owner = coordinator(subscribe); + const membership = active(owner, "scope", { + prepareCatalogChange: () => () => { + listenerCalls += 1; + }, + }); + expect(capture(membership).expectedEpoch).toBeNull(); + } + expect(listenerCalls).toBe(0); + }); + + it("captures one cleanup closure, invokes it this-free once, and rejects non-functions without getters", () => { + const receivers: unknown[] = []; + let getterCalls = 0; + const validOwner = coordinator( + () => + function (this: unknown) { + receivers.push(this); + }, + ); + const membership = active(validOwner, "scope"); + membership.dispose(); + membership.dispose(); + expect(membership.activate()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + validOwner.dispose(); + expect(receivers).toEqual([undefined]); + + const nonEnumerable = {}; + Object.defineProperty(nonEnumerable, "dispose", { + enumerable: false, + value: () => {}, + }); + const invalidCleanupValues: unknown[] = [ + {}, + Object.create({ dispose() {} }), + nonEnumerable, + new Proxy( + { dispose() {} }, + { + getOwnPropertyDescriptor() { + throw new Error("hostile"); + }, + }, + ), + { + get dispose() { + getterCalls += 1; + return () => {}; + }, + }, + ]; + for (const cleanupValue of invalidCleanupValues) { + const owner = coordinator(() => cleanupValue); + expect(active(owner, "scope").captureEpoch().status).toBe( + "captured", + ); + } + expect(getterCalls).toBe(0); + }); + + it("isolates thrown cleanup and retains no live callback after failed subscribe", () => { + let retained: RawInvalidationListener | undefined; + const owner = coordinator((_scope, notify) => { + retained = notify; + return () => { + throw new Error("cleanup failed"); + }; + }); + const target = counterTarget(); + const membership = active(owner, "scope", target.target); + expect(() => membership.dispose()).not.toThrow(); + retained?.(invalidation(1)); + expect(target.dispatched).toBe(0); + + let failedRetained: RawInvalidationListener | undefined; + const failedOwner = coordinator((_scope, notify) => { + failedRetained = notify; + return null; + }); + const failedTarget = counterTarget(); + active(failedOwner, "scope", failedTarget.target); + failedRetained?.(invalidation(1)); + expect(failedTarget.dispatched).toBe(0); + }); + + it("drains rejected and hostile cleanup results after making state inert", async () => { + let asyncCleanupCalls = 0; + const rejectingOwner = coordinator( + () => async () => { + asyncCleanupCalls += 1; + throw new Error("async cleanup failed"); + }, + ); + const rejectingMembership = active(rejectingOwner, "scope"); + rejectingMembership.dispose(); + rejectingMembership.dispose(); + + let applyCalls = 0; + let thenReads = 0; + const thenProperty = ["th", "en"].join(""); + const hostileResult = Object.defineProperty({}, thenProperty, { + get() { + thenReads += 1; + throw new Error("hostile then getter"); + }, + }); + const hostileCleanup = new Proxy(() => hostileResult, { + apply(target, receiver, argumentsList) { + applyCalls += 1; + expect(receiver).toBeUndefined(); + return Reflect.apply(target, receiver, argumentsList); + }, + }); + const hostileOwner = coordinator(() => hostileCleanup); + const hostileMembership = active(hostileOwner, "scope"); + hostileMembership.dispose(); + hostileMembership.dispose(); + + let catchReads = 0; + const throwingCatchResult = Promise.reject( + new Error("shadowed catch rejection"), + ); + Object.defineProperty(throwingCatchResult, "catch", { + get() { + catchReads += 1; + throw new Error("hostile catch getter"); + }, + }); + const throwingCatchOwner = coordinator( + () => () => throwingCatchResult, + ); + active(throwingCatchOwner, "scope").dispose(); + + const nonCallableCatchResult = Promise.reject( + new Error("non-callable catch rejection"), + ); + Object.defineProperty(nonCallableCatchResult, "catch", { + value: null, + }); + const nonCallableCatchOwner = coordinator( + () => () => nonCallableCatchResult, + ); + active(nonCallableCatchOwner, "scope").dispose(); + + let thenCalls = 0; + let reentrantOwner: SqlCatalogEpochCoordinator; + const reentrantResult = Object.defineProperty( + {}, + thenProperty, + { + value: (resolve: () => void): void => { + thenCalls += 1; + const replacement = active( + reentrantOwner, + "replacement", + ); + replacement.dispose(); + resolve(); + }, + }, + ); + const reentrantCleanup = () => reentrantResult; + let reentrantSubscriptions = 0; + reentrantOwner = coordinator(() => { + reentrantSubscriptions += 1; + return reentrantSubscriptions === 1 + ? reentrantCleanup + : () => {}; + }); + active(reentrantOwner, "scope").dispose(); + + await Promise.resolve(); + await Promise.resolve(); + expect(asyncCleanupCalls).toBe(1); + expect(applyCalls).toBe(1); + expect(thenReads).toBe(1); + expect(catchReads).toBe(0); + expect(thenCalls).toBe(1); + }); + + it("retries a failed subscription only in a new last-owner incarnation", () => { + const listeners: RawInvalidationListener[] = []; + let attempts = 0; + const owner = coordinator((_scope, notify) => { + attempts += 1; + listeners.push(notify); + if (attempts === 1) return null; + return () => {}; + }); + const first = active(owner, "scope"); + const second = active(owner, "scope"); + expect(attempts).toBe(1); + first.dispose(); + expect(attempts).toBe(1); + second.dispose(); + const replacement = active(owner, "scope"); + expect(attempts).toBe(2); + listeners[0]?.(invalidation(10)); + expect(capture(replacement).expectedEpoch).toBeNull(); + listeners[1]?.(invalidation(1)); + expect(capture(replacement).expectedEpoch).toEqual(epoch(1)); + }); + + it("retires old callbacks before reentrant cleanup installs a replacement", () => { + const listeners: RawInvalidationListener[] = []; + const memberships: SqlCatalogScopeMembership[] = []; + let owner: SqlCatalogEpochCoordinator; + const provider = (_scope: string, notify: RawInvalidationListener) => { + listeners.push(notify); + return () => { + if (memberships.length === 1) { + memberships.push(active(owner, "scope")); + } + }; + }; + owner = coordinator(provider); + memberships.push(active(owner, "scope")); + memberships[0]?.dispose(); + expect(listeners).toHaveLength(2); + listeners[0]?.(invalidation(10)); + expect( + capture(memberships[1] ?? memberships[0]!).expectedEpoch, + ).toBeNull(); + listeners[1]?.(invalidation(1)); + expect( + capture(memberships[1] ?? memberships[0]!).expectedEpoch, + ).toEqual(epoch(1)); + }); + + it("returns unavailable when synchronous activation work disposes membership or coordinator", () => { + let membership: SqlCatalogScopeMembership; + const memberOwner = coordinator((_scope, notify) => { + notify(invalidation(1)); + return () => {}; + }); + membership = prepared(memberOwner, "scope", { + prepareCatalogChange: () => () => membership.dispose(), + }); + expect(membership.activate()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + let service: SqlCatalogEpochCoordinator; + service = coordinator((_scope, notify) => { + notify(invalidation(1)); + return () => {}; + }); + const serviceMembership = prepared(service, "scope", { + prepareCatalogChange: () => () => service.dispose(), + }); + expect(serviceMembership.activate()).toEqual({ + reason: "coordinator-disposed", + status: "unavailable", + }); + }); + + it("quarantines 257 callbacks across microtasks and cleans an install-time overload once", async () => { + let notify: RawInvalidationListener | undefined; + let cleanupCalls = 0; + const owner = coordinator((_scope, listener) => { + notify = listener; + return () => { + cleanupCalls += 1; + }; + }); + const membership = active(owner, "scope"); + for ( + let index = 0; + index < MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + await Promise.resolve(); + notify?.({ malformed: index }); + } + await Promise.resolve(); + notify?.(invalidation(1)); + expect(cleanupCalls).toBe(1); + expect(capture(membership).expectedEpoch).toBeNull(); + + let installCleanup = 0; + const installOwner = coordinator((_scope, listener) => { + for ( + let index = 0; + index <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + listener({ malformed: index }); + } + return () => { + installCleanup += 1; + }; + }); + active(installOwner, "scope"); + expect(installCleanup).toBe(1); + }); + + it("resets callback allowance only after its reset timer fires", async () => { + let notify: RawInvalidationListener | undefined; + const owner = coordinator((_scope, listener) => { + notify = listener; + return () => {}; + }); + const membership = active(owner, "scope"); + for ( + let index = 0; + index < MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + notify?.({ malformed: index }); + } + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + notify?.(invalidation(1)); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + }); + + it("conservatively counts an earlier queued MessageChannel task in the reset window", async () => { + let notify: RawInvalidationListener | undefined; + let cleanupCalls = 0; + const owner = coordinator((_scope, listener) => { + notify = listener; + return () => { + cleanupCalls += 1; + }; + }); + const membership = active(owner, "scope"); + const channel = new MessageChannel(); + const messageTask = new Promise((resolve) => { + channel.port2.once("message", () => { + notify?.(invalidation(1)); + resolve(); + }); + }); + channel.port1.postMessage("queued-before-reset-timer"); + for ( + let index = 0; + index < MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + notify?.({ malformed: index }); + } + await messageTask; + channel.port1.close(); + channel.port2.close(); + + expect(cleanupCalls).toBe(1); + expect(capture(membership).expectedEpoch).toBeNull(); + }); +}); + +describe("service disposal and bounded command draining", () => { + it("deduplicates install-time overload cleanup within one outer barrier", () => { + let driverNotify: RawInvalidationListener | undefined; + let overloadedCleanupCalls = 0; + let driverCleanupCalls = 0; + let preparedOverloads = false; + let owner: SqlCatalogEpochCoordinator; + owner = coordinator((scope, notify) => { + if (scope === "driver") { + driverNotify = notify; + } else { + for ( + let index = 0; + index <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + notify({ malformed: index }); + } + } + return () => { + if (scope === "driver") { + driverCleanupCalls += 1; + } else { + overloadedCleanupCalls += 1; + } + }; + }); + const driver = active(owner, "driver", { + prepareCatalogChange: () => { + if (!preparedOverloads) { + preparedOverloads = true; + for ( + let index = 0; + index < MAX_CATALOG_CLEANUPS_PER_BARRIER; + index += 1 + ) { + active(owner, `overloaded-${index}`).dispose(); + } + } + return () => {}; + }, + }); + + driverNotify?.(invalidation(1)); + expect(overloadedCleanupCalls).toBe( + MAX_CATALOG_CLEANUPS_PER_BARRIER, + ); + expect(driverCleanupCalls).toBe(0); + expect(capture(driver).expectedEpoch).toEqual(epoch(1)); + expect( + owner.prepareScopeMembership( + "after-deduplicated-cleanup", + counterTarget().target, + ).status, + ).toBe("prepared"); + + driver.dispose(); + expect(driverCleanupCalls).toBe(1); + }); + + it("quarantines cleanup reentrancy at the documented barrier limit", () => { + const listeners = new Map(); + let cleanupCalls = 0; + let cleanupDepth = 0; + let lateCleanupCalls = 0; + let maximumCleanupDepth = 0; + let sentinelCleanupCalls = 0; + let subscriptionCalls = 0; + let owner: SqlCatalogEpochCoordinator; + owner = coordinator((scope, notify) => { + subscriptionCalls += 1; + listeners.set(scope, notify); + const installsAfterCleanupLimit = + subscriptionCalls === + MAX_CATALOG_CLEANUPS_PER_BARRIER + 2; + if (installsAfterCleanupLimit) { + for ( + let index = 0; + index <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + index += 1 + ) { + notify({ malformed: index }); + } + } + return () => { + if (installsAfterCleanupLimit) { + lateCleanupCalls += 1; + return; + } + cleanupDepth += 1; + maximumCleanupDepth = Math.max( + maximumCleanupDepth, + cleanupDepth, + ); + cleanupCalls += 1; + if (scope === "sentinel") { + sentinelCleanupCalls += 1; + } else { + const next = active( + owner, + `cleanup-chain-${subscriptionCalls}`, + ); + next.dispose(); + } + cleanupDepth -= 1; + }; + }); + const sentinelTarget = counterTarget(); + const sentinel = active( + owner, + "sentinel", + sentinelTarget.target, + ); + active(owner, "cleanup-chain-0", { + prepareCatalogChange: () => null, + }); + + listeners.get("cleanup-chain-0")?.(invalidation(1)); + expect(subscriptionCalls).toBe( + MAX_CATALOG_CLEANUPS_PER_BARRIER + 2, + ); + expect(cleanupCalls).toBe( + MAX_CATALOG_CLEANUPS_PER_BARRIER, + ); + expect(maximumCleanupDepth).toBe(1); + expect(lateCleanupCalls).toBe(0); + expect(sentinelCleanupCalls).toBe(0); + expect(sentinel.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect( + owner.prepareScopeMembership( + "after-overflow", + counterTarget().target, + ), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + listeners.get("sentinel")?.(invalidation(2)); + owner.dispose(); + sentinel.dispose(); + expect(sentinelTarget.dispatched).toBe(0); + expect(cleanupCalls).toBe( + MAX_CATALOG_CLEANUPS_PER_BARRIER, + ); + expect(lateCleanupCalls).toBe(0); + expect(sentinelCleanupCalls).toBe(0); + }); + + it("retires callbacks, members, and cleanup exactly once on disposal", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const target = counterTarget(); + const membership = active(owner, "scope", target.target); + const retained = harness.listeners[0]; + owner.dispose(); + owner.dispose(); + expect(harness.cleanupCalls).toBe(1); + retained?.(invalidation(1)); + expect(target.dispatched).toBe(0); + expect(membership.activate()).toEqual({ + reason: "coordinator-disposed", + status: "unavailable", + }); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + membership.dispose(); + membership.dispose(); + expect(harness.cleanupCalls).toBe(1); + expect( + owner.prepareScopeMembership("new", target.target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + let callbackCalls = 0; + expect( + owner.submitResponseEpoch({}, epoch(1), () => { + callbackCalls += 1; + }), + ).toEqual({ + decision: { reason: "disposed", status: "discarded" }, + status: "settled", + }); + expect(callbackCalls).toBe(0); + }); + + it("retires the provider callback before reentrant cleanup", () => { + let retained: RawInvalidationListener | undefined; + let cleanupCalls = 0; + let payloadReads = 0; + const hostilePayload = new Proxy( + {}, + { + get(target, property, receiver) { + payloadReads += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + payloadReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + ownKeys(target) { + payloadReads += 1; + return Reflect.ownKeys(target); + }, + }, + ); + const owner = coordinator((_scope, listener) => { + retained = listener; + return () => { + cleanupCalls += 1; + retained?.(hostilePayload); + }; + }); + const target = counterTarget(); + const membership = active(owner, "scope", target.target); + + expect(() => owner.dispose()).not.toThrow(); + owner.dispose(); + expect(cleanupCalls).toBe(1); + expect(payloadReads).toBe(0); + expect(target.dispatched).toBe(0); + + expect(() => retained?.(hostilePayload)).not.toThrow(); + expect(payloadReads).toBe(0); + expect(target.dispatched).toBe(0); + expect(membership.activate()).toEqual({ + reason: "coordinator-disposed", + status: "unavailable", + }); + expect(membership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + membership.dispose(); + membership.dispose(); + expect(cleanupCalls).toBe(1); + expect( + owner.prepareScopeMembership("new", target.target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + let callbackCalls = 0; + expect( + owner.submitResponseEpoch({}, epoch(2), () => { + callbackCalls += 1; + }), + ).toEqual({ + decision: { reason: "disposed", status: "discarded" }, + status: "settled", + }); + expect(callbackCalls).toBe(0); + }); + + it("settles queued response sinks as disposed without recursive callbacks", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const decisions: SqlCatalogResponseEpochDecision[] = []; + let queuedResult: SqlCatalogResponseEpochSubmissionResult | undefined; + const membership = active(owner, "scope", { + prepareCatalogChange: () => () => { + queuedResult = owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => decisions.push(decision), + ); + owner.dispose(); + }, + }); + harness.listeners[0]?.(invalidation(1)); + expect(queuedResult).toEqual({ status: "submitted" }); + expect(decisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + }); + + it("isolates thrown decision callbacks and continues draining", () => { + const owner = coordinator(); + const membership = active(owner, "scope"); + expect(() => + owner.submitResponseEpoch( + capture(membership), + epoch(1), + () => { + throw new Error("consumer failure"); + }, + ), + ).not.toThrow(); + const next = submit(owner, capture(membership), epoch(1)); + expect(next.decisions).toEqual([ + { + epoch: epoch(1), + observation: "equal", + status: "usable", + }, + ]); + }); + + it("bounds response commands and keeps overload rejection nonrecursive", () => { + const harness = listenerProvider(); + const owner = coordinator(harness.subscribe); + const membership = active(owner, "scope", { + prepareCatalogChange: () => () => { + const admitted: SqlCatalogEpochCapture[] = []; + for ( + let index = 0; + index < MAX_CATALOG_EPOCH_COMMANDS; + index += 1 + ) { + admitted.push(capture(membership)); + } + for (const item of admitted) { + expect( + owner.submitResponseEpoch(item, epoch(1), () => {}), + ).toEqual({ status: "submitted" }); + } + let recursiveCalls = 0; + const overflow = capture(membership); + const result = owner.submitResponseEpoch( + overflow, + epoch(1), + () => { + recursiveCalls += 1; + owner.submitResponseEpoch( + capture(membership), + epoch(1), + () => { + recursiveCalls += 1; + }, + ); + }, + ); + expect(result).toEqual({ + decision: { reason: "overloaded", status: "discarded" }, + status: "settled", + }); + expect(recursiveCalls).toBe(0); + expect( + owner.submitResponseEpoch(overflow, epoch(1), () => { + recursiveCalls += 1; + }), + ).toEqual({ + decision: { reason: "retired", status: "discarded" }, + status: "settled", + }); + }, + }); + harness.listeners[0]?.(invalidation(1)); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + }); + + it("caps one-at-a-time response chains by total admissions per drain", () => { + const owner = coordinator(); + const membership = active(owner, "scope"); + const captures = Array.from( + { length: MAX_CATALOG_EPOCH_COMMANDS + 1 }, + () => capture(membership), + ); + let callbacks = 0; + let callbackDepth = 0; + let maximumCallbackDepth = 0; + let overflow: SqlCatalogResponseEpochSubmissionResult | undefined; + + const onDecision = (): void => { + callbackDepth += 1; + maximumCallbackDepth = Math.max( + maximumCallbackDepth, + callbackDepth, + ); + callbacks += 1; + const next = captures[callbacks]; + if (next) { + const result = owner.submitResponseEpoch( + next, + epoch(1), + onDecision, + ); + if (result.status === "settled") overflow = result; + } + callbackDepth -= 1; + }; + + const first = captures[0]; + if (!first) throw new Error("Expected an epoch capture"); + expect( + owner.submitResponseEpoch(first, epoch(1), onDecision), + ).toEqual({ status: "submitted" }); + expect(callbacks).toBe(MAX_CATALOG_EPOCH_COMMANDS); + expect(maximumCallbackDepth).toBe(1); + expect(overflow).toEqual({ + decision: { reason: "overloaded", status: "discarded" }, + status: "settled", + }); + + let afterDrain = 0; + expect( + owner.submitResponseEpoch( + capture(membership), + epoch(1), + () => { + afterDrain += 1; + }, + ), + ).toEqual({ status: "submitted" }); + expect(afterDrain).toBe(1); + }); + + it("quarantines the overflowing invalidation subscription and retires its queued commands", () => { + const listeners = new Map(); + const cleanupScopes: string[] = []; + const owner = coordinator((scope, notify) => { + listeners.set(scope, notify); + return () => cleanupScopes.push(scope); + }); + const stormMemberships = Array.from({ length: 5 }, (_, index) => + active(owner, `storm-${index}`), + ); + let fired = false; + active(owner, "trigger", { + prepareCatalogChange: () => () => { + if (fired) return; + fired = true; + for (let scopeIndex = 0; scopeIndex < 5; scopeIndex += 1) { + const notify = listeners.get(`storm-${scopeIndex}`); + for (let generation = 1; generation <= 205; generation += 1) { + notify?.(invalidation(generation)); + } + } + }, + }); + listeners.get("trigger")?.(invalidation(1)); + expect(cleanupScopes).toEqual(["storm-4"]); + for (const membership of stormMemberships.slice(0, 4)) { + expect(capture(membership).expectedEpoch).toEqual(epoch(205)); + } + expect(capture(stormMemberships[4]!).expectedEpoch).toBeNull(); + }); +}); diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/vnext/relation-catalog-epoch-coordinator.ts new file mode 100644 index 0000000..ef96b25 --- /dev/null +++ b/src/vnext/relation-catalog-epoch-coordinator.ts @@ -0,0 +1,1223 @@ +import { + MAX_CATALOG_SCOPE_LENGTH, + compareSqlCatalogEpoch, + decodeSqlCatalogInvalidation, + resolveSqlRelationCatalogProvider, +} from "./relation-catalog-boundary.js"; +import type { SqlCapturedRelationCatalogProviderContext } from "./relation-catalog-boundary.js"; +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; + +export const MAX_CATALOG_LIVE_SCOPES = 128; +export const MAX_CATALOG_MEMBERSHIPS = 1_024; +export const MAX_CATALOG_MEMBERSHIPS_PER_SCOPE = 256; +export const MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW = 256; +export const MAX_CATALOG_EPOCH_COMMANDS = 1_024; +export const MAX_CATALOG_CLEANUPS_PER_BARRIER = 1_024; + +export interface SqlCatalogRevisionTarget { + readonly prepareCatalogChange: ( + this: void, + ) => ((this: void) => void) | null; +} + +const epochCaptureBrand: unique symbol = Symbol( + "SqlCatalogEpochCapture", +); +const EPOCH_CAPTURE_KIND = "SqlCatalogEpochCapture"; + +export interface SqlCatalogEpochCapture { + readonly [epochCaptureBrand]: "SqlCatalogEpochCapture"; + readonly expectedEpoch: SqlCatalogEpoch | null; +} + +export type SqlCatalogMembershipActivationResult = + | { readonly status: "active" } + | { + readonly status: "unavailable"; + readonly reason: + | "disposed" + | "scope-capacity" + | "scope-membership-capacity" + | "coordinator-disposed"; + }; + +export type SqlCatalogEpochCaptureResult = + | { + readonly status: "captured"; + readonly capture: SqlCatalogEpochCapture; + } + | { + readonly status: "unavailable"; + readonly reason: "inactive" | "disposed"; + }; + +export interface SqlCatalogScopeMembership { + readonly activate: ( + this: void, + ) => SqlCatalogMembershipActivationResult; + readonly captureEpoch: ( + this: void, + ) => SqlCatalogEpochCaptureResult; + readonly dispose: (this: void) => void; +} + +export type SqlCatalogScopeMembershipResult = + | { + readonly status: "prepared"; + readonly membership: SqlCatalogScopeMembership; + } + | { + readonly status: "unavailable"; + readonly reason: + | "disposed" + | "invalid-scope" + | "invalid-target" + | "membership-capacity"; + }; + +export type SqlCatalogResponseEpochDecision = + | { + readonly status: "usable"; + readonly observation: "baseline" | "equal"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly status: "superseded"; + readonly epoch: SqlCatalogEpoch; + } + | { + readonly status: "discarded"; + readonly reason: + | "stale" + | "token-conflict" + | "retired" + | "disposed" + | "malformed" + | "overloaded"; + }; + +export type SqlCatalogResponseEpochSubmissionResult = + | { + readonly status: "submitted"; + } + | { + readonly status: "settled"; + readonly decision: Extract< + SqlCatalogResponseEpochDecision, + { readonly status: "discarded" } + >; + }; + +export interface SqlCatalogEpochCoordinator { + readonly providerId: string; + readonly prepareScopeMembership: ( + this: void, + scope: unknown, + target: SqlCatalogRevisionTarget, + ) => SqlCatalogScopeMembershipResult; + readonly submitResponseEpoch: ( + this: void, + capture: unknown, + epoch: unknown, + onDecision: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void, + ) => SqlCatalogResponseEpochSubmissionResult; + readonly dispose: (this: void) => void; +} + +export type SqlCatalogEpochCoordinatorResult = + | { + readonly status: "created"; + readonly coordinator: SqlCatalogEpochCoordinator; + } + | { + readonly status: "unavailable"; + readonly reason: "invalid-provider"; + }; + +interface CoordinatorState { + cleanupAdmissions: number; + cleanupDeferralDepth: number; + cleanupOverloaded: boolean; + disposed: boolean; + draining: boolean; + readonly captures: WeakMap; + readonly commands: EpochCommand[]; + readonly deferredCleanup: Set; + readonly memberships: Set; + readonly providerId: string; + readonly scopes: Map; + subscribe: SqlCapturedRelationCatalogProviderContext["subscribe"]; +} + +interface ScopeEntry { + readonly members: Set; + notificationSequence: number; + observedEpoch: SqlCatalogEpoch | null; + readonly scope: string; + subscription: SubscriptionState | null; + subscriptionAttempted: boolean; +} + +interface MembershipState { + active: boolean; + disposed: boolean; + disposedByCoordinator: boolean; + entry: ScopeEntry | null; + owner: CoordinatorState | null; + prepareCatalogChange: (this: void) => unknown; + readonly scope: string; +} + +interface CaptureState { + claimed: boolean; + readonly entry: ScopeEntry; + readonly membership: MembershipState; + readonly notificationSequence: number; + readonly owner: CoordinatorState; +} + +interface CallbackCell { + active: boolean; + callbackCount: number; + decode: + | ((value: unknown) => SqlCatalogEpoch | null) + | null; + decoding: boolean; + deliver: + | ((value: SqlCatalogEpoch) => void) + | null; + overload: (() => void) | null; + resetTimer: ReturnType | null; +} + +interface SubscriptionState { + readonly cell: CallbackCell; + cleanupCalled: boolean; + cleanup: Function | null; + failed: boolean; + installing: boolean; + readonly pending: SqlCatalogEpoch[]; +} + +interface InvalidationCommand { + readonly entry: ScopeEntry; + readonly kind: "invalidation"; + readonly subscription: SubscriptionState; + readonly epoch: SqlCatalogEpoch; +} + +interface ResponseCommand { + readonly capture: CaptureState; + readonly epoch: unknown; + readonly kind: "response"; + readonly onDecision: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void; +} + +type EpochCommand = InvalidationCommand | ResponseCommand; + +const ACTIVE_RESULT: SqlCatalogMembershipActivationResult = + Object.freeze({ status: "active" }); +const SUBMITTED_RESULT: SqlCatalogResponseEpochSubmissionResult = + Object.freeze({ status: "submitted" }); +const NO_PREPARE_CATALOG_CHANGE = (): null => null; +const IGNORE_CLEANUP_REJECTION = (): void => {}; + +function unavailableActivation( + reason: Exclude< + SqlCatalogMembershipActivationResult, + { readonly status: "active" } + >["reason"], +): SqlCatalogMembershipActivationResult { + return Object.freeze({ reason, status: "unavailable" }); +} + +function discarded( + reason: Extract< + SqlCatalogResponseEpochDecision, + { readonly status: "discarded" } + >["reason"], +): Extract< + SqlCatalogResponseEpochDecision, + { readonly status: "discarded" } +> { + return Object.freeze({ reason, status: "discarded" }); +} + +function settled( + decision: Extract< + SqlCatalogResponseEpochDecision, + { readonly status: "discarded" } + >, +): SqlCatalogResponseEpochSubmissionResult { + return Object.freeze({ decision, status: "settled" }); +} + +function settleDecision( + onDecision: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void, + decision: SqlCatalogResponseEpochDecision, +): void { + try { + onDecision(decision); + } catch { + // Consumers cannot break the serialized epoch gate. + } +} + +function isValidScope(candidate: unknown): candidate is string { + if ( + typeof candidate !== "string" || + candidate.length < 1 || + candidate.length > MAX_CATALOG_SCOPE_LENGTH + ) { + return false; + } + for (let index = 0; index < candidate.length; index += 1) { + const code = candidate.charCodeAt(index); + if (code === 0) return false; + if (code >= 0xd800 && code <= 0xdbff) { + if (index + 1 >= candidate.length) return false; + const next = candidate.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function snapshotAudience( + entry: ScopeEntry, +): readonly MembershipState[] { + const audience: MembershipState[] = []; + for (const member of entry.members) { + audience.push(member); + } + return audience; +} + +function resetCallbackAllowance(cell: CallbackCell): void { + cell.resetTimer = null; + cell.callbackCount = 0; +} + +function receiveSubscriptionCallback( + cell: CallbackCell, + value: unknown, +): void { + if (!cell.active) return; + cell.callbackCount += 1; + if (cell.resetTimer === null) { + cell.resetTimer = setTimeout( + resetCallbackAllowance, + 0, + cell, + ); + } + if ( + cell.callbackCount > + MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + ) { + const overload = cell.overload; + cell.active = false; + cell.deliver = null; + cell.overload = null; + clearTimeout(cell.resetTimer); + cell.resetTimer = null; + overload?.(); + return; + } + if (cell.decoding) return; + const decode = cell.decode; + if (!decode) return; + cell.decoding = true; + let decoded: SqlCatalogEpoch | null; + try { + decoded = decode(value); + } finally { + cell.decoding = false; + } + if (decoded) cell.deliver?.(decoded); +} + +function retireCallbackCell(cell: CallbackCell): void { + cell.active = false; + cell.callbackCount = 0; + cell.decode = null; + cell.decoding = false; + cell.deliver = null; + cell.overload = null; + if (cell.resetTimer !== null) { + clearTimeout(cell.resetTimer); + cell.resetTimer = null; + } +} + +function captureCleanup(candidate: unknown): Function | null { + return typeof candidate === "function" ? candidate : null; +} + +function cleanupSubscription(subscription: SubscriptionState): void { + if (subscription.cleanupCalled) { + subscription.cleanup = null; + return; + } + const cleanup = subscription.cleanup; + if (!cleanup) return; + subscription.cleanupCalled = true; + subscription.cleanup = null; + try { + const result = Reflect.apply(cleanup, undefined, []); + const settlement = new Promise((resolve) => { + resolve(result); + }); + void settlement.then(undefined, IGNORE_CLEANUP_REJECTION); + } catch { + // Provider cleanup is isolated after state is inert. + } +} + +function abandonSubscriptionCleanup( + subscription: SubscriptionState, +): void { + subscription.cleanupCalled = true; + subscription.cleanup = null; +} + +function quarantineCleanupOverflow( + state: CoordinatorState, + rejected: SubscriptionState, +): void { + abandonSubscriptionCleanup(rejected); + state.cleanupOverloaded = true; + disposeCoordinator(state); + for (const subscription of state.deferredCleanup) { + abandonSubscriptionCleanup(subscription); + } + state.deferredCleanup.clear(); +} + +function scheduleSubscriptionCleanup( + state: CoordinatorState, + subscription: SubscriptionState, +): void { + if (subscription.cleanupCalled) { + subscription.cleanup = null; + return; + } + if (state.cleanupOverloaded) { + abandonSubscriptionCleanup(subscription); + return; + } + if (state.deferredCleanup.has(subscription)) return; + if ( + state.cleanupAdmissions >= + MAX_CATALOG_CLEANUPS_PER_BARRIER + ) { + quarantineCleanupOverflow(state, subscription); + return; + } + state.cleanupAdmissions += 1; + state.deferredCleanup.add(subscription); + if (state.cleanupDeferralDepth === 0) { + flushDeferredCleanup(state); + } +} + +function enterCleanupBarrier(state: CoordinatorState): void { + if (state.cleanupDeferralDepth === 0) { + state.cleanupAdmissions = 0; + } + state.cleanupDeferralDepth += 1; +} + +function flushDeferredCleanup(state: CoordinatorState): void { + if (state.deferredCleanup.size === 0) return; + state.cleanupDeferralDepth = 1; + try { + while (state.deferredCleanup.size > 0) { + for (const subscription of state.deferredCleanup) { + state.deferredCleanup.delete(subscription); + cleanupSubscription(subscription); + break; + } + } + } finally { + state.cleanupDeferralDepth = 0; + if (!state.cleanupOverloaded) { + state.cleanupAdmissions = 0; + } + } +} + +function leaveCleanupBarrier(state: CoordinatorState): void { + state.cleanupDeferralDepth -= 1; + if (state.cleanupDeferralDepth === 0) { + flushDeferredCleanup(state); + if (!state.cleanupOverloaded) { + state.cleanupAdmissions = 0; + } + } +} + +function disableSubscription( + state: CoordinatorState, + entry: ScopeEntry, + subscription: SubscriptionState, +): void { + if (entry.subscription === subscription) { + entry.subscription = null; + } + subscription.failed = true; + subscription.pending.length = 0; + retireCallbackCell(subscription.cell); + scheduleSubscriptionCleanup(state, subscription); +} + +function enqueueCommand( + state: CoordinatorState, + command: EpochCommand, +): boolean { + if (state.commands.length >= MAX_CATALOG_EPOCH_COMMANDS) { + if (command.kind === "response") { + return false; + } else { + disableSubscription( + state, + command.entry, + command.subscription, + ); + } + return false; + } + state.commands.push(command); + if (!state.draining) drainCommands(state); + return true; +} + +function enqueueInvalidation( + state: CoordinatorState, + entry: ScopeEntry, + subscription: SubscriptionState, + epoch: SqlCatalogEpoch, +): void { + if ( + state.disposed || + entry.subscription !== subscription || + subscription.failed + ) { + return; + } + enqueueCommand(state, { + entry, + epoch, + kind: "invalidation", + subscription, + }); +} + +function decodeSubscriptionValue( + state: CoordinatorState, + entry: ScopeEntry, + subscription: SubscriptionState, + value: unknown, +): SqlCatalogEpoch | null { + const decoded = decodeSqlCatalogInvalidation(value); + if (decoded.status !== "accepted") return null; + if ( + state.disposed || + entry.subscription !== subscription || + subscription.failed + ) { + return null; + } + return decoded.value.epoch; +} + +function deliverSubscriptionValue( + state: CoordinatorState, + entry: ScopeEntry, + subscription: SubscriptionState, + epoch: SqlCatalogEpoch, +): void { + if (subscription.installing) { + subscription.pending.push(epoch); + } else { + enqueueInvalidation( + state, + entry, + subscription, + epoch, + ); + } +} + +function installSubscription( + state: CoordinatorState, + entry: ScopeEntry, +): void { + const subscribe = state.subscribe; + if ( + state.disposed || + entry.subscriptionAttempted || + !subscribe + ) { + entry.subscriptionAttempted = true; + return; + } + entry.subscriptionAttempted = true; + const cell: CallbackCell = { + active: true, + callbackCount: 0, + decode: null, + decoding: false, + deliver: null, + overload: null, + resetTimer: null, + }; + const subscription: SubscriptionState = { + cell, + cleanupCalled: false, + cleanup: null, + failed: false, + installing: true, + pending: [], + }; + entry.subscription = subscription; + cell.decode = ( + value: unknown, + ): SqlCatalogEpoch | null => + decodeSubscriptionValue( + state, + entry, + subscription, + value, + ); + cell.deliver = (value: SqlCatalogEpoch): void => { + deliverSubscriptionValue( + state, + entry, + subscription, + value, + ); + }; + cell.overload = (): void => { + disableSubscription(state, entry, subscription); + }; + let rawCleanup: unknown; + try { + rawCleanup = subscribe( + entry.scope, + (value: unknown): void => { + receiveSubscriptionCallback(cell, value); + }, + ); + } catch { + disableSubscription(state, entry, subscription); + return; + } + const cleanup = captureCleanup(rawCleanup); + if (!cleanup) { + disableSubscription(state, entry, subscription); + return; + } + subscription.cleanup = cleanup; + subscription.installing = false; + if ( + subscription.failed || + state.disposed || + entry.subscription !== subscription + ) { + disableSubscription(state, entry, subscription); + return; + } + const pending = subscription.pending.splice( + 0, + subscription.pending.length, + ); + const stageWholeFlush = !state.draining; + if (stageWholeFlush) state.draining = true; + try { + for (const epoch of pending) { + enqueueInvalidation( + state, + entry, + subscription, + epoch, + ); + } + } finally { + if (stageWholeFlush) { + state.draining = false; + drainCommands(state); + } + } +} + +interface PreparedRevision { + readonly dispatch: (this: void) => unknown; + readonly member: MembershipState; +} + +function prepareRevisions( + audience: readonly MembershipState[], + entry: ScopeEntry, +): readonly PreparedRevision[] { + const prepared: PreparedRevision[] = []; + for (const member of audience) { + if ( + !member.active || + member.disposed || + member.entry !== entry + ) { + continue; + } + let dispatch: unknown = null; + try { + const prepareCatalogChange = + member.prepareCatalogChange; + dispatch = prepareCatalogChange(); + } catch { + dispatch = null; + } + if ( + typeof dispatch === "function" && + member.active && + !member.disposed && + member.entry === entry + ) { + const dispatchFunction = dispatch; + prepared.push({ + dispatch: (): unknown => + Reflect.apply(dispatchFunction, undefined, []), + member, + }); + continue; + } + detachMembership(member); + } + return prepared; +} + +function dispatchRevisions( + prepared: readonly PreparedRevision[], + entry: ScopeEntry, +): void { + for (const item of prepared) { + if ( + !item.member.active || + item.member.disposed || + item.member.entry !== entry + ) { + continue; + } + try { + const dispatch = item.dispatch; + dispatch(); + } catch { + // Listener failures are isolated from the epoch gate. + } + } +} + +function processInvalidation( + state: CoordinatorState, + command: InvalidationCommand, +): void { + enterCleanupBarrier(state); + try { + if ( + state.disposed || + state.scopes.get(command.entry.scope) !== command.entry || + command.entry.subscription !== command.subscription || + command.subscription.failed + ) { + return; + } + const comparison = compareSqlCatalogEpoch( + command.entry.observedEpoch, + command.epoch, + ); + if ( + comparison.kind !== "baseline" && + comparison.kind !== "advance" + ) { + return; + } + const audience = snapshotAudience(command.entry); + command.entry.observedEpoch = comparison.epoch; + command.entry.notificationSequence += 1; + const prepared = prepareRevisions( + audience, + command.entry, + ); + dispatchRevisions(prepared, command.entry); + } finally { + leaveCleanupBarrier(state); + } +} + +function isLiveCapture( + state: CoordinatorState, + capture: CaptureState, +): boolean { + return ( + capture.owner === state && + capture.membership.active && + !capture.membership.disposed && + capture.membership.entry === capture.entry && + state.scopes.get(capture.entry.scope) === capture.entry + ); +} + +function processResponse( + state: CoordinatorState, + command: ResponseCommand, +): void { + enterCleanupBarrier(state); + try { + if (state.disposed) { + settleDecision(command.onDecision, discarded("disposed")); + return; + } + if (!isLiveCapture(state, command.capture)) { + settleDecision(command.onDecision, discarded("retired")); + return; + } + const entry = command.capture.entry; + const comparison = compareSqlCatalogEpoch( + entry.observedEpoch, + command.epoch, + ); + if (state.disposed) { + settleDecision(command.onDecision, discarded("disposed")); + return; + } + if (!isLiveCapture(state, command.capture)) { + settleDecision(command.onDecision, discarded("retired")); + return; + } + if (comparison.kind === "malformed") { + settleDecision(command.onDecision, discarded("malformed")); + return; + } + if (comparison.kind === "stale") { + settleDecision(command.onDecision, discarded("stale")); + return; + } + if (comparison.kind === "token-conflict") { + settleDecision( + command.onDecision, + discarded("token-conflict"), + ); + return; + } + if (comparison.kind === "equal") { + if ( + command.capture.notificationSequence !== + entry.notificationSequence + ) { + settleDecision( + command.onDecision, + Object.freeze({ + epoch: comparison.epoch, + status: "superseded", + }), + ); + } else { + settleDecision( + command.onDecision, + Object.freeze({ + epoch: comparison.epoch, + observation: "equal", + status: "usable", + }), + ); + } + return; + } + if (comparison.kind === "baseline") { + entry.observedEpoch = comparison.epoch; + settleDecision( + command.onDecision, + Object.freeze({ + epoch: comparison.epoch, + observation: "baseline", + status: "usable", + }), + ); + return; + } + const audience = snapshotAudience(entry); + entry.observedEpoch = comparison.epoch; + entry.notificationSequence += 1; + const prepared = prepareRevisions(audience, entry); + if (state.disposed) { + settleDecision(command.onDecision, discarded("disposed")); + return; + } + if (isLiveCapture(state, command.capture)) { + settleDecision( + command.onDecision, + Object.freeze({ + epoch: comparison.epoch, + status: "superseded", + }), + ); + } else { + settleDecision(command.onDecision, discarded("retired")); + } + dispatchRevisions(prepared, entry); + } finally { + leaveCleanupBarrier(state); + } +} + +function drainCommands(state: CoordinatorState): void { + state.draining = true; + try { + for (const command of state.commands) { + if (command.kind === "invalidation") { + processInvalidation(state, command); + } else { + processResponse(state, command); + } + } + } finally { + state.commands.length = 0; + state.draining = false; + } +} + +function detachMembership( + member: MembershipState, +): void { + if (member.disposed) return; + const owner = member.owner; + const entry = member.entry; + member.disposed = true; + member.active = false; + member.entry = null; + member.owner = null; + member.prepareCatalogChange = NO_PREPARE_CATALOG_CHANGE; + if (!owner) return; + owner.memberships.delete(member); + if (!entry) return; + entry.members.delete(member); + if (entry.members.size !== 0) return; + owner.scopes.delete(entry.scope); + const subscription = entry.subscription; + entry.subscription = null; + if (subscription) { + retireCallbackCell(subscription.cell); + scheduleSubscriptionCleanup(owner, subscription); + } +} + +function disposeMembership(member: MembershipState): void { + detachMembership(member); +} + +function createMembershipHandle( + member: MembershipState, +): SqlCatalogScopeMembership { + return Object.freeze({ + activate: (): SqlCatalogMembershipActivationResult => + activateMembership(member), + captureEpoch: (): SqlCatalogEpochCaptureResult => + captureMembershipEpoch(member), + dispose: (): void => { + disposeMembership(member); + }, + }); +} + +function activateMembership( + member: MembershipState, +): SqlCatalogMembershipActivationResult { + if (member.disposed) { + return unavailableActivation( + member.disposedByCoordinator + ? "coordinator-disposed" + : "disposed", + ); + } + const owner = member.owner; + if (!owner || owner.disposed) { + return unavailableActivation("coordinator-disposed"); + } + if (member.active) return ACTIVE_RESULT; + let entry = owner.scopes.get(member.scope); + if (!entry) { + if ( + owner.scopes.size >= MAX_CATALOG_LIVE_SCOPES + ) { + return unavailableActivation("scope-capacity"); + } + entry = { + members: new Set(), + notificationSequence: 0, + observedEpoch: null, + scope: member.scope, + subscription: null, + subscriptionAttempted: false, + }; + owner.scopes.set(member.scope, entry); + } + if ( + entry.members.size >= MAX_CATALOG_MEMBERSHIPS_PER_SCOPE + ) { + return unavailableActivation( + "scope-membership-capacity", + ); + } + member.entry = entry; + member.active = true; + entry.members.add(member); + installSubscription(owner, entry); + if (member.disposed) { + return unavailableActivation( + member.disposedByCoordinator + ? "coordinator-disposed" + : "disposed", + ); + } + return ACTIVE_RESULT; +} + +function captureMembershipEpoch( + member: MembershipState, +): SqlCatalogEpochCaptureResult { + const owner = member.owner; + if (member.disposed || !owner || owner.disposed) { + return Object.freeze({ + reason: "disposed", + status: "unavailable", + }); + } + const entry = member.entry; + if (!member.active || !entry) { + return Object.freeze({ + reason: "inactive", + status: "unavailable", + }); + } + const captureValue: SqlCatalogEpochCapture = { + [epochCaptureBrand]: EPOCH_CAPTURE_KIND, + expectedEpoch: entry.observedEpoch, + }; + const capture = Object.freeze(captureValue); + owner.captures.set(capture, { + claimed: false, + entry, + membership: member, + notificationSequence: entry.notificationSequence, + owner, + }); + return Object.freeze({ capture, status: "captured" }); +} + +function prepareMembership( + state: CoordinatorState, + scope: unknown, + target: SqlCatalogRevisionTarget, +): SqlCatalogScopeMembershipResult { + if (state.disposed) { + return Object.freeze({ + reason: "disposed", + status: "unavailable", + }); + } + if (!isValidScope(scope)) { + return Object.freeze({ + reason: "invalid-scope", + status: "unavailable", + }); + } + if (state.memberships.size >= MAX_CATALOG_MEMBERSHIPS) { + return Object.freeze({ + reason: "membership-capacity", + status: "unavailable", + }); + } + const member: MembershipState = { + active: false, + disposed: false, + disposedByCoordinator: false, + entry: null, + owner: state, + prepareCatalogChange: NO_PREPARE_CATALOG_CHANGE, + scope, + }; + state.memberships.add(member); + let prepareCandidate: unknown; + try { + prepareCandidate = target.prepareCatalogChange; + } catch { + const reason = + state.disposed || member.disposed + ? "disposed" + : "invalid-target"; + detachMembership(member); + return Object.freeze({ + reason, + status: "unavailable", + }); + } + if (state.disposed || member.disposed) { + detachMembership(member); + return Object.freeze({ + reason: "disposed", + status: "unavailable", + }); + } + if (typeof prepareCandidate !== "function") { + detachMembership(member); + return Object.freeze({ + reason: "invalid-target", + status: "unavailable", + }); + } + const prepareFunction = prepareCandidate; + member.prepareCatalogChange = (): unknown => + Reflect.apply(prepareFunction, undefined, []); + const membership = createMembershipHandle(member); + return Object.freeze({ membership, status: "prepared" }); +} + +function submitResponse( + state: CoordinatorState, + captureCandidate: unknown, + epoch: unknown, + onDecision: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void, +): SqlCatalogResponseEpochSubmissionResult { + if (state.disposed) { + return settled(discarded("disposed")); + } + if ( + captureCandidate === null || + typeof captureCandidate !== "object" + ) { + return settled(discarded("malformed")); + } + const capture = state.captures.get(captureCandidate); + if (!capture) { + return settled(discarded("malformed")); + } + if (capture.claimed) { + return settled(discarded("retired")); + } + if (!isLiveCapture(state, capture)) { + return settled(discarded("retired")); + } + capture.claimed = true; + const admitted = enqueueCommand(state, { + capture, + epoch, + kind: "response", + onDecision, + }); + return admitted + ? SUBMITTED_RESULT + : settled(discarded("overloaded")); +} + +function disposeCoordinator(state: CoordinatorState): void { + if (state.disposed) return; + state.disposed = true; + state.subscribe = null; + const subscriptions: SubscriptionState[] = []; + for (const entry of state.scopes.values()) { + if (entry.subscription) { + subscriptions.push(entry.subscription); + entry.subscription = null; + } + entry.members.clear(); + } + state.scopes.clear(); + for (const member of state.memberships) { + member.active = false; + member.disposed = true; + member.disposedByCoordinator = true; + member.entry = null; + member.owner = null; + member.prepareCatalogChange = NO_PREPARE_CATALOG_CHANGE; + } + state.memberships.clear(); + for (const subscription of subscriptions) { + retireCallbackCell(subscription.cell); + } + for (const subscription of subscriptions) { + scheduleSubscriptionCleanup(state, subscription); + } + if (!state.draining) drainCommands(state); +} + +function createCoordinatorHandle( + state: CoordinatorState, +): SqlCatalogEpochCoordinator { + return Object.freeze({ + dispose: (): void => { + disposeCoordinator(state); + }, + prepareScopeMembership: ( + scope: unknown, + target: SqlCatalogRevisionTarget, + ): SqlCatalogScopeMembershipResult => + prepareMembership(state, scope, target), + providerId: state.providerId, + submitResponseEpoch: ( + capture: unknown, + epoch: unknown, + onDecision: ( + this: void, + decision: SqlCatalogResponseEpochDecision, + ) => void, + ): SqlCatalogResponseEpochSubmissionResult => + submitResponse(state, capture, epoch, onDecision), + }); +} + +export function createSqlCatalogEpochCoordinator( + capturedProvider: unknown, +): SqlCatalogEpochCoordinatorResult { + const provider = resolveSqlRelationCatalogProvider( + capturedProvider, + ); + if (!provider) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const providerId = provider.id; + const subscribe = provider.subscribe; + const state: CoordinatorState = { + cleanupAdmissions: 0, + cleanupDeferralDepth: 0, + cleanupOverloaded: false, + captures: new WeakMap(), + commands: [], + deferredCleanup: new Set(), + disposed: false, + draining: false, + memberships: new Set(), + providerId, + scopes: new Map(), + subscribe, + }; + const coordinator = createCoordinatorHandle(state); + return Object.freeze({ coordinator, status: "created" }); +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 6a7094a..f5a6e8c 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -9,9 +9,13 @@ import type { // Provisional package-private declarations until the vertical slice is proven. export interface SqlDisposable { - readonly dispose: () => void; + readonly dispose: (this: void) => void; } +export type SqlCatalogSubscriptionCleanup = ( + this: void, +) => void | PromiseLike; + export type SqlCatalogContainerRole = | "catalog" | "schema" @@ -125,7 +129,7 @@ export interface SqlRelationCatalogProvider { this: void, scope: string, onInvalidation: (event: SqlCatalogInvalidation) => void, - ) => SqlDisposable; + ) => SqlCatalogSubscriptionCleanup; } export type SqlIdentifierDecodeResult = diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 8656ba1..1a6973f 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -14,8 +14,10 @@ import type { SqlCatalogRelation, SqlCatalogSearchRequest, SqlCatalogSearchResponse, + SqlCatalogSubscriptionCleanup, SqlCompletionCancellationReason, SqlCompletionIssue, + SqlDisposable, SqlRelationCompletionItem, SqlRelationCompletionList, SqlRelationCatalogProvider, @@ -125,7 +127,7 @@ const provider: SqlRelationCatalogProvider = { }, subscribe: (_scope, onInvalidation) => { onInvalidation({ epoch: { generation: 1, token: "tables-updated" } }); - return { dispose: () => undefined }; + return () => undefined; }, }; void provider; @@ -148,6 +150,21 @@ const receiverDependentProvider: SqlRelationCatalogProvider = { search: receiverDependentSearch, }; +const receiverDependentDispose = function ( + this: { readonly id: string }, +): void { + void this.id; +}; +const receiverDependentDisposable: SqlDisposable = { + // @ts-expect-error disposable callbacks are this-free closures + dispose: receiverDependentDispose, +}; + +// @ts-expect-error catalog cleanup callbacks are this-free closures +const receiverDependentCatalogCleanup: SqlCatalogSubscriptionCleanup = + receiverDependentDispose; +void receiverDependentCatalogCleanup; + const relationPath = [ { quoted: false, role: "catalog", value: "memory" }, { quoted: false, role: "schema", value: "main" }, @@ -328,5 +345,6 @@ void openWithRegions; void openWithoutRegions; void providerRenderedSql; void receiverDependentProvider; +void receiverDependentDisposable; void synchronousProvider; void undefinedContext; From 5ad22fd3c5e0449258e9bbd3e85b9d0a6f0e56e0 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 15:36:42 +0800 Subject: [PATCH 25/42] feat(vnext): prepare catalog epoch transitions (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #169. Follows #193. - Add a package-private synchronous epoch-transition hook so shared catalog work can retire before session revision listeners observe an accepted epoch. - Preserve deterministic prepare/commit/dispatch ordering for provider invalidations and higher response epochs. - Tighten provider cleanup and transition dispatch to an exact `undefined` contract, with fail-closed quarantine for invalid returns. - Dispose before inspecting invalid thenables, closing the hostile getter reentrancy window identified after #193 merged. - Keep the no-hook and null-transition paths allocation-free and add a configured-hook storm benchmark. - Extend ADR 0005, type fixtures, lifecycle tests, and adversarial Promise/thenable coverage. --- ## Summary by cubic Adds a package-private, synchronous epoch-transition hook that runs after epoch install to retire catalog work before session listeners. Enforces exact-return contracts and fail-closed handling to harden reentrancy, cleanup, and poisoned-thenable edge cases; supports #169. - **New Features** - Added `SqlCatalogEpochTransitionTarget` that may return a dispatch closure or `null`; dispatch must return exactly `undefined` (sync only). - Preserved strict ordering: transition-prepare → revision-prepare → producer settle → transition-dispatch → revision-dispatch; reentrant dispatch queues behind current dispatch. - Snapshots the revision audience before transition; producers/aborts see prepared revisions; listeners see already-retired work. - Rejects non-function transition targets during coordinator creation (`reason: "invalid-transition-target"`); any thrown preparation, thrown dispatch, or non-`undefined` return disposes fail-closed and discards response decisions. - Drains detached Promise/thenable results for cleanup and transition returns after disposal, avoiding hostile getter reentrancy; no-hook and `null`-dispatch paths allocate nothing. Updated ADR 0005, added a configured-hook storm benchmark, and expanded adversarial tests. - **Migration** - Update `SqlCatalogSubscriptionCleanup` to be synchronous and return `undefined` only (no Promises or other return values). Written for commit 9d7c9c3a8e9fb66e83cc634f390ccc4581e2a2d1. Summary will update on new commits. Review in cubic --- ...-parser-independent-relation-completion.md | 44 +- ...elation-catalog-epoch-coordinator.bench.ts | 33 +- ...relation-catalog-epoch-coordinator.test.ts | 554 +++++++++++++++++- .../relation-catalog-epoch-coordinator.ts | 142 ++++- src/vnext/relation-completion-types.ts | 2 +- .../marimo-relation-completion.test-d.ts | 25 +- 6 files changed, 778 insertions(+), 22 deletions(-) diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 6f21ab5..9c4cb42 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -463,6 +463,28 @@ A higher accepted invalidation or response atomically installs the new epoch, clears older scope cache entries, supersedes affected work, and then advances each subscribed session revision exactly once. +The epoch coordinator accepts one optional package-owned, receiver-free +transition-preparation closure. For an accepted invalidation baseline or +advance, and for a higher response, it snapshots the active revision audience, +installs the epoch and notification sequence, and then calls that closure with +a copied scope and the already-frozen epoch. The closure synchronously detaches +incompatible work, cache entries, and epoch-specific gates and may return one +receiver-free dispatch closure. The no-hook and no-dispatch paths allocate no +transition wrapper state. Only after transition and session preparation +has finished does the coordinator settle the producing response, invoke the +transition dispatch, and dispatch session listeners. Provider aborts and work +settlement therefore observe every prepared session revision, while listener +code observes already-retired catalog work. Reentrant transition dispatch +commands remain behind the current revision dispatch in the epoch FIFO. +A missing or `null` dispatch is valid. A thrown transition preparation or +non-function result is an internal contract failure and disposes the +coordinator fail-closed. Dispatch is synchronously exact-return: it must return +`undefined`. A throw or any other return value disposes the coordinator after +state preparation and suppresses later revision listeners; an accidental +Promise result receives best-effort detached rejection draining. Coordinator +disposal revokes the preparation closure before external cleanup. The hook +remains package-private and is not a provider or session extension point. + A search that discovers a higher epoch supersedes itself instead of publishing against its older captured revision. Pages and cache entries from different epochs are never merged. @@ -523,13 +545,21 @@ A thrown subscription or malformed returned cleanup value discards that buffer and disables automatic invalidation for the live scope; explicit search remains available. The returned cleanup closure is itself untrusted: the service captures only a -function, calls it with `this === undefined` at most once, and isolates -malformed values, thrown cleanup, and rejected or hostile thenable results. -Detached cleanup settlement retains no coordinator state. A closure avoids a -structural TypeScript contract that would accept class instances or inherited -methods which the hostile runtime boundary could not safely validate. Dropping -the last owner removes the complete scope incarnation before cleanup. A later -join creates a new unobserved incarnation and may attempt a fresh subscription. +function and calls it with `this === undefined` at most once. Its exact +synchronous contract returns `undefined`; TypeScript therefore rejects async +cleanup functions rather than silently accepting them through `void` +assignability. A throw or any other return value quarantines the coordinator +after the subscription is inert. Accidental ordinary Promise or thenable +returns receive best-effort detached rejection draining, but a same-realm +callback can always create a poisoned or independent unhandled rejection that +no JavaScript library can retroactively contain. Legitimate asynchronous +teardown must consume or report its own failure before the cleanup closure +returns `undefined`. The valid `undefined` path returns without Promise +allocation or a microtask. A closure avoids a structural TypeScript +contract that would accept class instances or inherited methods which the +hostile runtime boundary could not safely validate. Dropping the last owner +removes the complete scope incarnation before cleanup. A later join creates a +new unobserved incarnation and may attempt a fresh subscription. Malformed, duplicate, stale, or token-conflicting invalidations do not mutate state and do not tear down an otherwise valid subscription. Every raw callback diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts index 66f0650..e8b327e 100644 --- a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts @@ -8,6 +8,7 @@ import { } from "../relation-catalog-epoch-coordinator.js"; import type { SqlCatalogEpochCoordinator, + SqlCatalogEpochTransitionTarget, SqlCatalogResponseEpochDecision, SqlCatalogResponseEpochSubmissionResult, SqlCatalogRevisionTarget, @@ -110,7 +111,10 @@ function requireDecision( return decision; } -function createFixture(memberCount: number): CoordinatorFixture { +function createFixture( + memberCount: number, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, +): CoordinatorFixture { let invalidationListener: | ((this: void, event: unknown) => void) | null = null; @@ -141,6 +145,7 @@ function createFixture(memberCount: number): CoordinatorFixture { } const created = createSqlCatalogEpochCoordinator( captured.value, + prepareEpochTransition, ); if (created.status !== "created") { return benchmarkFailure("coordinator creation was unavailable"); @@ -416,6 +421,32 @@ describe("relation catalog epoch coordinator", () => { }, ); + bench( + "process a bounded 256-event storm with a null transition dispatch", + () => { + let transitions = 0; + const fixture = createFixture(1, () => { + transitions += 1; + return null; + }); + for ( + let generation = 1; + generation <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + generation += 1 + ) { + fixture.emitInvalidation(generation); + } + if ( + transitions !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + ) { + benchmarkFailure( + "configured transition hook lost an accepted epoch", + ); + } + fixture.dispose(); + }, + ); + bench("fan out a bounded 256-event storm to 256 members", () => { const fixture = createFixture(256); for ( diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts index f0d0a50..24413b3 100644 --- a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts @@ -15,6 +15,7 @@ import { MAX_CATALOG_MEMBERSHIPS_PER_SCOPE, type SqlCatalogEpochCapture, type SqlCatalogEpochCoordinator, + type SqlCatalogEpochTransitionTarget, type SqlCatalogResponseEpochDecision, type SqlCatalogResponseEpochSubmissionResult, type SqlCatalogRevisionTarget, @@ -61,9 +62,27 @@ function capturedProvider( function coordinator( subscribe?: RawSubscribe, id = "catalog", + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, ): SqlCatalogEpochCoordinator { const result = createSqlCatalogEpochCoordinator( capturedProvider(subscribe, id), + prepareEpochTransition, + ); + expect(result.status).toBe("created"); + if (result.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + return result.coordinator; +} + +function coordinatorWithRawTransition( + prepareEpochTransition: unknown, + subscribe?: RawSubscribe, +): SqlCatalogEpochCoordinator { + const result = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [capturedProvider(subscribe), prepareEpochTransition], ); expect(result.status).toBe("created"); if (result.status !== "created") { @@ -194,6 +213,29 @@ describe("catalog epoch coordinator construction and membership", () => { expect(Object.isFrozen(owner)).toBe(true); }); + it("rejects a non-function epoch transition target without inspecting it", () => { + let reads = 0; + const target = new Proxy( + {}, + { + get() { + reads += 1; + throw new Error("hostile transition target"); + }, + }, + ); + expect( + Reflect.apply(createSqlCatalogEpochCoordinator, undefined, [ + capturedProvider(), + target, + ]), + ).toEqual({ + reason: "invalid-transition-target", + status: "unavailable", + }); + expect(reads).toBe(0); + }); + it("validates exact bounded well-formed scopes without raw errors", () => { const owner = coordinator(); expect( @@ -722,6 +764,461 @@ describe("response epoch observations and capture authority", () => { }); }); +describe("service epoch transition ordering", () => { + it("prepares retirement after epoch installation and dispatches it before revision listeners", () => { + const harness = listenerProvider(); + const events: string[] = []; + let membership: SqlCatalogScopeMembership; + const owner = coordinator( + harness.subscribe, + "catalog", + function (this: void, scope, nextEpoch) { + events.push("transition-prepare"); + expect(this).toBeUndefined(); + expect(scope).toBe("scope"); + expect(Object.isFrozen(nextEpoch)).toBe(true); + expect(nextEpoch).toEqual(epoch(1)); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + return () => { + events.push("transition-dispatch"); + }; + }, + ); + membership = active(owner, "scope", { + prepareCatalogChange: () => { + events.push("revision-prepare"); + return () => { + events.push("revision-dispatch"); + }; + }, + }); + + harness.listeners[0]?.(invalidation(1)); + expect(events).toEqual([ + "transition-prepare", + "revision-prepare", + "transition-dispatch", + "revision-dispatch", + ]); + }); + + it("runs only for response advances and settles the producer before retirement dispatch", () => { + const events: string[] = []; + const owner = coordinator( + undefined, + "catalog", + (_scope, nextEpoch) => { + events.push(`transition-prepare-${nextEpoch.generation}`); + return () => { + events.push( + `transition-dispatch-${nextEpoch.generation}`, + ); + }; + }, + ); + const membership = active(owner, "scope", { + prepareCatalogChange: () => { + events.push("revision-prepare"); + return () => { + events.push("revision-dispatch"); + }; + }, + }); + + const baseline = submit(owner, capture(membership), epoch(1)); + expect(baseline.decisions).toEqual([ + { + epoch: epoch(1), + observation: "baseline", + status: "usable", + }, + ]); + expect(events).toEqual([]); + + const advancedDecisions: SqlCatalogResponseEpochDecision[] = []; + expect( + owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => { + events.push(`decision-${decision.status}`); + advancedDecisions.push(decision); + }, + ), + ).toEqual({ status: "submitted" }); + expect(advancedDecisions).toEqual([ + { epoch: epoch(2), status: "superseded" }, + ]); + expect(events).toEqual([ + "transition-prepare-2", + "revision-prepare", + "decision-superseded", + "transition-dispatch-2", + "revision-dispatch", + ]); + + events.length = 0; + expect( + submit(owner, capture(membership), epoch(2)).decisions, + ).toEqual([ + { + epoch: epoch(2), + observation: "equal", + status: "usable", + }, + ]); + expect(events).toEqual([]); + }); + + it("snapshots the revision audience before transition preparation", () => { + const harness = listenerProvider(); + const first = counterTarget(); + const second = counterTarget(); + let secondMembership: SqlCatalogScopeMembership; + let joinedEpoch: SqlCatalogEpochCapture["expectedEpoch"] = null; + const owner = coordinator( + harness.subscribe, + "catalog", + (_scope, nextEpoch) => { + if (nextEpoch.generation === 1) { + expect(secondMembership.activate()).toEqual({ + status: "active", + }); + joinedEpoch = capture(secondMembership).expectedEpoch; + } + return null; + }, + ); + active(owner, "scope", first.target); + secondMembership = prepared(owner, "scope", second.target); + + harness.listeners[0]?.(invalidation(1)); + expect(joinedEpoch).toEqual(epoch(1)); + expect(first).toMatchObject({ dispatched: 1, prepared: 1 }); + expect(second).toMatchObject({ dispatched: 0, prepared: 0 }); + + harness.listeners[0]?.(invalidation(2)); + expect(first).toMatchObject({ dispatched: 2, prepared: 2 }); + expect(second).toMatchObject({ dispatched: 1, prepared: 1 }); + }); + + it("queues transition-dispatch reentrancy behind the current revision dispatch", () => { + const harness = listenerProvider(); + const events: string[] = []; + const owner = coordinator( + harness.subscribe, + "catalog", + (_scope, nextEpoch) => { + const generation = nextEpoch.generation; + events.push(`transition-prepare-${generation}`); + return () => { + events.push(`transition-dispatch-${generation}`); + if (generation === 1) { + harness.listeners[0]?.(invalidation(2)); + } + }; + }, + ); + active(owner, "scope", { + prepareCatalogChange: () => { + const generation = + events.filter((event) => + event.startsWith("transition-prepare"), + ).length; + events.push(`revision-prepare-${generation}`); + return () => { + events.push(`revision-dispatch-${generation}`); + }; + }, + }); + + harness.listeners[0]?.(invalidation(1)); + expect(events).toEqual([ + "transition-prepare-1", + "revision-prepare-1", + "transition-dispatch-1", + "revision-dispatch-1", + "transition-prepare-2", + "revision-prepare-2", + "transition-dispatch-2", + "revision-dispatch-2", + ]); + }); + + it("dispatches prepared retirement after reentrant coordinator disposal", () => { + const events: string[] = []; + let owner: SqlCatalogEpochCoordinator; + owner = coordinator( + undefined, + "catalog", + () => { + events.push("transition-prepare"); + owner.dispose(); + return () => { + events.push("transition-dispatch"); + }; + }, + ); + const membership = active(owner, "scope"); + submit(owner, capture(membership), epoch(1)); + + const decisions: SqlCatalogResponseEpochDecision[] = []; + expect( + owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => { + events.push(`decision-${decision.status}`); + decisions.push(decision); + }, + ), + ).toEqual({ status: "submitted" }); + expect(decisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + expect(events).toEqual([ + "transition-prepare", + "decision-discarded", + "transition-dispatch", + ]); + }); + + it("drains rejected and poisoned transition dispatch results", async () => { + const asyncHarness = listenerProvider(); + let asyncDispatchCalls = 0; + const asyncOwner = coordinatorWithRawTransition( + () => async () => { + asyncDispatchCalls += 1; + throw new Error("async transition dispatch failed"); + }, + asyncHarness.subscribe, + ); + active(asyncOwner, "scope"); + asyncHarness.listeners[0]?.(invalidation(1)); + + let thenReads = 0; + let catchReads = 0; + const thenProperty = ["th", "en"].join(""); + const poisonedResult = Promise.reject( + new Error("poisoned transition rejection"), + ); + Object.defineProperty(poisonedResult, thenProperty, { + get() { + thenReads += 1; + throw new Error("poisoned transition then"); + }, + }); + Object.defineProperty(poisonedResult, "catch", { + get() { + catchReads += 1; + throw new Error("poisoned transition catch"); + }, + }); + const poisonedHarness = listenerProvider(); + const poisonedOwner = coordinatorWithRawTransition( + () => () => poisonedResult, + poisonedHarness.subscribe, + ); + active(poisonedOwner, "scope"); + poisonedHarness.listeners[0]?.(invalidation(1)); + + let hostileThenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; + const hostileThenable = Object.defineProperty( + {}, + thenProperty, + { + get() { + hostileThenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; + throw new Error("hostile transition thenable"); + }, + }, + ); + const hostileHarness = listenerProvider(); + hostileOwner = coordinatorWithRawTransition( + () => () => hostileThenable, + hostileHarness.subscribe, + ); + active(hostileOwner, "scope"); + hostileHarness.listeners[0]?.(invalidation(1)); + + await Promise.resolve(); + await Promise.resolve(); + expect(asyncDispatchCalls).toBe(1); + expect(thenReads).toBe(0); + expect(catchReads).toBe(0); + expect(hostileThenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); + }); + + it("quarantines thrown dispatch and invalid preparation", () => { + const harness = listenerProvider(); + const live = counterTarget(); + const throwingDispatchOwner = coordinator( + harness.subscribe, + "catalog", + () => () => { + throw new Error("transition dispatch failed"); + }, + ); + const throwingDispatchMembership = active( + throwingDispatchOwner, + "scope", + live.target, + ); + harness.listeners[0]?.(invalidation(1)); + expect(live).toMatchObject({ dispatched: 0, prepared: 1 }); + expect(throwingDispatchMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + const throwingHarness = listenerProvider(); + const throwingOwner = coordinator( + throwingHarness.subscribe, + "catalog", + () => { + throw new Error("transition preparation failed"); + }, + ); + const retired = active(throwingOwner, "scope"); + throwingHarness.listeners[0]?.(invalidation(1)); + expect(retired.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(throwingHarness.cleanupCalls).toBe(1); + + const invalidHarness = listenerProvider(); + const created = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [ + capturedProvider(invalidHarness.subscribe), + () => 1, + ], + ); + expect(created.status).toBe("created"); + if (created.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + const invalidMembership = active( + created.coordinator, + "scope", + ); + invalidHarness.listeners[0]?.(invalidation(1)); + expect(invalidMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(invalidHarness.cleanupCalls).toBe(1); + + let responseOwner: SqlCatalogEpochCoordinator; + responseOwner = coordinator( + undefined, + "catalog", + () => { + throw new Error("response transition failed"); + }, + ); + const responseMembership = active(responseOwner, "scope"); + submit(responseOwner, capture(responseMembership), epoch(1)); + const responseDecisions: SqlCatalogResponseEpochDecision[] = []; + expect( + responseOwner.submitResponseEpoch( + capture(responseMembership), + epoch(2), + (decision) => responseDecisions.push(decision), + ), + ).toEqual({ status: "submitted" }); + expect(responseDecisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + expect(responseMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("drains invalid preparation results after making state inert", async () => { + let asyncPreparationCalls = 0; + const asyncHarness = listenerProvider(); + const asyncOwner = coordinatorWithRawTransition( + async () => { + asyncPreparationCalls += 1; + throw new Error("async transition preparation failed"); + }, + asyncHarness.subscribe, + ); + active(asyncOwner, "scope"); + asyncHarness.listeners[0]?.(invalidation(1)); + + let thenReads = 0; + let catchReads = 0; + const thenProperty = ["th", "en"].join(""); + const poisonedResult = Promise.reject( + new Error("poisoned preparation rejection"), + ); + Object.defineProperty(poisonedResult, thenProperty, { + get() { + thenReads += 1; + throw new Error("poisoned preparation then"); + }, + }); + Object.defineProperty(poisonedResult, "catch", { + get() { + catchReads += 1; + throw new Error("poisoned preparation catch"); + }, + }); + const poisonedHarness = listenerProvider(); + const poisonedOwner = coordinatorWithRawTransition( + () => poisonedResult, + poisonedHarness.subscribe, + ); + active(poisonedOwner, "scope"); + poisonedHarness.listeners[0]?.(invalidation(1)); + + let hostileThenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; + const hostileThenable = Object.defineProperty( + {}, + thenProperty, + { + get() { + hostileThenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; + throw new Error("hostile preparation thenable"); + }, + }, + ); + const hostileHarness = listenerProvider(); + hostileOwner = coordinatorWithRawTransition( + () => hostileThenable, + hostileHarness.subscribe, + ); + active(hostileOwner, "scope"); + hostileHarness.listeners[0]?.(invalidation(1)); + + await Promise.resolve(); + await Promise.resolve(); + expect(asyncPreparationCalls).toBe(1); + expect(thenReads).toBe(0); + expect(catchReads).toBe(0); + expect(hostileThenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); + }); +}); + describe("subscription invalidation ordering and isolation", () => { it("classifies baseline/equal/advance while preserving state-before-listener and insertion order", () => { const harness = listenerProvider(); @@ -1307,7 +1804,7 @@ describe("hostile subscription lifecycle", () => { expect(getterCalls).toBe(0); }); - it("isolates thrown cleanup and retains no live callback after failed subscribe", () => { + it("quarantines thrown cleanup and retains no live callback after failed subscribe", () => { let retained: RawInvalidationListener | undefined; const owner = coordinator((_scope, notify) => { retained = notify; @@ -1320,6 +1817,12 @@ describe("hostile subscription lifecycle", () => { expect(() => membership.dispose()).not.toThrow(); retained?.(invalidation(1)); expect(target.dispatched).toBe(0); + expect( + owner.prepareScopeMembership("replacement", target.target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); let failedRetained: RawInvalidationListener | undefined; const failedOwner = coordinator((_scope, notify) => { @@ -1346,10 +1849,16 @@ describe("hostile subscription lifecycle", () => { let applyCalls = 0; let thenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; const thenProperty = ["th", "en"].join(""); const hostileResult = Object.defineProperty({}, thenProperty, { get() { thenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; throw new Error("hostile then getter"); }, }); @@ -1360,7 +1869,7 @@ describe("hostile subscription lifecycle", () => { return Reflect.apply(target, receiver, argumentsList); }, }); - const hostileOwner = coordinator(() => hostileCleanup); + hostileOwner = coordinator(() => hostileCleanup); const hostileMembership = active(hostileOwner, "scope"); hostileMembership.dispose(); hostileMembership.dispose(); @@ -1391,6 +1900,32 @@ describe("hostile subscription lifecycle", () => { ); active(nonCallableCatchOwner, "scope").dispose(); + let nativeThenReads = 0; + const throwingThenResult = Promise.reject( + new Error("shadowed then rejection"), + ); + Object.defineProperty(throwingThenResult, thenProperty, { + get() { + nativeThenReads += 1; + throw new Error("hostile native then getter"); + }, + }); + const throwingThenOwner = coordinator( + () => () => throwingThenResult, + ); + active(throwingThenOwner, "scope").dispose(); + + const nonCallableThenResult = Promise.reject( + new Error("non-callable then rejection"), + ); + Object.defineProperty(nonCallableThenResult, thenProperty, { + value: null, + }); + const nonCallableThenOwner = coordinator( + () => () => nonCallableThenResult, + ); + active(nonCallableThenOwner, "scope").dispose(); + let thenCalls = 0; let reentrantOwner: SqlCatalogEpochCoordinator; const reentrantResult = Object.defineProperty( @@ -1423,8 +1958,19 @@ describe("hostile subscription lifecycle", () => { expect(asyncCleanupCalls).toBe(1); expect(applyCalls).toBe(1); expect(thenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); expect(catchReads).toBe(0); + expect(nativeThenReads).toBe(0); expect(thenCalls).toBe(1); + expect( + rejectingOwner.prepareScopeMembership( + "replacement", + counterTarget().target, + ), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); }); it("retries a failed subscription only in a new last-owner incarnation", () => { @@ -2018,7 +2564,9 @@ describe("service disposal and bounded command draining", () => { const cleanupScopes: string[] = []; const owner = coordinator((scope, notify) => { listeners.set(scope, notify); - return () => cleanupScopes.push(scope); + return () => { + cleanupScopes.push(scope); + }; }); const stormMemberships = Array.from({ length: 5 }, (_, index) => active(owner, `storm-${index}`), diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/vnext/relation-catalog-epoch-coordinator.ts index ef96b25..3a3a1fe 100644 --- a/src/vnext/relation-catalog-epoch-coordinator.ts +++ b/src/vnext/relation-catalog-epoch-coordinator.ts @@ -20,6 +20,14 @@ export interface SqlCatalogRevisionTarget { ) => ((this: void) => void) | null; } +export type SqlCatalogEpochTransitionTarget = ( + this: void, + scope: string, + epoch: SqlCatalogEpoch, +) => + | ((this: void) => undefined) + | null; + const epochCaptureBrand: unique symbol = Symbol( "SqlCatalogEpochCapture", ); @@ -134,7 +142,9 @@ export type SqlCatalogEpochCoordinatorResult = } | { readonly status: "unavailable"; - readonly reason: "invalid-provider"; + readonly reason: + | "invalid-provider" + | "invalid-transition-target"; }; interface CoordinatorState { @@ -147,6 +157,7 @@ interface CoordinatorState { readonly commands: EpochCommand[]; readonly deferredCleanup: Set; readonly memberships: Set; + prepareEpochTransition: Function | null; readonly providerId: string; readonly scopes: Map; subscribe: SqlCapturedRelationCatalogProviderContext["subscribe"]; @@ -226,7 +237,11 @@ const ACTIVE_RESULT: SqlCatalogMembershipActivationResult = const SUBMITTED_RESULT: SqlCatalogResponseEpochSubmissionResult = Object.freeze({ status: "submitted" }); const NO_PREPARE_CATALOG_CHANGE = (): null => null; -const IGNORE_CLEANUP_REJECTION = (): void => {}; +const IGNORE_DETACHED_REJECTION = (): void => {}; +const INTRINSIC_PROMISE_THEN = Promise.prototype.then; +const FAILED_EPOCH_TRANSITION: unique symbol = Symbol( + "FailedSqlCatalogEpochTransition", +); function unavailableActivation( reason: Exclude< @@ -366,7 +381,36 @@ function captureCleanup(candidate: unknown): Function | null { return typeof candidate === "function" ? candidate : null; } -function cleanupSubscription(subscription: SubscriptionState): void { +function drainDetachedSettlement(result: unknown): void { + if ( + result === null || + (typeof result !== "object" && + typeof result !== "function") + ) { + return; + } + try { + Reflect.apply(INTRINSIC_PROMISE_THEN, result, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); + return; + } catch { + // Non-native thenables are assimilated through a fresh wrapper. + } + const settlement = new Promise((resolve) => { + resolve(result); + }); + Reflect.apply(INTRINSIC_PROMISE_THEN, settlement, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); +} + +function cleanupSubscription( + state: CoordinatorState, + subscription: SubscriptionState, +): void { if (subscription.cleanupCalled) { subscription.cleanup = null; return; @@ -377,12 +421,12 @@ function cleanupSubscription(subscription: SubscriptionState): void { subscription.cleanup = null; try { const result = Reflect.apply(cleanup, undefined, []); - const settlement = new Promise((resolve) => { - resolve(result); - }); - void settlement.then(undefined, IGNORE_CLEANUP_REJECTION); + if (result !== undefined) { + disposeCoordinator(state); + drainDetachedSettlement(result); + } } catch { - // Provider cleanup is isolated after state is inert. + disposeCoordinator(state); } } @@ -447,7 +491,7 @@ function flushDeferredCleanup(state: CoordinatorState): void { while (state.deferredCleanup.size > 0) { for (const subscription of state.deferredCleanup) { state.deferredCleanup.delete(subscription); - cleanupSubscription(subscription); + cleanupSubscription(state, subscription); break; } } @@ -668,6 +712,55 @@ interface PreparedRevision { readonly member: MembershipState; } +type PreparedEpochTransition = + | Function + | null + | typeof FAILED_EPOCH_TRANSITION; + +function prepareEpochTransition( + state: CoordinatorState, + scope: string, + epoch: SqlCatalogEpoch, +): PreparedEpochTransition { + const prepare = state.prepareEpochTransition; + if (!prepare) return null; + let candidate: unknown; + try { + candidate = Reflect.apply(prepare, undefined, [scope, epoch]); + } catch { + disposeCoordinator(state); + return FAILED_EPOCH_TRANSITION; + } + if (candidate === null) return null; + if (typeof candidate !== "function") { + disposeCoordinator(state); + drainDetachedSettlement(candidate); + return FAILED_EPOCH_TRANSITION; + } + return candidate; +} + +function dispatchEpochTransition( + state: CoordinatorState, + prepared: PreparedEpochTransition, +): void { + if ( + prepared === null || + prepared === FAILED_EPOCH_TRANSITION + ) { + return; + } + try { + const result = Reflect.apply(prepared, undefined, []); + if (result !== undefined) { + disposeCoordinator(state); + drainDetachedSettlement(result); + } + } catch { + disposeCoordinator(state); + } +} + function prepareRevisions( audience: readonly MembershipState[], entry: ScopeEntry, @@ -756,10 +849,17 @@ function processInvalidation( const audience = snapshotAudience(command.entry); command.entry.observedEpoch = comparison.epoch; command.entry.notificationSequence += 1; + const transition = prepareEpochTransition( + state, + command.entry.scope, + comparison.epoch, + ); + if (transition === FAILED_EPOCH_TRANSITION) return; const prepared = prepareRevisions( audience, command.entry, ); + dispatchEpochTransition(state, transition); dispatchRevisions(prepared, command.entry); } finally { leaveCleanupBarrier(state); @@ -860,9 +960,19 @@ function processResponse( const audience = snapshotAudience(entry); entry.observedEpoch = comparison.epoch; entry.notificationSequence += 1; + const transition = prepareEpochTransition( + state, + entry.scope, + comparison.epoch, + ); + if (transition === FAILED_EPOCH_TRANSITION) { + settleDecision(command.onDecision, discarded("disposed")); + return; + } const prepared = prepareRevisions(audience, entry); if (state.disposed) { settleDecision(command.onDecision, discarded("disposed")); + dispatchEpochTransition(state, transition); return; } if (isLiveCapture(state, command.capture)) { @@ -876,6 +986,7 @@ function processResponse( } else { settleDecision(command.onDecision, discarded("retired")); } + dispatchEpochTransition(state, transition); dispatchRevisions(prepared, entry); } finally { leaveCleanupBarrier(state); @@ -1137,6 +1248,7 @@ function submitResponse( function disposeCoordinator(state: CoordinatorState): void { if (state.disposed) return; state.disposed = true; + state.prepareEpochTransition = null; state.subscribe = null; const subscriptions: SubscriptionState[] = []; for (const entry of state.scopes.values()) { @@ -1192,6 +1304,7 @@ function createCoordinatorHandle( export function createSqlCatalogEpochCoordinator( capturedProvider: unknown, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, ): SqlCatalogEpochCoordinatorResult { const provider = resolveSqlRelationCatalogProvider( capturedProvider, @@ -1202,6 +1315,15 @@ export function createSqlCatalogEpochCoordinator( status: "unavailable", }); } + if ( + prepareEpochTransition !== undefined && + typeof prepareEpochTransition !== "function" + ) { + return Object.freeze({ + reason: "invalid-transition-target", + status: "unavailable", + }); + } const providerId = provider.id; const subscribe = provider.subscribe; const state: CoordinatorState = { @@ -1214,6 +1336,8 @@ export function createSqlCatalogEpochCoordinator( disposed: false, draining: false, memberships: new Set(), + prepareEpochTransition: + prepareEpochTransition ?? null, providerId, scopes: new Map(), subscribe, diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index f5a6e8c..1222d10 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -14,7 +14,7 @@ export interface SqlDisposable { export type SqlCatalogSubscriptionCleanup = ( this: void, -) => void | PromiseLike; +) => undefined; export type SqlCatalogContainerRole = | "catalog" diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 1a6973f..721dfa4 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -23,6 +23,9 @@ import type { SqlRelationCatalogProvider, SqlRelationCompletionSession, } from "../../src/vnext/relation-completion-types.js"; +import type { + SqlCatalogEpochTransitionTarget, +} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; interface MarimoSqlContext extends SqlDocumentContext { readonly engine: string; @@ -152,8 +155,9 @@ const receiverDependentProvider: SqlRelationCatalogProvider = { const receiverDependentDispose = function ( this: { readonly id: string }, -): void { +): undefined { void this.id; + return undefined; }; const receiverDependentDisposable: SqlDisposable = { // @ts-expect-error disposable callbacks are this-free closures @@ -165,6 +169,25 @@ const receiverDependentCatalogCleanup: SqlCatalogSubscriptionCleanup = receiverDependentDispose; void receiverDependentCatalogCleanup; +// @ts-expect-error catalog cleanup is synchronously exact-return +const asynchronousCatalogCleanup: SqlCatalogSubscriptionCleanup = + async () => {}; +void asynchronousCatalogCleanup; + +const nonUndefinedCatalogCleanup: SqlCatalogSubscriptionCleanup = + // @ts-expect-error catalog cleanup must return exactly undefined + () => 1; +void nonUndefinedCatalogCleanup; + +const synchronousEpochTransition: SqlCatalogEpochTransitionTarget = + () => () => undefined; +void synchronousEpochTransition; + +const asynchronousEpochTransition: SqlCatalogEpochTransitionTarget = + // @ts-expect-error epoch transition dispatch is synchronously exact-return + () => async () => {}; +void asynchronousEpochTransition; + const relationPath = [ { quoted: false, role: "catalog", value: "memory" }, { quoted: false, role: "schema", value: "main" }, From d39d908c750160657205ad8e449c1b3055638e5a Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 19:48:23 +0800 Subject: [PATCH 26/42] feat(vnext): coordinate bounded catalog search work (#195) ## Summary - add the package-private bounded catalog search-work coordinator - compose provider authentication, epoch authority, latest-wins ownership, cancellation, deadlines, and response publication - harden hostile and reentrant lifecycle boundaries, including exact disposal hooks and captured Promise intrinsics - document the scheduler contract and deferred cache/session/UI responsibilities in ADR 0005 - add strict API fixtures, invariant-heavy unit coverage, and capacity/performance benchmarks ## Invariants - at most 8 provider searches are active and 64 are queued - owners join only exact structural request keys and supersede independently - scope epoch changes retire same-scope work without crossing scope boundaries - failed captures, invalid input, cancellation, disposal, overload, and deadlines settle promptly and exactly once - provider calls, timers, clocks, thenables, callbacks, and response decoding may be hostile or reentrant without reviving retired work - the authenticated dialect runtime is the sole dialect identity authority ## Validation - `vitest`: 1,856 passed, 1 expected failure - changed coverage: 97.29% statements, 96.23% branches, 99.46% functions, 97.73% lines - search coordinator: 97.03% statements, 95.09% branches, 100% functions, 97.84% lines - epoch coordinator: 98.31% statements, 97.42% branches, 98.21% functions, 98.89% lines - strict source, tests, vNext API fixture, loose-optional fixture, and demo typechecks - zero-warning `oxlint` - test-integrity gate - 4 browser files / 7 browser tests - exact-tarball package smoke test - worker placement and bundle budgets - production dependency audit: no known vulnerabilities - catalog search-work benchmark suite - two independent adversarial exact-head approvals Part of #169. --- ## Summary by cubic Adds a package-private, bounded catalog search-work coordinator that composes provider auth with epoch authority to run searches safely, dedupe by exact keys, and enforce deadlines. Also centralizes catalog scope validation to a shared boundary utility. Progress toward #169. - **New Features** - Added `createSqlCatalogSearchWorkCoordinator` with 8 active and 64 queued limits, exact-key in-flight sharing, and latest-wins per owner. - Owners capture scope and dialect; the authenticated dialect runtime defines the provider ID and epoch authority. - Independent cancellation, queue and execution deadlines, and a small synchronous budget; outcomes include usable, superseded, cancelled, and unavailable. - Safe response decoding and epoch publication; first baseline re-keys unobserved work in the same scope. - Benchmarks and strict unit/type tests for lifecycle, concurrency, deadlines, and adversarial scenarios. - ADR 0005 updated to document scheduler/deadlines and package-owned disposal semantics. - **Refactors** - Epoch coordinator now accepts a package-owned disposal target, invokes it exactly once, and drains non-undefined returns with captured Promise intrinsics (resilient to a replaced global Promise). - Centralized scope validation into `isValidSqlCatalogScope` in the boundary; adopted by epoch and search-work paths with new tests. - Hardened lifecycle boundaries and cleanup; exact disposal and hostile thenable handling. - Dialect runtime exposes a stable `id`; tests assert coherence and deep-freeze properties. Written for commit 061a3c6ca56fb2e24c5a3bb65b9bef0671edc9ed. Summary will update on new commits. Review in cubic --- ...-parser-independent-relation-completion.md | 31 +- .../relation-catalog-boundary.test.ts | 24 + ...relation-catalog-epoch-coordinator.test.ts | 102 + .../relation-catalog-search-work.bench.ts | 417 +++ .../relation-catalog-search-work.test.ts | 3105 +++++++++++++++++ src/vnext/__tests__/relation-dialect.test.ts | 3 +- src/vnext/relation-catalog-boundary.ts | 12 + .../relation-catalog-epoch-coordinator.ts | 71 +- src/vnext/relation-catalog-search-work.ts | 1866 ++++++++++ src/vnext/relation-dialect.ts | 14 +- .../relation-catalog-search-work.test-d.ts | 257 ++ 11 files changed, 5859 insertions(+), 43 deletions(-) create mode 100644 src/vnext/__tests__/relation-catalog-search-work.bench.ts create mode 100644 src/vnext/__tests__/relation-catalog-search-work.test.ts create mode 100644 src/vnext/relation-catalog-search-work.ts create mode 100644 test/vnext-types/relation-catalog-search-work.test-d.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 9c4cb42..13d6ae7 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -484,6 +484,12 @@ state preparation and suppresses later revision listeners; an accidental Promise result receives best-effort detached rejection draining. Coordinator disposal revokes the preparation closure before external cleanup. The hook remains package-private and is not a provider or session extension point. +The combined search coordinator also installs one package-owned disposal +target. Epoch self-quarantine makes the outer coordinator inert before +subscription cleanup continues, so search work cannot outlive its epoch +authority. The target is receiver-free, invoked at most once, and its failure +cannot reopen disposal. It is synchronously exact-return and must return +`undefined`; any other runtime result is detached and rejection-drained. A search that discovers a higher epoch supersedes itself instead of publishing against its older captured revision. Pages and cache entries from different @@ -555,7 +561,9 @@ callback can always create a poisoned or independent unhandled rejection that no JavaScript library can retroactively contain. Legitimate asynchronous teardown must consume or report its own failure before the cleanup closure returns `undefined`. The valid `undefined` path returns without Promise -allocation or a microtask. A closure avoids a structural TypeScript +allocation or a microtask. Detached assimilation uses module-captured Promise +intrinsics, so replacing the global Promise constructor cannot disable the +drain. A closure avoids a structural TypeScript contract that would accept class instances or inherited methods which the hostile runtime boundary could not safely validate. Dropping the last owner removes the complete scope incarnation before cleanup. A later join creates a @@ -654,6 +662,8 @@ outcomes settle through a discriminated request result. Completion is latest-wins per session: - a new completion request supersedes the previous request; +- a new request that cannot capture an active epoch still supersedes and + detaches the previous request before reporting its unavailable outcome; - same-key supersession atomically attaches the new request consumer, or retags the existing observer as that consumer, before removing old request ownership; different-key supersession revokes the old observer first; @@ -744,6 +754,25 @@ refresh notification and leaves the already-returned incomplete result valid. No optional catalog promise can keep `complete()` pending indefinitely or block the local baseline past its product response budget. +The first implementation increment of this section is intentionally +package-private. It combines an authenticated provider with the epoch +coordinator and owns the fixed 8-active/64-queued scheduler, exact-key +in-flight sharing, one latest-wins consumer per owner, independent +cancellation, absolute queue and execution deadlines, response decoding, and +epoch publication. An owner captures its scope and dialect when prepared, so +individual requests cannot substitute provider, scope, dialect, or epoch +authority. The authenticated dialect runtime owns its canonical provider ID; +callers cannot pair an unrelated ID and runtime. Establishing the first +baseline re-keys other joinable unobserved work in that scope, allowing +newly-observed consumers to join it without duplicating a provider call. + +Cache entries, loading/retry policy, refresh observers and their leases, the +40 ms completion-response budget, pagination composition, ranking, session +composition, and CodeMirror integration do not belong to that increment. They +remain explicit follow-up increments; the coordinator must not expose a +premature public surface that makes those deferred semantics difficult to add +or test. + The exact structural cache and shared-work key contains: - service-owned provider configuration identity and unique provider ID; diff --git a/src/vnext/__tests__/relation-catalog-boundary.test.ts b/src/vnext/__tests__/relation-catalog-boundary.test.ts index 93ef31a..0e610bc 100644 --- a/src/vnext/__tests__/relation-catalog-boundary.test.ts +++ b/src/vnext/__tests__/relation-catalog-boundary.test.ts @@ -5,6 +5,7 @@ import { createSqlCatalogSearchRequest, decodeSqlCatalogInvalidation, decodeSqlCatalogSearchResponse, + isValidSqlCatalogScope, MAX_CATALOG_CONTINUATION_TOKEN_LENGTH, MAX_CATALOG_DETAIL_LENGTH, MAX_CATALOG_ENTITY_ID_LENGTH, @@ -345,6 +346,29 @@ describe("relation catalog provider capture", () => { }); }); +describe("catalog scope validation", () => { + it("accepts only bounded, non-NUL, well-formed text", () => { + expect(isValidSqlCatalogScope("connection:primary")).toBe(true); + expect( + isValidSqlCatalogScope( + `\ud83d\ude80${"x".repeat(MAX_CATALOG_SCOPE_LENGTH - 2)}`, + ), + ).toBe(true); + for (const candidate of [ + null, + 1, + "", + "bad\0scope", + "\ud800", + "\ud800x", + "\udc00", + "x".repeat(MAX_CATALOG_SCOPE_LENGTH + 1), + ]) { + expect(isValidSqlCatalogScope(candidate)).toBe(false); + } + }); +}); + describe("catalog search request snapshots", () => { it("copies and recursively freezes the exact provider request", () => { const raw = request(); diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts index 24413b3..fddbbab 100644 --- a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts @@ -236,6 +236,108 @@ describe("catalog epoch coordinator construction and membership", () => { expect(reads).toBe(0); }); + it("validates, drains, and invokes the package disposal target exactly once", async () => { + expect( + Reflect.apply(createSqlCatalogEpochCoordinator, undefined, [ + capturedProvider(), + undefined, + 1, + ]), + ).toEqual({ + reason: "invalid-disposal-target", + status: "unavailable", + }); + + let disposalCalls = 0; + const created = createSqlCatalogEpochCoordinator( + capturedProvider(), + undefined, + () => { + disposalCalls += 1; + throw new Error("package disposal target failed"); + }, + ); + expect(created.status).toBe("created"); + if (created.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + expect(() => { + created.coordinator.dispose(); + created.coordinator.dispose(); + }).not.toThrow(); + expect(disposalCalls).toBe(1); + + const invalidReturn = Promise.reject( + new Error("invalid async package disposal"), + ); + const withInvalidReturn = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [ + capturedProvider(), + undefined, + () => invalidReturn, + ], + ); + expect(withInvalidReturn.status).toBe("created"); + if (withInvalidReturn.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + withInvalidReturn.coordinator.dispose(); + await Promise.resolve(); + await Promise.resolve(); + }); + + it("drains a plain thenable with captured Promise intrinsics after the global constructor is replaced", async () => { + const intrinsicPromise = Promise; + let thenCalls = 0; + const thenable = new Proxy( + {}, + { + get(target, property, receiver): unknown { + if (property === "then") { + return ( + _resolve: (value: unknown) => void, + reject: (reason?: unknown) => void, + ): void => { + thenCalls += 1; + reject(new Error("detached plain thenable")); + }; + } + return Reflect.get(target, property, receiver); + }, + }, + ); + function HostilePromise(): never { + throw new Error("mutable global Promise was used"); + } + expect( + Reflect.set(globalThis, "Promise", HostilePromise), + ).toBe(true); + try { + const created = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [ + capturedProvider(), + undefined, + () => thenable, + ], + ); + expect(created.status).toBe("created"); + if (created.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + const createdCoordinator = created.coordinator; + expect(() => createdCoordinator.dispose()).not.toThrow(); + } finally { + Reflect.set(globalThis, "Promise", intrinsicPromise); + } + await intrinsicPromise.resolve(); + await intrinsicPromise.resolve(); + expect(thenCalls).toBe(1); + }); + it("validates exact bounded well-formed scopes without raw errors", () => { const owner = coordinator(); expect( diff --git a/src/vnext/__tests__/relation-catalog-search-work.bench.ts b/src/vnext/__tests__/relation-catalog-search-work.bench.ts new file mode 100644 index 0000000..1591d84 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-search-work.bench.ts @@ -0,0 +1,417 @@ +import { bench, describe } from "vitest"; +import { + captureSqlRelationCatalogProvider, +} from "../relation-catalog-boundary.js"; +import type { + CapturedSqlRelationCatalogProvider, + SqlCatalogBoundaryResult, +} from "../relation-catalog-boundary.js"; +import { + createSqlCatalogSearchWorkCoordinator, + MAX_CATALOG_ACTIVE_SEARCH_WORK, + MAX_CATALOG_EXECUTION_DEADLINE_MS, + MAX_CATALOG_QUEUED_SEARCH_WORK, + MAX_CATALOG_QUEUE_DEADLINE_MS, +} from "../relation-catalog-search-work.js"; +import type { + SqlCatalogSearchWorkCoordinator, + SqlCatalogSearchWorkInput, + SqlCatalogSearchWorkOutcome, + SqlCatalogSearchWorkOwner, + SqlCatalogSearchWorkTicket, +} from "../relation-catalog-search-work.js"; +import { + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; +import type { + SqlCatalogSearchRequest, +} from "../relation-completion-types.js"; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: unknown) => void; +} + +interface ProviderCall { + readonly request: SqlCatalogSearchRequest; + readonly settlement: Deferred; + readonly signal: AbortSignal; +} + +interface ProviderFixture { + readonly calls: ProviderCall[]; + readonly captured: CapturedSqlRelationCatalogProvider; + readonly emitInvalidation: ( + this: void, + generation: number, + ) => void; +} + +let providerSequence = 0; + +function benchmarkFailure(message: string): never { + throw new Error( + `Catalog search-work benchmark preflight failed: ${message}`, + ); +} + +function accepted( + result: SqlCatalogBoundaryResult, +): Value { + if (result.status !== "accepted") { + return benchmarkFailure( + `provider capture was rejected: ${result.reason}`, + ); + } + return result.value; +} + +function deferred(): Deferred { + let resolve: ((value: unknown) => void) | null = null; + const promise = new Promise((onResolve) => { + resolve = onResolve; + }); + return { + promise, + resolve: (value: unknown): void => { + if (!resolve) { + return benchmarkFailure("deferred resolver was unavailable"); + } + resolve(value); + }, + }; +} + +function readyResponse(generation = 1): unknown { + return { + coverage: { kind: "complete" }, + epoch: { + generation, + token: `benchmark-epoch-${generation}`, + }, + relations: [], + status: "ready", + }; +} + +function input(prefix: string): SqlCatalogSearchWorkInput { + return { + continuationToken: null, + limit: 20, + prefix: { quoted: false, value: prefix }, + qualifier: [{ quoted: false, value: "public" }], + searchPaths: [[{ quoted: false, value: "public" }]], + }; +} + +function providerFixture(subscribe: boolean): ProviderFixture { + const calls: ProviderCall[] = []; + let invalidation: + | ((this: void, event: unknown) => void) + | null = null; + providerSequence += 1; + const provider = subscribe + ? { + id: `search-work-benchmark-${providerSequence}`, + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ): Promise { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + _scope: string, + listener: (this: void, event: unknown) => void, + ): (this: void) => undefined { + invalidation = listener; + return (): undefined => undefined; + }, + } + : { + id: `search-work-benchmark-${providerSequence}`, + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ): Promise { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + }; + return { + calls, + captured: accepted( + captureSqlRelationCatalogProvider(provider), + ), + emitInvalidation: (generation: number): void => { + const listener = invalidation; + if (!listener) { + return benchmarkFailure( + "invalidation listener was unavailable", + ); + } + listener({ + epoch: { + generation, + token: `benchmark-epoch-${generation}`, + }, + }); + }, + }; +} + +function coordinator( + fixture: ProviderFixture, +): SqlCatalogSearchWorkCoordinator { + const created = createSqlCatalogSearchWorkCoordinator( + fixture.captured, + { + executionDeadlineMs: MAX_CATALOG_EXECUTION_DEADLINE_MS, + queueDeadlineMs: MAX_CATALOG_QUEUE_DEADLINE_MS, + }, + ); + if (created.status !== "created") { + return benchmarkFailure( + `coordinator creation was unavailable: ${created.reason}`, + ); + } + return created.coordinator; +} + +function owner( + service: SqlCatalogSearchWorkCoordinator, +): SqlCatalogSearchWorkOwner { + const prepared = service.prepareOwner( + "benchmark-scope", + POSTGRESQL_SQL_RELATION_DIALECT, + { + prepareCatalogChange: () => () => undefined, + }, + ); + if (prepared.status !== "prepared") { + return benchmarkFailure( + `owner preparation was unavailable: ${prepared.reason}`, + ); + } + const activated = prepared.owner.activate(); + if (activated.status !== "active") { + return benchmarkFailure( + `owner activation was unavailable: ${activated.reason}`, + ); + } + return prepared.owner; +} + +function requireCall( + fixture: ProviderFixture, + index: number, +): ProviderCall { + const call = fixture.calls[index]; + if (!call) { + return benchmarkFailure( + `provider call ${index} was unavailable`, + ); + } + return call; +} + +async function requireUsable( + ticket: SqlCatalogSearchWorkTicket, +): Promise { + const outcome = await ticket.result; + if (outcome.status !== "usable") { + benchmarkFailure( + `expected usable work, received ${outcome.status}`, + ); + } +} + +async function requireStatus( + ticket: SqlCatalogSearchWorkTicket, + status: SqlCatalogSearchWorkOutcome["status"], +): Promise { + const outcome = await ticket.result; + if (outcome.status !== status) { + benchmarkFailure( + `expected ${status} work, received ${outcome.status}`, + ); + } +} + +async function flushProviderCalls( + fixture: ProviderFixture, + expected: number, +): Promise { + for (let index = 0; index < expected; index += 1) { + for ( + let attempts = 0; + !fixture.calls[index] && attempts < 4; + attempts += 1 + ) { + await Promise.resolve(); + } + requireCall(fixture, index).settlement.resolve( + readyResponse(), + ); + await Promise.resolve(); + } +} + +async function benchmarkSameKeyJoins( + ownerCount: number, +): Promise { + const fixture = providerFixture(false); + const service = coordinator(fixture); + const tickets = Array.from( + { length: ownerCount }, + () => owner(service).request(input("shared")), + ); + if (fixture.calls.length !== 1) { + benchmarkFailure( + `${ownerCount} same-key owners created ${fixture.calls.length} calls`, + ); + } + requireCall(fixture, 0).settlement.resolve(readyResponse()); + await Promise.all(tickets.map(requireUsable)); + service.dispose(); +} + +async function benchmarkQueuePump(): Promise { + const fixture = providerFixture(false); + const service = coordinator(fixture); + const workCount = + MAX_CATALOG_ACTIVE_SEARCH_WORK + + MAX_CATALOG_QUEUED_SEARCH_WORK; + const tickets = Array.from( + { length: workCount }, + (_, index) => + owner(service).request(input(`queue-${index}`)), + ); + if ( + fixture.calls.length !== MAX_CATALOG_ACTIVE_SEARCH_WORK + ) { + benchmarkFailure("active search capacity was not filled"); + } + await flushProviderCalls(fixture, workCount); + await Promise.all(tickets.map(requireUsable)); + if (fixture.calls.length !== workCount) { + benchmarkFailure("queued search work was not fully pumped"); + } + service.dispose(); +} + +async function benchmarkWorstCaseExactKeyScan(): Promise { + const fixture = providerFixture(false); + const service = coordinator(fixture); + const workCount = + MAX_CATALOG_ACTIVE_SEARCH_WORK + + MAX_CATALOG_QUEUED_SEARCH_WORK; + const tickets = Array.from( + { length: workCount }, + (_, index) => + owner(service).request(input(`scan-${index}`)), + ); + const joined = owner(service).request( + input(`scan-${workCount - 1}`), + ); + if ( + fixture.calls.length !== MAX_CATALOG_ACTIVE_SEARCH_WORK + ) { + benchmarkFailure("worst-case exact key did not join queued work"); + } + for (const ticket of tickets) ticket.cancel(); + joined.cancel(); + await Promise.all([ + ...tickets.map((ticket) => + requireStatus(ticket, "cancelled"), + ), + requireStatus(joined, "cancelled"), + ]); + for (const call of fixture.calls) { + call.settlement.resolve(readyResponse()); + } + await Promise.resolve(); + await Promise.resolve(); + service.dispose(); +} + +async function benchmarkScopeRetirement(): Promise { + const fixture = providerFixture(true); + const service = coordinator(fixture); + const workCount = + MAX_CATALOG_ACTIVE_SEARCH_WORK + + MAX_CATALOG_QUEUED_SEARCH_WORK; + const tickets = Array.from( + { length: workCount }, + (_, index) => + owner(service).request(input(`epoch-${index}`)), + ); + fixture.emitInvalidation(1); + await Promise.all( + tickets.map((ticket) => + requireStatus(ticket, "superseded"), + ), + ); + for (const call of fixture.calls) { + if (!call.signal.aborted) { + benchmarkFailure( + "scope retirement left active provider work un-aborted", + ); + } + call.settlement.resolve(readyResponse()); + } + await Promise.resolve(); + await Promise.resolve(); + service.dispose(); +} + +async function benchmarkAcquireCancel10k(): Promise { + const fixture = providerFixture(false); + const service = coordinator(fixture); + const session = owner(service); + for (let index = 0; index < 10_000; index += 1) { + session.request(input(`cancel-${index}`)).cancel(); + } + if ( + fixture.calls.length !== MAX_CATALOG_ACTIVE_SEARCH_WORK + ) { + benchmarkFailure( + "cancelled active work did not retain its slot until settlement", + ); + } + for (const call of fixture.calls) { + if (!call.signal.aborted) { + benchmarkFailure("cancelled active work was not aborted"); + } + call.settlement.resolve(readyResponse()); + } + await Promise.resolve(); + await Promise.resolve(); + service.dispose(); +} + +describe("relation catalog search work", () => { + for (const ownerCount of [1, 10, 50]) { + bench(`${ownerCount} same-key owner joins`, async () => { + await benchmarkSameKeyJoins(ownerCount); + }); + } + + bench("pump 8 active and 64 queued searches", async () => { + await benchmarkQueuePump(); + }); + + bench("scan the worst-case exact key at capacity", async () => { + await benchmarkWorstCaseExactKeyScan(); + }); + + bench("retire a full scope on an epoch change", async () => { + await benchmarkScopeRetirement(); + }); + + bench("acquire and cancel 10,000 requests", async () => { + await benchmarkAcquireCancel10k(); + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-search-work.test.ts b/src/vnext/__tests__/relation-catalog-search-work.test.ts new file mode 100644 index 0000000..a16aa0d --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-search-work.test.ts @@ -0,0 +1,3105 @@ +import { describe, expect, it, vi } from "vitest"; +import { + captureSqlRelationCatalogProvider, + type CapturedSqlRelationCatalogProvider, + type SqlCatalogBoundaryResult, +} from "../relation-catalog-boundary.js"; +import { + createSqlCatalogSearchWorkCoordinator, + MAX_CATALOG_ACTIVE_SEARCH_WORK, + MAX_CATALOG_QUEUED_SEARCH_WORK, + type SqlCatalogSearchDeadlineScheduler, + type SqlCatalogSearchWorkCoordinator, + type SqlCatalogSearchWorkInput, + type SqlCatalogSearchWorkOptions, + type SqlCatalogSearchWorkOwner, + type SqlCatalogSearchWorkOutcome, + type SqlCatalogSearchWorkTicket, +} from "../relation-catalog-search-work.js"; +import { + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import type { SqlCatalogSearchRequest } from "../relation-completion-types.js"; +import type { SqlIdentifierComponent } from "../types.js"; + +interface Deferred { + readonly promise: Promise; + readonly reject: (reason?: unknown) => void; + readonly resolve: (value: Value) => void; +} + +interface ProviderCall { + readonly request: SqlCatalogSearchRequest; + readonly settlement: Deferred; + readonly signal: AbortSignal; +} + +interface ProviderHarness { + readonly calls: ProviderCall[]; + readonly captured: CapturedSqlRelationCatalogProvider; +} + +interface ScheduledDeadline { + active: boolean; + readonly callback: (this: void) => void; + readonly deadline: number; +} + +class ManualDeadlineScheduler + implements SqlCatalogSearchDeadlineScheduler +{ + nowValue = 0; + readonly tasks = new Map(); + private nextHandle = 1; + + readonly clearTimeout = (handle: unknown): void => { + if (typeof handle !== "number") return; + const task = this.tasks.get(handle); + if (task) task.active = false; + }; + + readonly now = (): number => this.nowValue; + + readonly setTimeout = ( + callback: (this: void) => void, + delayMs: number, + ): unknown => { + const handle = this.nextHandle; + this.nextHandle += 1; + this.tasks.set(handle, { + active: true, + callback, + deadline: this.nowValue + delayMs, + }); + return handle; + }; + + advanceBy(deltaMs: number): void { + this.nowValue += deltaMs; + this.flushDue(); + } + + flushDue(): void { + for (;;) { + const due = [...this.tasks.entries()] + .filter( + ([, task]) => + task.active && task.deadline <= this.nowValue, + ) + .sort( + ([leftHandle, left], [rightHandle, right]) => + left.deadline - right.deadline || + leftHandle - rightHandle, + )[0]; + if (!due) return; + const [handle, task] = due; + task.active = false; + this.tasks.delete(handle); + Reflect.apply(task.callback, undefined, []); + } + } + + get pendingCount(): number { + let count = 0; + for (const task of this.tasks.values()) { + if (task.active) count += 1; + } + return count; + } +} + +function accepted( + result: SqlCatalogBoundaryResult, +): Value { + expect(result.status).toBe("accepted"); + if (result.status !== "accepted") { + throw new Error(`Expected accepted, received ${result.reason}`); + } + return result.value; +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, reject, resolve }; +} + +function epoch( + generation = 1, + token = `snapshot-${generation}`, +): { + readonly generation: number; + readonly token: string; +} { + return { generation, token }; +} + +function component( + value: string, + quoted = false, +): SqlIdentifierComponent { + return { quoted, value }; +} + +function pathComponent( + role: "relation" | "schema", + value: string, +) { + return { quoted: false, role, value }; +} + +function readyResponse(generation = 1) { + return { + coverage: { kind: "complete" }, + epoch: epoch(generation), + relations: [ + { + canonicalPath: [ + pathComponent("schema", "public"), + pathComponent("relation", `users-${generation}`), + ], + completionPathStart: 0, + entityId: `relation-${generation}`, + matchQuality: "exact", + relationKind: "table", + }, + ], + status: "ready", + }; +} + +function input( + prefix = "us", + overrides: Partial = {}, +): SqlCatalogSearchWorkInput { + return { + continuationToken: null, + limit: 20, + prefix: component(prefix), + qualifier: [component("public")], + searchPaths: [[component("public")]], + ...overrides, + }; +} + +function providerHarness(): ProviderHarness { + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + }), + ); + return { calls, captured }; +} + +function coordinator( + captured: CapturedSqlRelationCatalogProvider, + options: SqlCatalogSearchWorkOptions = {}, +): SqlCatalogSearchWorkCoordinator { + const created = createSqlCatalogSearchWorkCoordinator( + captured, + options, + ); + expect(created.status).toBe("created"); + if (created.status !== "created") { + throw new Error( + `Expected coordinator, received ${created.reason}`, + ); + } + return created.coordinator; +} + +function owner( + service: SqlCatalogSearchWorkCoordinator, + scope = "connection:primary", + dialect: SqlRelationDialectRuntime = + POSTGRESQL_SQL_RELATION_DIALECT, +): SqlCatalogSearchWorkOwner { + const prepared = service.prepareOwner( + scope, + dialect, + { + prepareCatalogChange: () => () => {}, + }, + ); + expect(prepared.status).toBe("prepared"); + if (prepared.status !== "prepared") { + throw new Error(`Expected owner, received ${prepared.reason}`); + } + expect(prepared.owner.activate()).toEqual({ status: "active" }); + return prepared.owner; +} + +async function settled( + ticket: Promise, +): Promise { + await Promise.resolve(); + return ticket; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("catalog search coordinator construction", () => { + it("rejects unauthenticated providers and malformed scheduler options", () => { + expect( + Reflect.apply( + createSqlCatalogSearchWorkCoordinator, + undefined, + [ + { + id: "catalog", + search: () => readyResponse(), + }, + ], + ), + ).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + + const provider = providerHarness(); + for (const options of [ + { executionDeadlineMs: 9 }, + { executionDeadlineMs: 5_001 }, + { queueDeadlineMs: 9 }, + { queueDeadlineMs: 2_001 }, + { + deadlineScheduler: { + clearTimeout() {}, + now: 1, + setTimeout() { + return 1; + }, + }, + }, + ]) { + expect( + Reflect.apply( + createSqlCatalogSearchWorkCoordinator, + undefined, + [provider.captured, options], + ), + ).toEqual({ + reason: "invalid-options", + status: "unavailable", + }); + } + }); + + it("freezes public handles, tickets, outcomes, and decoded responses", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const ticket = session.request(input()); + expect(Object.isFrozen(service)).toBe(true); + expect(Object.isFrozen(session)).toBe(true); + expect(Object.isFrozen(ticket)).toBe(true); + + provider.calls[0]?.settlement.resolve(readyResponse()); + const outcome = await ticket.result; + expect(Object.isFrozen(outcome)).toBe(true); + expect(outcome.status).toBe("usable"); + if (outcome.status === "usable") { + expect(Object.isFrozen(outcome.response)).toBe(true); + } + }); + + it("does not expire default-scheduled work synchronously", async () => { + let calls = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + calls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured); + const ticket = owner(service).request(input()); + + expect(calls).toBe(1); + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("rejects structurally copied dialect runtimes without invoking them", () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const result = service.prepareOwner( + "scope", + { ...POSTGRESQL_SQL_RELATION_DIALECT }, + { prepareCatalogChange: () => () => {} }, + ); + expect(result).toEqual({ + reason: "invalid-dialect", + status: "unavailable", + }); + }); + + it("returns closed tickets for inactive, malformed, and disposed owners", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const prepared = service.prepareOwner( + "scope", + POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ); + expect(prepared.status).toBe("prepared"); + if (prepared.status !== "prepared") { + throw new Error("Expected prepared owner"); + } + + await expect( + prepared.owner.request(input()).result, + ).resolves.toEqual({ + reason: "inactive", + status: "unavailable", + }); + expect(prepared.owner.activate()).toEqual({ + status: "active", + }); + await expect( + prepared.owner.request(input("bad", { limit: 0 })).result, + ).resolves.toEqual({ + reason: "invalid-request", + status: "unavailable", + }); + prepared.owner.dispose(); + await expect( + prepared.owner.request(input()).result, + ).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(provider.calls).toHaveLength(0); + }); +}); + +describe("catalog search structural sharing and admission", () => { + it("shares exactly equal copied requests while preserving structural distinctions", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const first = owner(service); + const second = owner(service); + const sharedInput = input(); + const firstTicket = first.request(sharedInput); + const secondTicket = second.request({ + ...sharedInput, + prefix: { ...sharedInput.prefix }, + qualifier: sharedInput.qualifier.map((part) => ({ + ...part, + })), + searchPaths: sharedInput.searchPaths.map((path) => + path.map((part) => ({ ...part })), + ), + }); + + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0]?.request).not.toBe(sharedInput); + expect(Object.isFrozen(provider.calls[0]?.request)).toBe(true); + + const distinct = [ + input("us", { continuationToken: "page-2" }), + input("us", { limit: 19 }), + input("US"), + input("us", { prefix: component("us", true) }), + input("us", { qualifier: [component("other")] }), + input("us", { searchPaths: [[component("other")]] }), + ]; + for (const candidate of distinct) { + owner(service).request(candidate); + } + owner( + service, + "connection:primary", + DUCKDB_SQL_RELATION_DIALECT, + ).request(input()); + expect(provider.calls).toHaveLength(2 + distinct.length); + expect(provider.calls.at(-1)?.request.dialectId).toBe( + "duckdb", + ); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(firstTicket.result).resolves.toMatchObject({ + status: "usable", + }); + await expect(secondTicket.result).resolves.toMatchObject({ + status: "usable", + }); + service.dispose(); + }); + + it("admits a same-key join at full queue capacity but rejects a new key", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + }); + const tickets = Array.from( + { + length: + MAX_CATALOG_ACTIVE_SEARCH_WORK + + MAX_CATALOG_QUEUED_SEARCH_WORK, + }, + (_, index) => + owner(service).request(input(`key-${index}`)), + ); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + + const queuedIndex = MAX_CATALOG_ACTIVE_SEARCH_WORK; + const joined = owner(service).request( + input(`key-${queuedIndex}`), + ); + const rejected = owner(service).request(input("overflow")); + await expect(rejected.result).resolves.toEqual({ + reason: "overloaded", + status: "unavailable", + }); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await Promise.resolve(); + await Promise.resolve(); + expect(provider.calls.at(-1)?.request.prefix.value).toBe( + `key-${queuedIndex}`, + ); + provider.calls.at(-1)?.settlement.resolve(readyResponse()); + await expect(tickets[queuedIndex]?.result).resolves.toMatchObject( + { status: "usable" }, + ); + await expect(joined.result).resolves.toMatchObject({ + status: "usable", + }); + service.dispose(); + }); + + it("promotes queued work FIFO without recursively invoking providers", async () => { + let depth = 0; + let maximumDepth = 0; + const order: string[] = []; + let service!: SqlCatalogSearchWorkCoordinator; + const reentrantTickets: SqlCatalogSearchWorkTicket[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search(request: SqlCatalogSearchRequest) { + depth += 1; + maximumDepth = Math.max(maximumDepth, depth); + order.push(request.prefix.value); + if ( + request.prefix.value === + `key-${MAX_CATALOG_ACTIVE_SEARCH_WORK}` && + reentrantTickets.length === 0 + ) { + reentrantTickets.push( + owner(service).request( + input("key-reentrant"), + ), + ); + } + depth -= 1; + return Promise.resolve(readyResponse()); + }, + }), + ); + service = coordinator(captured); + const tickets = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK + 3 }, + (_, index) => owner(service).request(input(`key-${index}`)), + ); + await Promise.all(tickets.map((ticket) => ticket.result)); + const reentrantTicket = reentrantTickets[0]; + if (!reentrantTicket) { + throw new Error("Expected reentrant queued ticket"); + } + await reentrantTicket.result; + + expect(order).toEqual( + [ + ...Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK + 3 }, + (_, index) => `key-${index}`, + ), + "key-reentrant", + ], + ); + expect(maximumDepth).toBe(1); + }); +}); + +describe("catalog search ownership and active-slot lifecycle", () => { + it("keeps cancellation idempotent before and after settlement", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const cancelled = owner(service).request(input("cancelled")); + cancelled.cancel(); + cancelled.cancel(); + await expect(cancelled.result).resolves.toEqual({ + status: "cancelled", + }); + + const completed = owner(service).request(input("completed")); + provider.calls.at(-1)?.settlement.resolve(readyResponse()); + await expect(completed.result).resolves.toMatchObject({ + status: "usable", + }); + completed.cancel(); + completed.cancel(); + service.dispose(); + }); + + it("transfers same-key latest-wins ownership before detaching the old consumer", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const first = session.request(input("same")); + const signal = provider.calls[0]?.signal; + const replacement = session.request(input("same")); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(1); + expect(signal?.aborted).toBe(false); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(replacement.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("aborts only after the last shared owner leaves and retains the active slot until settlement", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const firstOwner = owner(service); + const secondOwner = owner(service); + const first = firstOwner.request(input("shared")); + const second = secondOwner.request(input("shared")); + const filling = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK - 1 }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + const queued = owner(service).request(input("queued")); + const sharedCall = provider.calls[0]; + + first.cancel(); + await expect(first.result).resolves.toEqual({ + status: "cancelled", + }); + expect(sharedCall?.signal.aborted).toBe(false); + + second.cancel(); + await expect(second.result).resolves.toEqual({ + status: "cancelled", + }); + expect(sharedCall?.signal.aborted).toBe(true); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + + sharedCall?.settlement.resolve(readyResponse()); + await Promise.resolve(); + await Promise.resolve(); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK + 1, + ); + expect(provider.calls.at(-1)?.request.prefix.value).toBe( + "queued", + ); + + for (const call of provider.calls.slice(1)) { + call.settlement.resolve(readyResponse()); + } + await Promise.all([ + ...filling.map((ticket) => settled(ticket.result)), + settled(queued.result), + ]); + }); + + it("supersedes a different key and aborts the abandoned work exactly once", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const first = session.request(input("first")); + let aborts = 0; + provider.calls[0]?.signal.addEventListener("abort", () => { + aborts += 1; + }); + const replacement = session.request(input("replacement")); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(2); + expect(provider.calls[0]?.signal.aborted).toBe(true); + expect(aborts).toBe(1); + + provider.calls[0]?.settlement.reject( + new Error("late abandoned rejection"), + ); + provider.calls[1]?.settlement.resolve(readyResponse()); + await expect(replacement.result).resolves.toMatchObject({ + status: "usable", + }); + await flushMicrotasks(); + expect(aborts).toBe(1); + }); + + it("keeps one deterministic current owner when an abandoned abort reenters with a third key", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + }); + const session = owner(service); + const abandoned = session.request(input("abandoned")); + const reentrantTickets: SqlCatalogSearchWorkTicket[] = []; + provider.calls[0]?.signal.addEventListener("abort", () => { + reentrantTickets.push( + session.request(input("reentrant")), + ); + }); + + const displaced = session.request(input("displaced")); + await expect(abandoned.result).resolves.toEqual({ + status: "superseded", + }); + await expect(displaced.result).resolves.toEqual({ + status: "superseded", + }); + expect( + provider.calls.map((call) => call.request.prefix.value), + ).toEqual(["abandoned", "reentrant"]); + expect(provider.calls[0]?.signal.aborted).toBe(true); + + const reentrantTicket = reentrantTickets[0]; + if (!reentrantTicket) { + throw new Error("Expected abort-listener reentrant request"); + } + provider.calls[0]?.settlement.reject( + new Error("late abandoned rejection"), + ); + provider.calls[1]?.settlement.resolve(readyResponse()); + await expect(reentrantTicket.result).resolves.toMatchObject({ + status: "usable", + }); + displaced.cancel(); + abandoned.cancel(); + await flushMicrotasks(); + expect(provider.calls).toHaveLength(2); + expect(scheduler.pendingCount).toBe(0); + }); +}); + +describe("catalog search absolute deadlines", () => { + it("does not retain or invoke work when a deadline expires while it is armed", async () => { + const cases = [ + { + expectedReason: "execution-timeout", + nowValues: [0, 0, 0, 0, 20], + }, + ] as const; + for (const testCase of cases) { + let index = 0; + let providerCalls = 0; + const activeHandles = new Set(); + let nextHandle = 0; + const scheduler: SqlCatalogSearchDeadlineScheduler = { + clearTimeout(handle) { + if (typeof handle === "number") { + activeHandles.delete(handle); + } + }, + now() { + const value = testCase.nowValues[index]; + index += 1; + return ( + value ?? + testCase.nowValues.at(-1) ?? + Number.NaN + ); + }, + setTimeout() { + nextHandle += 1; + activeHandles.add(nextHandle); + return nextHandle; + }, + }; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toEqual({ + reason: testCase.expectedReason, + status: "unavailable", + }); + expect(providerCalls).toBe(1); + expect(activeHandles.size).toBe(0); + service.dispose(); + } + }); + + it("fails closed when adding a duration would overflow the clock", async () => { + let providerCalls = 0; + let timerCalls = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => Number.MAX_VALUE, + setTimeout() { + timerCalls += 1; + return 1; + }, + }, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(providerCalls).toBe(0); + expect(timerCalls).toBe(0); + service.dispose(); + }); + + it("does not let a late joiner extend the first enqueue deadline", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + const first = owner(service).request(input("queued")); + scheduler.advanceBy(9); + const joined = owner(service).request(input("queued")); + scheduler.advanceBy(1); + + await expect(first.result).resolves.toEqual({ + reason: "queue-timeout", + status: "unavailable", + }); + await expect(joined.result).resolves.toEqual({ + reason: "queue-timeout", + status: "unavailable", + }); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + service.dispose(); + }); + + it("checks the absolute queue deadline during promotion when the timer is delayed", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + const queued = owner(service).request(input("queued")); + + scheduler.nowValue = 10; + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + + await expect(queued.result).resolves.toEqual({ + reason: "queue-timeout", + status: "unavailable", + }); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + service.dispose(); + }); + + it("does not let a late joiner or delayed timer publish at the execution deadline", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const first = owner(service).request(input("active")); + scheduler.nowValue = 19; + const joined = owner(service).request(input("active")); + scheduler.nowValue = 20; + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + + await expect(first.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + await expect(joined.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(provider.calls[0]?.signal.aborted).toBe(true); + expect(scheduler.pendingCount).toBe(0); + }); + + it("discards a synchronous provider return that exceeds the observation budget", async () => { + const scheduler = new ManualDeadlineScheduler(); + let signal: AbortSignal | undefined; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + _request: SqlCatalogSearchRequest, + nextSignal: AbortSignal, + ) { + signal = nextSignal; + scheduler.nowValue += 6; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(signal?.aborted).toBe(true); + expect(scheduler.pendingCount).toBe(0); + }); + + it("does not let synchronously repeated early timer callbacks violate the absolute deadline", async () => { + let timerCallback: (() => void) | undefined; + let providerCalls = 0; + let synchronousFirings = 0; + const delays: number[] = []; + const scheduler: SqlCatalogSearchDeadlineScheduler = { + clearTimeout() {}, + now: () => 0, + setTimeout(callback, delayMs) { + delays.push(delayMs); + timerCallback = callback; + if ( + delayMs === 20 && + synchronousFirings < 1 + ) { + synchronousFirings += 1; + callback(); + callback(); + } + return 1; + }, + }; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const ticket = owner(service).request(input()); + + expect({ + delays, + providerCalls, + synchronousFirings, + }).toEqual({ + delays: [20, 20], + providerCalls: 1, + synchronousFirings: 1, + }); + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + expect(() => timerCallback?.()).not.toThrow(); + }); +}); + +describe("catalog provider failures and hostile settlement", () => { + it("maps synchronous throws, rejections, and malformed responses to closed outcomes", async () => { + const values: readonly { + readonly expected: SqlCatalogSearchWorkOutcome; + readonly search: () => unknown; + }[] = [ + { + expected: { + reason: "provider-failed", + status: "unavailable", + }, + search: () => { + throw new Error("synchronous provider failure"); + }, + }, + { + expected: { + reason: "provider-failed", + status: "unavailable", + }, + search: () => + Promise.reject(new Error("asynchronous provider failure")), + }, + { + expected: { + reason: "malformed-response", + status: "unavailable", + }, + search: () => ({ status: "ready" }), + }, + ]; + + for (const { expected, search } of values) { + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search, + }), + ); + const service = coordinator(captured); + await expect( + owner(service).request(input()).result, + ).resolves.toEqual(expected); + service.dispose(); + } + }); + + it("settles once when a hostile thenable resolves and rejects repeatedly", async () => { + let calls = 0; + const thenable: Record = {}; + Object.defineProperty(thenable, ["th", "en"].join(""), { + value( + resolve: (value: unknown) => void, + reject: (reason: unknown) => void, + ) { + calls += 1; + resolve(readyResponse()); + reject(new Error("late rejection")); + resolve({ status: "invalid" }); + }, + }); + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + return thenable; + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: new ManualDeadlineScheduler(), + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + expect(calls).toBe(1); + }); + + it("drains a rejected native promise without reading poisoned own then or catch properties", async () => { + let thenReads = 0; + let catchReads = 0; + const thenProperty = ["th", "en"].join(""); + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + const poisoned = Promise.reject( + new Error("poisoned provider rejection"), + ); + Object.defineProperty(poisoned, thenProperty, { + get() { + thenReads += 1; + throw new Error("poisoned own then"); + }, + }); + Object.defineProperty(poisoned, "catch", { + get() { + catchReads += 1; + throw new Error("poisoned own catch"); + }, + }); + return poisoned; + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: new ManualDeadlineScheduler(), + }); + + await expect( + owner(service).request(input()).result, + ).resolves.toEqual({ + reason: "provider-failed", + status: "unavailable", + }); + expect(thenReads).toBe(0); + expect(catchReads).toBe(0); + }); + + it("handles a throwing then getter that reenters with a replacement", async () => { + let service!: SqlCatalogSearchWorkCoordinator; + let session!: SqlCatalogSearchWorkOwner; + const reentrantTickets: SqlCatalogSearchWorkTicket[] = []; + let thenReads = 0; + let calls = 0; + const thenable: Record = {}; + Object.defineProperty(thenable, ["th", "en"].join(""), { + get() { + thenReads += 1; + reentrantTickets.push( + session.request(input("replacement")), + ); + throw new Error("hostile provider then getter"); + }, + }); + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + calls += 1; + return calls === 1 ? thenable : readyResponse(); + }, + }), + ); + service = coordinator(captured, { + deadlineScheduler: new ManualDeadlineScheduler(), + }); + session = owner(service); + const first = session.request(input("hostile")); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + const replacement = reentrantTickets[0]; + if (!replacement) { + throw new Error("Expected then-getter replacement"); + } + await expect(replacement.result).resolves.toMatchObject({ + status: "usable", + }); + expect(thenReads).toBe(1); + expect(calls).toBe(2); + }); + + it("invokes the captured provider receiver-free with immutable input and a work-owned signal", async () => { + let observedThisIsUndefined = false; + let requestFrozen = false; + let observedSignal: AbortSignal | undefined; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search: function ( + this: void, + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + observedThisIsUndefined = this === undefined; + requestFrozen = Object.isFrozen(request); + observedSignal = signal; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + expect(observedThisIsUndefined).toBe(true); + expect(requestFrozen).toBe(true); + expect(observedSignal).toBeInstanceOf(AbortSignal); + }); +}); + +describe("catalog search epoch authority and isolation", () => { + it("keeps shared response authority when the first owner is disposed", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const firstOwner = owner(service); + const remainingOwner = owner(service); + const first = firstOwner.request(input("shared")); + const remaining = remainingOwner.request(input("shared")); + + firstOwner.dispose(); + await expect(first.result).resolves.toEqual({ + status: "cancelled", + }); + expect(provider.calls[0]?.signal.aborted).toBe(false); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(remaining.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("rekeys same-scope unobserved work after a baseline without crossing scope boundaries", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const baseline = owner(service, "scope-a").request( + input("baseline"), + ); + const pendingInScope = owner(service, "scope-a").request( + input("pending"), + ); + const pendingInOtherScope = owner(service, "scope-b").request( + input("pending"), + ); + + expect(provider.calls).toHaveLength(3); + expect( + provider.calls.map((call) => call.request.expectedEpoch), + ).toEqual([null, null, null]); + + provider.calls[0]?.settlement.resolve(readyResponse(7)); + await expect(baseline.result).resolves.toMatchObject({ + observation: "baseline", + status: "usable", + }); + + const joinedInScope = owner(service, "scope-a").request( + input("pending"), + ); + const joinedInOtherScope = owner(service, "scope-b").request( + input("pending"), + ); + expect(provider.calls).toHaveLength(3); + + provider.calls[1]?.settlement.resolve(readyResponse(7)); + const [originalScopeResult, joinedScopeResult] = + await Promise.all([ + pendingInScope.result, + joinedInScope.result, + ]); + expect(originalScopeResult).toMatchObject({ + observation: "equal", + status: "usable", + }); + expect(joinedScopeResult).toBe(originalScopeResult); + + provider.calls[2]?.settlement.resolve(readyResponse(11)); + const [originalOtherResult, joinedOtherResult] = + await Promise.all([ + pendingInOtherScope.result, + joinedInOtherScope.result, + ]); + expect(originalOtherResult).toMatchObject({ + observation: "baseline", + status: "usable", + }); + expect(joinedOtherResult).toBe(originalOtherResult); + }); + + it("retires same-scope work before abort and revision dispatch while isolating another scope", async () => { + const listeners = new Map< + string, + (event: unknown) => void + >(); + const calls: ProviderCall[] = []; + const events: string[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + signal.addEventListener("abort", () => { + events.push(`abort-${request.scope}`); + }); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + scope: string, + listener: (event: unknown) => void, + ) { + listeners.set(scope, listener); + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + const prepareOwner = (scope: string) => { + const prepared = service.prepareOwner( + scope, + POSTGRESQL_SQL_RELATION_DIALECT, + { + prepareCatalogChange: () => { + events.push(`prepare-${scope}`); + return () => { + events.push(`dispatch-${scope}`); + }; + }, + }, + ); + if (prepared.status !== "prepared") { + throw new Error("Expected prepared owner"); + } + expect(prepared.owner.activate()).toEqual({ + status: "active", + }); + return prepared.owner; + }; + const firstScope = prepareOwner("scope-a").request(input("a")); + const otherScope = prepareOwner("scope-b").request(input("b")); + + listeners.get("scope-a")?.({ epoch: epoch(1) }); + await expect(firstScope.result).resolves.toEqual({ + status: "superseded", + }); + expect(events).toEqual([ + "prepare-scope-a", + "abort-scope-a", + "dispatch-scope-a", + ]); + expect(calls[0]?.signal.aborted).toBe(true); + expect(calls[1]?.signal.aborted).toBe(false); + + calls[1]?.settlement.resolve(readyResponse()); + await expect(otherScope.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("supersedes reentrant work when its retiring membership makes the next capture fail", async () => { + const listener: { + current: ((event: unknown) => void) | null; + } = { current: null }; + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + _scope: string, + onInvalidation: (event: unknown) => void, + ) { + listener.current = onInvalidation; + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + let session: SqlCatalogSearchWorkOwner | null = null; + const reentrant: { + current: SqlCatalogSearchWorkTicket | null; + } = { current: null }; + const prepared = service.prepareOwner( + "scope", + POSTGRESQL_SQL_RELATION_DIALECT, + { + prepareCatalogChange: () => { + reentrant.current = + session?.request(input("reentrant")) ?? null; + return null; + }, + }, + ); + expect(prepared.status).toBe("prepared"); + if (prepared.status !== "prepared") { + throw new Error("Expected prepared owner"); + } + session = prepared.owner; + expect(session.activate()).toEqual({ status: "active" }); + const initial = session.request(input("initial")); + + listener.current?.({ epoch: epoch(1) }); + await expect(initial.result).resolves.toEqual({ + status: "superseded", + }); + expect(reentrant.current).not.toBeNull(); + expect(calls).toHaveLength(2); + expect(calls[0]?.signal.aborted).toBe(true); + expect(calls[1]?.signal.aborted).toBe(false); + + const afterRetirement = session.request(input("after")); + await expect(afterRetirement.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + if (!reentrant.current) { + throw new Error("Expected reentrant request"); + } + await expect(reentrant.current.result).resolves.toEqual({ + status: "superseded", + }); + expect(calls[1]?.signal.aborted).toBe(true); + }); + + it("makes a higher response self-supersede and retire other same-scope work only", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const baselineOwner = owner(service, "scope-a"); + const baseline = baselineOwner.request(input("baseline")); + provider.calls[0]?.settlement.resolve(readyResponse(1)); + await expect(baseline.result).resolves.toMatchObject({ + status: "usable", + }); + + const producing = owner(service, "scope-a").request( + input("producing"), + ); + const sameScope = owner(service, "scope-a").request( + input("same-scope"), + ); + const otherScope = owner(service, "scope-b").request( + input("other-scope"), + ); + const producingCall = provider.calls.find( + (call) => call.request.prefix.value === "producing", + ); + const sameScopeCall = provider.calls.find( + (call) => call.request.prefix.value === "same-scope", + ); + const otherScopeCall = provider.calls.find( + (call) => call.request.prefix.value === "other-scope", + ); + + producingCall?.settlement.resolve(readyResponse(2)); + await expect(producing.result).resolves.toEqual({ + status: "superseded", + }); + await expect(sameScope.result).resolves.toEqual({ + status: "superseded", + }); + expect(producingCall?.signal.aborted).toBe(false); + expect(sameScopeCall?.signal.aborted).toBe(true); + expect(otherScopeCall?.signal.aborted).toBe(false); + + otherScopeCall?.settlement.resolve(readyResponse()); + await expect(otherScope.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("fails closed for stale, token-conflicting, and malformed response epochs", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const baseline = session.request(input("baseline")); + provider.calls[0]?.settlement.resolve(readyResponse(2)); + await expect(baseline.result).resolves.toMatchObject({ + status: "usable", + }); + + const stale = session.request(input("stale")); + provider.calls[1]?.settlement.resolve(readyResponse(1)); + await expect(stale.result).resolves.toEqual({ + status: "superseded", + }); + + const conflicting = session.request(input("conflicting")); + provider.calls[2]?.settlement.resolve({ + ...readyResponse(2), + epoch: epoch(2, "conflicting-token"), + }); + await expect(conflicting.result).resolves.toEqual({ + status: "superseded", + }); + + const malformed = session.request(input("malformed")); + provider.calls[3]?.settlement.resolve({ + ...readyResponse(3), + epoch: { generation: -1, token: "invalid" }, + }); + await expect(malformed.result).resolves.toEqual({ + reason: "malformed-response", + status: "unavailable", + }); + }); +}); + +describe("catalog search disposal and late settlement", () => { + it("settles active and queued owners before abort and drains late rejection", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const tickets = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK + 1 }, + (_, index) => owner(service).request(input(`key-${index}`)), + ); + const events: string[] = []; + let reentrantStatus: string | undefined; + provider.calls[0]?.signal.addEventListener("abort", () => { + events.push("abort"); + reentrantStatus = service.prepareOwner( + "reentrant", + POSTGRESQL_SQL_RELATION_DIALECT, + { + prepareCatalogChange: () => () => {}, + }, + ).status; + void tickets[0]?.result.then(() => { + events.push("settled-before-abort"); + }); + }); + + service.dispose(); + await expect(tickets.at(-1)?.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await Promise.all( + tickets.map((ticket) => + expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }), + ), + ); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + expect( + provider.calls.every((call) => call.signal.aborted), + ).toBe(true); + expect(reentrantStatus).toBe("unavailable"); + expect(scheduler.pendingCount).toBe(0); + + for (const call of provider.calls) { + call.settlement.reject(new Error("late rejection")); + } + await flushMicrotasks(); + expect(events).toContain("abort"); + }); + + it("propagates epoch cleanup quarantine across scopes into search disposal", async () => { + for (const cleanupFailure of [ + "non-undefined", + "throw", + ] as const) { + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: `catalog-${cleanupFailure}`, + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe(scope: string) { + if (scope !== "scope-a") { + return () => undefined; + } + if (cleanupFailure === "throw") { + return () => { + throw new Error("cleanup failed"); + }; + } + return () => 1; + }, + }), + ); + const service = coordinator(captured); + const scopeA = owner(service, "scope-a"); + const scopeB = owner(service, "scope-b"); + const active = scopeB.request(input("scope-b-active")); + expect(calls).toHaveLength(1); + + scopeA.dispose(); + await expect(active.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(calls[0]?.signal.aborted).toBe(true); + await expect( + scopeB.request(input("after-quarantine")).result, + ).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(calls).toHaveLength(1); + calls[0]?.settlement.reject( + new Error("late quarantined rejection"), + ); + await flushMicrotasks(); + } + }); +}); + +describe("catalog search defensive lifecycle coverage", () => { + it("returns disposed when the request clock disposes its coordinator", async () => { + let disposeOnNow = false; + let service: SqlCatalogSearchWorkCoordinator | undefined; + const provider = providerHarness(); + service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (disposeOnNow) service?.dispose(); + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + const session = owner(service); + disposeOnNow = true; + + await expect(session.request(input()).result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(provider.calls).toHaveLength(0); + }); + + it("contains disposal while installing a queued deadline", async () => { + let service: SqlCatalogSearchWorkCoordinator | undefined; + const provider = providerHarness(); + service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout(_callback, delayMs) { + if (delayMs === 100) service?.dispose(); + return 1; + }, + }, + executionDeadlineMs: 20, + queueDeadlineMs: 100, + }); + const tickets = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK + 1 }, + (_, index) => + owner(service).request(input(`work-${index}`)), + ); + + await Promise.all( + tickets.map((ticket) => + expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }), + ), + ); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + }); + + it("rejects throwing, non-finite, negative, and malformed scheduler configuration", () => { + const provider = providerHarness(); + const validMethods = { + clearTimeout() {}, + now: () => 0, + setTimeout() { + return 1; + }, + }; + const candidates: unknown[] = [ + { + get queueDeadlineMs() { + throw new Error("hostile options"); + }, + }, + { + deadlineScheduler: { + ...validMethods, + now() { + throw new Error("hostile clock"); + }, + }, + }, + { + deadlineScheduler: { + ...validMethods, + now: () => Number.NaN, + }, + }, + { + deadlineScheduler: { + ...validMethods, + now: () => -1, + }, + }, + { + deadlineScheduler: { + ...validMethods, + now: () => "now", + }, + }, + { + synchronousBudgetMs: 0, + }, + { + synchronousBudgetMs: 51, + }, + { + deadlineScheduler: { + clearTimeout: 1, + now: () => 0, + setTimeout() { + return 1; + }, + }, + }, + { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout: 1, + }, + }, + ]; + + for (const candidate of candidates) { + expect( + Reflect.apply( + createSqlCatalogSearchWorkCoordinator, + undefined, + [provider.captured, candidate], + ), + ).toEqual({ + reason: "invalid-options", + status: "unavailable", + }); + } + }); + + it("fails closed when a live monotonic clock throws, becomes non-finite, or moves backward", async () => { + const provider = providerHarness(); + const laterValues: Array<() => number> = [ + () => { + throw new Error("clock failed"); + }, + () => Number.NaN, + () => -1, + ]; + + for (const later of laterValues) { + let reads = 0; + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + reads += 1; + return reads === 1 ? 0 : later(); + }, + setTimeout() { + return 1; + }, + }, + }); + await expect( + owner(service).request(input(`clock-${reads}`)).result, + ).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + service.dispose(); + } + expect(provider.calls).toHaveLength(0); + }); + + it("contains throwing timer installation and cleanup", async () => { + const provider = providerHarness(); + const installationFailure = coordinator( + provider.captured, + { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout() { + throw new Error("timer install failed"); + }, + }, + }, + ); + await expect( + owner(installationFailure).request( + input("install-failure"), + ).result, + ).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + + const cleanupProvider = accepted( + captureSqlRelationCatalogProvider({ + id: "cleanup", + search() { + return readyResponse(); + }, + }), + ); + const cleanupFailure = coordinator(cleanupProvider, { + deadlineScheduler: { + clearTimeout() { + throw new Error("timer cleanup failed"); + }, + now: () => 0, + setTimeout() { + return 1; + }, + }, + }); + await expect( + owner(cleanupFailure).request(input("cleanup-failure")) + .result, + ).resolves.toMatchObject({ status: "usable" }); + expect(() => cleanupFailure.dispose()).not.toThrow(); + }); + + it("bounds a scheduler that fires every deadline synchronously without advancing time", async () => { + let timerCalls = 0; + let providerCalls = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout(callback) { + timerCalls += 1; + callback(); + return timerCalls; + }, + }, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(timerCalls).toBe(257); + expect(providerCalls).toBe(0); + }); + + it("rearms an early asynchronous deadline and ignores its obsolete generation", async () => { + const callbacks: Array<() => void> = []; + const scheduler: SqlCatalogSearchDeadlineScheduler = { + clearTimeout() {}, + now: () => 0, + setTimeout(callback) { + callbacks.push(callback); + if (callbacks.length === 1) callback(); + return callbacks.length; + }, + }; + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + }); + const ticket = owner(service).request(input()); + + expect(callbacks.length).toBeGreaterThanOrEqual(2); + expect(() => callbacks[0]?.()).not.toThrow(); + expect(() => callbacks[1]?.()).not.toThrow(); + ticket.cancel(); + await expect(ticket.result).resolves.toEqual({ + status: "cancelled", + }); + provider.calls[0]?.settlement.reject( + new Error("late rejection"), + ); + await flushMicrotasks(); + }); + + it("expires safely when execution scheduling fires synchronously before provider invocation", async () => { + let providerCalls = 0; + let timerCalls = 0; + let nowValue = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => nowValue, + setTimeout(callback, delayMs) { + timerCalls += 1; + if (delayMs === 250) { + nowValue = 250; + callback(); + } + return timerCalls; + }, + }, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(providerCalls).toBe(0); + expect(timerCalls).toBe(1); + }); + + it("stays inert when clearing the execution timer reentrantly disposes the coordinator", async () => { + let service: SqlCatalogSearchWorkCoordinator | undefined; + let providerCalls = 0; + let clearCalls = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() { + clearCalls += 1; + if (clearCalls === 1) service?.dispose(); + }, + now: () => 0, + setTimeout() { + return clearCalls + 1; + }, + }, + }); + const ticket = owner(service).request(input()); + + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + expect(providerCalls).toBe(1); + expect(clearCalls).toBeGreaterThan(0); + }); + + it("validates scope and dialect runtime without leaking malformed UTF-16 into memberships", () => { + const service = coordinator(providerHarness().captured); + const invalidTexts: unknown[] = [ + null, + "", + "a".repeat(513), + "nul\u0000value", + "\ud800", + "\ud800x", + "\udc00", + ]; + const target = { prepareCatalogChange: () => () => {} }; + + for (const scope of invalidTexts) { + expect( + Reflect.apply(service.prepareOwner, undefined, [ + scope, + POSTGRESQL_SQL_RELATION_DIALECT, + target, + ]), + ).toEqual({ + reason: "invalid-scope", + status: "unavailable", + }); + } + for (const dialect of invalidTexts) { + expect( + Reflect.apply(service.prepareOwner, undefined, [ + "scope", + dialect, + target, + ]), + ).toEqual({ + reason: "invalid-dialect", + status: "unavailable", + }); + } + const validAstral = service.prepareOwner( + "scope-\ud83d\ude80", + POSTGRESQL_SQL_RELATION_DIALECT, + target, + ); + expect(validAstral.status).toBe("prepared"); + if (validAstral.status === "prepared") { + validAstral.owner.dispose(); + validAstral.owner.dispose(); + } + service.dispose(); + service.dispose(); + }); + + it("maps hostile and capacity-rejected revision targets to closed owner failures", () => { + const service = coordinator(providerHarness().captured); + const throwingTarget = Object.defineProperty( + {}, + "prepareCatalogChange", + { + get() { + throw new Error("hostile target"); + }, + }, + ); + expect( + Reflect.apply(service.prepareOwner, undefined, [ + "scope", + POSTGRESQL_SQL_RELATION_DIALECT, + throwingTarget, + ]), + ).toEqual({ + reason: "invalid-target", + status: "unavailable", + }); + + const owners = Array.from({ length: 1_024 }, (_, index) => + service.prepareOwner( + `bounded-scope-${index}`, + POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ), + ); + expect(owners.every((result) => result.status === "prepared")) + .toBe(true); + expect( + service.prepareOwner( + "bounded-scope-overflow", + POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ), + ).toEqual({ + reason: "membership-capacity", + status: "unavailable", + }); + service.dispose(); + }); + + it("maps a throwing request getter to invalid-request after superseding current work", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const previous = session.request(input("previous")); + const hostile = Object.defineProperty( + {}, + "continuationToken", + { + enumerable: true, + get() { + throw new Error("hostile request"); + }, + }, + ); + + const rejected = Reflect.apply( + session.request, + undefined, + [hostile], + ); + await expect(rejected.result).resolves.toEqual({ + reason: "invalid-request", + status: "unavailable", + }); + await expect(previous.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls[0]?.signal.aborted).toBe(true); + }); + + it("lets a request getter reenter with a newer request without the older frame overwriting it", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + let nested: SqlCatalogSearchWorkTicket | undefined; + const outerInput = { + get continuationToken() { + nested ??= session.request(input("nested")); + return null; + }, + limit: 20, + prefix: component("outer"), + qualifier: [component("public")], + searchPaths: [[component("public")]], + }; + + const outer = session.request(outerInput); + await expect(outer.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0]?.request.prefix.value).toBe("nested"); + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(nested?.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("fails closed when request decoding reentrantly disposes the coordinator", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const hostile = Object.defineProperty( + input("disposing-request"), + "continuationToken", + { + enumerable: true, + get() { + service.dispose(); + return null; + }, + }, + ); + + await expect( + session.request(hostile).result, + ).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(provider.calls).toHaveLength(0); + }); + + it("revalidates active response work after the clock reenters with a replacement", async () => { + const provider = providerHarness(); + let onNow: (() => void) | undefined; + let reading = false; + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (!reading && onNow) { + reading = true; + const callback = onNow; + onNow = undefined; + callback(); + reading = false; + } + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + const session = owner(service); + const stale = session.request(input("clock-stale")); + let replacement: SqlCatalogSearchWorkTicket | undefined; + onNow = () => { + replacement = session.request(input("clock-current")); + }; + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(stale.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(2); + provider.calls[1]?.settlement.resolve(readyResponse()); + await expect(replacement?.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("stops provider-result handling when the clock reentrantly disposes the service", async () => { + const provider = providerHarness(); + let onNow: (() => void) | undefined; + let reading = false; + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (!reading && onNow) { + reading = true; + const callback = onNow; + onNow = undefined; + callback(); + reading = false; + } + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + const ticket = owner(service).request(input("clock-dispose")); + onNow = () => service.dispose(); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await flushMicrotasks(); + }); + + it("revalidates queued promotion after the clock reenters with newer work", async () => { + const provider = providerHarness(); + let onNow: (() => void) | undefined; + let reading = false; + let skippedNowCallbacks = 0; + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (!reading && onNow) { + if (skippedNowCallbacks > 0) { + skippedNowCallbacks -= 1; + return 0; + } + reading = true; + const callback = onNow; + onNow = undefined; + callback(); + reading = false; + } + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + const active = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => + owner(service).request(input(`active-${index}`)), + ); + const queuedOwner = owner(service); + const staleQueued = queuedOwner.request( + input("queued-stale"), + ); + let replacement: SqlCatalogSearchWorkTicket | undefined; + onNow = () => { + replacement = queuedOwner.request( + input("queued-current"), + ); + }; + skippedNowCallbacks = 1; + + provider.calls[0]?.settlement.reject( + new Error("release active slot"), + ); + await expect(staleQueued.result).resolves.toEqual({ + status: "superseded", + }); + await flushMicrotasks(); + expect( + provider.calls.some( + (call) => + call.request.prefix.value === "queued-current", + ), + ).toBe(true); + const replacementCall = provider.calls.find( + (call) => + call.request.prefix.value === "queued-current", + ); + replacementCall?.settlement.resolve(readyResponse()); + await expect(replacement?.result).resolves.toMatchObject({ + status: "usable", + }); + for (const ticket of active.slice(1)) ticket.cancel(); + service.dispose(); + }); + + it("does not let a clock-reentrant request overwrite the newer request", async () => { + const provider = providerHarness(); + let onNow: (() => void) | undefined; + let reading = false; + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (!reading && onNow) { + reading = true; + const callback = onNow; + onNow = undefined; + callback(); + reading = false; + } + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + const session = owner(service); + let current: SqlCatalogSearchWorkTicket | undefined; + onNow = () => { + current = session.request(input("clock-newer")); + }; + + const obsolete = session.request(input("clock-older")); + await expect(obsolete.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0]?.request.prefix.value).toBe( + "clock-newer", + ); + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(current?.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("ignores stale queue and execution callbacks after successful settlement", async () => { + const callbacks: Array<() => void> = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout(callback) { + callbacks.push(callback); + return callbacks.length; + }, + }, + }); + const ticket = owner(service).request(input("stale-timers")); + await expect(ticket.result).resolves.toMatchObject({ + status: "usable", + }); + + expect(callbacks).toHaveLength(1); + expect(() => { + callbacks[0]?.(); + callbacks[0]?.(); + }).not.toThrow(); + }); + + it("fails closed when the execution deadline cannot advance a large monotonic clock", async () => { + const base = 2 ** 57; + let reads = 0; + let providerCalls = 0; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + providerCalls += 1; + return readyResponse(); + }, + }), + ); + const service = coordinator(captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + reads += 1; + return reads < 4 ? base : base + 1_952; + }, + setTimeout() { + return 1; + }, + }, + executionDeadlineMs: 10, + queueDeadlineMs: 2_000, + }); + const ticket = owner(service).request(input("large-clock")); + + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(providerCalls).toBe(0); + }); + + it("fails closed when a promoted queued search cannot allocate an execution deadline", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 10, + queueDeadlineMs: 2_000, + }); + Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + scheduler.nowValue = 2 ** 57 - 1_024; + const queued = owner(service).request(input("large-promoted")); + scheduler.nowValue = 2 ** 57; + provider.calls[0]?.settlement.reject( + new Error("release active slot"), + ); + + await expect(queued.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect( + provider.calls.some( + (call) => + call.request.prefix.value === "large-promoted", + ), + ).toBe(false); + service.dispose(); + }); + + it("clears a queued deadline when execution expiry fires synchronously during promotion", async () => { + let nowValue = 0; + let fireExecution = false; + const callbacks = new Map void>(); + let nextHandle = 0; + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout(handle) { + if (typeof handle === "number") { + callbacks.delete(handle); + } + }, + now: () => nowValue, + setTimeout(callback, delayMs) { + nextHandle += 1; + callbacks.set(nextHandle, callback); + if (fireExecution && delayMs === 20) { + nowValue += 20; + callback(); + } + return nextHandle; + }, + }, + executionDeadlineMs: 20, + queueDeadlineMs: 100, + }); + Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + const queued = owner(service).request( + input("synchronous-promotion-expiry"), + ); + fireExecution = true; + provider.calls[0]?.settlement.reject( + new Error("release active slot"), + ); + + await expect(queued.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect( + provider.calls.some( + (call) => + call.request.prefix.value === + "synchronous-promotion-expiry", + ), + ).toBe(false); + service.dispose(); + }); + + it("contains disposal reentrancy while a promoted search clears its queue deadline", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout(handle) { + scheduler.clearTimeout(handle); + if (skipClears > 0) { + skipClears -= 1; + } else { + service.dispose(); + } + }, + now: scheduler.now, + setTimeout: scheduler.setTimeout, + }, + executionDeadlineMs: 20, + queueDeadlineMs: 100, + }); + let skipClears = Number.MAX_SAFE_INTEGER; + Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => owner(service).request(input(`active-${index}`)), + ); + const queued = owner(service).request( + input("dispose-during-promotion"), + ); + skipClears = 1; + provider.calls[0]?.settlement.reject( + new Error("release active slot"), + ); + + await expect(queued.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect( + provider.calls.some( + (call) => + call.request.prefix.value === + "dispose-during-promotion", + ), + ).toBe(false); + }); + + it("cancels reentrantly during response decoding and ignores the now-obsolete result", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const ticket = session.request(input("decode-cancel")); + const response = new Proxy(readyResponse(), { + ownKeys(target) { + ticket.cancel(); + return Reflect.ownKeys(target); + }, + }); + + provider.calls[0]?.settlement.resolve(response); + await expect(ticket.result).resolves.toEqual({ + status: "cancelled", + }); + await flushMicrotasks(); + expect(provider.calls[0]?.signal.aborted).toBe(false); + }); + + it("keeps a response that reenters with a replacement from publishing stale epoch evidence", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const stale = session.request(input("stale")); + let replacement: SqlCatalogSearchWorkTicket | undefined; + const response = new Proxy(readyResponse(), { + ownKeys(target) { + replacement ??= session.request(input("replacement")); + return Reflect.ownKeys(target); + }, + }); + + provider.calls[0]?.settlement.resolve(response); + await expect(stale.result).resolves.toEqual({ + status: "superseded", + }); + expect(replacement).toBeDefined(); + expect(provider.calls).toHaveLength(2); + provider.calls[1]?.settlement.resolve(readyResponse()); + await expect(replacement?.result).resolves.toMatchObject({ + observation: "baseline", + status: "usable", + }); + }); + + it("does not conflate paths with different component counts", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const tickets = [ + owner(service).request( + input("path-length", { + qualifier: [component("public")], + }), + ), + owner(service).request( + input("path-length", { + qualifier: [ + component("catalog"), + component("public"), + ], + }), + ), + owner(service).request( + input("path-length", { + searchPaths: [[component("public")]], + }), + ), + owner(service).request( + input("path-length", { + searchPaths: [ + [ + component("catalog"), + component("public"), + ], + ], + }), + ), + ]; + + expect(provider.calls).toHaveLength(3); + service.dispose(); + await Promise.all( + tickets.map((ticket) => + expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }), + ), + ); + }); + + it("applies the absolute execution deadline when response decoding advances the clock", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + queueDeadlineMs: 10, + synchronousBudgetMs: 5, + }); + const ticket = owner(service).request(input("decode-timeout")); + const response = new Proxy(readyResponse(), { + ownKeys(target) { + scheduler.nowValue = 20; + return Reflect.ownKeys(target); + }, + }); + + provider.calls[0]?.settlement.resolve(response); + await expect(ticket.result).resolves.toEqual({ + reason: "execution-timeout", + status: "unavailable", + }); + expect(provider.calls[0]?.signal.aborted).toBe(true); + }); + + it("does not publish a decoded response that disposes the coordinator through a getter", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const ticket = owner(service).request(input("decode-dispose")); + const response = new Proxy(readyResponse(), { + ownKeys(target) { + service.dispose(); + return Reflect.ownKeys(target); + }, + }); + + provider.calls[0]?.settlement.resolve(response); + await expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await flushMicrotasks(); + }); + + it("drains provider results returned after synchronous service disposal", async () => { + for (const kind of ["pending", "rejected"] as const) { + let service: + | SqlCatalogSearchWorkCoordinator + | undefined; + const pending = deferred(); + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: `dispose-${kind}`, + search() { + service?.dispose(); + if (kind === "rejected") { + return Promise.reject( + new Error("rejected after disposal"), + ); + } + return pending.promise; + }, + }), + ); + service = coordinator(captured); + const ticket = owner(service).request(input(kind)); + + await expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + if (kind === "pending") { + pending.reject(new Error("late pending rejection")); + } + await flushMicrotasks(); + } + }); + + it("accepts an epoch transition with no search work and keeps disposal idempotent", () => { + let invalidation: ((event: unknown) => void) | undefined; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + return readyResponse(); + }, + subscribe( + _scope: string, + listener: (event: unknown) => void, + ) { + invalidation = listener; + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + const session = owner(service, "idle-scope"); + + expect(() => invalidation?.({ epoch: epoch() })).not.toThrow(); + const immediate = service.prepareOwner( + "inactive", + POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ); + expect(immediate.status).toBe("prepared"); + if (immediate.status === "prepared") { + const ticket = immediate.owner.request(input()); + expect(() => { + ticket.cancel(); + ticket.cancel(); + }).not.toThrow(); + } + session.dispose(); + session.dispose(); + service.dispose(); + service.dispose(); + expect( + service.prepareOwner( + "late", + POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); +}); + +describe("catalog search epoch dependency failures", () => { + it("maps every closed epoch decision and rotates retired captures", async () => { + type Decision = + | "disposed" + | "malformed" + | "overloaded" + | "retired-exhausted" + | "retired-then-usable" + | "superseded"; + let decision: Decision = "disposed"; + let captureFailure = false; + let captureHook: (() => void) | undefined; + let capturedExpectedEpoch: ReturnType | null = + null; + let disposeCandidate: (() => void) | undefined; + let factoryFailure = false; + let membershipFailure = false; + let searchCalls = 0; + let submissions = 0; + vi.resetModules(); + vi.doMock( + "../relation-catalog-epoch-coordinator.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../relation-catalog-epoch-coordinator.js") + >(); + return { + ...actual, + createSqlCatalogEpochCoordinator() { + if (factoryFailure) { + return { + reason: "invalid-provider", + status: "unavailable", + }; + } + return { + coordinator: { + dispose() {}, + prepareScopeMembership() { + if (membershipFailure) { + return { + reason: "disposed", + status: "unavailable", + }; + } + return { + membership: { + activate() { + return { status: "active" }; + }, + captureEpoch() { + const hook = captureHook; + captureHook = undefined; + hook?.(); + if (captureFailure) { + return { + reason: "disposed", + status: "unavailable", + }; + } + return { + capture: { + expectedEpoch: capturedExpectedEpoch, + }, + status: "captured", + }; + }, + dispose() {}, + }, + status: "prepared", + }; + }, + providerId: "catalog", + submitResponseEpoch( + _capture: unknown, + responseEpoch: ReturnType, + onDecision: (value: unknown) => void, + ) { + submissions += 1; + if ( + (decision === "retired-then-usable" || + decision === "retired-exhausted") && + submissions === 1 + ) { + disposeCandidate?.(); + return { + decision: { + reason: "retired", + status: "discarded", + }, + status: "settled", + }; + } + if (decision === "superseded") { + onDecision({ + epoch: responseEpoch, + status: "superseded", + }); + } else if ( + decision === "retired-then-usable" + ) { + onDecision({ + epoch: responseEpoch, + observation: "baseline", + status: "usable", + }); + } else { + onDecision({ + reason: decision, + status: "discarded", + }); + } + return { status: "submitted" }; + }, + }, + status: "created", + }; + }, + }; + }, + ); + const isolated = await import( + "../relation-catalog-search-work.js" + ); + const isolatedBoundary = await import( + "../relation-catalog-boundary.js" + ); + const isolatedDialect = await import( + "../relation-dialect.js" + ); + const captured = accepted( + isolatedBoundary.captureSqlRelationCatalogProvider({ + id: "catalog", + search() { + searchCalls += 1; + return readyResponse(); + }, + }), + ); + + const expected = new Map< + Exclude, + SqlCatalogSearchWorkOutcome + >([ + [ + "disposed", + { reason: "disposed", status: "unavailable" }, + ], + [ + "malformed", + { + reason: "malformed-response", + status: "unavailable", + }, + ], + [ + "overloaded", + { reason: "overloaded", status: "unavailable" }, + ], + ["superseded", { status: "superseded" }], + ]); + for (const [nextDecision, outcome] of expected) { + decision = nextDecision; + submissions = 0; + const created = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(created.status).toBe("created"); + if (created.status !== "created") continue; + const session = owner( + created.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + await expect( + session.request(input(nextDecision)).result, + ).resolves.toEqual(outcome); + created.coordinator.dispose(); + } + + decision = "retired-then-usable"; + submissions = 0; + const created = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(created.status).toBe("created"); + if (created.status === "created") { + const first = owner( + created.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + const second = owner( + created.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + const firstTicket = first.request(input("rotation")); + const secondTicket = second.request(input("rotation")); + await expect(firstTicket.result).resolves.toMatchObject({ + status: "usable", + }); + await expect(secondTicket.result).resolves.toMatchObject({ + status: "usable", + }); + expect(submissions).toBe(2); + created.coordinator.dispose(); + } + + decision = "retired-exhausted"; + submissions = 0; + const exhausted = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(exhausted.status).toBe("created"); + if (exhausted.status === "created") { + const first = owner( + exhausted.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + const second = owner( + exhausted.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + const firstTicket = first.request(input("exhausted")); + const secondTicket = second.request(input("exhausted")); + disposeCandidate = () => second.dispose(); + await expect(secondTicket.result).resolves.toEqual({ + status: "cancelled", + }); + await expect(firstTicket.result).resolves.toEqual({ + status: "superseded", + }); + expect(submissions).toBe(1); + exhausted.coordinator.dispose(); + } + + decision = "disposed"; + capturedExpectedEpoch = null; + searchCalls = 0; + const epochMismatch = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(epochMismatch.status).toBe("created"); + if (epochMismatch.status === "created") { + const first = owner( + epochMismatch.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ).request(input("epoch-key")); + capturedExpectedEpoch = epoch(7); + const second = owner( + epochMismatch.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ).request(input("epoch-key")); + expect(searchCalls).toBe(2); + epochMismatch.coordinator.dispose(); + await expect(first.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await expect(second.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + } + capturedExpectedEpoch = null; + + decision = "retired-then-usable"; + captureFailure = true; + const captureRejected = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(captureRejected.status).toBe("created"); + if (captureRejected.status === "created") { + const session = owner( + captureRejected.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + await expect( + session.request(input("capture-disposed")).result, + ).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + captureRejected.coordinator.dispose(); + } + captureFailure = false; + + const captureReentrant = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(captureReentrant.status).toBe("created"); + if (captureReentrant.status === "created") { + const session = owner( + captureReentrant.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + let current: SqlCatalogSearchWorkTicket | undefined; + captureHook = () => { + current = session.request(input("capture-current")); + }; + const obsolete = session.request(input("capture-obsolete")); + await expect(obsolete.result).resolves.toEqual({ + status: "superseded", + }); + await expect(current?.result).resolves.toMatchObject({ + status: "usable", + }); + captureReentrant.coordinator.dispose(); + } + + const captureDisposal = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(captureDisposal.status).toBe("created"); + if (captureDisposal.status === "created") { + const session = owner( + captureDisposal.coordinator, + "connection:primary", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + ); + captureHook = () => { + captureDisposal.coordinator.dispose(); + }; + await expect( + session.request(input("capture-disposal")).result, + ).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + } + + membershipFailure = true; + const membershipRejected = + isolated.createSqlCatalogSearchWorkCoordinator(captured); + expect(membershipRejected.status).toBe("created"); + if (membershipRejected.status === "created") { + expect( + membershipRejected.coordinator.prepareOwner( + "scope", + isolatedDialect.POSTGRESQL_SQL_RELATION_DIALECT, + { prepareCatalogChange: () => () => {} }, + ), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + membershipRejected.coordinator.dispose(); + } + membershipFailure = false; + + factoryFailure = true; + expect( + isolated.createSqlCatalogSearchWorkCoordinator(captured), + ).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + vi.doUnmock("../relation-catalog-epoch-coordinator.js"); + vi.resetModules(); + }); +}); diff --git a/src/vnext/__tests__/relation-dialect.test.ts b/src/vnext/__tests__/relation-dialect.test.ts index 38a4c9f..667b416 100644 --- a/src/vnext/__tests__/relation-dialect.test.ts +++ b/src/vnext/__tests__/relation-dialect.test.ts @@ -110,8 +110,9 @@ describe("built-in relation dialect runtime", () => { }); it("owns stable, deeply frozen, coherent views", () => { - for (const runtime of Object.values(RUNTIMES)) { + for (const [id, runtime] of Object.entries(RUNTIMES)) { expectDeepFrozenRuntime(runtime); + expect(runtime.id).toBe(id); expect(runtime.cteLayout.lexicalProfile).toBe( runtime.querySite.lexicalProfile, ); diff --git a/src/vnext/relation-catalog-boundary.ts b/src/vnext/relation-catalog-boundary.ts index 274f5a8..8149513 100644 --- a/src/vnext/relation-catalog-boundary.ts +++ b/src/vnext/relation-catalog-boundary.ts @@ -341,6 +341,18 @@ function isWellFormed(value: string): boolean { return true; } +export function isValidSqlCatalogScope( + candidate: unknown, +): candidate is string { + return ( + typeof candidate === "string" && + candidate.length > 0 && + candidate.length <= MAX_CATALOG_SCOPE_LENGTH && + !candidate.includes("\0") && + isWellFormed(candidate) + ); +} + function readRecord( state: DecodeState, value: unknown, diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/vnext/relation-catalog-epoch-coordinator.ts index 3a3a1fe..a82018e 100644 --- a/src/vnext/relation-catalog-epoch-coordinator.ts +++ b/src/vnext/relation-catalog-epoch-coordinator.ts @@ -1,7 +1,7 @@ import { - MAX_CATALOG_SCOPE_LENGTH, compareSqlCatalogEpoch, decodeSqlCatalogInvalidation, + isValidSqlCatalogScope, resolveSqlRelationCatalogProvider, } from "./relation-catalog-boundary.js"; import type { SqlCapturedRelationCatalogProviderContext } from "./relation-catalog-boundary.js"; @@ -143,6 +143,7 @@ export type SqlCatalogEpochCoordinatorResult = | { readonly status: "unavailable"; readonly reason: + | "invalid-disposal-target" | "invalid-provider" | "invalid-transition-target"; }; @@ -157,6 +158,7 @@ interface CoordinatorState { readonly commands: EpochCommand[]; readonly deferredCleanup: Set; readonly memberships: Set; + onDispose: ((this: void) => undefined) | null; prepareEpochTransition: Function | null; readonly providerId: string; readonly scopes: Map; @@ -238,6 +240,8 @@ const SUBMITTED_RESULT: SqlCatalogResponseEpochSubmissionResult = Object.freeze({ status: "submitted" }); const NO_PREPARE_CATALOG_CHANGE = (): null => null; const IGNORE_DETACHED_REJECTION = (): void => {}; +const INTRINSIC_PROMISE = Promise; +const INTRINSIC_PROMISE_RESOLVE = Promise.resolve; const INTRINSIC_PROMISE_THEN = Promise.prototype.then; const FAILED_EPOCH_TRANSITION: unique symbol = Symbol( "FailedSqlCatalogEpochTransition", @@ -287,29 +291,6 @@ function settleDecision( } } -function isValidScope(candidate: unknown): candidate is string { - if ( - typeof candidate !== "string" || - candidate.length < 1 || - candidate.length > MAX_CATALOG_SCOPE_LENGTH - ) { - return false; - } - for (let index = 0; index < candidate.length; index += 1) { - const code = candidate.charCodeAt(index); - if (code === 0) return false; - if (code >= 0xd800 && code <= 0xdbff) { - if (index + 1 >= candidate.length) return false; - const next = candidate.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return false; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return false; - } - } - return true; -} - function snapshotAudience( entry: ScopeEntry, ): readonly MembershipState[] { @@ -390,21 +371,18 @@ function drainDetachedSettlement(result: unknown): void { return; } try { - Reflect.apply(INTRINSIC_PROMISE_THEN, result, [ + const settlement = Reflect.apply( + INTRINSIC_PROMISE_RESOLVE, + INTRINSIC_PROMISE, + [result], + ); + Reflect.apply(INTRINSIC_PROMISE_THEN, settlement, [ undefined, IGNORE_DETACHED_REJECTION, ]); - return; } catch { - // Non-native thenables are assimilated through a fresh wrapper. + // The detached value is hostile and cannot be observed safely. } - const settlement = new Promise((resolve) => { - resolve(result); - }); - Reflect.apply(INTRINSIC_PROMISE_THEN, settlement, [ - undefined, - IGNORE_DETACHED_REJECTION, - ]); } function cleanupSubscription( @@ -1148,7 +1126,7 @@ function prepareMembership( status: "unavailable", }); } - if (!isValidScope(scope)) { + if (!isValidSqlCatalogScope(scope)) { return Object.freeze({ reason: "invalid-scope", status: "unavailable", @@ -1248,6 +1226,8 @@ function submitResponse( function disposeCoordinator(state: CoordinatorState): void { if (state.disposed) return; state.disposed = true; + const onDispose = state.onDispose; + state.onDispose = null; state.prepareEpochTransition = null; state.subscribe = null; const subscriptions: SubscriptionState[] = []; @@ -1271,6 +1251,16 @@ function disposeCoordinator(state: CoordinatorState): void { for (const subscription of subscriptions) { retireCallbackCell(subscription.cell); } + if (onDispose) { + try { + const result = Reflect.apply(onDispose, undefined, []); + if (result !== undefined) { + drainDetachedSettlement(result); + } + } catch { + // Disposal remains authoritative if its package owner fails. + } + } for (const subscription of subscriptions) { scheduleSubscriptionCleanup(state, subscription); } @@ -1305,6 +1295,7 @@ function createCoordinatorHandle( export function createSqlCatalogEpochCoordinator( capturedProvider: unknown, prepareEpochTransition?: SqlCatalogEpochTransitionTarget, + onDispose?: (this: void) => undefined, ): SqlCatalogEpochCoordinatorResult { const provider = resolveSqlRelationCatalogProvider( capturedProvider, @@ -1324,6 +1315,15 @@ export function createSqlCatalogEpochCoordinator( status: "unavailable", }); } + if ( + onDispose !== undefined && + typeof onDispose !== "function" + ) { + return Object.freeze({ + reason: "invalid-disposal-target", + status: "unavailable", + }); + } const providerId = provider.id; const subscribe = provider.subscribe; const state: CoordinatorState = { @@ -1336,6 +1336,7 @@ export function createSqlCatalogEpochCoordinator( disposed: false, draining: false, memberships: new Set(), + onDispose: onDispose ?? null, prepareEpochTransition: prepareEpochTransition ?? null, providerId, diff --git a/src/vnext/relation-catalog-search-work.ts b/src/vnext/relation-catalog-search-work.ts new file mode 100644 index 0000000..6fc35ee --- /dev/null +++ b/src/vnext/relation-catalog-search-work.ts @@ -0,0 +1,1866 @@ +import { + createSqlCatalogSearchRequest, + decodeSqlCatalogSearchResponse, + isValidSqlCatalogScope, + resolveSqlRelationCatalogProvider, +} from "./relation-catalog-boundary.js"; +import type { + CapturedSqlRelationCatalogProvider, + SqlValidatedCatalogSearchResponse, +} from "./relation-catalog-boundary.js"; +import { + createSqlCatalogEpochCoordinator, +} from "./relation-catalog-epoch-coordinator.js"; +import type { + SqlCatalogEpochCapture, + SqlCatalogEpochCoordinator, + SqlCatalogResponseEpochDecision, + SqlCatalogRevisionTarget, + SqlCatalogScopeMembership, +} from "./relation-catalog-epoch-coordinator.js"; +import type { + SqlCatalogSearchRequest, +} from "./relation-completion-types.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { + isSqlRelationDialectRuntime, +} from "./relation-runtime-auth.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export const MAX_CATALOG_ACTIVE_SEARCH_WORK = 8; +export const MAX_CATALOG_QUEUED_SEARCH_WORK = 64; +export const DEFAULT_CATALOG_QUEUE_DEADLINE_MS = 100; +export const DEFAULT_CATALOG_EXECUTION_DEADLINE_MS = 250; +export const DEFAULT_CATALOG_SYNCHRONOUS_BUDGET_MS = 8; +export const MIN_CATALOG_QUEUE_DEADLINE_MS = 10; +export const MAX_CATALOG_QUEUE_DEADLINE_MS = 2_000; +export const MIN_CATALOG_EXECUTION_DEADLINE_MS = 10; +export const MAX_CATALOG_EXECUTION_DEADLINE_MS = 5_000; +export const MIN_CATALOG_SYNCHRONOUS_BUDGET_MS = 1; +export const MAX_CATALOG_SYNCHRONOUS_BUDGET_MS = 50; + +export interface SqlCatalogSearchDeadlineScheduler { + readonly clearTimeout: ( + this: void, + handle: unknown, + ) => void; + readonly now: (this: void) => number; + readonly setTimeout: ( + this: void, + callback: (this: void) => void, + delayMs: number, + ) => unknown; +} + +export interface SqlCatalogSearchWorkOptions { + readonly deadlineScheduler?: SqlCatalogSearchDeadlineScheduler; + readonly executionDeadlineMs?: number; + readonly queueDeadlineMs?: number; + readonly synchronousBudgetMs?: number; +} + +export interface SqlCatalogSearchWorkInput { + readonly continuationToken: string | null; + readonly limit: number; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +export type SqlCatalogSearchWorkUnavailableReason = + | "disposed" + | "execution-timeout" + | "inactive" + | "invalid-request" + | "malformed-response" + | "overloaded" + | "provider-failed" + | "queue-timeout"; + +export type SqlCatalogSearchWorkOutcome = + | { + readonly status: "usable"; + readonly observation: "baseline" | "equal"; + readonly response: SqlValidatedCatalogSearchResponse; + } + | { + readonly status: "superseded"; + } + | { + readonly status: "cancelled"; + } + | { + readonly status: "unavailable"; + readonly reason: SqlCatalogSearchWorkUnavailableReason; + }; + +export interface SqlCatalogSearchWorkTicket { + readonly cancel: (this: void) => void; + readonly result: Promise; +} + +export interface SqlCatalogSearchWorkOwner { + readonly activate: SqlCatalogScopeMembership["activate"]; + readonly request: ( + this: void, + input: SqlCatalogSearchWorkInput, + ) => SqlCatalogSearchWorkTicket; + readonly dispose: (this: void) => void; +} + +export type SqlCatalogSearchWorkOwnerResult = + | { + readonly status: "prepared"; + readonly owner: SqlCatalogSearchWorkOwner; + } + | { + readonly status: "unavailable"; + readonly reason: + | "disposed" + | "invalid-dialect" + | "invalid-scope" + | "invalid-target" + | "membership-capacity"; + }; + +export interface SqlCatalogSearchWorkCoordinator { + readonly prepareOwner: ( + this: void, + scope: unknown, + dialect: SqlRelationDialectRuntime, + target: SqlCatalogRevisionTarget, + ) => SqlCatalogSearchWorkOwnerResult; + readonly providerId: string; + readonly dispose: (this: void) => void; +} + +export type SqlCatalogSearchWorkCoordinatorResult = + | { + readonly status: "created"; + readonly coordinator: SqlCatalogSearchWorkCoordinator; + } + | { + readonly status: "unavailable"; + readonly reason: "invalid-options" | "invalid-provider"; + }; + +interface NormalizedOptions { + readonly deadlineScheduler: SqlCatalogSearchDeadlineScheduler; + readonly executionDeadlineMs: number; + readonly queueDeadlineMs: number; + readonly synchronousBudgetMs: number; +} + +interface OwnerState { + current: ConsumerState | null; + readonly dialect: SqlRelationDialectRuntime; + disposed: boolean; + readonly membership: SqlCatalogScopeMembership; + owner: CoordinatorState | null; + requestToken: object | null; + readonly scope: string; +} + +interface ConsumerState { + readonly capture: SqlCatalogEpochCapture; + cancelled: boolean; + owner: OwnerState | null; + resolve: (outcome: SqlCatalogSearchWorkOutcome) => void; + settled: boolean; + work: WorkState | null; +} + +interface WorkState { + abortController: AbortController | null; + abortIssued: boolean; + dialect: SqlRelationDialectRuntime | null; + decisionCell: ResponseDecisionCell | null; + executionDeadline: number | null; + executionTimer: DeadlineCell | null; + joinable: boolean; + readonly owners: Set; + phase: + | "active" + | "deciding" + | "queued" + | "retired" + | "terminal"; + providerCell: ProviderResultCell | null; + queueDeadline: number; + queueTimer: DeadlineCell | null; + request: SqlCatalogSearchRequest | null; + scope: string; +} + +interface DeadlineCell { + active: boolean; + generation: number; + handle: unknown; + tick: ((generation: number) => void) | null; +} + +interface ProviderResultCell { + active: boolean; + deliver: + | (( + fulfilled: boolean, + value: unknown, + ) => void) + | null; +} + +interface CoordinatorState { + activeCount: number; + disposed: boolean; + readonly epochs: SqlCatalogEpochCoordinator; + readonly joinable: Set; + lastNow: number; + readonly options: NormalizedOptions; + readonly owners: Set; + pumpRequested: boolean; + pumping: boolean; + readonly queue: WorkState[]; + search: + | (( + this: void, + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) => unknown) + | null; + readonly works: Set; +} + +interface ResponseDecisionCell { + active: boolean; + advancing: boolean; + candidates: readonly ConsumerState[]; + index: number; + pending: SqlCatalogResponseEpochDecision | null; + response: SqlValidatedCatalogSearchResponse | null; + state: CoordinatorState | null; + work: WorkState | null; +} + +interface DetachedSettlement { + readonly outcome: SqlCatalogSearchWorkOutcome; + readonly resolve: ( + outcome: SqlCatalogSearchWorkOutcome, + ) => void; +} + +interface Effects { + readonly aborts: AbortController[]; + pump: boolean; + readonly settlements: DetachedSettlement[]; + readonly timers: DeadlineCell[]; +} + +const HOST_CLEAR_TIMEOUT = globalThis.clearTimeout; +const HOST_SET_TIMEOUT = globalThis.setTimeout; +const HOST_PERFORMANCE = globalThis.performance; +const HOST_NOW = HOST_PERFORMANCE.now; +const INTRINSIC_PROMISE = Promise; +const INTRINSIC_PROMISE_RESOLVE = Promise.resolve; +const INTRINSIC_PROMISE_THEN = Promise.prototype.then; +const IGNORE_DETACHED_REJECTION = (): void => {}; +const MAX_SYNCHRONOUS_DEADLINE_REARMS = 256; + +const DEFAULT_DEADLINE_SCHEDULER: SqlCatalogSearchDeadlineScheduler = + Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(HOST_CLEAR_TIMEOUT, globalThis, [ + handle, + ]); + }, + now(): number { + return Reflect.apply(HOST_NOW, HOST_PERFORMANCE, []); + }, + setTimeout( + callback: (this: void) => void, + delayMs: number, + ): unknown { + return Reflect.apply(HOST_SET_TIMEOUT, globalThis, [ + callback, + delayMs, + ]); + }, + }); + +const CANCELLED_OUTCOME: SqlCatalogSearchWorkOutcome = + Object.freeze({ status: "cancelled" }); +const SUPERSEDED_OUTCOME: SqlCatalogSearchWorkOutcome = + Object.freeze({ status: "superseded" }); + +function unavailableOutcome( + reason: SqlCatalogSearchWorkUnavailableReason, +): SqlCatalogSearchWorkOutcome { + return Object.freeze({ reason, status: "unavailable" }); +} + +function effects(): Effects { + return { + aborts: [], + pump: false, + settlements: [], + timers: [], + }; +} + +function isDuration( + value: unknown, + minimum: number, + maximum: number, +): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value >= minimum && + value <= maximum + ); +} + +function normalizeOptions( + candidate: SqlCatalogSearchWorkOptions | undefined, +): { + readonly initialNow: number; + readonly options: NormalizedOptions; +} | null { + try { + const queueDeadlineMs = + candidate?.queueDeadlineMs ?? + DEFAULT_CATALOG_QUEUE_DEADLINE_MS; + const executionDeadlineMs = + candidate?.executionDeadlineMs ?? + DEFAULT_CATALOG_EXECUTION_DEADLINE_MS; + const synchronousBudgetMs = + candidate?.synchronousBudgetMs ?? + DEFAULT_CATALOG_SYNCHRONOUS_BUDGET_MS; + const deadlineScheduler = + candidate?.deadlineScheduler ?? + DEFAULT_DEADLINE_SCHEDULER; + const clearTimeoutMethod = deadlineScheduler.clearTimeout; + const nowMethod = deadlineScheduler.now; + const setTimeoutMethod = deadlineScheduler.setTimeout; + if ( + !isDuration( + queueDeadlineMs, + MIN_CATALOG_QUEUE_DEADLINE_MS, + MAX_CATALOG_QUEUE_DEADLINE_MS, + ) || + !isDuration( + executionDeadlineMs, + MIN_CATALOG_EXECUTION_DEADLINE_MS, + MAX_CATALOG_EXECUTION_DEADLINE_MS, + ) || + !isDuration( + synchronousBudgetMs, + MIN_CATALOG_SYNCHRONOUS_BUDGET_MS, + MAX_CATALOG_SYNCHRONOUS_BUDGET_MS, + ) || + typeof nowMethod !== "function" || + typeof setTimeoutMethod !== "function" || + typeof clearTimeoutMethod !== "function" + ) { + return null; + } + const initialNow = Reflect.apply( + nowMethod, + undefined, + [], + ); + if ( + typeof initialNow !== "number" || + !Number.isFinite(initialNow) || + initialNow < 0 + ) { + return null; + } + const capturedScheduler: SqlCatalogSearchDeadlineScheduler = + Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(clearTimeoutMethod, undefined, [handle]); + }, + now(): number { + return Reflect.apply(nowMethod, undefined, []); + }, + setTimeout( + callback: (this: void) => void, + delayMs: number, + ): unknown { + return Reflect.apply(setTimeoutMethod, undefined, [ + callback, + delayMs, + ]); + }, + }); + return { + initialNow, + options: Object.freeze({ + deadlineScheduler: capturedScheduler, + executionDeadlineMs, + queueDeadlineMs, + synchronousBudgetMs, + }), + }; + } catch { + return null; + } +} + +function readNow(state: CoordinatorState): number | null { + let value: unknown; + try { + value = Reflect.apply( + state.options.deadlineScheduler.now, + undefined, + [], + ); + } catch { + return null; + } + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < state.lastNow + ) { + return null; + } + state.lastNow = value; + return value; +} + +function deadlineFrom( + now: number, + duration: number, +): number | null { + const deadline = now + duration; + return Number.isFinite(deadline) && deadline > now + ? deadline + : null; +} + +function clearDeadline( + state: CoordinatorState, + cell: DeadlineCell, +): void { + if (!cell.active) return; + cell.active = false; + cell.generation += 1; + cell.tick = null; + try { + Reflect.apply( + state.options.deadlineScheduler.clearTimeout, + undefined, + [cell.handle], + ); + } catch { + // Deadline cleanup cannot reopen retired work. + } +} + +function scheduleDeadline( + state: CoordinatorState, + deadline: number, + expire: () => void, +): DeadlineCell { + const cell: DeadlineCell = { + active: true, + generation: 0, + handle: undefined, + tick: null, + }; + let arming = false; + let synchronousRearms = 0; + const expireNow = (): void => { + if (!cell.active) return; + cell.active = false; + cell.generation += 1; + cell.tick = null; + expire(); + }; + const arm = (): void => { + arming = true; + try { + for (;;) { + const now = readNow(state); + if (now === null || now >= deadline) { + expireNow(); + return; + } + const generation = cell.generation + 1; + cell.generation = generation; + const token = { fired: false }; + const callback = (): void => { + token.fired = true; + cell.tick?.(generation); + }; + let handle: unknown; + try { + handle = Reflect.apply( + state.options.deadlineScheduler.setTimeout, + undefined, + [callback, deadline - now], + ); + } catch { + expireNow(); + return; + } + if (!token.fired) { + cell.handle = handle; + return; + } + try { + Reflect.apply( + state.options.deadlineScheduler.clearTimeout, + undefined, + [handle], + ); + } catch { + // The synchronously fired generation is already obsolete. + } + if (!cell.active) return; + synchronousRearms += 1; + if ( + synchronousRearms > + MAX_SYNCHRONOUS_DEADLINE_REARMS + ) { + expireNow(); + return; + } + } + } finally { + arming = false; + } + }; + cell.tick = (generation): void => { + if ( + !cell.active || + generation !== cell.generation + ) { + return; + } + const now = readNow(state); + if (now === null || now >= deadline) { + expireNow(); + return; + } + if (!arming) arm(); + }; + arm(); + return cell; +} + +function settleDetached( + list: DetachedSettlement[], + consumer: ConsumerState, + outcome: SqlCatalogSearchWorkOutcome, +): void { + consumer.settled = true; + const resolve = consumer.resolve; + consumer.resolve = IGNORE_DETACHED_REJECTION; + consumer.work = null; + const owner = consumer.owner; + consumer.owner = null; + if (owner?.current === consumer) owner.current = null; + list.push({ outcome, resolve }); +} + +function runEffects( + state: CoordinatorState, + pending: Effects, +): void { + for (const timer of pending.timers) { + clearDeadline(state, timer); + } + for (const settlement of pending.settlements) { + settlement.resolve(settlement.outcome); + } + for (const controller of pending.aborts) { + try { + controller.abort(); + } catch { + // Work was made inert before provider abort. + } + } + if (pending.pump) pump(state); +} + +function sameComponent( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + return ( + left.value === right.value && + left.quoted === right.quoted + ); +} + +function samePath( + left: SqlIdentifierPath, + right: SqlIdentifierPath, +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + if ( + leftPart === undefined || + rightPart === undefined || + !sameComponent(leftPart, rightPart) + ) { + return false; + } + } + return true; +} + +function sameRequest( + work: WorkState, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, +): boolean { + const existing = work.request; + if ( + !work.joinable || + existing === null || + work.dialect !== dialect || + existing.scope !== request.scope || + existing.dialectId !== request.dialectId || + existing.limit !== request.limit || + existing.continuationToken !== request.continuationToken || + !sameComponent(existing.prefix, request.prefix) || + !samePath(existing.qualifier, request.qualifier) || + existing.searchPaths.length !== request.searchPaths.length + ) { + return false; + } + const leftEpoch = existing.expectedEpoch; + const rightEpoch = request.expectedEpoch; + if ( + (leftEpoch === null) !== (rightEpoch === null) || + (leftEpoch !== null && + rightEpoch !== null && + (leftEpoch.generation !== rightEpoch.generation || + leftEpoch.token !== rightEpoch.token)) + ) { + return false; + } + for ( + let index = 0; + index < existing.searchPaths.length; + index += 1 + ) { + const left = existing.searchPaths[index]; + const right = request.searchPaths[index]; + if ( + left === undefined || + right === undefined || + !samePath(left, right) + ) { + return false; + } + } + return true; +} + +function removeQueued( + state: CoordinatorState, + work: WorkState, +): void { + const index = state.queue.indexOf(work); + if (index >= 0) state.queue.splice(index, 1); +} + +function removeJoinable( + state: CoordinatorState, + work: WorkState, +): void { + if (!work.joinable) return; + work.joinable = false; + state.joinable.delete(work); +} + +function revokeProviderCell(work: WorkState): void { + const cell = work.providerCell; + work.providerCell = null; + if (!cell) return; + cell.active = false; + cell.deliver = null; +} + +function revokeDecisionCell(work: WorkState): void { + const cell = work.decisionCell; + work.decisionCell = null; + if (!cell) return; + cell.active = false; + cell.candidates = []; + cell.pending = null; + cell.response = null; + cell.state = null; + cell.work = null; +} + +function detachAbort( + work: WorkState, + pending: Effects, +): void { + const controller = work.abortController; + work.abortController = null; + if (controller && !work.abortIssued) { + work.abortIssued = true; + pending.aborts.push(controller); + } +} + +function detachOwners( + work: WorkState, + outcome: SqlCatalogSearchWorkOutcome, + pending: Effects, +): void { + const consumers = [...work.owners]; + work.owners.clear(); + for (const consumer of consumers) { + settleDetached(pending.settlements, consumer, outcome); + } +} + +function finishWork( + state: CoordinatorState, + work: WorkState, + outcome: SqlCatalogSearchWorkOutcome, + pending: Effects, + abort: boolean, +): void { + if (work.phase === "terminal") return; + const occupied = + work.phase === "active" || + work.phase === "deciding" || + work.phase === "retired"; + removeJoinable(state, work); + removeQueued(state, work); + state.works.delete(work); + work.phase = "terminal"; + if (occupied && state.activeCount > 0) { + state.activeCount -= 1; + pending.pump = true; + } + const queueTimer = work.queueTimer; + const executionTimer = work.executionTimer; + work.queueTimer = null; + work.executionTimer = null; + if (queueTimer) pending.timers.push(queueTimer); + if (executionTimer) pending.timers.push(executionTimer); + revokeProviderCell(work); + revokeDecisionCell(work); + detachOwners(work, outcome, pending); + if (abort) detachAbort(work, pending); + else work.abortController = null; + work.dialect = null; + work.request = null; + work.scope = ""; +} + +function retireActiveOwners( + state: CoordinatorState, + work: WorkState, + outcome: SqlCatalogSearchWorkOutcome, + pending: Effects, +): void { + removeJoinable(state, work); + work.phase = "retired"; + detachOwners(work, outcome, pending); + detachAbort(work, pending); + work.dialect = null; + work.request = null; + work.scope = ""; +} + +function detachConsumerInto( + state: CoordinatorState, + consumer: ConsumerState, + outcome: SqlCatalogSearchWorkOutcome, + pending: Effects, +): void { + const work = consumer.work; + if (work) { + work.owners.delete(consumer); + } + settleDetached(pending.settlements, consumer, outcome); + if (work && work.owners.size === 0) { + if (work.phase === "queued") { + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + } else if (work.phase === "active") { + retireActiveOwners( + state, + work, + CANCELLED_OUTCOME, + pending, + ); + } else if (work.phase === "deciding") { + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + } + } +} + +function detachConsumer( + state: CoordinatorState, + consumer: ConsumerState, + outcome: SqlCatalogSearchWorkOutcome, +): void { + const pending = effects(); + detachConsumerInto( + state, + consumer, + outcome, + pending, + ); + runEffects(state, pending); +} + +function handleQueueTimeout( + state: CoordinatorState, + work: WorkState, +): void { + if (work.phase !== "queued") return; + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("queue-timeout"), + pending, + false, + ); + runEffects(state, pending); +} + +function handleExecutionTimeout( + state: CoordinatorState, + work: WorkState, +): void { + if ( + work.phase !== "active" && + work.phase !== "retired" && + work.phase !== "deciding" + ) { + return; + } + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("execution-timeout"), + pending, + true, + ); + runEffects(state, pending); +} + +function workPhase(work: WorkState): WorkState["phase"] { + return work.phase; +} + +function deliverProviderResult( + cell: ProviderResultCell, + fulfilled: boolean, + value: unknown, +): void { + if (!cell.active) return; + cell.active = false; + const deliver = cell.deliver; + cell.deliver = null; + deliver?.(fulfilled, value); +} + +function attachProviderResult( + state: CoordinatorState, + work: WorkState, + value: unknown, +): boolean { + const cell: ProviderResultCell = { + active: true, + deliver: (fulfilled, result): void => { + handleProviderResult(state, work, fulfilled, result); + }, + }; + work.providerCell = cell; + const onFulfilled = (result: unknown): void => { + deliverProviderResult(cell, true, result); + }; + const onRejected = (reason: unknown): void => { + deliverProviderResult(cell, false, reason); + }; + try { + const promise = Reflect.apply( + INTRINSIC_PROMISE_RESOLVE, + INTRINSIC_PROMISE, + [value], + ); + Reflect.apply(INTRINSIC_PROMISE_THEN, promise, [ + onFulfilled, + onRejected, + ]); + return true; + } catch { + revokeProviderCell(work); + return false; + } +} + +function drainDetachedSettlement(value: unknown): void { + try { + const promise = Reflect.apply( + INTRINSIC_PROMISE_RESOLVE, + INTRINSIC_PROMISE, + [value], + ); + Reflect.apply(INTRINSIC_PROMISE_THEN, promise, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); + } catch { + // The detached result has no authority over coordinator state. + } +} + +function settleDecision( + cell: ResponseDecisionCell, + decision: SqlCatalogResponseEpochDecision, +): void { + if (!cell.active) return; + cell.pending = decision; + advanceDecision(cell); +} + +function finishDecision( + cell: ResponseDecisionCell, + state: CoordinatorState, + work: WorkState, + outcome: SqlCatalogSearchWorkOutcome, +): void { + cell.active = false; + const pending = effects(); + finishWork(state, work, outcome, pending, false); + runEffects(state, pending); +} + +function decisionOutcome( + decision: Exclude< + SqlCatalogResponseEpochDecision, + { readonly status: "usable" } + >, +): SqlCatalogSearchWorkOutcome { + if (decision.status === "superseded") { + return SUPERSEDED_OUTCOME; + } + switch (decision.reason) { + case "disposed": + return unavailableOutcome("disposed"); + case "malformed": + return unavailableOutcome("malformed-response"); + case "overloaded": + return unavailableOutcome("overloaded"); + case "retired": + case "stale": + case "token-conflict": + return SUPERSEDED_OUTCOME; + } +} + +function rekeyUnobservedWork( + state: CoordinatorState, + producingWork: WorkState, + response: SqlValidatedCatalogSearchResponse, +): void { + const scope = producingWork.scope; + for (const work of state.joinable) { + if (work === producingWork || work.scope !== scope) { + continue; + } + const request = work.request; + if (!request || request.expectedEpoch !== null) { + continue; + } + work.request = Object.freeze({ + ...request, + expectedEpoch: response.epoch, + }); + } +} + +function advanceDecision(cell: ResponseDecisionCell): void { + if (!cell.active || cell.advancing) return; + cell.advancing = true; + try { + for (;;) { + if (!cell.active) return; + const state = cell.state; + const work = cell.work; + const response = cell.response; + if (!state || !work || !response) return; + const pendingDecision = cell.pending; + cell.pending = null; + if (pendingDecision) { + if ( + pendingDecision.status === "discarded" && + pendingDecision.reason === "retired" + ) { + // Try a capture belonging to another live shared owner. + } else if (pendingDecision.status === "usable") { + if (pendingDecision.observation === "baseline") { + rekeyUnobservedWork(state, work, response); + } + finishDecision( + cell, + state, + work, + Object.freeze({ + observation: pendingDecision.observation, + response, + status: "usable", + }), + ); + return; + } else { + finishDecision( + cell, + state, + work, + decisionOutcome(pendingDecision), + ); + return; + } + } + let capture: SqlCatalogEpochCapture | null = null; + while (cell.index < cell.candidates.length) { + const candidate = cell.candidates[cell.index]; + cell.index += 1; + if ( + candidate && + !candidate.settled && + candidate.work === work + ) { + capture = candidate.capture; + break; + } + } + if (!capture) { + finishDecision( + cell, + state, + work, + SUPERSEDED_OUTCOME, + ); + return; + } + const submitted = state.epochs.submitResponseEpoch( + capture, + response.epoch, + (decision): void => { + settleDecision(cell, decision); + }, + ); + if (submitted.status === "submitted") { + if (cell.pending === null) return; + continue; + } + cell.pending = submitted.decision; + } + } finally { + cell.advancing = false; + if (cell.active && cell.pending !== null) { + advanceDecision(cell); + } + } +} + +function handleProviderResult( + state: CoordinatorState, + work: WorkState, + fulfilled: boolean, + raw: unknown, +): void { + if ( + work.phase !== "active" && + work.phase !== "retired" + ) { + return; + } + revokeProviderCell(work); + if (work.phase === "retired") { + const pending = effects(); + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + runEffects(state, pending); + return; + } + const now = readNow(state); + const phaseAfterClock = workPhase(work); + if ( + state.disposed || + (phaseAfterClock !== "active" && + phaseAfterClock !== "retired") + ) { + return; + } + if (phaseAfterClock === "retired") { + const pending = effects(); + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + runEffects(state, pending); + return; + } + if ( + now === null || + work.executionDeadline === null || + now >= work.executionDeadline + ) { + handleExecutionTimeout(state, work); + return; + } + removeJoinable(state, work); + work.phase = "deciding"; + if (!fulfilled) { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("provider-failed"), + pending, + false, + ); + runEffects(state, pending); + return; + } + const request = work.request; + const dialect = work.dialect; + if (!request || !dialect) { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("disposed"), + pending, + false, + ); + runEffects(state, pending); + return; + } + const decoded = decodeSqlCatalogSearchResponse( + raw, + request.limit, + dialect, + ); + if ( + state.disposed || + work.phase !== "deciding" || + work.owners.size === 0 + ) { + return; + } + const afterDecode = readNow(state); + if ( + state.disposed || + work.phase !== "deciding" || + work.owners.size === 0 || + afterDecode === null || + work.executionDeadline === null || + afterDecode >= work.executionDeadline + ) { + handleExecutionTimeout(state, work); + return; + } + if (decoded.status !== "accepted") { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("malformed-response"), + pending, + false, + ); + runEffects(state, pending); + return; + } + const cell: ResponseDecisionCell = { + active: true, + advancing: false, + candidates: [...work.owners], + index: 0, + pending: null, + response: decoded.value, + state, + work, + }; + work.decisionCell = cell; + advanceDecision(cell); +} + +function startWork( + state: CoordinatorState, + work: WorkState, +): void { + const startedAt = readNow(state); + if ( + state.disposed || + work.phase !== "queued" || + work.owners.size === 0 + ) { + return; + } + if ( + startedAt === null || + startedAt >= work.queueDeadline + ) { + handleQueueTimeout(state, work); + return; + } + const queueTimer = work.queueTimer; + work.queueTimer = null; + work.phase = "active"; + state.activeCount += 1; + const controller = new AbortController(); + work.abortController = controller; + const executionDeadline = deadlineFrom( + startedAt, + state.options.executionDeadlineMs, + ); + if (executionDeadline === null) { + handleExecutionTimeout(state, work); + if (queueTimer) clearDeadline(state, queueTimer); + return; + } + work.executionDeadline = executionDeadline; + const executionTimer = scheduleDeadline( + state, + executionDeadline, + () => handleExecutionTimeout(state, work), + ); + if (work.phase !== "active") { + clearDeadline(state, executionTimer); + if (queueTimer) clearDeadline(state, queueTimer); + return; + } + work.executionTimer = executionTimer; + if (queueTimer) clearDeadline(state, queueTimer); + const search = state.search; + const request = work.request; + if (!search || !request || state.disposed) { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("disposed"), + pending, + true, + ); + runEffects(state, pending); + return; + } + let returned: unknown; + try { + returned = Reflect.apply(search, undefined, [ + request, + controller.signal, + ]); + } catch { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome("provider-failed"), + pending, + true, + ); + runEffects(state, pending); + return; + } + const phaseAfterSearch = workPhase(work); + if ( + state.disposed || + phaseAfterSearch === "terminal" + ) { + drainDetachedSettlement(returned); + return; + } + const attached = attachProviderResult(state, work, returned); + const observedAt = readNow(state); + if ( + !attached || + observedAt === null || + observedAt - startedAt > + state.options.synchronousBudgetMs + ) { + const pending = effects(); + finishWork( + state, + work, + unavailableOutcome( + attached + ? "execution-timeout" + : "provider-failed", + ), + pending, + true, + ); + runEffects(state, pending); + } +} + +function pump(state: CoordinatorState): void { + state.pumpRequested = true; + if (state.pumping || state.disposed) return; + state.pumping = true; + try { + while (state.pumpRequested && !state.disposed) { + state.pumpRequested = false; + while ( + state.activeCount < + MAX_CATALOG_ACTIVE_SEARCH_WORK && + state.queue.length > 0 + ) { + const work = state.queue.shift(); + if (!work) break; + startWork(state, work); + } + } + } finally { + state.pumping = false; + } +} + +function makeImmediateTicket( + outcome: SqlCatalogSearchWorkOutcome, +): SqlCatalogSearchWorkTicket { + return Object.freeze({ + cancel: (): void => {}, + result: new INTRINSIC_PROMISE( + (resolve) => { + resolve(outcome); + }, + ), + }); +} + +function makeConsumer( + capture: SqlCatalogEpochCapture, + owner: OwnerState, +): { + readonly consumer: ConsumerState; + readonly ticket: SqlCatalogSearchWorkTicket; +} { + let resolve: (outcome: SqlCatalogSearchWorkOutcome) => void = + IGNORE_DETACHED_REJECTION; + const result = + new INTRINSIC_PROMISE( + (settle) => { + resolve = settle; + }, + ); + const consumer: ConsumerState = { + cancelled: false, + capture, + owner, + resolve, + settled: false, + work: null, + }; + return { + consumer, + ticket: Object.freeze({ + cancel: (): void => { + if (consumer.cancelled || consumer.settled) return; + consumer.cancelled = true; + const state = consumer.owner?.owner; + if (state) { + detachConsumer( + state, + consumer, + CANCELLED_OUTCOME, + ); + } + }, + result, + }), + }; +} + +function findWork( + state: CoordinatorState, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, +): WorkState | null { + for (const work of state.joinable) { + if (sameRequest(work, request, dialect)) return work; + } + return null; +} + +function replaceOwnerConsumer( + state: CoordinatorState, + owner: OwnerState, + consumer: ConsumerState, + work: WorkState, +): void { + const pending = effects(); + const previous = owner.current; + work.owners.add(consumer); + consumer.work = work; + owner.current = consumer; + if (previous && previous !== consumer) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + pending, + ); + } + runEffects(state, pending); +} + +function requestWork( + state: CoordinatorState, + owner: OwnerState, + input: SqlCatalogSearchWorkInput, +): SqlCatalogSearchWorkTicket { + const requestToken = {}; + owner.requestToken = requestToken; + const captured = owner.membership.captureEpoch(); + if ( + state.disposed || + owner.disposed || + owner.owner !== state + ) { + return makeImmediateTicket( + unavailableOutcome("disposed"), + ); + } + if (owner.requestToken !== requestToken) { + return makeImmediateTicket(SUPERSEDED_OUTCOME); + } + if (captured.status !== "captured") { + const pending = effects(); + const previous = owner.current; + if (previous) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + pending, + ); + } + runEffects(state, pending); + return makeImmediateTicket( + unavailableOutcome( + captured.reason === "inactive" + ? "inactive" + : "disposed", + ), + ); + } + let request: SqlCatalogSearchRequest | null = null; + try { + const requestResult = createSqlCatalogSearchRequest({ + continuationToken: input.continuationToken, + dialectId: owner.dialect.id, + expectedEpoch: captured.capture.expectedEpoch, + limit: input.limit, + prefix: input.prefix, + qualifier: input.qualifier, + scope: owner.scope, + searchPaths: input.searchPaths, + }); + if (requestResult.status === "accepted") { + request = requestResult.value; + } + } catch { + // Hostile runtime input is an invalid request. + } + if ( + state.disposed || + owner.disposed || + owner.owner !== state + ) { + return makeImmediateTicket( + unavailableOutcome("disposed"), + ); + } + if (owner.requestToken !== requestToken) { + return makeImmediateTicket(SUPERSEDED_OUTCOME); + } + if (!request) { + const pending = effects(); + const previous = owner.current; + if (previous) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + pending, + ); + } + runEffects(state, pending); + return makeImmediateTicket( + unavailableOutcome("invalid-request"), + ); + } + const created = makeConsumer(captured.capture, owner); + const existing = findWork( + state, + request, + owner.dialect, + ); + if (existing) { + replaceOwnerConsumer( + state, + owner, + created.consumer, + existing, + ); + return created.ticket; + } + const pending = effects(); + const previous = owner.current; + if (previous) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + pending, + ); + } + if ( + state.queue.length >= + MAX_CATALOG_QUEUED_SEARCH_WORK + ) { + runEffects(state, pending); + return makeImmediateTicket( + unavailableOutcome("overloaded"), + ); + } + const now = readNow(state); + const disposedDuringClock = + state.disposed || + owner.disposed || + owner.owner !== state; + if ( + owner.requestToken !== requestToken || + disposedDuringClock + ) { + runEffects(state, pending); + return makeImmediateTicket( + disposedDuringClock + ? unavailableOutcome("disposed") + : SUPERSEDED_OUTCOME, + ); + } + if (now === null) { + runEffects(state, pending); + return makeImmediateTicket( + unavailableOutcome("execution-timeout"), + ); + } + const queueDeadline = deadlineFrom( + now, + state.options.queueDeadlineMs, + ); + if (queueDeadline === null) { + runEffects(state, pending); + return makeImmediateTicket( + unavailableOutcome("execution-timeout"), + ); + } + const work: WorkState = { + abortController: null, + abortIssued: false, + decisionCell: null, + dialect: owner.dialect, + executionDeadline: null, + executionTimer: null, + joinable: true, + owners: new Set([created.consumer]), + phase: "queued", + providerCell: null, + queueDeadline, + queueTimer: null, + request, + scope: owner.scope, + }; + created.consumer.work = work; + owner.current = created.consumer; + state.joinable.add(work); + state.works.add(work); + state.queue.push(work); + runEffects(state, pending); + pump(state); + if (work.phase !== "queued") { + return created.ticket; + } + const queueTimer = scheduleDeadline( + state, + queueDeadline, + () => handleQueueTimeout(state, work), + ); + if (work.phase === "queued") { + work.queueTimer = queueTimer; + } else { + clearDeadline(state, queueTimer); + } + return created.ticket; +} + +function prepareTransition( + state: CoordinatorState, + scope: string, +): (() => undefined) | null { + if (state.disposed) return null; + const pending = effects(); + for (const work of state.works) { + if ( + work.scope !== scope || + work.phase === "retired" || + work.phase === "terminal" + ) { + continue; + } + if (work.phase === "active") { + retireActiveOwners( + state, + work, + SUPERSEDED_OUTCOME, + pending, + ); + } else { + finishWork( + state, + work, + SUPERSEDED_OUTCOME, + pending, + false, + ); + } + } + if ( + pending.aborts.length === 0 && + pending.settlements.length === 0 && + pending.timers.length === 0 && + !pending.pump + ) { + return null; + } + return (): undefined => { + runEffects(state, pending); + return undefined; + }; +} + +function unavailableOwner( + reason: Exclude< + SqlCatalogSearchWorkOwnerResult, + { readonly status: "prepared" } + >["reason"], +): SqlCatalogSearchWorkOwnerResult { + return Object.freeze({ reason, status: "unavailable" }); +} + +function disposeOwner(owner: OwnerState): void { + if (owner.disposed) return; + const state = owner.owner; + owner.disposed = true; + owner.owner = null; + owner.requestToken = null; + state?.owners.delete(owner); + const current = owner.current; + owner.current = null; + if (state && current) { + detachConsumer(state, current, CANCELLED_OUTCOME); + } + owner.membership.dispose(); +} + +function createOwnerHandle( + owner: OwnerState, +): SqlCatalogSearchWorkOwner { + return Object.freeze({ + activate: () => { + return owner.membership.activate(); + }, + dispose: (): void => { + disposeOwner(owner); + }, + request: ( + input: SqlCatalogSearchWorkInput, + ): SqlCatalogSearchWorkTicket => { + const state = owner.owner; + if (!state || owner.disposed) { + return makeImmediateTicket( + unavailableOutcome("disposed"), + ); + } + return requestWork(state, owner, input); + }, + }); +} + +function prepareOwner( + state: CoordinatorState, + scope: unknown, + dialect: SqlRelationDialectRuntime, + target: SqlCatalogRevisionTarget, +): SqlCatalogSearchWorkOwnerResult { + if (state.disposed) return unavailableOwner("disposed"); + if (!isValidSqlCatalogScope(scope)) { + return unavailableOwner("invalid-scope"); + } + if (!isSqlRelationDialectRuntime(dialect)) { + return unavailableOwner("invalid-dialect"); + } + const prepared = state.epochs.prepareScopeMembership( + scope, + target, + ); + if (prepared.status !== "prepared") { + return unavailableOwner( + prepared.reason === "disposed" + ? "disposed" + : prepared.reason, + ); + } + const owner: OwnerState = { + current: null, + dialect, + disposed: false, + membership: prepared.membership, + owner: state, + requestToken: null, + scope, + }; + state.owners.add(owner); + return Object.freeze({ + owner: createOwnerHandle(owner), + status: "prepared", + }); +} + +function disposeCoordinatorState( + state: CoordinatorState, +): void { + if (state.disposed) return; + state.disposed = true; + state.search = null; + state.pumpRequested = false; + const pending = effects(); + for (const work of state.works) { + finishWork( + state, + work, + unavailableOutcome("disposed"), + pending, + true, + ); + } + for (const owner of state.owners) { + owner.disposed = true; + owner.current = null; + owner.owner = null; + owner.requestToken = null; + } + state.owners.clear(); + state.epochs.dispose(); + runEffects(state, pending); +} + +export function createSqlCatalogSearchWorkCoordinator( + provider: CapturedSqlRelationCatalogProvider, + options?: SqlCatalogSearchWorkOptions, +): SqlCatalogSearchWorkCoordinatorResult { + const context = resolveSqlRelationCatalogProvider(provider); + if (!context) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const normalized = normalizeOptions(options); + if (!normalized) { + return Object.freeze({ + reason: "invalid-options", + status: "unavailable", + }); + } + let state: CoordinatorState | null = null; + const epochResult = createSqlCatalogEpochCoordinator( + provider, + (scope): (() => undefined) | null => + state ? prepareTransition(state, scope) : null, + (): undefined => { + if (state) disposeCoordinatorState(state); + return undefined; + }, + ); + if (epochResult.status !== "created") { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + state = { + activeCount: 0, + disposed: false, + epochs: epochResult.coordinator, + joinable: new Set(), + lastNow: normalized.initialNow, + options: normalized.options, + owners: new Set(), + pumpRequested: false, + pumping: false, + queue: [], + search: context.search, + works: new Set(), + }; + const capturedState = state; + return Object.freeze({ + coordinator: Object.freeze({ + dispose: (): void => { + disposeCoordinatorState(capturedState); + }, + prepareOwner: ( + scope: unknown, + dialect: SqlRelationDialectRuntime, + target: SqlCatalogRevisionTarget, + ): SqlCatalogSearchWorkOwnerResult => + prepareOwner( + capturedState, + scope, + dialect, + target, + ), + providerId: context.id, + }), + status: "created", + }); +} diff --git a/src/vnext/relation-dialect.ts b/src/vnext/relation-dialect.ts index 76bbf50..39c2fe5 100644 --- a/src/vnext/relation-dialect.ts +++ b/src/vnext/relation-dialect.ts @@ -35,10 +35,11 @@ import type { SqlIdentifierComponent } from "./types.js"; export interface SqlRelationDialectRuntime { readonly completion: SqlRelationCompletionDialectRuntime; readonly cteLayout: SqlCteLayoutDialect; + readonly id: SqlRelationDialectId; readonly querySite: SqlQuerySiteDialect; } -type DialectKind = +export type SqlRelationDialectId = | "bigquery" | "dremio" | "duckdb" @@ -46,7 +47,7 @@ type DialectKind = interface RelationDialectSpec { readonly cteGrammar: SqlCteLayoutDialect["grammar"]; - readonly kind: DialectKind; + readonly kind: SqlRelationDialectId; readonly lexicalProfile: SqlLexicalProfile; readonly maximumPathDepth: number; readonly supportsNaturalJoin: boolean; @@ -245,7 +246,7 @@ function decodedIdentifier( function decodeDoubleQuoted( token: string, mode: "complete" | "completion-prefix", - kind: Exclude, + kind: Exclude, ): SqlIdentifierDecodeResult { if (token.length > MAX_STANDARD_QUOTED_IDENTIFIER_RAW_LENGTH) { return INVALID_IDENTIFIER; @@ -598,7 +599,7 @@ function decodeSegmentedPath( maximumPathDepth: number, quote: "\"" | "`", decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], - kind: DialectKind, + kind: SqlRelationDialectId, ): SqlDecodedQueryPath { if ( typeof rawPath !== "string" || @@ -883,7 +884,7 @@ function createPathDecoder( } function createQueryClassifier( - kind: DialectKind, + kind: SqlRelationDialectId, decodeIdentifier: SqlRelationCompletionDialectRuntime["decodeIdentifier"], ): SqlQuerySiteDialect["classifyIdentifierToken"] { return (rawIdentifier, quoted, role) => { @@ -1167,7 +1168,7 @@ function quoteBigQuery(value: string): string { } function legalRoleSequence( - kind: DialectKind, + kind: SqlRelationDialectId, roles: readonly string[], ): boolean { if (roles.length === 0 || roles.at(-1) !== "relation") { @@ -1420,6 +1421,7 @@ function createRuntime(spec: RelationDialectSpec): SqlRelationDialectRuntime { Object.freeze({ completion, cteLayout, + id: spec.kind, querySite, }), ); diff --git a/test/vnext-types/relation-catalog-search-work.test-d.ts b/test/vnext-types/relation-catalog-search-work.test-d.ts new file mode 100644 index 0000000..8176b88 --- /dev/null +++ b/test/vnext-types/relation-catalog-search-work.test-d.ts @@ -0,0 +1,257 @@ +import type { + CapturedSqlRelationCatalogProvider, + SqlValidatedCatalogSearchResponse, +} from "../../src/vnext/relation-catalog-boundary.js"; +import type { + SqlCatalogRevisionTarget, +} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; +import { + createSqlCatalogEpochCoordinator, +} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; +import type { + SqlCatalogSearchWorkCoordinator, + SqlCatalogSearchWorkInput, + SqlCatalogSearchWorkOutcome, + SqlCatalogSearchWorkOwner, + SqlCatalogSearchWorkOwnerResult, + SqlCatalogSearchWorkTicket, +} from "../../src/vnext/relation-catalog-search-work.js"; +import { + createSqlCatalogSearchWorkCoordinator, +} from "../../src/vnext/relation-catalog-search-work.js"; +import type { + SqlRelationDialectRuntime, +} from "../../src/vnext/relation-dialect.js"; + +declare const capturedProvider: CapturedSqlRelationCatalogProvider; +declare const coordinator: SqlCatalogSearchWorkCoordinator; +declare const dialect: SqlRelationDialectRuntime; +declare const owner: SqlCatalogSearchWorkOwner; +declare const target: SqlCatalogRevisionTarget; +declare const response: SqlValidatedCatalogSearchResponse; +declare const ticket: SqlCatalogSearchWorkTicket; + +const input: SqlCatalogSearchWorkInput = { + continuationToken: null, + limit: 25, + prefix: { quoted: false, value: "ord" }, + qualifier: [{ quoted: false, value: "analytics" }], + searchPaths: [ + [ + { quoted: false, value: "warehouse" }, + { quoted: false, value: "public" }, + ], + ], +}; + +const created = createSqlCatalogSearchWorkCoordinator( + capturedProvider, +); +if (created.status === "created") { + const prepared = created.coordinator.prepareOwner( + "notebook:demo", + dialect, + target, + ); + if (prepared.status === "prepared") { + const activation = prepared.owner.activate(); + if (activation.status === "active") { + const ticket = prepared.owner.request(input); + ticket.cancel(); + void ticket.result; + } + prepared.owner.dispose(); + } + created.coordinator.dispose(); +} + +const thisFreeCoordinatorDispose: ( + this: void, +) => void = coordinator.dispose; +const thisFreePrepareOwner: SqlCatalogSearchWorkCoordinator["prepareOwner"] = + coordinator.prepareOwner; +const thisFreeActivate: SqlCatalogSearchWorkOwner["activate"] = + owner.activate; +const thisFreeOwnerDispose: ( + this: void, +) => void = owner.dispose; +const thisFreeRequest: ( + this: void, + input: SqlCatalogSearchWorkInput, +) => SqlCatalogSearchWorkTicket = owner.request; +const thisFreeCancel: ( + this: void, +) => void = ticket.cancel; + +const receiverDependentRequest = function ( + this: { readonly active: boolean }, + _input: SqlCatalogSearchWorkInput, +): SqlCatalogSearchWorkTicket { + void this.active; + throw new Error("type fixture only"); +}; +// @ts-expect-error request callbacks cannot depend on a receiver +const invalidRequestReceiver: SqlCatalogSearchWorkOwner["request"] = + receiverDependentRequest; + +const receiverDependentPrepareOwner = function ( + this: { readonly active: boolean }, + _scope: unknown, + _dialect: unknown, + _target: SqlCatalogRevisionTarget, +): SqlCatalogSearchWorkOwnerResult { + void this.active; + return { reason: "disposed", status: "unavailable" }; +}; +// @ts-expect-error owner preparation cannot depend on a receiver +const invalidPrepareOwnerReceiver: SqlCatalogSearchWorkCoordinator["prepareOwner"] = + receiverDependentPrepareOwner; + +const asyncDisposalTarget = async (): Promise => {}; +type EpochDisposalTarget = NonNullable< + Parameters[2] +>; +// @ts-expect-error package disposal targets are synchronously exact-undefined +const invalidAsyncDisposalTarget: EpochDisposalTarget = + asyncDisposalTarget; +void invalidAsyncDisposalTarget; + +const receiverDependentActivate = function ( + this: { readonly active: boolean }, +): ReturnType { + void this.active; + return { status: "active" }; +}; +// @ts-expect-error owner activation cannot depend on a receiver +const invalidActivateReceiver: SqlCatalogSearchWorkOwner["activate"] = + receiverDependentActivate; + +const receiverDependentCancel = function ( + this: { readonly active: boolean }, +): void { + void this.active; +}; +// @ts-expect-error cancellation callbacks cannot depend on a receiver +const invalidCancelReceiver: SqlCatalogSearchWorkTicket["cancel"] = + receiverDependentCancel; + +// @ts-expect-error request input is readonly +input.limit = 50; +// @ts-expect-error ticket result ownership is readonly +ticket.result = Promise.resolve({ status: "cancelled" }); + +const scopeLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error scope belongs to the prepared owner, not a request + scope: "notebook:other", +}; +const providerLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error provider identity belongs to the coordinator + providerId: "foreign", +}; +const providerHandleLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error provider handles belong to the coordinator + provider: capturedProvider, +}; +const epochLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error epochs are captured by the prepared owner + expectedEpoch: { generation: 1, token: "foreign" }, +}; +const epochAliasLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error epoch aliases are captured by the prepared owner + epoch: { generation: 1, token: "foreign" }, +}; +const dialectLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error dialect identity belongs to the prepared owner + dialectId: "postgresql", +}; +const runtimeLeakingInput: SqlCatalogSearchWorkInput = { + ...input, + // @ts-expect-error dialect runtime belongs to the prepared owner + dialect, +}; + +const usableOutcome: SqlCatalogSearchWorkOutcome = { + observation: "baseline", + response, + status: "usable", +}; +// @ts-expect-error outcomes are readonly +usableOutcome.status = "cancelled"; +const extraOutcomeField: SqlCatalogSearchWorkOutcome = { + // @ts-expect-error outcome variants reject foreign fields + providerId: "foreign", + observation: "equal", + response, + status: "usable", +}; +const unknownOutcomeStatus: SqlCatalogSearchWorkOutcome = { + // @ts-expect-error outcome status is a closed discriminant + status: "loading", +}; + +function consumeOutcome(outcome: SqlCatalogSearchWorkOutcome): void { + switch (outcome.status) { + case "usable": + void outcome.response; + break; + case "superseded": + case "cancelled": + break; + case "unavailable": + switch (outcome.reason) { + case "disposed": + case "execution-timeout": + case "inactive": + case "invalid-request": + case "malformed-response": + case "overloaded": + case "provider-failed": + case "queue-timeout": + break; + default: { + const exhaustiveReason: never = outcome.reason; + void exhaustiveReason; + } + } + break; + default: { + const exhaustiveOutcome: never = outcome; + void exhaustiveOutcome; + } + } +} + +// @ts-expect-error captured providers are authentic, not structural objects +const foreignCapturedProvider: CapturedSqlRelationCatalogProvider = {}; + +declare const ownerResult: SqlCatalogSearchWorkOwnerResult; +if (ownerResult.status === "prepared") { + void ownerResult.owner.request(input).result.then(consumeOutcome); +} + +void thisFreeCoordinatorDispose; +void thisFreePrepareOwner; +void thisFreeActivate; +void thisFreeOwnerDispose; +void thisFreeRequest; +void thisFreeCancel; +void invalidPrepareOwnerReceiver; +void invalidActivateReceiver; +void invalidRequestReceiver; +void invalidCancelReceiver; +void scopeLeakingInput; +void providerLeakingInput; +void providerHandleLeakingInput; +void epochLeakingInput; +void epochAliasLeakingInput; +void dialectLeakingInput; +void runtimeLeakingInput; +void extraOutcomeField; +void unknownOutcomeStatus; +void foreignCapturedProvider; From f0f4d5e64f0b3244c3969ca7470a67c2a77454e6 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 20:54:33 +0800 Subject: [PATCH 27/42] feat(vnext): add catalog search cache policy (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a bounded exact-key ready-page cache with epoch-scoped invalidation - add fail-closed loading barriers and retry gates for subscribed and unsubscribed providers - add atomic refresh-observer transfer with reentrancy-safe availability dispatch - document marimo connection-incarnation and unresolved-metadata semantics - codify the accelerated candidate-boundary delivery cadence ## Why Catalog completion needs to reuse proven results without allowing stale pages, same-epoch loading transitions, remounted editors, or cache pressure to bypass epoch authority. This keeps the package-private scheduler, policy state, and observer lifecycle coherent before session composition and the public adapter are added. ## Verification - 1,895 tests passed; 1 expected failure - changed coverage: 98.02% statements, 95.86% branches, 99.47% functions, 98.49% lines - full typecheck, oxlint, test-integrity, browser, demo, and package smoke passed - two independent exact-head adversarial reviews approved - catalog scheduler benchmark passed --- ## Summary by cubic Adds a bounded, epoch-aware cache for catalog search to reuse “ready” pages without serving stale data, and introduces refresh leases to prevent duplicate provider work. - **New Features** - Added `SqlCatalogSearchPolicyStore`: exact-key cache for ready pages with epoch-scoped invalidation. - Bounded by `MAX_CATALOG_POLICY_STORE_ENTRIES` (256) and `MAX_CATALOG_POLICY_STORE_RETAINED_BYTES` (2 MiB). - Integrated the policy store into `relation-catalog-search-work` with fail-closed loading/retry gates. - Added refresh leasing (`retainForRefresh`) and single-shot availability dispatch with reentrancy safety. - New default `DEFAULT_CATALOG_REFRESH_LEASE_MS = 1000`. - Exposed `hasLiveSubscription(scope)` on the epoch coordinator. - Updated ADR and implementation docs; added comprehensive unit and type tests. - **Migration** - If you provide a custom `SqlCatalogEpochCoordinator`, implement `hasLiveSubscription(scope)`. - Availability targets must be synchronous and return `undefined`. - Optional: tune `refreshLeaseMs` via search-work options; no other changes required. Written for commit 803afd1908df810ab5d63600048799c99be18fe9. Summary will update on new commits. Review in cubic --- ...-parser-independent-relation-completion.md | 145 ++- implementation.md | 23 + ...lation-catalog-search-policy-store.test.ts | 694 +++++++++++++ .../relation-catalog-search-work.test.ts | 931 ++++++++++++++++++ .../relation-catalog-epoch-coordinator.ts | 15 + .../relation-catalog-search-policy-store.ts | 762 ++++++++++++++ src/vnext/relation-catalog-search-work.ts | 715 ++++++++++++-- .../relation-catalog-search-work.test-d.ts | 29 + 8 files changed, 3212 insertions(+), 102 deletions(-) create mode 100644 src/vnext/__tests__/relation-catalog-search-policy-store.test.ts create mode 100644 src/vnext/relation-catalog-search-policy-store.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 13d6ae7..967ec05 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -347,7 +347,11 @@ search request contains only copied, recursively frozen plain data: The `AbortSignal` is passed separately. Providers never receive a session, revision, `EditorState`, DOM object, credential object, or arbitrary live host context. Connection identity belongs in the catalog scope; live resources stay -inside the provider closure. The provider object is caller-owned. The service +inside the provider closure. The scope is an opaque host-generated identity +that includes the live connection incarnation, not merely a reusable display +name. Rebinding a marimo engine variable to a different connection therefore +uses a new scope even when the variable name is unchanged. The provider object +is caller-owned. The service captures its own-data callbacks once and owns searches, subscriptions, and their cleanup, but does not imply provider-wide disposal. Provider callbacks are closure functions with a declared `this: void` contract and are invoked @@ -389,15 +393,23 @@ Search responses distinguish: - loading; and - failed with a closed code and retry policy. -A terminal `loading` response is not cached. A provider that later becomes -ready must publish a strictly higher epoch through its subscription; same-epoch -duplicate invalidation is not a readiness signal. A still-pending search -promise can instead resolve ready under the service-owned response/refresh -rules below. Every completion result carrying `catalog-loading`, whether caused -by terminal loading or soft expiry, also carries a checked remaining -completion-intent lease. For terminal loading that lease is independent of -in-flight work ownership and is keyed to the exact session, query, and captured -observed or unobserved epoch. +A terminal `loading` response is not cached. It installs a bounded barrier for +the exact request key and captured epoch. When the provider has a live +subscription, matching requests observe loading without invoking it again +until a strictly higher epoch retires the barrier. Same-epoch duplicate +invalidation is not a readiness signal. Without a subscription, a later +explicit request may become the single shared probe behind that barrier. The +probe uses the normal queue and execution budgets and may publish readiness +only with a strictly higher epoch; a same-epoch ready response cannot cross the +loading barrier. A still-pending original search promise can instead resolve +ready before returning terminal loading under the service-owned +response/refresh rules below. + +Every completion result carrying `catalog-loading`, whether caused by terminal +loading or soft expiry, also carries a checked remaining completion-intent +lease. For terminal loading that lease is independent of in-flight work +ownership and is keyed to the exact session, query, scope incarnation, and +captured observed or unobserved epoch. Partial positive entities may be offered. A partial or absent result never proves that a relation does not exist. A complete empty search means only that @@ -501,9 +513,12 @@ searches remain usable; only the epoch observation is a duplicate. A terminal `loading` response requires a higher epoch before that exact search can become ready. Providers without subscriptions may return `loading`, but they cannot cause automatic refresh; a later explicit request must observe the higher -epoch. Failed responses are attempt evidence: `next-request` may recover at -the same epoch, while `after-invalidation` and `never` create bounded retry -gates rather than cached search results. +epoch through the single shared probe. Failed responses are attempt evidence, +not cache entries. `next-request` installs no gate and may recover at the same +epoch. `after-invalidation` blocks the exact request until a strictly higher +epoch. `never` blocks it for the remaining scope incarnation and is retired +only with that incarnation or the service. Retry gates are bounded internal +metadata; they never acquire result authority or extend work deadlines. The service reference-counts one provider subscription per provider configuration and scope, then fans invalidation out to subscribed sessions. @@ -733,8 +748,10 @@ and CTE evidence is composed first and never waits past the remaining response budget. On soft expiry the request settles ready, possibly empty, with `isIncomplete: true`, `catalog-loading`, and bounded remaining completion-intent lease metadata; its session may atomically retag the request -consumer as a service-owned refresh observer. The intent lease is no longer -than the remaining work lease. +consumer as a service-owned refresh observer. The transfer removes consumer +authority and installs observer authority as one serialized mutation, with no +zero-owner interval in which shared work can be aborted or a settlement can be +lost. The intent lease is no longer than the remaining work lease. That bounded refresh lease can retain the shared operation only until its existing hard deadline and active/queued service limits. With no consumers or live observers, the service removes and aborts the work. An observer is bound @@ -754,24 +771,44 @@ refresh notification and leaves the already-returned incomplete result valid. No optional catalog promise can keep `complete()` pending indefinitely or block the local baseline past its product response budget. -The first implementation increment of this section is intentionally -package-private. It combines an authenticated provider with the epoch -coordinator and owns the fixed 8-active/64-queued scheduler, exact-key -in-flight sharing, one latest-wins consumer per owner, independent -cancellation, absolute queue and execution deadlines, response decoding, and -epoch publication. An owner captures its scope and dialect when prepared, so -individual requests cannot substitute provider, scope, dialect, or epoch -authority. The authenticated dialect runtime owns its canonical provider ID; -callers cannot pair an unrelated ID and runtime. Establishing the first -baseline re-keys other joinable unobserved work in that scope, allowing -newly-observed consumers to join it without duplicating a provider call. - -Cache entries, loading/retry policy, refresh observers and their leases, the -40 ms completion-response budget, pagination composition, ranking, session -composition, and CodeMirror integration do not belong to that increment. They -remain explicit follow-up increments; the coordinator must not expose a -premature public surface that makes those deferred semantics difficult to add -or test. +Terminal loading has no work consumer to transfer. The subsequent session +composition increment owns its independent intent, retaining only the bounded +exact key, scope incarnation, captured epoch, and lease. A higher accepted +response or invalidation retires it and emits only the normal higher-epoch +`catalog` revision. By contrast, a compatible same-epoch pending-work +settlement emits exactly one `catalog-availability` notification. A higher +epoch retires both kinds of observer and never emits a second availability +notification. + +The package-private coordinator is delivered in two increments. The first +combines an authenticated provider with the epoch coordinator and owns the +fixed 8-active/64-queued scheduler, exact-key in-flight sharing, one +latest-wins consumer per owner, independent cancellation, absolute queue and +execution deadlines, response decoding, and epoch publication. The second is +one combined cache/loading/retry/observer increment over that scheduler. It +adds the ready-page cache, terminal-loading barriers and probes, retry gates, +and atomic consumer-to-observer transfer. The session increment then adds +independent terminal-loading intents together with the revision events they +own; splitting the store states would permit stale cache hits or missed +readiness transitions, while placing a session event lease in the scheduler +would invert ownership. + +An owner captures its scope and dialect when prepared, so individual requests +cannot substitute provider, scope, dialect, or epoch authority. The +authenticated dialect runtime owns its canonical provider ID; callers cannot +pair an unrelated ID and runtime. Establishing the first baseline re-keys other +joinable unobserved work in that scope, allowing newly-observed consumers to +join it without duplicating a provider call. + +The combined increment exposes only package-private lifecycle outcomes and +observer primitives. Selection of the 40 ms completion-response budget, +session revision ownership, conversion of readiness into session events, +pagination composition, ranking, and CodeMirror menu refresh remain subsequent +increments. A marimo adapter will map connection replacement, deferred +schema/table resolution, local-table changes, and DuckDB catalog DDL to higher +epochs. Its unresolved `schemas_resolved`, `tables_resolved`, and +`child_schemas_resolved` states map to terminal loading, not complete-empty +catalog evidence. The exact structural cache and shared-work key contains: @@ -792,15 +829,34 @@ to its epoch. Only decoded ready responses are cached under their response epoch. Loading, failure, malformed, timeout, cancellation, supersession, queue overload, and disposal outcomes are not cached. Complete-empty results are reused only for the exact key and never prove an unknown-object diagnostic. -Partial and paginated entries retain incomplete coverage; pages combine only -for the identical base key and epoch, and each continuation remains a distinct -request key. Higher-epoch results may be cached only after older scope entries -are cleared and never publish to the revision that observed the older epoch. - -Decoded entity IDs are unique for `(provider configuration, scope, epoch)` -across every page composed into one result. A repeated ID, even with otherwise -identical data, makes the page chain malformed before ranking or caching; array -and page order therefore cannot resolve conflicting identity records. +Partial and paginated entries retain incomplete coverage. This increment +caches individual decoded pages only; each continuation is a distinct request +key, and automatic page following or composition remains deferred. +Higher-epoch results may be cached only after older scope entries are cleared +and never publish to the revision that observed the older epoch. + +Cache retention is bounded simultaneously to 256 entries and 2 MiB of +deterministically estimated retained bytes, including the copied structural key +and frozen decoded page. A successful exact-key lookup and an insertion each +receive the next service-owned access sequence. Before an insertion becomes +visible, least-recently used entries are evicted in ascending access sequence +until both bounds hold. A page whose own estimate exceeds the byte bound +remains usable for its attached consumers but is not cached. Epoch retirement +removes incompatible entries before admission, and eviction invokes no +provider or host callback. + +Loading and retry gates share the bounds but are correctness state, not cache +entries. Their admission evicts ready pages first; an admitted gate is never +evicted before its epoch or scope-incarnation retirement rule. If the bounds +contain only live gates and cannot admit another, the producing request fails +closed as overloaded and the policy store remains overloaded until its provider +configuration is replaced. This configuration-level quarantine prevents a +later request from publishing across the barrier that could not be retained. + +Decoded entity IDs are unique within each cached page for `(provider +configuration, scope, epoch)`. Cross-page uniqueness is checked later by the +pagination-composition increment; array or page order can never resolve +conflicting identity records. ### Initial resource budgets @@ -973,8 +1029,9 @@ Mixed cached assets fail the exact version check and retire the generation. 5. Add the service-owned scoped epoch/subscription coordinator and its hostile lifecycle contract suite. It does not invoke provider search or retain result pages. -6. Add catalog search scheduling, cache, in-flight sharing, cancellation, - retry gates, and availability observers over the proven epoch coordinator. +6. Add catalog search scheduling and in-flight sharing, then one combined + package-private cache/loading/retry/availability-observer increment over the + proven epoch coordinator. 7. Add the session completion method and deterministic composition. 8. Add the separate CodeMirror adapter and packed/browser fixtures. 9. Add the pinned packed/browser/runtime marimo fixture and 1/10/50-editor diff --git a/implementation.md b/implementation.md index 47493ab..906ccb1 100644 --- a/implementation.md +++ b/implementation.md @@ -570,6 +570,29 @@ Prefer reviewers with different prompts or models; two identical agents are correlated evidence. Independent downstream work may target an accepted interface contract, but cannot merge until its dependency is accepted. +### Delivery cadence + +Keep the rigor at candidate boundaries without paying the full cost after every +small edit: + +- Combine naturally coupled package-private work into one coherent vertical + slice when separate PRs would only create temporary APIs and duplicate + review cycles. +- Run focused unit, type, lint, and benchmark checks while developing. Run the + complete local gate once at a clean candidate commit and again only after a + material fix. +- Start the two independent reviews in parallel against that coherent + candidate. A reviewer may recheck a narrow non-architectural amendment + without repeating unrelated probes; public-contract, concurrency, or + lifecycle changes still require two full exact-head approvals. +- Treat hosted CI as the authoritative cross-platform and package-matrix rerun. + Do not duplicate the same unchanged full matrix between every local commit. +- Parallelize independent design, implementation, consumer research, and + review work. Time-box exploratory research and record nonblocking ideas as + follow-ups instead of expanding the active slice. +- Request Copilot once when the PR first has a coherent reviewable head. Never + delay a later exact-head fix solely to obtain another Copilot pass. + ### Review automation Generate one review packet containing: diff --git a/src/vnext/__tests__/relation-catalog-search-policy-store.test.ts b/src/vnext/__tests__/relation-catalog-search-policy-store.test.ts new file mode 100644 index 0000000..9f5d414 --- /dev/null +++ b/src/vnext/__tests__/relation-catalog-search-policy-store.test.ts @@ -0,0 +1,694 @@ +import { describe, expect, it } from "vitest"; +import type { + SqlValidatedCatalogRelation, + SqlValidatedCatalogSearchResponse, +} from "../relation-catalog-boundary.js"; +import { + createSqlCatalogSearchPolicyStore, + MAX_CATALOG_POLICY_STORE_ENTRIES, + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES, +} from "../relation-catalog-search-policy-store.js"; +import { + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; +import type { + SqlCanonicalRelationPath, + SqlCatalogEpoch, + SqlCatalogReadyCoverage, + SqlCatalogSearchRequest, +} from "../relation-completion-types.js"; + +function epoch(generation: number): SqlCatalogEpoch { + return Object.freeze({ + generation, + token: `epoch-${generation}`, + }); +} + +function request( + prefix: string, + expectedEpoch: SqlCatalogEpoch | null, + options: { + readonly continuationToken?: string | null; + readonly dialectId?: string; + readonly limit?: number; + readonly qualifier?: string; + readonly quoted?: boolean; + readonly searchPath?: string; + readonly scope?: string; + } = {}, +): SqlCatalogSearchRequest { + return Object.freeze({ + continuationToken: options.continuationToken ?? null, + dialectId: options.dialectId ?? "postgresql", + expectedEpoch, + limit: options.limit ?? 20, + prefix: Object.freeze({ + quoted: options.quoted ?? false, + value: prefix, + }), + qualifier: Object.freeze([ + Object.freeze({ + quoted: false, + value: options.qualifier ?? "public", + }), + ]), + scope: options.scope ?? "scope-a", + searchPaths: Object.freeze([ + Object.freeze([ + Object.freeze({ + quoted: false, + value: options.searchPath ?? "public", + }), + ]), + ]), + }); +} + +function relation( + entityId: string, + detail?: string, +): SqlValidatedCatalogRelation { + const path: SqlCanonicalRelationPath = Object.freeze([ + Object.freeze({ + quoted: false, + role: "relation" as const, + value: entityId, + }), + ]); + const base = { + canonicalPath: path, + completionPath: path, + completionPathStart: 0, + completionText: entityId, + entityId, + matchQuality: "exact" as const, + relationKind: "table" as const, + }; + return Object.freeze( + detail === undefined ? base : { ...base, detail }, + ); +} + +function ready( + value: SqlCatalogEpoch, + coverage: SqlCatalogReadyCoverage = { kind: "complete" }, + relations: readonly SqlValidatedCatalogRelation[] = [], +): Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "ready" } +> { + return Object.freeze({ + coverage: Object.freeze(coverage), + epoch: value, + relations: Object.freeze(relations), + status: "ready", + }); +} + +function loading( + value: SqlCatalogEpoch, +): Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "loading" } +> { + return Object.freeze({ epoch: value, status: "loading" }); +} + +function failed( + value: SqlCatalogEpoch, + retry: "after-invalidation" | "never" | "next-request", +): Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "failed" } +> { + return Object.freeze({ + code: "unavailable", + epoch: value, + retry, + status: "failed", + }); +} + +describe("relation catalog search policy store", () => { + it("caches ready baseline pages only under their concrete exact epoch key", () => { + const store = createSqlCatalogSearchPolicyStore(); + const firstEpoch = epoch(1); + const baseline = request("u", null); + const response = ready( + firstEpoch, + { + continuationToken: "next", + kind: "paginated", + }, + [relation("users")], + ); + + expect( + store.record( + baseline, + POSTGRESQL_SQL_RELATION_DIALECT, + response, + ), + ).toMatchObject({ retained: "ready", status: "accepted" }); + expect( + store.probe( + baseline, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ loadingEpoch: null, status: "miss" }); + expect( + store.probe( + request("u", firstEpoch), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ response, status: "ready" }); + expect( + store.probe( + request("u", firstEpoch, { + continuationToken: "next", + }), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + expect( + store.probe( + request("u", firstEpoch), + DUCKDB_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + }); + + it("retains partial and complete-empty results without upgrading coverage", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(2); + const partial = ready(current, { kind: "partial" }, [ + relation("partial"), + ]); + const empty = ready(current); + + store.record( + request("p", current), + POSTGRESQL_SQL_RELATION_DIALECT, + partial, + ); + store.record( + request("e", current), + POSTGRESQL_SQL_RELATION_DIALECT, + empty, + ); + + expect( + store.probe( + request("p", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ response: partial, status: "ready" }); + expect( + store.probe( + request("e", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ response: empty, status: "ready" }); + }); + + it("uses every structural key field and replaces an exact cached value", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(2); + const key = request("key", current); + const replacement = ready(current, { kind: "partial" }); + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ); + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + replacement, + ); + + const variants = [ + request("key", current, { continuationToken: "page" }), + request("key", current, { dialectId: "other" }), + request("key", current, { limit: 19 }), + request("key", current, { qualifier: "other" }), + request("key", current, { quoted: true }), + request("key", current, { searchPath: "other" }), + request("key", current, { scope: "other" }), + request("other", current), + Object.freeze({ + ...key, + qualifier: Object.freeze([]), + }), + ]; + for (const variant of variants) { + expect( + store.probe( + variant, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + } + expect( + store.probe( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ response: replacement, status: "ready" }); + const continued = request("continued", current, { + continuationToken: "page-2", + }); + store.record( + continued, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ); + expect( + store.probe( + continued, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "ready" }); + expect(store.metrics().entries).toBe(2); + }); + + it("permits loading probes but rejects same-epoch ready publication", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(3); + const key = request("load", current); + + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + loading(current), + ), + ).toMatchObject({ + retained: "loading-barrier", + status: "accepted", + }); + expect( + store.probe(key, POSTGRESQL_SQL_RELATION_DIALECT), + ).toEqual({ + loadingEpoch: current, + status: "miss", + }); + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + loading(current), + ), + ).toMatchObject({ retained: "loading-barrier" }); + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ), + ).toEqual({ + reason: "loading-transition", + status: "conflict", + }); + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(epoch(4)), + ), + ).toEqual({ + reason: "epoch-mismatch", + status: "conflict", + }); + expect(store.metrics()).toMatchObject({ + loadingBarriers: 1, + }); + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "next-request"), + ), + ).toMatchObject({ retained: "none" }); + expect( + store.probe(key, POSTGRESQL_SQL_RELATION_DIALECT), + ).toEqual({ loadingEpoch: current, status: "miss" }); + }); + + it("implements all failure retry policies", () => { + const current = epoch(4); + const key = request("retry", current); + + const nextStore = createSqlCatalogSearchPolicyStore(); + expect( + nextStore.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "next-request"), + ), + ).toMatchObject({ retained: "none" }); + expect( + nextStore.probe( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + + const invalidationStore = + createSqlCatalogSearchPolicyStore(); + invalidationStore.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "after-invalidation"), + ); + expect( + invalidationStore.probe( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ + retry: "after-invalidation", + status: "failed", + }); + expect( + invalidationStore.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ), + ).toEqual({ + reason: "retry-gated", + status: "conflict", + }); + expect( + invalidationStore.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + loading(current), + ), + ).toEqual({ + reason: "retry-gated", + status: "conflict", + }); + invalidationStore.advanceScope("scope-a", epoch(5)); + expect( + invalidationStore.probe( + request("retry", epoch(5)), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + + const neverStore = createSqlCatalogSearchPolicyStore(); + neverStore.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "never"), + ); + neverStore.advanceScope("scope-a", epoch(5)); + expect( + neverStore.probe( + request("retry", epoch(5)), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + code: "unavailable", + epoch: epoch(5), + retry: "never", + status: "failed", + }); + expect( + neverStore.probe( + request("retry", null), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ loadingEpoch: null, status: "miss" }); + expect( + neverStore.record( + request("retry", epoch(5)), + POSTGRESQL_SQL_RELATION_DIALECT, + loading(epoch(5)), + ), + ).toEqual({ + reason: "retry-gated", + status: "conflict", + }); + }); + + it("invalidates only older epoch-scoped state in the selected scope", () => { + const store = createSqlCatalogSearchPolicyStore(); + const oldEpoch = epoch(7); + const newEpoch = epoch(8); + const keepResponse = ready(newEpoch); + + store.record( + request("old", oldEpoch), + POSTGRESQL_SQL_RELATION_DIALECT, + ready(oldEpoch), + ); + store.record( + request("keep", newEpoch), + POSTGRESQL_SQL_RELATION_DIALECT, + keepResponse, + ); + store.record( + request("other", oldEpoch, { scope: "scope-b" }), + POSTGRESQL_SQL_RELATION_DIALECT, + ready(oldEpoch), + ); + store.advanceScope("scope-a", newEpoch); + store.advanceScope("scope-missing", newEpoch); + + expect( + store.probe( + request("old", newEpoch), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + expect( + store.probe( + request("keep", newEpoch), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ response: keepResponse, status: "ready" }); + expect(store.metrics()).toMatchObject({ + entries: 2, + readyEntries: 2, + }); + }); + + it("evicts deterministically by recency at the shared entry ceiling", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(9); + for ( + let index = 0; + index < MAX_CATALOG_POLICY_STORE_ENTRIES; + index += 1 + ) { + store.record( + request(`key-${index}`, current), + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ); + } + store.probe( + request("key-0", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ); + store.record( + request("newest", current), + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "after-invalidation"), + ); + + expect(store.metrics()).toMatchObject({ + entries: MAX_CATALOG_POLICY_STORE_ENTRIES, + failureGates: 1, + }); + expect( + store.probe( + request("key-0", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "ready" }); + expect( + store.probe( + request("key-1", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + }); + + it("never evicts correctness gates when bounded capacity is exhausted", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(9); + for ( + let index = 0; + index < MAX_CATALOG_POLICY_STORE_ENTRIES; + index += 1 + ) { + expect( + store.record( + request(`gate-${index}`, current), + POSTGRESQL_SQL_RELATION_DIALECT, + loading(current), + ), + ).toMatchObject({ + retained: "loading-barrier", + status: "accepted", + }); + } + + expect( + store.record( + request("overflow", current), + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "after-invalidation"), + ), + ).toEqual({ + reason: "capacity", + status: "conflict", + }); + expect( + store.record( + request("loading-overflow", current), + POSTGRESQL_SQL_RELATION_DIALECT, + loading(current), + ), + ).toEqual({ + reason: "capacity", + status: "conflict", + }); + expect( + store.probe( + request("gate-0", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ status: "overloaded" }); + expect(store.metrics()).toMatchObject({ + entries: MAX_CATALOG_POLICY_STORE_ENTRIES, + loadingBarriers: MAX_CATALOG_POLICY_STORE_ENTRIES, + }); + }); + + it("enforces the retained-byte ceiling without truncating responses", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(10); + const relations = Array.from( + { length: 50 }, + (_, index) => + relation(`large-${index}`, "x".repeat(800)), + ); + for (let index = 0; index < 80; index += 1) { + store.record( + request(`large-${index}`, current), + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current, { kind: "partial" }, relations), + ); + } + + expect(store.metrics().retainedBytes).toBeLessThanOrEqual( + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES, + ); + expect(store.metrics().entries).toBeLessThan(80); + expect( + store.probe( + request("large-0", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "miss" }); + expect( + store.probe( + request("large-79", current), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ status: "ready" }); + }); + + it("does not retain one response larger than the byte ceiling", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(10); + const oversized = ready(current, { kind: "partial" }, [ + relation( + "oversized", + "x".repeat( + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES, + ), + ), + ]); + + expect( + store.record( + request("oversized", current), + POSTGRESQL_SQL_RELATION_DIALECT, + oversized, + ), + ).toMatchObject({ + retained: "none", + response: oversized, + status: "accepted", + }); + expect(store.metrics().entries).toBe(0); + }); + + it("rejects an individually oversized correctness gate", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(10); + const oversizedKey = request( + "x".repeat( + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES, + ), + current, + ); + + expect( + store.record( + oversizedKey, + POSTGRESQL_SQL_RELATION_DIALECT, + failed(current, "after-invalidation"), + ), + ).toEqual({ + reason: "capacity", + status: "conflict", + }); + expect(store.metrics()).toMatchObject({ + entries: 0, + failureGates: 0, + }); + }); + + it("disposes retained state and leaves the frozen handle inert", () => { + const store = createSqlCatalogSearchPolicyStore(); + const current = epoch(11); + const key = request("disposed", current); + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ); + + expect(Object.isFrozen(store)).toBe(true); + store.dispose(); + store.dispose(); + store.advanceScope("scope-a", epoch(12)); + + expect(store.metrics()).toEqual({ + entries: 0, + failureGates: 0, + loadingBarriers: 0, + readyEntries: 0, + retainedBytes: 0, + }); + expect( + store.probe(key, POSTGRESQL_SQL_RELATION_DIALECT), + ).toEqual({ status: "disposed" }); + expect( + store.record( + key, + POSTGRESQL_SQL_RELATION_DIALECT, + ready(current), + ), + ).toEqual({ status: "disposed" }); + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-search-work.test.ts b/src/vnext/__tests__/relation-catalog-search-work.test.ts index a16aa0d..a6914c7 100644 --- a/src/vnext/__tests__/relation-catalog-search-work.test.ts +++ b/src/vnext/__tests__/relation-catalog-search-work.test.ts @@ -16,6 +16,7 @@ import { type SqlCatalogSearchWorkOutcome, type SqlCatalogSearchWorkTicket, } from "../relation-catalog-search-work.js"; +import { MAX_CATALOG_POLICY_STORE_ENTRIES } from "../relation-catalog-search-policy-store.js"; import { DUCKDB_SQL_RELATION_DIALECT, POSTGRESQL_SQL_RELATION_DIALECT, @@ -281,6 +282,8 @@ describe("catalog search coordinator construction", () => { { executionDeadlineMs: 5_001 }, { queueDeadlineMs: 9 }, { queueDeadlineMs: 2_001 }, + { refreshLeaseMs: 0 }, + { refreshLeaseMs: 5_001 }, { deadlineScheduler: { clearTimeout() {}, @@ -710,6 +713,567 @@ describe("catalog search ownership and active-slot lifecycle", () => { expect(provider.calls).toHaveLength(2); expect(scheduler.pendingCount).toBe(0); }); + + it("transfers pending ownership to a bounded refresh observer and dispatches ready availability once", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + refreshLeaseMs: 50, + }); + const ticket = owner(service).request(input("observed")); + let preparations = 0; + let dispatches = 0; + + expect( + ticket.retainForRefresh(() => { + preparations += 1; + expect(provider.calls[0]?.signal.aborted).toBe(false); + return (): undefined => { + dispatches += 1; + return undefined; + }; + }), + ).toEqual({ + remainingLeaseMs: 50, + status: "retained", + }); + await expect(ticket.result).resolves.toEqual({ + status: "cancelled", + }); + expect( + ticket.retainForRefresh(() => null), + ).toEqual({ + reason: "not-retainable", + status: "unavailable", + }); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(preparations).toBe(1); + expect(dispatches).toBe(1); + scheduler.advanceBy(50); + expect(dispatches).toBe(1); + }); + + it("rejects invalid and already-expired refresh retention without transferring ownership", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 20, + }); + const ticket = owner(service).request(input("invalid-retention")); + + expect( + Reflect.apply(ticket.retainForRefresh, undefined, [null]), + ).toEqual({ + reason: "invalid-target", + status: "unavailable", + }); + scheduler.nowValue = 20; + expect(ticket.retainForRefresh(() => null)).toEqual({ + reason: "expired", + status: "unavailable", + }); + + ticket.cancel(); + await expect(ticket.result).resolves.toEqual({ + status: "cancelled", + }); + }); + + it("fails refresh retention closed when its clock disposes or supersedes the owner", async () => { + for (const reentry of ["dispose", "replace"] as const) { + let trigger = false; + let service: SqlCatalogSearchWorkCoordinator | undefined; + let session: SqlCatalogSearchWorkOwner | undefined; + const replacements: SqlCatalogSearchWorkTicket[] = []; + const provider = providerHarness(); + service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now() { + if (trigger) { + trigger = false; + if (reentry === "dispose") { + service?.dispose(); + } else { + const replacement = session?.request( + input("replacement"), + ); + if (replacement) replacements.push(replacement); + } + } + return 0; + }, + setTimeout() { + return 1; + }, + }, + }); + session = owner(service); + const ticket = session.request(input("observed")); + trigger = true; + + expect(ticket.retainForRefresh(() => null)).toEqual({ + reason: + reentry === "dispose" ? "disposed" : "superseded", + status: "unavailable", + }); + await expect(ticket.result).resolves.toEqual( + reentry === "dispose" + ? { reason: "disposed", status: "unavailable" } + : { status: "superseded" }, + ); + + service.dispose(); + for (const replacement of replacements) { + await expect(replacement.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + } + } + }); + + it("returns expired when a hostile scheduler fires the refresh lease synchronously", async () => { + let nextHandle = 0; + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: { + clearTimeout() {}, + now: () => 0, + setTimeout(callback, delayMs) { + nextHandle += 1; + if (delayMs === 50) callback(); + return nextHandle; + }, + }, + refreshLeaseMs: 50, + }); + const ticket = owner(service).request(input("sync-expiry")); + + expect(ticket.retainForRefresh(() => null)).toEqual({ + reason: "expired", + status: "unavailable", + }); + await expect(ticket.result).resolves.toEqual({ + status: "cancelled", + }); + expect(provider.calls[0]?.signal.aborted).toBe(true); + }); + + it("contains hostile availability preparation and dispatch results", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const prepared: string[] = []; + const dispatched: string[] = []; + const targets = [ + () => { + prepared.push("throw"); + throw new Error("preparation failed"); + }, + () => { + prepared.push("non-function"); + return Promise.reject(new Error("detached preparation")); + }, + () => { + prepared.push("null"); + return null; + }, + () => { + prepared.push("throwing-dispatch"); + return () => { + dispatched.push("throw"); + throw new Error("dispatch failed"); + }; + }, + () => { + prepared.push("async-dispatch"); + return () => { + dispatched.push("async"); + return Promise.reject(new Error("detached dispatch")); + }; + }, + ] as const; + const tickets = targets.map(() => + owner(service).request(input("hostile-availability")), + ); + for (const [index, ticket] of tickets.entries()) { + expect( + Reflect.apply(ticket.retainForRefresh, undefined, [ + targets[index] ?? (() => null), + ]), + ).toMatchObject({ status: "retained" }); + } + + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(prepared).toEqual([ + "throw", + "non-function", + "null", + "throwing-dispatch", + "async-dispatch", + ]); + expect(dispatched).toEqual(["throw", "async"]); + await Promise.all( + tickets.map((ticket) => + expect(ticket?.result).resolves.toEqual({ + status: "cancelled", + }), + ), + ); + }); + + it("stops availability preparation and dispatch when preparation disposes the coordinator", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + let secondPreparation = 0; + let dispatches = 0; + const disposing = owner(service).request( + input("disposing-availability"), + ); + const skipped = owner(service).request( + input("disposing-availability"), + ); + + expect( + disposing.retainForRefresh(() => { + service.dispose(); + return () => { + dispatches += 1; + }; + }).status, + ).toBe("retained"); + expect( + skipped.retainForRefresh(() => { + secondPreparation += 1; + return null; + }).status, + ).toBe("retained"); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(secondPreparation).toBe(0); + expect(dispatches).toBe(0); + }); + + it.each(["owner-dispose", "new-request", "higher-epoch"] as const)( + "suppresses prepared availability after reentrant %s", + async (reentry) => { + const listener: { + current: ((event: unknown) => void) | null; + } = { current: null }; + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + _scope: string, + onInvalidation: (event: unknown) => void, + ) { + listener.current = onInvalidation; + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + const session = owner(service); + const ticket = session.request(input("availability-race")); + let dispatches = 0; + expect( + ticket.retainForRefresh(() => { + if (reentry === "owner-dispose") { + session.dispose(); + } else if (reentry === "new-request") { + session.request(input("newer")); + } else { + listener.current?.({ epoch: epoch(2) }); + } + return () => { + dispatches += 1; + }; + }).status, + ).toBe("retained"); + + calls[0]?.settlement.resolve(readyResponse(1)); + await flushMicrotasks(); + expect(dispatches).toBe(0); + service.dispose(); + }, + ); + + it("revalidates each prepared dispatch when a later preparation retires one owner", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const firstOwner = owner(service); + const secondOwner = owner(service); + const first = firstOwner.request(input("shared-availability")); + const second = secondOwner.request(input("shared-availability")); + let firstDispatches = 0; + let secondDispatches = 0; + expect( + first.retainForRefresh(() => () => { + firstDispatches += 1; + }).status, + ).toBe("retained"); + expect( + second.retainForRefresh(() => { + firstOwner.dispose(); + return () => { + secondDispatches += 1; + }; + }).status, + ).toBe("retained"); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(firstDispatches).toBe(0); + expect(secondDispatches).toBe(1); + service.dispose(); + }); + + it("starts observer-only queued work when an active slot becomes free", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const active = Array.from( + { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, + (_, index) => + owner(service).request(input(`active-${index}`)), + ); + const queued = owner(service).request(input("queued-observer")); + let availability = 0; + + expect( + queued.retainForRefresh(() => { + availability += 1; + return null; + }), + ).toMatchObject({ + status: "retained", + }); + await expect(queued.result).resolves.toEqual({ + status: "cancelled", + }); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK, + ); + + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(active[0]?.result).resolves.toMatchObject({ + status: "usable", + }); + await flushMicrotasks(); + expect(provider.calls).toHaveLength( + MAX_CATALOG_ACTIVE_SEARCH_WORK + 1, + ); + expect(provider.calls.at(-1)?.request.prefix.value).toBe( + "queued-observer", + ); + provider.calls.at(-1)?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(availability).toBe(1); + + service.dispose(); + await Promise.all( + active.slice(1).map((ticket) => + expect(ticket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }), + ), + ); + }); + + it("retires a refresh observer when its owner starts different work or is disposed", async () => { + for (const action of ["request", "dispose"] as const) { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const observed = session.request(input(`observed-${action}`)); + expect( + observed.retainForRefresh(() => null).status, + ).toBe("retained"); + const observedSignal = provider.calls[0]?.signal; + + if (action === "request") { + const replacement = session.request( + input("different-work"), + ); + expect(observedSignal?.aborted).toBe(true); + expect(provider.calls).toHaveLength(2); + service.dispose(); + await expect(replacement.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + } else { + session.dispose(); + expect(observedSignal?.aborted).toBe(true); + } + await expect(observed.result).resolves.toEqual({ + status: "cancelled", + }); + service.dispose(); + } + }); + + it("hands an observed same-key operation back to a consumer without aborting or restarting it", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const observed = session.request(input("same-observed")); + let availability = 0; + expect( + observed.retainForRefresh(() => { + availability += 1; + return null; + }).status, + ).toBe("retained"); + const signal = provider.calls[0]?.signal; + + const replacement = session.request(input("same-observed")); + expect(provider.calls).toHaveLength(1); + expect(signal?.aborted).toBe(false); + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(replacement.result).resolves.toMatchObject({ + status: "usable", + }); + expect(availability).toBe(0); + }); + + it("counts consumers and observers together before issuing the last-owner abort", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const first = owner(service).request(input("shared-observed")); + const second = owner(service).request(input("shared-observed")); + const signal = provider.calls[0]?.signal; + expect( + first.retainForRefresh(() => null).status, + ).toBe("retained"); + + second.cancel(); + await expect(second.result).resolves.toEqual({ + status: "cancelled", + }); + expect(signal?.aborted).toBe(false); + + first.cancel(); + expect(signal?.aborted).toBe(true); + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + }); + + it("clips observer retention to the hard work deadline and never notifies on expiry", async () => { + const scheduler = new ManualDeadlineScheduler(); + const provider = providerHarness(); + const service = coordinator(provider.captured, { + deadlineScheduler: scheduler, + executionDeadlineMs: 25, + refreshLeaseMs: 50, + }); + const ticket = owner(service).request(input("leased")); + let availability = 0; + expect( + ticket.retainForRefresh(() => { + availability += 1; + return null; + }), + ).toEqual({ + remainingLeaseMs: 25, + status: "retained", + }); + + scheduler.advanceBy(25); + expect(provider.calls[0]?.signal.aborted).toBe(true); + expect(availability).toBe(0); + provider.calls[0]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(availability).toBe(0); + }); + + it.each(["loading", "failed"] as const)( + "removes observers without availability for a terminal %s response", + async (status) => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const ticket = owner(service).request(input(status)); + let availability = 0; + expect( + ticket.retainForRefresh(() => { + availability += 1; + return null; + }).status, + ).toBe("retained"); + provider.calls[0]?.settlement.resolve( + status === "loading" + ? { + epoch: epoch(), + status, + } + : { + code: "unavailable", + epoch: epoch(), + retry: "next-request", + status, + }, + ); + await flushMicrotasks(); + expect(availability).toBe(0); + expect(provider.calls[0]?.signal.aborted).toBe(false); + }, + ); + + it("clears observers without availability on higher epochs and disposal", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const baselineOwner = owner(service); + const baseline = baselineOwner.request(input("baseline")); + provider.calls[0]?.settlement.resolve(readyResponse(1)); + await expect(baseline.result).resolves.toMatchObject({ + status: "usable", + }); + + const higher = baselineOwner.request(input("higher")); + let higherAvailability = 0; + expect( + higher.retainForRefresh(() => { + higherAvailability += 1; + return null; + }).status, + ).toBe("retained"); + provider.calls[1]?.settlement.resolve(readyResponse(2)); + await flushMicrotasks(); + expect(higherAvailability).toBe(0); + + const disposable = owner(service, "other-scope").request( + input("disposed-observer"), + ); + let disposedAvailability = 0; + expect( + disposable.retainForRefresh(() => { + disposedAvailability += 1; + return null; + }).status, + ).toBe("retained"); + const disposableSignal = provider.calls[2]?.signal; + service.dispose(); + expect(disposableSignal?.aborted).toBe(true); + expect(disposedAvailability).toBe(0); + provider.calls[2]?.settlement.resolve(readyResponse()); + await flushMicrotasks(); + expect(disposedAvailability).toBe(0); + }); }); describe("catalog search absolute deadlines", () => { @@ -1485,6 +2049,373 @@ describe("catalog search epoch authority and isolation", () => { }); }); +describe("catalog search policy integration", () => { + it("fails closed when loading barriers exhaust policy capacity", async () => { + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "capacity-catalog", + search(request: SqlCatalogSearchRequest) { + return { + epoch: request.expectedEpoch ?? epoch(1), + status: "loading", + }; + }, + }), + ); + const service = coordinator(captured); + const session = owner(service); + + for ( + let index = 0; + index < MAX_CATALOG_POLICY_STORE_ENTRIES; + index += 1 + ) { + await expect( + session.request(input(`gate-${index}`)).result, + ).resolves.toMatchObject({ + response: { status: "loading" }, + status: "usable", + }); + } + await expect( + session.request(input("gate-overflow")).result, + ).resolves.toEqual({ + reason: "overloaded", + status: "unavailable", + }); + service.dispose(); + }); + + it("serves a repeated ready search from the exact-epoch cache", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const first = session.request(input("cached")); + + provider.calls[0]?.settlement.resolve(readyResponse(3)); + const firstOutcome = await first.result; + expect(firstOutcome).toMatchObject({ + observation: "baseline", + status: "usable", + }); + + const cached = session.request(input("cached")); + expect(provider.calls).toHaveLength(1); + await expect(cached.result).resolves.toEqual({ + observation: "equal", + response: + firstOutcome.status === "usable" + ? firstOutcome.response + : undefined, + status: "usable", + }); + + const distinct = session.request(input("cached", { limit: 19 })); + expect(provider.calls).toHaveLength(2); + service.dispose(); + await expect(distinct.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("supersedes a cache hit when retiring old work reenters with a newer request", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const baseline = session.request(input("cached-reentrant")); + provider.calls[0]?.settlement.resolve(readyResponse()); + await expect(baseline.result).resolves.toMatchObject({ + status: "usable", + }); + + const old = session.request(input("old-work")); + const newest: { + current: SqlCatalogSearchWorkTicket | null; + } = { current: null }; + provider.calls[1]?.signal.addEventListener("abort", () => { + newest.current = session.request(input("newest-work")); + }); + const displacedCacheHit = session.request( + input("cached-reentrant"), + ); + + await expect(old.result).resolves.toEqual({ + status: "superseded", + }); + await expect(displacedCacheHit.result).resolves.toEqual({ + status: "superseded", + }); + expect(provider.calls).toHaveLength(3); + provider.calls[2]?.settlement.resolve(readyResponse()); + if (!newest.current) { + throw new Error("Expected reentrant request"); + } + await expect(newest.current.result).resolves.toMatchObject({ + status: "usable", + }); + }); + + it("keeps immediate tickets non-retainable and fails a reentrant cache hit closed on disposal", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const baseline = session.request(input("cached-disposal")); + provider.calls[0]?.settlement.resolve(readyResponse()); + await baseline.result; + + const observed = session.request(input("observed-before-cache")); + expect( + observed.retainForRefresh(() => null).status, + ).toBe("retained"); + const cached = session.request(input("cached-disposal")); + expect(cached.retainForRefresh(() => null)).toEqual({ + reason: "not-retainable", + status: "unavailable", + }); + await expect(cached.result).resolves.toMatchObject({ + status: "usable", + }); + + const active = session.request(input("dispose-on-abort")); + provider.calls.at(-1)?.signal.addEventListener("abort", () => { + service.dispose(); + }); + const disposedHit = session.request(input("cached-disposal")); + await expect(active.result).resolves.toEqual({ + status: "superseded", + }); + await expect(disposedHit.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(disposedHit.retainForRefresh(() => null)).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("gates loading until a higher epoch is observed and then caches readiness", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const session = owner(service); + const baseline = session.request(input("baseline")); + provider.calls[0]?.settlement.resolve(readyResponse(1)); + await expect(baseline.result).resolves.toMatchObject({ + observation: "baseline", + status: "usable", + }); + + const loading = session.request(input("loading")); + provider.calls[1]?.settlement.resolve({ + epoch: epoch(1), + status: "loading", + }); + await expect(loading.result).resolves.toMatchObject({ + response: { status: "loading" }, + status: "usable", + }); + + const sameEpochReady = session.request(input("loading")); + provider.calls[2]?.settlement.resolve(readyResponse(1)); + await expect(sameEpochReady.result).resolves.toEqual({ + status: "superseded", + }); + + const advancing = session.request(input("loading")); + provider.calls[3]?.settlement.resolve(readyResponse(2)); + await expect(advancing.result).resolves.toEqual({ + status: "superseded", + }); + + const current = session.request(input("loading")); + provider.calls[4]?.settlement.resolve(readyResponse(2)); + const currentOutcome = await current.result; + expect(currentOutcome).toMatchObject({ + observation: "equal", + status: "usable", + }); + + const cached = session.request(input("loading")); + expect(provider.calls).toHaveLength(5); + await expect(cached.result).resolves.toEqual({ + observation: "equal", + response: + currentOutcome.status === "usable" + ? currentOutcome.response + : undefined, + status: "usable", + }); + }); + + it("reuses loading without a provider call while a scope subscription is live", async () => { + const listeners = new Map< + string, + (event: unknown) => void + >(); + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + scope: string, + onInvalidation: (event: unknown) => void, + ) { + listeners.set(scope, onInvalidation); + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + const session = owner(service); + const first = session.request(input("subscribed-loading")); + calls[0]?.settlement.resolve({ + epoch: epoch(1), + status: "loading", + }); + const firstOutcome = await first.result; + expect(firstOutcome).toMatchObject({ + observation: "baseline", + response: { status: "loading" }, + status: "usable", + }); + + const retained = session.request( + input("subscribed-loading"), + ); + expect(calls).toHaveLength(1); + await expect(retained.result).resolves.toEqual({ + observation: "equal", + response: + firstOutcome.status === "usable" + ? firstOutcome.response + : undefined, + status: "usable", + }); + + listeners + .get("connection:primary") + ?.({ epoch: epoch(2) }); + const retried = session.request( + input("subscribed-loading"), + ); + expect(calls).toHaveLength(2); + service.dispose(); + await expect(retried.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("reuses after-invalidation failures only until the scope advances", async () => { + const listener: { + current: ((event: unknown) => void) | null; + } = { current: null }; + const calls: ProviderCall[] = []; + const captured = accepted( + captureSqlRelationCatalogProvider({ + id: "catalog", + search( + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) { + const settlement = deferred(); + calls.push({ request, settlement, signal }); + return settlement.promise; + }, + subscribe( + _scope: string, + onInvalidation: (event: unknown) => void, + ) { + listener.current = onInvalidation; + return () => undefined; + }, + }), + ); + const service = coordinator(captured); + const session = owner(service); + const failing = session.request(input("failure")); + calls[0]?.settlement.resolve({ + code: "unavailable", + epoch: epoch(1), + retry: "after-invalidation", + status: "failed", + }); + const failedOutcome = await failing.result; + expect(failedOutcome).toMatchObject({ + observation: "baseline", + response: { status: "failed" }, + status: "usable", + }); + + const gated = session.request(input("failure")); + expect(calls).toHaveLength(1); + await expect(gated.result).resolves.toEqual({ + observation: "equal", + response: + failedOutcome.status === "usable" + ? failedOutcome.response + : undefined, + status: "usable", + }); + + listener.current?.({ epoch: epoch(2) }); + const retried = session.request(input("failure")); + expect(calls).toHaveLength(2); + service.dispose(); + await expect(retried.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("re-establishes epoch authority before reusing a never gate after remount", async () => { + const provider = providerHarness(); + const service = coordinator(provider.captured); + const firstOwner = owner(service); + const failing = firstOwner.request(input("never-remount")); + provider.calls[0]?.settlement.resolve({ + code: "invalid-configuration", + epoch: epoch(1), + retry: "never", + status: "failed", + }); + await expect(failing.result).resolves.toMatchObject({ + observation: "baseline", + status: "usable", + }); + firstOwner.dispose(); + + const remounted = owner(service); + const baseline = remounted.request(input("never-remount")); + expect(provider.calls).toHaveLength(2); + expect(provider.calls[1]?.request.expectedEpoch).toBeNull(); + provider.calls[1]?.settlement.resolve(readyResponse(1)); + await expect(baseline.result).resolves.toEqual({ + status: "superseded", + }); + + const gated = remounted.request(input("never-remount")); + expect(provider.calls).toHaveLength(2); + await expect(gated.result).resolves.toMatchObject({ + observation: "equal", + response: { + code: "invalid-configuration", + status: "failed", + }, + status: "usable", + }); + }); +}); + describe("catalog search disposal and late settlement", () => { it("settles active and queued owners before abort and drains late rejection", async () => { const scheduler = new ManualDeadlineScheduler(); diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/vnext/relation-catalog-epoch-coordinator.ts index a82018e..b567d3c 100644 --- a/src/vnext/relation-catalog-epoch-coordinator.ts +++ b/src/vnext/relation-catalog-epoch-coordinator.ts @@ -117,6 +117,10 @@ export type SqlCatalogResponseEpochSubmissionResult = }; export interface SqlCatalogEpochCoordinator { + readonly hasLiveSubscription: ( + this: void, + scope: string, + ) => boolean; readonly providerId: string; readonly prepareScopeMembership: ( this: void, @@ -1274,6 +1278,17 @@ function createCoordinatorHandle( dispose: (): void => { disposeCoordinator(state); }, + hasLiveSubscription: (scope: string): boolean => { + const subscription = + state.scopes.get(scope)?.subscription; + return Boolean( + !state.disposed && + subscription && + !subscription.failed && + !subscription.installing && + subscription.cell.active, + ); + }, prepareScopeMembership: ( scope: unknown, target: SqlCatalogRevisionTarget, diff --git a/src/vnext/relation-catalog-search-policy-store.ts b/src/vnext/relation-catalog-search-policy-store.ts new file mode 100644 index 0000000..2f155d7 --- /dev/null +++ b/src/vnext/relation-catalog-search-policy-store.ts @@ -0,0 +1,762 @@ +import type { + SqlValidatedCatalogSearchResponse, +} from "./relation-catalog-boundary.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import type { + SqlCatalogEpoch, + SqlCatalogFailureCode, + SqlCatalogRetryPolicy, + SqlCatalogSearchRequest, +} from "./relation-completion-types.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export const MAX_CATALOG_POLICY_STORE_ENTRIES = 256; +export const MAX_CATALOG_POLICY_STORE_RETAINED_BYTES = + 2 * 1_024 * 1_024; + +export type SqlCatalogSearchPolicyProbe = + | { + readonly status: "ready"; + readonly response: Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "ready" } + >; + } + | { + readonly status: "failed"; + readonly code: SqlCatalogFailureCode; + readonly epoch: SqlCatalogEpoch; + readonly retry: Exclude< + SqlCatalogRetryPolicy, + "next-request" + >; + } + | { + readonly status: "miss"; + readonly loadingEpoch: SqlCatalogEpoch | null; + } + | { + readonly status: "disposed"; + } + | { + readonly status: "overloaded"; + }; + +export type SqlCatalogSearchPolicyRecordResult = + | { + readonly status: "accepted"; + readonly response: SqlValidatedCatalogSearchResponse; + readonly retained: + | "failure-gate" + | "loading-barrier" + | "none" + | "ready"; + } + | { + readonly status: "conflict"; + readonly reason: + | "capacity" + | "epoch-mismatch" + | "loading-transition" + | "retry-gated"; + } + | { + readonly status: "disposed"; + }; + +export interface SqlCatalogSearchPolicyStoreMetrics { + readonly entries: number; + readonly failureGates: number; + readonly loadingBarriers: number; + readonly readyEntries: number; + readonly retainedBytes: number; +} + +export interface SqlCatalogSearchPolicyStore { + readonly advanceScope: ( + this: void, + scope: string, + epoch: SqlCatalogEpoch, + ) => void; + readonly dispose: (this: void) => void; + readonly metrics: ( + this: void, + ) => SqlCatalogSearchPolicyStoreMetrics; + readonly probe: ( + this: void, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, + ) => SqlCatalogSearchPolicyProbe; + readonly record: ( + this: void, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, + response: SqlValidatedCatalogSearchResponse, + ) => SqlCatalogSearchPolicyRecordResult; +} + +interface StructuralKey { + readonly continuationToken: string | null; + readonly dialect: SqlRelationDialectRuntime; + readonly dialectId: string; + readonly limit: number; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly scope: string; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +interface RetainedRecordBase { + readonly bytes: number; + readonly key: StructuralKey; + lastUse: bigint; +} + +interface ReadyRecord extends RetainedRecordBase { + readonly kind: "ready"; + readonly response: Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "ready" } + >; +} + +interface LoadingRecord extends RetainedRecordBase { + readonly epoch: SqlCatalogEpoch; + readonly kind: "loading"; +} + +interface FailureRecord extends RetainedRecordBase { + readonly code: SqlCatalogFailureCode; + readonly epoch: SqlCatalogEpoch; + readonly kind: "failure"; + readonly retry: Exclude< + SqlCatalogRetryPolicy, + "next-request" + >; +} + +type RetainedRecord = + | FailureRecord + | LoadingRecord + | ReadyRecord; + +interface StoreState { + disposed: boolean; + readonly records: RetainedRecord[]; + retainedBytes: number; + saturated: boolean; + sequence: bigint; +} + +const DISPOSED_PROBE: SqlCatalogSearchPolicyProbe = + Object.freeze({ status: "disposed" }); +const DISPOSED_RECORD: SqlCatalogSearchPolicyRecordResult = + Object.freeze({ status: "disposed" }); +const KEY_FIXED_BYTES = 160; +const PATH_FIXED_BYTES = 24; +const COMPONENT_FIXED_BYTES = 24; +const RESPONSE_FIXED_BYTES = 96; +const RELATION_FIXED_BYTES = 128; + +function sameEpoch( + left: SqlCatalogEpoch, + right: SqlCatalogEpoch, +): boolean { + return ( + left.generation === right.generation && + left.token === right.token + ); +} + +function sameComponent( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + return ( + left.quoted === right.quoted && + left.value === right.value + ); +} + +function samePath( + left: SqlIdentifierPath, + right: SqlIdentifierPath, +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftComponent = left[index]; + const rightComponent = right[index]; + if ( + !leftComponent || + !rightComponent || + !sameComponent(leftComponent, rightComponent) + ) { + return false; + } + } + return true; +} + +function sameBaseKey( + left: StructuralKey, + right: StructuralKey, +): boolean { + if ( + left.dialect !== right.dialect || + left.dialectId !== right.dialectId || + left.scope !== right.scope || + left.limit !== right.limit || + left.continuationToken !== right.continuationToken || + !sameComponent(left.prefix, right.prefix) || + !samePath(left.qualifier, right.qualifier) || + left.searchPaths.length !== right.searchPaths.length + ) { + return false; + } + for ( + let index = 0; + index < left.searchPaths.length; + index += 1 + ) { + const leftPath = left.searchPaths[index]; + const rightPath = right.searchPaths[index]; + if ( + !leftPath || + !rightPath || + !samePath(leftPath, rightPath) + ) { + return false; + } + } + return true; +} + +function keyFrom( + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, +): StructuralKey { + return { + continuationToken: request.continuationToken, + dialect, + dialectId: request.dialectId, + limit: request.limit, + prefix: request.prefix, + qualifier: request.qualifier, + scope: request.scope, + searchPaths: request.searchPaths, + }; +} + +function textBytes(value: string): number { + return 16 + value.length * 2; +} + +function componentBytes( + component: SqlIdentifierComponent, +): number { + return COMPONENT_FIXED_BYTES + textBytes(component.value); +} + +function pathBytes(path: SqlIdentifierPath): number { + let bytes = PATH_FIXED_BYTES; + for (const component of path) { + bytes += componentBytes(component); + } + return bytes; +} + +function keyBytes(key: StructuralKey): number { + let bytes = + KEY_FIXED_BYTES + + textBytes(key.scope) + + textBytes(key.dialectId) + + componentBytes(key.prefix) + + pathBytes(key.qualifier); + if (key.continuationToken !== null) { + bytes += textBytes(key.continuationToken); + } + for (const path of key.searchPaths) { + bytes += pathBytes(path); + } + return bytes; +} + +function epochBytes(epoch: SqlCatalogEpoch): number { + return 32 + textBytes(epoch.token); +} + +function readyResponseBytes( + response: Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "ready" } + >, +): number { + let bytes = + RESPONSE_FIXED_BYTES + epochBytes(response.epoch); + if (response.coverage.kind === "paginated") { + bytes += textBytes(response.coverage.continuationToken); + } + for (const relation of response.relations) { + bytes += + RELATION_FIXED_BYTES + + textBytes(relation.entityId) + + textBytes(relation.completionText); + if (relation.detail !== undefined) { + bytes += textBytes(relation.detail); + } + for (const component of relation.canonicalPath) { + bytes += componentBytes(component); + } + for (const component of relation.completionPath) { + bytes += componentBytes(component); + } + } + return bytes; +} + +function touch( + state: StoreState, + record: RetainedRecord, +): void { + state.sequence += 1n; + record.lastUse = state.sequence; +} + +function removeAt(state: StoreState, index: number): void { + const removed = state.records.splice(index, 1); + for (const record of removed) { + state.retainedBytes -= record.bytes; + } +} + +function removeWhere( + state: StoreState, + predicate: (record: RetainedRecord) => boolean, +): void { + for ( + let index = state.records.length - 1; + index >= 0; + index -= 1 + ) { + const record = state.records[index]; + if (record && predicate(record)) removeAt(state, index); + } +} + +function retainReady( + state: StoreState, + record: ReadyRecord, +): boolean { + if ( + record.bytes > + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES + ) { + return false; + } + touch(state, record); + state.records.push(record); + state.retainedBytes += record.bytes; + while ( + state.records.length > + MAX_CATALOG_POLICY_STORE_ENTRIES || + state.retainedBytes > + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES + ) { + let oldestIndex = state.records.indexOf(record); + for (let index = 0; index < state.records.length; index += 1) { + const candidate = state.records[index]; + const oldest = state.records[oldestIndex]; + if ( + candidate?.kind === "ready" && + oldest && + candidate.lastUse < oldest.lastUse + ) { + oldestIndex = index; + } + } + removeAt(state, oldestIndex); + } + return state.records.includes(record); +} + +function retainGate( + state: StoreState, + record: FailureRecord | LoadingRecord, +): boolean { + if ( + record.bytes > + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES + ) { + return false; + } + while ( + state.records.length >= + MAX_CATALOG_POLICY_STORE_ENTRIES || + state.retainedBytes + record.bytes > + MAX_CATALOG_POLICY_STORE_RETAINED_BYTES + ) { + let oldestReadyIndex = -1; + for (let index = 0; index < state.records.length; index += 1) { + const candidate = state.records[index]; + const oldest = + oldestReadyIndex < 0 + ? null + : state.records[oldestReadyIndex]; + if ( + candidate?.kind === "ready" && + (!oldest || candidate.lastUse < oldest.lastUse) + ) { + oldestReadyIndex = index; + } + } + if (oldestReadyIndex < 0) return false; + removeAt(state, oldestReadyIndex); + } + touch(state, record); + state.records.push(record); + state.retainedBytes += record.bytes; + return true; +} + +function findReady( + state: StoreState, + key: StructuralKey, + epoch: SqlCatalogEpoch, +): ReadyRecord | null { + for (const record of state.records) { + if ( + record.kind === "ready" && + sameEpoch(record.response.epoch, epoch) && + sameBaseKey(record.key, key) + ) { + return record; + } + } + return null; +} + +function findNeverGate( + state: StoreState, + key: StructuralKey, +): FailureRecord | null { + for (const record of state.records) { + if ( + record.kind === "failure" && + record.retry === "never" && + sameBaseKey(record.key, key) + ) { + return record; + } + } + return null; +} + +function findEpochGate( + state: StoreState, + key: StructuralKey, + epoch: SqlCatalogEpoch, +): FailureRecord | LoadingRecord | null { + for (const record of state.records) { + if ( + record.kind !== "ready" && + (record.kind === "loading" || + record.retry !== "never") && + sameEpoch(record.epoch, epoch) && + sameBaseKey(record.key, key) + ) { + return record; + } + } + return null; +} + +function accepted( + response: SqlValidatedCatalogSearchResponse, + retained: + | "failure-gate" + | "loading-barrier" + | "none" + | "ready", +): SqlCatalogSearchPolicyRecordResult { + return Object.freeze({ + response, + retained, + status: "accepted", + }); +} + +function conflict( + reason: + | "capacity" + | "epoch-mismatch" + | "loading-transition" + | "retry-gated", +): SqlCatalogSearchPolicyRecordResult { + return Object.freeze({ reason, status: "conflict" }); +} + +function probe( + state: StoreState, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, +): SqlCatalogSearchPolicyProbe { + if (state.disposed) return DISPOSED_PROBE; + if (state.saturated) { + return Object.freeze({ status: "overloaded" }); + } + const key = keyFrom(request, dialect); + const epoch = request.expectedEpoch; + if (epoch === null) { + return Object.freeze({ + loadingEpoch: null, + status: "miss", + }); + } + const neverGate = findNeverGate(state, key); + if (neverGate) { + touch(state, neverGate); + return Object.freeze({ + code: neverGate.code, + epoch: request.expectedEpoch ?? neverGate.epoch, + retry: neverGate.retry, + status: "failed", + }); + } + const ready = findReady(state, key, epoch); + if (ready) { + touch(state, ready); + return Object.freeze({ + response: ready.response, + status: "ready", + }); + } + const gate = findEpochGate(state, key, epoch); + if (!gate) { + return Object.freeze({ + loadingEpoch: null, + status: "miss", + }); + } + touch(state, gate); + if (gate.kind === "loading") { + return Object.freeze({ + loadingEpoch: gate.epoch, + status: "miss", + }); + } + return Object.freeze({ + code: gate.code, + epoch: gate.epoch, + retry: gate.retry, + status: "failed", + }); +} + +function recordResponse( + state: StoreState, + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, + response: SqlValidatedCatalogSearchResponse, +): SqlCatalogSearchPolicyRecordResult { + if (state.disposed) return DISPOSED_RECORD; + if (state.saturated) return conflict("capacity"); + if ( + request.expectedEpoch !== null && + !sameEpoch(request.expectedEpoch, response.epoch) + ) { + return conflict("epoch-mismatch"); + } + const key = keyFrom(request, dialect); + const neverGate = findNeverGate(state, key); + if (neverGate) return conflict("retry-gated"); + const epochGate = findEpochGate( + state, + key, + response.epoch, + ); + if (response.status === "ready") { + if (epochGate?.kind === "loading") { + return conflict("loading-transition"); + } + if (epochGate?.kind === "failure") { + return conflict("retry-gated"); + } + removeWhere( + state, + (candidate) => + candidate.kind === "ready" && + sameEpoch(candidate.response.epoch, response.epoch) && + sameBaseKey(candidate.key, key), + ); + const retained = retainReady(state, { + bytes: keyBytes(key) + readyResponseBytes(response), + key, + kind: "ready", + lastUse: 0n, + response, + }); + return accepted(response, retained ? "ready" : "none"); + } + if (response.status === "loading") { + if (epochGate?.kind === "failure") { + return conflict("retry-gated"); + } + if (epochGate?.kind === "loading") { + touch(state, epochGate); + return accepted(response, "loading-barrier"); + } + removeWhere( + state, + (candidate) => + candidate.kind === "loading" && + sameEpoch(candidate.epoch, response.epoch) && + sameBaseKey(candidate.key, key), + ); + const retained = retainGate(state, { + bytes: keyBytes(key) + epochBytes(response.epoch) + 64, + epoch: response.epoch, + key, + kind: "loading", + lastUse: 0n, + }); + if (retained) { + return accepted(response, "loading-barrier"); + } + state.saturated = true; + return conflict("capacity"); + } + if (response.retry === "next-request") { + return accepted(response, "none"); + } + const replacedGates = state.records.filter( + (candidate): candidate is FailureRecord | LoadingRecord => + candidate.kind !== "ready" && + ((candidate.kind === "loading" && + sameEpoch(candidate.epoch, response.epoch)) || + (candidate.kind === "failure" && + (response.retry === "never" || + candidate.retry === response.retry))) && + sameBaseKey(candidate.key, key), + ); + removeWhere( + state, + (candidate) => + candidate.kind === "loading" && + sameEpoch(candidate.epoch, response.epoch) && + sameBaseKey(candidate.key, key), + ); + removeWhere( + state, + (candidate) => + candidate.kind === "failure" && + (response.retry === "never" || + candidate.retry === response.retry) && + sameBaseKey(candidate.key, key), + ); + const retained = retainGate(state, { + bytes: + keyBytes(key) + + epochBytes(response.epoch) + + textBytes(response.code) + + 80, + code: response.code, + epoch: response.epoch, + key, + kind: "failure", + lastUse: 0n, + retry: response.retry, + }); + if (!retained) { + for (const replaced of replacedGates) { + retainGate(state, replaced); + } + state.saturated = true; + } + return retained + ? accepted(response, "failure-gate") + : conflict("capacity"); +} + +function advanceScope( + state: StoreState, + scope: string, + epoch: SqlCatalogEpoch, +): void { + if (state.disposed) return; + removeWhere(state, (record) => { + if (record.key.scope !== scope) return false; + if ( + record.kind === "failure" && + record.retry === "never" + ) { + return false; + } + const recordEpoch = + record.kind === "ready" + ? record.response.epoch + : record.epoch; + return !sameEpoch(recordEpoch, epoch); + }); +} + +function metrics( + state: StoreState, +): SqlCatalogSearchPolicyStoreMetrics { + let failureGates = 0; + let loadingBarriers = 0; + let readyEntries = 0; + for (const record of state.records) { + if (record.kind === "failure") failureGates += 1; + else if (record.kind === "loading") { + loadingBarriers += 1; + } else readyEntries += 1; + } + return Object.freeze({ + entries: state.records.length, + failureGates, + loadingBarriers, + readyEntries, + retainedBytes: state.retainedBytes, + }); +} + +export function createSqlCatalogSearchPolicyStore(): SqlCatalogSearchPolicyStore { + const state: StoreState = { + disposed: false, + records: [], + retainedBytes: 0, + saturated: false, + sequence: 0n, + }; + return Object.freeze({ + advanceScope: ( + scope: string, + epoch: SqlCatalogEpoch, + ): void => { + advanceScope(state, scope, epoch); + }, + dispose: (): void => { + if (state.disposed) return; + state.disposed = true; + state.records.length = 0; + state.retainedBytes = 0; + state.saturated = false; + }, + metrics: (): SqlCatalogSearchPolicyStoreMetrics => + metrics(state), + probe: ( + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, + ): SqlCatalogSearchPolicyProbe => + probe(state, request, dialect), + record: ( + request: SqlCatalogSearchRequest, + dialect: SqlRelationDialectRuntime, + response: SqlValidatedCatalogSearchResponse, + ): SqlCatalogSearchPolicyRecordResult => + recordResponse(state, request, dialect, response), + }); +} diff --git a/src/vnext/relation-catalog-search-work.ts b/src/vnext/relation-catalog-search-work.ts index 6fc35ee..ca1034e 100644 --- a/src/vnext/relation-catalog-search-work.ts +++ b/src/vnext/relation-catalog-search-work.ts @@ -18,7 +18,14 @@ import type { SqlCatalogRevisionTarget, SqlCatalogScopeMembership, } from "./relation-catalog-epoch-coordinator.js"; +import { + createSqlCatalogSearchPolicyStore, +} from "./relation-catalog-search-policy-store.js"; +import type { + SqlCatalogSearchPolicyStore, +} from "./relation-catalog-search-policy-store.js"; import type { + SqlCatalogEpoch, SqlCatalogSearchRequest, } from "./relation-completion-types.js"; import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; @@ -35,12 +42,15 @@ export const MAX_CATALOG_QUEUED_SEARCH_WORK = 64; export const DEFAULT_CATALOG_QUEUE_DEADLINE_MS = 100; export const DEFAULT_CATALOG_EXECUTION_DEADLINE_MS = 250; export const DEFAULT_CATALOG_SYNCHRONOUS_BUDGET_MS = 8; +export const DEFAULT_CATALOG_REFRESH_LEASE_MS = 1_000; export const MIN_CATALOG_QUEUE_DEADLINE_MS = 10; export const MAX_CATALOG_QUEUE_DEADLINE_MS = 2_000; export const MIN_CATALOG_EXECUTION_DEADLINE_MS = 10; export const MAX_CATALOG_EXECUTION_DEADLINE_MS = 5_000; export const MIN_CATALOG_SYNCHRONOUS_BUDGET_MS = 1; export const MAX_CATALOG_SYNCHRONOUS_BUDGET_MS = 50; +export const MIN_CATALOG_REFRESH_LEASE_MS = 1; +export const MAX_CATALOG_REFRESH_LEASE_MS = 5_000; export interface SqlCatalogSearchDeadlineScheduler { readonly clearTimeout: ( @@ -59,6 +69,7 @@ export interface SqlCatalogSearchWorkOptions { readonly deadlineScheduler?: SqlCatalogSearchDeadlineScheduler; readonly executionDeadlineMs?: number; readonly queueDeadlineMs?: number; + readonly refreshLeaseMs?: number; readonly synchronousBudgetMs?: number; } @@ -97,8 +108,33 @@ export type SqlCatalogSearchWorkOutcome = readonly reason: SqlCatalogSearchWorkUnavailableReason; }; +export type SqlCatalogSearchAvailabilityTarget = ( + this: void, +) => + | ((this: void) => undefined) + | null; + +export type SqlCatalogSearchRefreshRetentionResult = + | { + readonly status: "retained"; + readonly remainingLeaseMs: number; + } + | { + readonly status: "unavailable"; + readonly reason: + | "disposed" + | "expired" + | "invalid-target" + | "not-retainable" + | "superseded"; + }; + export interface SqlCatalogSearchWorkTicket { readonly cancel: (this: void) => void; + readonly retainForRefresh: ( + this: void, + prepareAvailability: SqlCatalogSearchAvailabilityTarget, + ) => SqlCatalogSearchRefreshRetentionResult; readonly result: Promise; } @@ -151,6 +187,7 @@ interface NormalizedOptions { readonly deadlineScheduler: SqlCatalogSearchDeadlineScheduler; readonly executionDeadlineMs: number; readonly queueDeadlineMs: number; + readonly refreshLeaseMs: number; readonly synchronousBudgetMs: number; } @@ -160,6 +197,7 @@ interface OwnerState { disposed: boolean; readonly membership: SqlCatalogScopeMembership; owner: CoordinatorState | null; + refreshObserver: RefreshObserverState | null; requestToken: object | null; readonly scope: string; } @@ -167,12 +205,29 @@ interface OwnerState { interface ConsumerState { readonly capture: SqlCatalogEpochCapture; cancelled: boolean; + readonly kind: "consumer"; owner: OwnerState | null; resolve: (outcome: SqlCatalogSearchWorkOutcome) => void; + readonly retention: TicketRetentionState; settled: boolean; work: WorkState | null; } +interface TicketRetentionState { + consumer: ConsumerState | null; + observer: RefreshObserverState | null; +} + +interface RefreshObserverState { + readonly capture: SqlCatalogEpochCapture; + readonly kind: "observer"; + readonly prepareAvailability: SqlCatalogSearchAvailabilityTarget; + readonly owner: OwnerState; + readonly retention: TicketRetentionState; + timer: DeadlineCell | null; + work: WorkState | null; +} + interface WorkState { abortController: AbortController | null; abortIssued: boolean; @@ -181,6 +236,7 @@ interface WorkState { executionDeadline: number | null; executionTimer: DeadlineCell | null; joinable: boolean; + readonly observers: Set; readonly owners: Set; phase: | "active" @@ -218,8 +274,10 @@ interface CoordinatorState { readonly epochs: SqlCatalogEpochCoordinator; readonly joinable: Set; lastNow: number; + readonly notifications: Set; readonly options: NormalizedOptions; readonly owners: Set; + readonly policyStore: SqlCatalogSearchPolicyStore; pumpRequested: boolean; pumping: boolean; readonly queue: WorkState[]; @@ -236,14 +294,18 @@ interface CoordinatorState { interface ResponseDecisionCell { active: boolean; advancing: boolean; - candidates: readonly ConsumerState[]; + candidates: readonly ResponseCandidate[]; index: number; pending: SqlCatalogResponseEpochDecision | null; - response: SqlValidatedCatalogSearchResponse | null; - state: CoordinatorState | null; - work: WorkState | null; + readonly dialect: SqlRelationDialectRuntime; + readonly request: SqlCatalogSearchRequest; + readonly response: SqlValidatedCatalogSearchResponse; + readonly state: CoordinatorState; + readonly work: WorkState; } +type ResponseCandidate = ConsumerState | RefreshObserverState; + interface DetachedSettlement { readonly outcome: SqlCatalogSearchWorkOutcome; readonly resolve: ( @@ -251,8 +313,26 @@ interface DetachedSettlement { ) => void; } +interface AvailabilityNotificationState { + active: boolean; + readonly owner: OwnerState; + readonly requestToken: object | null; + readonly scope: string; +} + +interface AvailabilityPreparation { + readonly notification: AvailabilityNotificationState; + readonly prepare: SqlCatalogSearchAvailabilityTarget; +} + +interface AvailabilityDispatch { + readonly dispatch: Function; + readonly notification: AvailabilityNotificationState; +} + interface Effects { readonly aborts: AbortController[]; + readonly availabilityPreparations: AvailabilityPreparation[]; pump: boolean; readonly settlements: DetachedSettlement[]; readonly timers: DeadlineCell[]; @@ -303,6 +383,7 @@ function unavailableOutcome( function effects(): Effects { return { aborts: [], + availabilityPreparations: [], pump: false, settlements: [], timers: [], @@ -335,6 +416,9 @@ function normalizeOptions( const executionDeadlineMs = candidate?.executionDeadlineMs ?? DEFAULT_CATALOG_EXECUTION_DEADLINE_MS; + const refreshLeaseMs = + candidate?.refreshLeaseMs ?? + DEFAULT_CATALOG_REFRESH_LEASE_MS; const synchronousBudgetMs = candidate?.synchronousBudgetMs ?? DEFAULT_CATALOG_SYNCHRONOUS_BUDGET_MS; @@ -360,6 +444,11 @@ function normalizeOptions( MIN_CATALOG_SYNCHRONOUS_BUDGET_MS, MAX_CATALOG_SYNCHRONOUS_BUDGET_MS, ) || + !isDuration( + refreshLeaseMs, + MIN_CATALOG_REFRESH_LEASE_MS, + MAX_CATALOG_REFRESH_LEASE_MS, + ) || typeof nowMethod !== "function" || typeof setTimeoutMethod !== "function" || typeof clearTimeoutMethod !== "function" @@ -402,6 +491,7 @@ function normalizeOptions( deadlineScheduler: capturedScheduler, executionDeadlineMs, queueDeadlineMs, + refreshLeaseMs, synchronousBudgetMs, }), }; @@ -559,6 +649,7 @@ function settleDetached( outcome: SqlCatalogSearchWorkOutcome, ): void { consumer.settled = true; + consumer.retention.consumer = null; const resolve = consumer.resolve; consumer.resolve = IGNORE_DETACHED_REJECTION; consumer.work = null; @@ -572,9 +663,38 @@ function runEffects( state: CoordinatorState, pending: Effects, ): void { + const availabilityDispatches: AvailabilityDispatch[] = []; for (const timer of pending.timers) { clearDeadline(state, timer); } + for (const preparation of pending.availabilityPreparations) { + const { notification, prepare } = preparation; + if (!isCurrentNotification(state, notification)) { + retireNotification(state, notification); + continue; + } + let candidate: unknown; + try { + candidate = Reflect.apply(prepare, undefined, []); + } catch { + retireNotification(state, notification); + continue; + } + if ( + typeof candidate === "function" && + isCurrentNotification(state, notification) + ) { + availabilityDispatches.push({ + dispatch: candidate, + notification, + }); + } else if (candidate !== null) { + drainDetachedSettlement(candidate); + retireNotification(state, notification); + } else { + retireNotification(state, notification); + } + } for (const settlement of pending.settlements) { settlement.resolve(settlement.outcome); } @@ -585,9 +705,65 @@ function runEffects( // Work was made inert before provider abort. } } + for (const item of availabilityDispatches) { + const { dispatch, notification } = item; + if (!isCurrentNotification(state, notification)) { + retireNotification(state, notification); + continue; + } + retireNotification(state, notification); + try { + const result = Reflect.apply(dispatch, undefined, []); + if (result !== undefined) { + drainDetachedSettlement(result); + } + } catch { + // Availability state was already detached and cannot reopen. + } + } if (pending.pump) pump(state); } +function isCurrentNotification( + state: CoordinatorState, + notification: AvailabilityNotificationState, +): boolean { + const owner = notification.owner; + return ( + notification.active && + !state.disposed && + !owner.disposed && + owner.owner === state && + owner.requestToken === notification.requestToken + ); +} + +function retireNotification( + state: CoordinatorState, + notification: AvailabilityNotificationState, +): void { + if (!notification.active) return; + notification.active = false; + state.notifications.delete(notification); +} + +function retireNotifications( + state: CoordinatorState, + predicate: ( + notification: AvailabilityNotificationState, + ) => boolean, +): void { + for (const notification of state.notifications) { + if (predicate(notification)) { + retireNotification(state, notification); + } + } +} + +function hasWorkOwners(work: WorkState): boolean { + return work.owners.size > 0 || work.observers.size > 0; +} + function sameComponent( left: SqlIdentifierComponent, right: SqlIdentifierComponent, @@ -698,9 +874,6 @@ function revokeDecisionCell(work: WorkState): void { cell.active = false; cell.candidates = []; cell.pending = null; - cell.response = null; - cell.state = null; - cell.work = null; } function detachAbort( @@ -727,6 +900,32 @@ function detachOwners( } } +function detachObserverState( + observer: RefreshObserverState, + pending: Effects, +): void { + const work = observer.work; + if (!work) return; + observer.work = null; + work.observers.delete(observer); + const owner = observer.owner; + owner.refreshObserver = null; + const timer = observer.timer; + observer.timer = null; + if (timer) pending.timers.push(timer); + observer.retention.observer = null; +} + +function detachObservers( + work: WorkState, + pending: Effects, +): void { + const observers = [...work.observers]; + for (const observer of observers) { + detachObserverState(observer, pending); + } +} + function finishWork( state: CoordinatorState, work: WorkState, @@ -756,6 +955,7 @@ function finishWork( revokeProviderCell(work); revokeDecisionCell(work); detachOwners(work, outcome, pending); + detachObservers(work, pending); if (abort) detachAbort(work, pending); else work.abortController = null; work.dialect = null; @@ -772,12 +972,44 @@ function retireActiveOwners( removeJoinable(state, work); work.phase = "retired"; detachOwners(work, outcome, pending); + detachObservers(work, pending); detachAbort(work, pending); work.dialect = null; work.request = null; work.scope = ""; } +function retireUnownedWork( + state: CoordinatorState, + work: WorkState, + pending: Effects, +): void { + if (work.phase === "queued") { + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + } else if (work.phase === "active") { + retireActiveOwners( + state, + work, + CANCELLED_OUTCOME, + pending, + ); + } else if (work.phase === "deciding") { + finishWork( + state, + work, + CANCELLED_OUTCOME, + pending, + false, + ); + } +} + function detachConsumerInto( state: CoordinatorState, consumer: ConsumerState, @@ -789,31 +1021,8 @@ function detachConsumerInto( work.owners.delete(consumer); } settleDetached(pending.settlements, consumer, outcome); - if (work && work.owners.size === 0) { - if (work.phase === "queued") { - finishWork( - state, - work, - CANCELLED_OUTCOME, - pending, - false, - ); - } else if (work.phase === "active") { - retireActiveOwners( - state, - work, - CANCELLED_OUTCOME, - pending, - ); - } else if (work.phase === "deciding") { - finishWork( - state, - work, - CANCELLED_OUTCOME, - pending, - false, - ); - } + if (work && !hasWorkOwners(work)) { + retireUnownedWork(state, work, pending); } } @@ -832,6 +1041,26 @@ function detachConsumer( runEffects(state, pending); } +function detachRefreshObserverInto( + state: CoordinatorState, + observer: RefreshObserverState, + pending: Effects, +): void { + const work = observer.work; + detachObserverState(observer, pending); + if (!work || hasWorkOwners(work)) return; + retireUnownedWork(state, work, pending); +} + +function detachRefreshObserver( + state: CoordinatorState, + observer: RefreshObserverState, +): void { + const pending = effects(); + detachRefreshObserverInto(state, observer, pending); + runEffects(state, pending); +} + function handleQueueTimeout( state: CoordinatorState, work: WorkState, @@ -958,6 +1187,64 @@ function finishDecision( runEffects(state, pending); } +function prepareAvailabilityObservers( + state: CoordinatorState, + work: WorkState, + pending: Effects, +): void { + const observers = [...work.observers]; + for (const observer of observers) { + const prepare = observer.prepareAvailability; + const notification: AvailabilityNotificationState = { + active: true, + owner: observer.owner, + requestToken: observer.owner.requestToken, + scope: observer.owner.scope, + }; + state.notifications.add(notification); + detachObserverState(observer, pending); + pending.availabilityPreparations.push({ + notification, + prepare, + }); + } +} + +function finishUsableDecision( + cell: ResponseDecisionCell, + state: CoordinatorState, + work: WorkState, + outcome: Extract< + SqlCatalogSearchWorkOutcome, + { readonly status: "usable" } + >, +): void { + const recorded = state.policyStore.record( + cell.request, + cell.dialect, + outcome.response, + ); + if (recorded.status !== "accepted") { + finishDecision( + cell, + state, + work, + recorded.status === "conflict" && + recorded.reason === "capacity" + ? unavailableOutcome("overloaded") + : SUPERSEDED_OUTCOME, + ); + return; + } + cell.active = false; + const pending = effects(); + if (outcome.response.status === "ready") { + prepareAvailabilityObservers(state, work, pending); + } + finishWork(state, work, outcome, pending, false); + runEffects(state, pending); +} + function decisionOutcome( decision: Exclude< SqlCatalogResponseEpochDecision, @@ -1011,7 +1298,6 @@ function advanceDecision(cell: ResponseDecisionCell): void { const state = cell.state; const work = cell.work; const response = cell.response; - if (!state || !work || !response) return; const pendingDecision = cell.pending; cell.pending = null; if (pendingDecision) { @@ -1024,7 +1310,7 @@ function advanceDecision(cell: ResponseDecisionCell): void { if (pendingDecision.observation === "baseline") { rekeyUnobservedWork(state, work, response); } - finishDecision( + finishUsableDecision( cell, state, work, @@ -1051,7 +1337,9 @@ function advanceDecision(cell: ResponseDecisionCell): void { cell.index += 1; if ( candidate && - !candidate.settled && + (candidate.kind === "observer" + ? candidate.work !== null + : !candidate.settled) && candidate.work === work ) { capture = candidate.capture; @@ -1178,7 +1466,7 @@ function handleProviderResult( if ( state.disposed || work.phase !== "deciding" || - work.owners.size === 0 + !hasWorkOwners(work) ) { return; } @@ -1186,7 +1474,7 @@ function handleProviderResult( if ( state.disposed || work.phase !== "deciding" || - work.owners.size === 0 || + !hasWorkOwners(work) || afterDecode === null || work.executionDeadline === null || afterDecode >= work.executionDeadline @@ -1209,9 +1497,14 @@ function handleProviderResult( const cell: ResponseDecisionCell = { active: true, advancing: false, - candidates: [...work.owners], + candidates: [ + ...work.owners, + ...work.observers, + ], + dialect, index: 0, pending: null, + request, response: decoded.value, state, work, @@ -1228,7 +1521,7 @@ function startWork( if ( state.disposed || work.phase !== "queued" || - work.owners.size === 0 + !hasWorkOwners(work) ) { return; } @@ -1358,6 +1651,16 @@ function makeImmediateTicket( ): SqlCatalogSearchWorkTicket { return Object.freeze({ cancel: (): void => {}, + retainForRefresh: + (): SqlCatalogSearchRefreshRetentionResult => + Object.freeze({ + reason: + outcome.status === "unavailable" && + outcome.reason === "disposed" + ? "disposed" + : "not-retainable", + status: "unavailable", + }), result: new INTRINSIC_PROMISE( (resolve) => { resolve(outcome); @@ -1366,6 +1669,110 @@ function makeImmediateTicket( }); } +function unavailableRetention( + reason: Extract< + SqlCatalogSearchRefreshRetentionResult, + { readonly status: "unavailable" } + >["reason"], +): SqlCatalogSearchRefreshRetentionResult { + return Object.freeze({ reason, status: "unavailable" }); +} + +function retainConsumerForRefresh( + state: CoordinatorState, + consumer: ConsumerState, + owner: OwnerState, + work: WorkState, + prepareAvailability: SqlCatalogSearchAvailabilityTarget, +): SqlCatalogSearchRefreshRetentionResult { + if (typeof prepareAvailability !== "function") { + return unavailableRetention("invalid-target"); + } + const retention = consumer.retention; + const hardDeadline = + work.phase === "queued" + ? work.queueDeadline + : work.executionDeadline; + const requestToken = owner.requestToken; + const now = readNow(state); + if ( + state.disposed || + owner.disposed || + owner.owner !== state + ) { + return unavailableRetention("disposed"); + } + if ( + owner.requestToken !== requestToken || + consumer.settled || + consumer.work !== work || + owner.current !== consumer + ) { + return unavailableRetention("superseded"); + } + if ( + now === null || + hardDeadline === null || + now >= hardDeadline + ) { + return unavailableRetention("expired"); + } + const leaseDeadline = deadlineFrom( + now, + state.options.refreshLeaseMs, + ); + if (leaseDeadline === null) { + return unavailableRetention("expired"); + } + const deadline = Math.min( + hardDeadline, + leaseDeadline, + ); + const observer: RefreshObserverState = { + capture: consumer.capture, + kind: "observer", + owner, + prepareAvailability, + retention, + timer: null, + work, + }; + work.observers.add(observer); + owner.refreshObserver = observer; + retention.observer = observer; + const pending = effects(); + work.owners.delete(consumer); + settleDetached( + pending.settlements, + consumer, + CANCELLED_OUTCOME, + ); + const timer = scheduleDeadline( + state, + deadline, + () => detachRefreshObserver(state, observer), + ); + if (observer.work) { + observer.timer = timer; + } else { + clearDeadline(state, timer); + } + runEffects(state, pending); + if (!observer.work) { + return unavailableRetention( + state.disposed || owner.disposed + ? "disposed" + : owner.requestToken !== requestToken + ? "superseded" + : "expired", + ); + } + return Object.freeze({ + remainingLeaseMs: deadline - now, + status: "retained", + }); +} + function makeConsumer( capture: SqlCatalogEpochCapture, owner: OwnerState, @@ -1381,20 +1788,37 @@ function makeConsumer( resolve = settle; }, ); + const retention: TicketRetentionState = { + consumer: null, + observer: null, + }; const consumer: ConsumerState = { cancelled: false, capture, + kind: "consumer", owner, resolve, + retention, settled: false, work: null, }; + retention.consumer = consumer; return { consumer, ticket: Object.freeze({ cancel: (): void => { - if (consumer.cancelled || consumer.settled) return; + if (consumer.cancelled) return; consumer.cancelled = true; + const observer = retention.observer; + const observerState = observer?.owner?.owner; + if (observer && observerState) { + detachRefreshObserver( + observerState, + observer, + ); + return; + } + if (consumer.settled) return; const state = consumer.owner?.owner; if (state) { detachConsumer( @@ -1404,6 +1828,30 @@ function makeConsumer( ); } }, + retainForRefresh: ( + prepareAvailability: + SqlCatalogSearchAvailabilityTarget, + ): SqlCatalogSearchRefreshRetentionResult => { + const retainedConsumer = retention.consumer; + const retainedOwner = retainedConsumer?.owner; + const retainedWork = retainedConsumer?.work; + const state = retainedOwner?.owner; + if ( + !retainedConsumer || + !retainedOwner || + !retainedWork || + !state + ) { + return unavailableRetention("not-retainable"); + } + return retainConsumerForRefresh( + state, + retainedConsumer, + retainedOwner, + retainedWork, + prepareAvailability, + ); + }, result, }), }; @@ -1428,6 +1876,7 @@ function replaceOwnerConsumer( ): void { const pending = effects(); const previous = owner.current; + const previousObserver = owner.refreshObserver; work.owners.add(consumer); consumer.work = work; owner.current = consumer; @@ -1439,9 +1888,56 @@ function replaceOwnerConsumer( pending, ); } + if (previousObserver) { + detachRefreshObserverInto( + state, + previousObserver, + pending, + ); + } runEffects(state, pending); } +function replaceOwnerWithImmediate( + state: CoordinatorState, + owner: OwnerState, + requestToken: object, + outcome: SqlCatalogSearchWorkOutcome, +): SqlCatalogSearchWorkTicket { + const pending = effects(); + const previous = owner.current; + const previousObserver = owner.refreshObserver; + if (previous) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + pending, + ); + } + if (previousObserver) { + detachRefreshObserverInto( + state, + previousObserver, + pending, + ); + } + runEffects(state, pending); + if ( + state.disposed || + owner.disposed || + owner.owner !== state + ) { + return makeImmediateTicket( + unavailableOutcome("disposed"), + ); + } + if (owner.requestToken !== requestToken) { + return makeImmediateTicket(SUPERSEDED_OUTCOME); + } + return makeImmediateTicket(outcome); +} + function requestWork( state: CoordinatorState, owner: OwnerState, @@ -1473,6 +1969,13 @@ function requestWork( pending, ); } + if (owner.refreshObserver) { + detachRefreshObserverInto( + state, + owner.refreshObserver, + pending, + ); + } runEffects(state, pending); return makeImmediateTicket( unavailableOutcome( @@ -1513,21 +2016,73 @@ function requestWork( return makeImmediateTicket(SUPERSEDED_OUTCOME); } if (!request) { - const pending = effects(); - const previous = owner.current; - if (previous) { - detachConsumerInto( - state, - previous, - SUPERSEDED_OUTCOME, - pending, - ); - } - runEffects(state, pending); - return makeImmediateTicket( + return replaceOwnerWithImmediate( + state, + owner, + requestToken, unavailableOutcome("invalid-request"), ); } + const policy = state.policyStore.probe( + request, + owner.dialect, + ); + if (policy.status === "ready") { + return replaceOwnerWithImmediate( + state, + owner, + requestToken, + Object.freeze({ + observation: "equal", + response: policy.response, + status: "usable", + }), + ); + } + if (policy.status === "failed") { + return replaceOwnerWithImmediate( + state, + owner, + requestToken, + Object.freeze({ + observation: "equal", + response: Object.freeze({ + code: policy.code, + epoch: policy.epoch, + retry: policy.retry, + status: "failed", + }), + status: "usable", + }), + ); + } + if (policy.status === "overloaded") { + return replaceOwnerWithImmediate( + state, + owner, + requestToken, + unavailableOutcome("overloaded"), + ); + } + if ( + policy.status === "miss" && + policy.loadingEpoch !== null && + state.epochs.hasLiveSubscription(owner.scope) + ) { + return replaceOwnerWithImmediate( + state, + owner, + requestToken, + Object.freeze({ + observation: "equal", + response: Object.freeze({ + epoch: policy.loadingEpoch, + status: "loading", + }), + status: "usable", + }), + ); + } const created = makeConsumer(captured.capture, owner); const existing = findWork( state, @@ -1545,6 +2100,7 @@ function requestWork( } const pending = effects(); const previous = owner.current; + const previousObserver = owner.refreshObserver; if (previous) { detachConsumerInto( state, @@ -1553,6 +2109,13 @@ function requestWork( pending, ); } + if (previousObserver) { + detachRefreshObserverInto( + state, + previousObserver, + pending, + ); + } if ( state.queue.length >= MAX_CATALOG_QUEUED_SEARCH_WORK @@ -1602,6 +2165,7 @@ function requestWork( executionDeadline: null, executionTimer: null, joinable: true, + observers: new Set(), owners: new Set([created.consumer]), phase: "queued", providerCell: null, @@ -1636,8 +2200,13 @@ function requestWork( function prepareTransition( state: CoordinatorState, scope: string, + epoch: SqlCatalogEpoch, ): (() => undefined) | null { - if (state.disposed) return null; + retireNotifications( + state, + (notification) => notification.scope === scope, + ); + state.policyStore.advanceScope(scope, epoch); const pending = effects(); for (const work of state.works) { if ( @@ -1693,11 +2262,35 @@ function disposeOwner(owner: OwnerState): void { owner.disposed = true; owner.owner = null; owner.requestToken = null; - state?.owners.delete(owner); + if (state) { + retireNotifications( + state, + (notification) => notification.owner === owner, + ); + state.owners.delete(owner); + } const current = owner.current; + const observer = owner.refreshObserver; owner.current = null; - if (state && current) { - detachConsumer(state, current, CANCELLED_OUTCOME); + owner.refreshObserver = null; + if (state && (current || observer)) { + const pending = effects(); + if (current) { + detachConsumerInto( + state, + current, + CANCELLED_OUTCOME, + pending, + ); + } + if (observer) { + detachRefreshObserverInto( + state, + observer, + pending, + ); + } + runEffects(state, pending); } owner.membership.dispose(); } @@ -1756,6 +2349,7 @@ function prepareOwner( disposed: false, membership: prepared.membership, owner: state, + refreshObserver: null, requestToken: null, scope, }; @@ -1773,6 +2367,7 @@ function disposeCoordinatorState( state.disposed = true; state.search = null; state.pumpRequested = false; + retireNotifications(state, () => true); const pending = effects(); for (const work of state.works) { finishWork( @@ -1787,9 +2382,11 @@ function disposeCoordinatorState( owner.disposed = true; owner.current = null; owner.owner = null; + owner.refreshObserver = null; owner.requestToken = null; } state.owners.clear(); + state.policyStore.dispose(); state.epochs.dispose(); runEffects(state, pending); } @@ -1815,8 +2412,8 @@ export function createSqlCatalogSearchWorkCoordinator( let state: CoordinatorState | null = null; const epochResult = createSqlCatalogEpochCoordinator( provider, - (scope): (() => undefined) | null => - state ? prepareTransition(state, scope) : null, + (scope, epoch): (() => undefined) | null => + state ? prepareTransition(state, scope, epoch) : null, (): undefined => { if (state) disposeCoordinatorState(state); return undefined; @@ -1834,8 +2431,10 @@ export function createSqlCatalogSearchWorkCoordinator( epochs: epochResult.coordinator, joinable: new Set(), lastNow: normalized.initialNow, + notifications: new Set(), options: normalized.options, owners: new Set(), + policyStore: createSqlCatalogSearchPolicyStore(), pumpRequested: false, pumping: false, queue: [], diff --git a/test/vnext-types/relation-catalog-search-work.test-d.ts b/test/vnext-types/relation-catalog-search-work.test-d.ts index 8176b88..d34f12f 100644 --- a/test/vnext-types/relation-catalog-search-work.test-d.ts +++ b/test/vnext-types/relation-catalog-search-work.test-d.ts @@ -9,6 +9,7 @@ import { createSqlCatalogEpochCoordinator, } from "../../src/vnext/relation-catalog-epoch-coordinator.js"; import type { + SqlCatalogSearchAvailabilityTarget, SqlCatalogSearchWorkCoordinator, SqlCatalogSearchWorkInput, SqlCatalogSearchWorkOutcome, @@ -82,6 +83,31 @@ const thisFreeRequest: ( const thisFreeCancel: ( this: void, ) => void = ticket.cancel; +const thisFreeRetainForRefresh: SqlCatalogSearchWorkTicket["retainForRefresh"] = + ticket.retainForRefresh; +const retention = ticket.retainForRefresh( + (): ((this: void) => undefined) => + (): undefined => undefined, +); +if (retention.status === "retained") { + void retention.remainingLeaseMs; +} + +const asyncAvailabilityDispatch = (): Promise => + Promise.resolve(undefined); +const invalidAsyncAvailabilityTarget: SqlCatalogSearchAvailabilityTarget = + // @ts-expect-error availability dispatch is synchronously exact-undefined + () => asyncAvailabilityDispatch; + +const receiverDependentAvailability = function ( + this: { readonly active: boolean }, +): null { + void this.active; + return null; +}; +// @ts-expect-error availability preparation cannot depend on a receiver +const invalidAvailabilityReceiver: SqlCatalogSearchAvailabilityTarget = + receiverDependentAvailability; const receiverDependentRequest = function ( this: { readonly active: boolean }, @@ -241,10 +267,13 @@ void thisFreeActivate; void thisFreeOwnerDispose; void thisFreeRequest; void thisFreeCancel; +void thisFreeRetainForRefresh; void invalidPrepareOwnerReceiver; void invalidActivateReceiver; void invalidRequestReceiver; void invalidCancelReceiver; +void invalidAsyncAvailabilityTarget; +void invalidAvailabilityReceiver; void scopeLeakingInput; void providerLeakingInput; void providerHandleLeakingInput; From 0f350efa11d299c8e6cf6b9bd8df687d93b7361e Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 22:10:10 +0800 Subject: [PATCH 28/42] feat(vnext): expose session relation completion (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - expose one framework-independent completion lifecycle through `SqlDocumentSession.complete()` and `onDidChange()` - compose visible CTEs with one bounded catalog page using deterministic ranking, proven shadowing, original-document UTF-16 edits, and closed incomplete reasons - share catalog scheduling/cache/epoch ownership at service scope while keeping request, refresh intent, and terminal-loading lifecycle session-local - make the public completion result extensible with a `kind: "relation"` item discriminant - document the consumer contract and add cold/warm local plus cold/cached catalog benchmarks ## Why The lower catalog scheduler and parser-independent query-site work were not yet usable through the public session. This slice connects those layers without exposing coordinator identities, epochs, timers, or CodeMirror state. It gives adapters a bounded local-first response and an explicit revision event when catalog evidence becomes usable. Catalog scope is treated as a live connection incarnation. Loading, partial, paginated, failed, overloaded, and timed-out evidence remains explicit, so unresolved marimo metadata cannot be mistaken for a complete empty catalog. ## Evidence - 1,989 unit tests pass with one expected failure - changed production files: 98.02% statements, 95.13% branches, 100% functions, 98.23% lines - strict and loose-optional vNext type fixtures pass - source, test, and demo typechecks pass - oxlint and test-integrity checks pass - 10 KiB completion benchmark: cold p99 about 1.5 ms; warm p99 below 0.8 ms - two independent exact-head reviews approved the final lifecycle, API, timer, and consumer semantics --- ## Summary by cubic Exposes a session-based, framework-independent relation completion API with deterministic CTE + catalog suggestions and clear change events. Aligns CI bundle budgets for both the core and the worker fixture to match the new relation service. - **New Features** - `SqlDocumentSession.complete(request)` returns `SqlCompletionResult` with `kind: "relation"` items. - `SqlDocumentSession.onDidChange(listener)` emits `SqlSessionChangeEvent` when catalog evidence becomes usable. - Deterministic ranking for local CTEs plus a bounded catalog page; supports cancellation and response budgets. - `createSqlLanguageService({ catalog, completion: { catalogResponseBudgetMs } })` to configure a catalog and timing. - Docs updated in `@marimo-team/codemirror-sql/vnext`; added unit tests and a 10 KiB session completion benchmark. - CI: set core bundle allocation to 48 KiB gzip (180 KiB raw) and raised worker fixture ceiling to 160 KiB gzip (700 KiB raw); updated worker README and capability charter. - **Migration** - Rename `SqlRelationCompletionItem` → `SqlCompletionItem` and `SqlRelationCompletionList` → `SqlCompletionList`. - Update CodeMirror resolver signatures to accept `SqlCompletionItem`. Written for commit c66cffcc0f9ce8e2df7a7ef985723c09074a85cb. Summary will update on new commits. Review in cubic --- docs/vnext/capability-charter.md | 1 + docs/vnext/session-primitives.md | 75 +- package.json | 1 + scripts/worker-placement.mjs | 8 +- .../__tests__/relation-completion.test.ts | 652 +++++ .../__tests__/session-completion.bench.ts | 152 + src/vnext/__tests__/session.test.ts | 2554 ++++++++++++++++- .../codemirror/relation-completion-types.ts | 4 +- src/vnext/index.ts | 23 + src/vnext/relation-completion-types.ts | 37 +- src/vnext/relation-completion.ts | 522 ++++ src/vnext/session.ts | 886 +++++- src/vnext/types.ts | 18 + state/artifacts/reports/pr-197-browser-ci.md | 44 + ...o-relation-completion-codemirror.test-d.ts | 6 +- .../marimo-relation-completion.test-d.ts | 14 +- test/vnext-types/session.test-d.ts | 35 + test/worker-placement/README.md | 22 +- 18 files changed, 4884 insertions(+), 170 deletions(-) create mode 100644 src/vnext/__tests__/relation-completion.test.ts create mode 100644 src/vnext/__tests__/session-completion.bench.ts create mode 100644 src/vnext/relation-completion.ts create mode 100644 state/artifacts/reports/pr-197-browser-ci.md diff --git a/docs/vnext/capability-charter.md b/docs/vnext/capability-charter.md index d6353aa..d04c8dd 100644 --- a/docs/vnext/capability-charter.md +++ b/docs/vnext/capability-charter.md @@ -239,6 +239,7 @@ count and estimated retained bytes. Provisional compressed bundle budgets: +- Framework-independent core with relation completion: 48 KiB - Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB - Each ordinary dialect module: 25 KiB - Optional `node-sql-parser` chunk: no regression from its recorded baseline diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 6c200d3..6018722 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -3,10 +3,10 @@ Status: experimental walking skeleton Import: `@marimo-team/codemirror-sql/vnext` -This entry point currently provides document ownership, atomic text/context -updates, opaque revisions, dialect registration, and lifecycle management. It -does not yet provide parsing, completion, diagnostics, hover, or navigation. -Those methods will be added only with working vertical slices. +This entry point provides document ownership, atomic text/context updates, +opaque revisions, dialect registration, lifecycle management, and +parser-independent relation completion. Diagnostics, hover, navigation, and +general expression completion are not yet available. See [source coordinates](./source-coordinates.md) for the shared UTF-16 range contract and the internal immutable source-snapshot model. @@ -56,6 +56,73 @@ Use `{ kind: "replace", text }` for full replacement and also supplies the complete embedded-region set for its resulting text. A region-only transaction can replace or clear that set without a fake text edit. +## Relation completion + +Completion works without a catalog for visible CTEs. A service can also own one +shared asynchronous relation-catalog provider: + +```ts +const service = createSqlLanguageService({ + catalog: { + id: "app-catalog", + search: async (request, signal) => { + signal.throwIfAborted(); + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }, + completion: { catalogResponseBudgetMs: 40 }, + dialects: [duckdbDialect()], +}); + +const session = service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + catalog: { scope: "connection-incarnation:1" }, + }, + text: "SELECT * FROM ", +}); + +const subscription = session.onDidChange(({ reason }) => { + if (reason === "catalog" || reason === "catalog-availability") { + // Ask the editor adapter to request completion again. + } +}); + +const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, +}); + +if (result.status === "ready" && session.isCurrent(result.revision)) { + for (const item of result.value.items) { + // Apply item.edit in the original document's UTF-16 coordinates. + } +} + +subscription.dispose(); +session.dispose(); +service.dispose(); +``` + +The catalog `scope` identifies a live connection incarnation, not a reusable +display name. Search responses distinguish complete, partial, paginated, +loading, and failed evidence. A ready result can therefore be incomplete; its +closed `issues` explain why, while `sources` reports catalog outcomes without +exposing provider errors or internal epochs. + +The interactive catalog wait is bounded from the start of `complete()`. When +the budget expires, the session returns local evidence with a +`catalog-loading` issue and a checked remaining intent lease. Compatible +readiness advances the session revision and emits `catalog-availability`; +higher provider epochs emit `catalog`. Consumers must request completion again +and apply only results whose revision remains current. + ## Dialect registration `duckdbDialect()`, `postgresDialect()`, `bigQueryDialect()`, and diff --git a/package.json b/package.json index 401581f..989463c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", + "bench:session-completion": "vitest bench --run src/vnext/__tests__/session-completion.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 351e535..fb6e692 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,11 +35,11 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_GZIP_LIMIT = 16 * 1024; -const CORE_TOTAL_RAW_LIMIT = 52 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 48 * 1024; +const CORE_TOTAL_RAW_LIMIT = 180 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 128 * 1024; -const WORKER_TOTAL_RAW_LIMIT = 570 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 160 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 700 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], diff --git a/src/vnext/__tests__/relation-completion.test.ts b/src/vnext/__tests__/relation-completion.test.ts new file mode 100644 index 0000000..8a25702 --- /dev/null +++ b/src/vnext/__tests__/relation-completion.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, it } from "vitest"; +import type { + SqlValidatedCatalogRelation, + SqlValidatedCatalogSearchResponse, +} from "../relation-catalog-boundary.js"; +import { + composeSqlRelationCompletion, + MAX_RELATION_COMPLETION_RESULTS, + type SqlComposableCatalogOutcome, +} from "../relation-completion.js"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationSiteResult, +} from "../local-relation-site.js"; +import { + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, +} from "../statement-index.js"; +import type { + SqlCanonicalRelationPath, + SqlCatalogRelationKind, + SqlCatalogReadyCoverage, + SqlCompletionList, + SqlRelationCompletionDialectRuntime, +} from "../relation-completion-types.js"; +import type { + SqlTextRange, +} from "../types.js"; + +interface MarkedLocalSite { + readonly localSite: Extract< + SqlLocalRelationSiteResult, + { readonly status: "ready" } + >; + readonly replacementRange: SqlTextRange; + readonly statementOffset: number; +} + +function markedLocalSite(marked: string): MarkedLocalSite { + const position = marked.indexOf("|"); + if ( + position < 0 || + marked.indexOf("|", position + 1) >= 0 + ) { + throw new Error("Expected exactly one cursor marker"); + } + const text = + marked.slice(0, position) + marked.slice(position + 1); + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + const prepared = prepareSqlLocalRelationStatement( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (prepared.status !== "ready") { + throw new Error(`Local statement unavailable: ${prepared.reason}`); + } + const localSite = analyzeSqlLocalRelationSite( + prepared.statement, + position, + ); + if (localSite.status !== "ready") { + throw new Error(`Local site unavailable: ${localSite.status}`); + } + if (slot.boundaryQuality !== "exact") { + throw new Error("Ready local relation site requires an exact slot"); + } + return { + localSite, + replacementRange: Object.freeze({ + from: + slot.source.from + localSite.querySite.typedPathRange.from, + to: + slot.source.from + localSite.querySite.typedPathRange.to, + }), + statementOffset: slot.source.from, + }; +} + +function relationPath( + containers: readonly string[], + name: string, +): SqlCanonicalRelationPath { + return Object.freeze([ + ...containers.map((value) => + Object.freeze({ + quoted: false, + role: "schema" as const, + value, + }), + ), + Object.freeze({ + quoted: false, + role: "relation" as const, + value: name, + }), + ]); +} + +function catalogRelation(input: { + readonly completionPathStart?: number; + readonly containers?: readonly string[]; + readonly detail?: string; + readonly entityId: string; + readonly kind?: SqlCatalogRelationKind; + readonly match?: "equivalent" | "exact"; + readonly name: string; +}): SqlValidatedCatalogRelation { + const path = relationPath( + input.containers ?? [], + input.name, + ); + const completionPathStart = + input.completionPathStart ?? 0; + if ( + completionPathStart !== 0 && + completionPathStart !== path.length - 1 + ) { + throw new Error("Test helper supports full or relation-only completion"); + } + const completionPath = + completionPathStart === 0 + ? path + : relationPath([], input.name); + const relation = { + canonicalPath: path, + completionPath, + completionPathStart, + completionText: completionPath + .map((component) => component.value) + .join("."), + entityId: input.entityId, + matchQuality: input.match ?? "exact", + relationKind: input.kind ?? "table", + }; + return Object.freeze( + input.detail === undefined + ? relation + : { ...relation, detail: input.detail }, + ); +} + +function readyResponse( + relations: readonly SqlValidatedCatalogRelation[], + coverage: SqlCatalogReadyCoverage = Object.freeze({ + kind: "complete", + }), +): Extract< + SqlValidatedCatalogSearchResponse, + { readonly status: "ready" } +> { + return Object.freeze({ + coverage, + epoch: Object.freeze({ generation: 1, token: "one" }), + relations: Object.freeze([...relations]), + status: "ready", + }); +} + +function usable( + response: SqlValidatedCatalogSearchResponse, +): SqlComposableCatalogOutcome { + return Object.freeze({ + observation: "equal", + response, + status: "usable", + }); +} + +function compose( + marked: string, + catalogOutcome: SqlComposableCatalogOutcome, +): SqlCompletionList { + const local = markedLocalSite(marked); + return composeSqlRelationCompletion({ + catalogOutcome, + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: local.localSite, + providerId: + catalogOutcome === null ? null : "catalog", + remainingIntentLeaseMs: 25, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }).value; +} + +describe("relation completion composition", () => { + it("ranks CTEs before deterministic catalog tiers and shadows only unqualified insertions", () => { + const relations = [ + catalogRelation({ + entityId: "equivalent", + match: "equivalent", + name: "aardvark", + }), + catalogRelation({ + completionPathStart: 0, + containers: ["public"], + entityId: "qualified-users", + name: "users", + }), + catalogRelation({ + entityId: "view", + kind: "view", + name: "beta", + }), + catalogRelation({ + entityId: "shadowed-users", + name: "users", + }), + catalogRelation({ + entityId: "table", + name: "alpha", + }), + ]; + const value = compose( + "WITH zed AS (SELECT 1), users AS (SELECT 1) SELECT * FROM |", + usable(readyResponse(relations)), + ); + + expect( + value.items.map((item) => [ + item.relationKind, + item.label, + item.edit.insert, + ]), + ).toEqual([ + ["cte", "users", "users"], + ["cte", "zed", "zed"], + ["table", "alpha", "alpha"], + ["view", "beta", "beta"], + ["table", "users", "public.users"], + ["table", "aardvark", "aardvark"], + ]); + expect( + value.items.some( + (item) => + item.provenance.kind === "catalog" && + item.provenance.entityId === "shadowed-users", + ), + ).toBe(false); + expect(value.isIncomplete).toBe(false); + }); + + it("uses the mapped whole-path replacement range and preserves qualified catalog candidates", () => { + const local = markedLocalSite( + "WITH users AS (SELECT 1) SELECT * FROM public.us|", + ); + const result = composeSqlRelationCompletion({ + catalogOutcome: usable( + readyResponse([ + catalogRelation({ + completionPathStart: 1, + containers: ["public"], + entityId: "users", + name: "users", + }), + ]), + ), + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: local.localSite, + providerId: "catalog", + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }); + + expect(result.value.items).toHaveLength(1); + expect(result.value.items[0]?.edit).toEqual({ + from: local.replacementRange.from, + insert: "users", + to: local.replacementRange.to, + }); + }); + + it("reports soft loading, terminal catalog evidence, and coverage in a stable issue order", () => { + const loading = compose( + "SELECT * FROM |", + Object.freeze({ status: "loading" }), + ); + expect(loading).toMatchObject({ + isIncomplete: true, + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 25, + }, + ], + }); + + const partial = compose( + 'SELECT * FROM "unterminated|', + usable( + readyResponse( + [], + Object.freeze({ kind: "partial" }), + ), + ), + ); + expect(partial.issues.map((issue) => issue.reason)).toEqual([ + "query-site-recovery", + "catalog-partial", + ]); + + const paginated = compose( + "SELECT * FROM |", + usable( + readyResponse( + [], + Object.freeze({ + continuationToken: "secret", + kind: "paginated", + }), + ), + ), + ); + expect(paginated.issues).toEqual([ + { reason: "catalog-paginated" }, + ]); + expect(JSON.stringify(paginated)).not.toContain("secret"); + }); + + it.each([ + ["execution-timeout", "catalog-timeout", "execution-timeout"], + ["malformed-response", "catalog-malformed", "malformed-response"], + ["overloaded", "catalog-overloaded", "queue-overloaded"], + ["provider-failed", "catalog-failed", "provider-rejected"], + ["queue-timeout", "catalog-queue-timeout", "queue-timeout"], + ] as const)( + "maps %s without discarding local evidence", + (reason, issue, reportReason) => { + const local = markedLocalSite( + "WITH local_table AS (SELECT 1) SELECT * FROM |", + ); + const result = composeSqlRelationCompletion({ + catalogOutcome: Object.freeze({ + reason, + status: "unavailable", + }), + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: local.localSite, + providerId: "catalog", + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }); + + expect(result.value.items[0]?.label).toBe("local_table"); + expect(result.value.issues).toContainEqual({ reason: issue }); + expect(result.sources).toEqual([ + { + feature: "relation-catalog", + outcome: "unavailable", + providerId: "catalog", + reason: reportReason, + }, + ]); + }, + ); + + it("composes terminal loading and failed provider evidence", () => { + const loading = compose( + "SELECT * FROM |", + usable( + Object.freeze({ + epoch: Object.freeze({ + generation: 2, + token: "loading", + }), + status: "loading", + }), + ), + ); + expect(loading.issues).toEqual([ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 25, + }, + ]); + + const local = markedLocalSite("SELECT * FROM |"); + const failed = composeSqlRelationCompletion({ + catalogOutcome: usable( + Object.freeze({ + code: "authentication", + epoch: Object.freeze({ + generation: 2, + token: "failed", + }), + retry: "after-invalidation", + status: "failed", + }), + ), + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: local.localSite, + providerId: "catalog", + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }); + expect(failed.value.issues).toEqual([ + { reason: "catalog-failed" }, + ]); + expect(failed.sources).toEqual([ + { + code: "authentication", + feature: "relation-catalog", + outcome: "failed", + providerId: "catalog", + retry: "after-invalidation", + }, + ]); + }); + + it("fails closed for uncertain and hostile CTE prefix policy", () => { + const local = markedLocalSite( + "WITH local_table AS (SELECT 1) SELECT * FROM |", + ); + const complete = ( + cteIdentifierMatchesPrefix: + SqlRelationCompletionDialectRuntime["cteIdentifierMatchesPrefix"], + ): SqlCompletionList => { + const completion = Object.freeze({ + ...POSTGRESQL_SQL_RELATION_DIALECT.completion, + cteIdentifierMatchesPrefix, + }); + const dialect: SqlRelationDialectRuntime = Object.freeze({ + ...POSTGRESQL_SQL_RELATION_DIALECT, + completion, + }); + return composeSqlRelationCompletion({ + catalogOutcome: null, + dialect, + localSite: local.localSite, + providerId: null, + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }).value; + }; + + const unknown = complete(() => "unknown"); + expect(unknown.items).toEqual([]); + expect(unknown.issues).toContainEqual({ + reason: "cte-scope-uncertainty", + }); + + const throwing = complete(() => { + throw new Error("hostile prefix policy"); + }); + expect(throwing.items).toEqual([]); + expect(throwing.issues).toContainEqual({ + reason: "cte-scope-uncertainty", + }); + + const noMatch = complete(() => "no-match"); + expect(noMatch.items).toEqual([]); + expect(noMatch.isIncomplete).toBe(false); + }); + + it("retains catalog evidence when CTE shadow comparison is uncertain", () => { + const local = markedLocalSite( + "WITH users AS (SELECT 1) SELECT * FROM |", + ); + const completion = Object.freeze({ + ...POSTGRESQL_SQL_RELATION_DIALECT.completion, + compareCteIdentifiers: () => { + throw new Error("hostile comparison policy"); + }, + renderRelationPath: () => + Object.freeze({ + reason: "illegal-role-sequence" as const, + status: "unsupported" as const, + }), + }); + const dialect: SqlRelationDialectRuntime = Object.freeze({ + ...POSTGRESQL_SQL_RELATION_DIALECT, + completion, + }); + const result = composeSqlRelationCompletion({ + catalogOutcome: usable( + readyResponse([ + catalogRelation({ + detail: "A catalog relation", + entityId: "users", + name: "users", + }), + ]), + ), + dialect, + localSite: local.localSite, + providerId: "catalog", + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }); + + expect(result.value.items).toHaveLength(2); + expect(result.value.items[1]).toMatchObject({ + detail: "A catalog relation", + label: "users", + provenance: { entityId: "users" }, + }); + expect(result.value.issues).toContainEqual({ + reason: "cte-scope-uncertainty", + }); + + if (local.localSite.local.kind !== "unqualified") { + throw new Error("Expected an unqualified local site"); + } + const uncertainSite: typeof local.localSite = Object.freeze({ + ...local.localSite, + local: Object.freeze({ + ...local.localSite.local, + cteVisibility: Object.freeze({ + ...local.localSite.local.cteVisibility, + quality: "recovered" as const, + shadowing: Object.freeze({ + coverage: "unknown" as const, + }), + }), + }), + }); + const unknownCoverage = composeSqlRelationCompletion({ + catalogOutcome: usable( + readyResponse([ + catalogRelation({ + entityId: "users", + name: "users", + }), + ]), + ), + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: uncertainSite, + providerId: "catalog", + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }); + expect(unknownCoverage.value.items).toHaveLength(2); + expect(unknownCoverage.value.issues).toContainEqual({ + reason: "cte-scope-uncertainty", + }); + }); + + it("surfaces recursive CTE and opaque query-site recovery", () => { + const recursive = compose( + "WITH RECURSIVE local_table AS (SELECT * FROM |) SELECT * FROM local_table", + null, + ); + expect(recursive.issues.map((issue) => issue.reason)).toEqual([ + "cte-scope-uncertainty", + "recursive-cte-uncertainty", + ]); + + const local = markedLocalSite("SELECT * FROM |"); + const opaqueSite: typeof local.localSite = Object.freeze({ + ...local.localSite, + querySite: Object.freeze({ + ...local.localSite.querySite, + recognition: Object.freeze({ + issues: Object.freeze([ + "opaque-template-context", + ] as const), + quality: "recovered" as const, + }), + }), + }); + const opaque = composeSqlRelationCompletion({ + catalogOutcome: null, + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + localSite: opaqueSite, + providerId: null, + remainingIntentLeaseMs: 0, + replacementRange: local.replacementRange, + statementOffset: local.statementOffset, + }).value; + expect(opaque.issues).toContainEqual({ + reason: "opaque-template-context", + }); + }); + + it("uses path and entity IDs as deterministic final catalog ties", () => { + const relations = [ + catalogRelation({ + completionPathStart: 0, + containers: ["zeta"], + entityId: "z-last", + name: "users", + }), + catalogRelation({ + completionPathStart: 0, + containers: ["alpha"], + entityId: "b", + name: "users", + }), + catalogRelation({ + completionPathStart: 0, + containers: ["alpha"], + entityId: "a", + name: "users", + }), + ]; + const value = compose( + "SELECT * FROM |", + usable(readyResponse(relations)), + ); + expect( + value.items.map( + (item) => + item.provenance.kind === "catalog" + ? item.provenance.entityId + : "cte", + ), + ).toEqual(["a", "b", "z-last"]); + }); + + it("applies the result cap after ranking and returns deeply frozen output", () => { + const declarations = Array.from( + { length: MAX_RELATION_COMPLETION_RESULTS + 1 }, + (_, index) => `cte_${String(index).padStart(3, "0")} AS (SELECT 1)`, + ).join(", "); + const value = compose( + `WITH ${declarations} SELECT * FROM |`, + null, + ); + + expect(value.items).toHaveLength( + MAX_RELATION_COMPLETION_RESULTS, + ); + expect(value.items[0]?.label).toBe("cte_000"); + expect(value.items.at(-1)?.label).toBe("cte_099"); + expect(value.issues).toContainEqual({ reason: "result-limit" }); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.items)).toBe(true); + expect(Object.isFrozen(value.items[0]?.edit)).toBe(true); + expect(Object.isFrozen(value.items[0]?.provenance)).toBe(true); + }); +}); diff --git a/src/vnext/__tests__/session-completion.bench.ts b/src/vnext/__tests__/session-completion.bench.ts new file mode 100644 index 0000000..b1b2739 --- /dev/null +++ b/src/vnext/__tests__/session-completion.bench.ts @@ -0,0 +1,152 @@ +import { bench, describe } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../index.js"; +import type { + SqlCatalogRelation, +} from "../relation-completion-types.js"; +import type { + SqlDocumentContext, + SqlDocumentSession, + SqlLanguageService, +} from "../types.js"; + +interface BenchmarkContext extends SqlDocumentContext { + readonly engine: "benchmark"; +} + +const dialect = duckdbDialect(); +const tenKibibytes = 10 * 1_024; +const prefix = "WITH local_cte AS (SELECT 1) SELECT "; +const suffix = " FROM local"; +const projectionLength = + tenKibibytes - prefix.length - suffix.length; +const projection = `${"x,".repeat( + Math.floor((projectionLength - 1) / 2), +)}x`; +const tenKibibyteText = `${prefix}${projection}${" ".repeat( + projectionLength - projection.length, +)}${suffix}`; +if (tenKibibyteText.length !== tenKibibytes) { + throw new Error("Session completion benchmark must be exactly 10 KiB"); +} + +function openLocalSession( + service: SqlLanguageService, +): SqlDocumentSession { + return service.openDocument({ + context: { dialect: "duckdb", engine: "benchmark" }, + text: tenKibibyteText, + }); +} + +const localService = + createSqlLanguageService({ + dialects: [dialect], + }); +const warmLocalSession = openLocalSession(localService); +const warmPreflight = await warmLocalSession.complete({ + position: tenKibibyteText.length, + trigger: { kind: "invoked" }, +}); +if ( + warmPreflight.status !== "ready" || + warmPreflight.value.items.length !== 1 +) { + throw new Error("Warm session benchmark preflight failed"); +} + +const catalogRelations = Array.from( + { length: 100 }, + (_, index): SqlCatalogRelation => ({ + canonicalPath: [ + { + quoted: false, + role: "relation" as const, + value: `relation_${index}`, + }, + ], + completionPathStart: 0, + entityId: `relation-${index}`, + matchQuality: "exact" as const, + relationKind: "table" as const, + }), +); + +function createCatalogService(): SqlLanguageService { + return createSqlLanguageService({ + catalog: { + id: "benchmark-catalog", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: catalogRelations, + status: "ready", + }), + }, + dialects: [dialect], + }); +} + +const cachedCatalogService = createCatalogService(); +const cachedCatalogSession = cachedCatalogService.openDocument({ + context: { + catalog: { scope: "benchmark:cached" }, + dialect: "duckdb", + engine: "benchmark", + }, + text: "SELECT * FROM relation_", +}); +const cachedPreflight = await cachedCatalogSession.complete({ + position: 23, + trigger: { kind: "invoked" }, +}); +if ( + cachedPreflight.status !== "ready" || + cachedPreflight.value.items.length !== 100 +) { + throw new Error("Cached catalog benchmark preflight failed"); +} + +describe("session completion", () => { + bench("cold 10 KiB local completion", async () => { + const session = openLocalSession(localService); + await session.complete({ + position: tenKibibyteText.length, + trigger: { kind: "invoked" }, + }); + session.dispose(); + }); + + bench("warm 10 KiB local completion", async () => { + await warmLocalSession.complete({ + position: tenKibibyteText.length, + trigger: { kind: "invoked" }, + }); + }); + + bench("cached 100-candidate catalog completion", async () => { + await cachedCatalogSession.complete({ + position: 23, + trigger: { kind: "invoked" }, + }); + }); + + bench("cold 100-candidate catalog completion", async () => { + const service = createCatalogService(); + const session = service.openDocument({ + context: { + catalog: { scope: "benchmark:cold" }, + dialect: "duckdb", + engine: "benchmark", + }, + text: "SELECT * FROM relation_", + }); + await session.complete({ + position: 23, + trigger: { kind: "invoked" }, + }); + service.dispose(); + }); +}); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index ed581dd..130b5c4 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { bigQueryDialect, createSqlLanguageService, @@ -27,7 +27,13 @@ import { import type { SqlDocumentContext, SqlDocumentReplacement, + SqlRevision, } from "../types.js"; +import type { + SqlCatalogInvalidation, + SqlRelationCatalogProvider, + SqlSessionChangeEvent, +} from "../relation-completion-types.js"; interface TestContext extends SqlDocumentContext { readonly engine: string; @@ -148,148 +154,970 @@ describe("dialect definitions", () => { }); }); -describe("statement-index session cache", () => { - it("binds lexical behavior through authentic dialect handles", () => { - const service = new DefaultSqlLanguageService({ - dialects: [ - bigQueryDialect(), - dremioDialect(), - duckdb, - postgres, - ], +describe("relation completion session integration", () => { + function catalogProvider( + search: SqlRelationCatalogProvider["search"], + subscribe?: SqlRelationCatalogProvider["subscribe"], + ): SqlRelationCatalogProvider { + return subscribe + ? { id: "test-catalog", search, subscribe } + : { id: "test-catalog", search }; + } + + it("completes visible CTEs without a catalog provider", async () => { + const { service, session } = openSession( + "WITH source_data AS (SELECT 1) SELECT * FROM sou", + ); + const result = await session.complete({ + position: 48, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + items: [ + { + edit: { from: 45, insert: "source_data", to: 48 }, + label: "source_data", + relationKind: "cte", + }, + ], + }, }); - const cases = [ - { - dialect: "bigquery", - profile: BIGQUERY_SQL_LEXICAL_PROFILE, - text: "# hidden; still hidden\nSELECT r'''a;b''';", + service.dispose(); + }); + + it("composes a validated catalog page through the public session", async () => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [ + { + canonicalPath: [ + { + quoted: false, + role: "catalog", + value: "memory", + }, + { + quoted: false, + role: "schema", + value: "main", + }, + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 2, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }, + ], + status: "ready", + })), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", }, - { - dialect: "dremio", - profile: DREMIO_SQL_LEXICAL_PROFILE, - text: "# code; SELECT $$a;b$$;", + text: "SELECT * FROM us", + }); + await expect( + session.complete({ + position: 16, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + sources: [ + { + coverage: "complete", + outcome: "ready", + providerId: "test-catalog", + }, + ], + status: "ready", + value: { + isIncomplete: false, + items: [ + { + edit: { from: 14, insert: "users", to: 16 }, + label: "users", + provenance: { + entityId: "users", + providerId: "test-catalog", + }, + }, + ], }, - { + }); + service.dispose(); + }); + + it("keeps completion edits in absolute UTF-16 document coordinates", async () => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [ + { + canonicalPath: [ + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }, + ], + status: "ready", + })), + dialects: [duckdb], + }); + const text = "SELECT '😀'; SELECT * FROM us"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, dialect: "duckdb", - profile: DUCKDB_SQL_LEXICAL_PROFILE, - text: "SELECT $$a;b$$; /* outer /* inner; */ done */", + engine: "local", }, - { - dialect: "postgresql", - profile: POSTGRESQL_SQL_LEXICAL_PROFILE, - text: "SELECT E'a\\';b'; SELECT $tag$c;d$tag$;", + text, + }); + const result = await session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + items: [ + { + edit: { + from: text.length - 2, + insert: "users", + to: text.length, + }, + kind: "relation", + }, + ], }, - ] as const; - - for (const testCase of cases) { - const session = service.openDocument({ - context: { - dialect: testCase.dialect, - engine: "warehouse", - }, - text: testCase.text, - }); - expect(session.getStatementIndexForTesting()).toEqual( - buildSqlStatementIndex(testCase.text, testCase.profile), - ); - } + }); + service.dispose(); }); - it("builds lazily and reuses the current cache", () => { - const { session } = openSession("SELECT 1; SELECT 2"); - expect(session.cachedStatementIndexForTesting).toBeNull(); - - const index = session.getStatementIndexForTesting(); - expect(session.cachedStatementIndexForTesting).toBe(index); - expect(session.getStatementIndexForTesting()).toBe(index); + it("uses absolute coordinates after a masked embedded region", async () => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [ + { + canonicalPath: [ + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }, + ], + status: "ready", + })), + dialects: [duckdb], + }); + const text = "SELECT * FROM {df}; SELECT * FROM us"; + const embeddedFrom = text.indexOf("{df}"); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + embeddedRegions: [ + { + from: embeddedFrom, + language: "python", + to: embeddedFrom + 4, + }, + ], + text, + }); + const result = await session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + items: [ + { + edit: { + from: text.length - 2, + insert: "users", + to: text.length, + }, + }, + ], + }, + }); + service.dispose(); }); - it("retains the index for unrelated context changes", () => { - const { session } = openSession("SELECT 1; SELECT 2"); - const index = session.getStatementIndexForTesting(); - session.update({ - baseRevision: session.revision, - context: { dialect: "duckdb", engine: "remote" }, + it("reports terminal loading with a bounded completion intent", async () => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + })), + dialects: [duckdb], }); - expect(session.cachedStatementIndexForTesting).toBe(index); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + isIncomplete: true, + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: + 1_000, + }, + ], + }, + }); + service.dispose(); }); - it("invalidates the index when lexical profile identity changes", () => { - const { session } = openSession("SELECT $$a;b$$;"); - const index = session.getStatementIndexForTesting(); - session.update({ - baseRevision: session.revision, - context: { dialect: "postgresql", engine: "warehouse" }, + it("returns on the soft budget and emits one availability revision", async () => { + let resolveSearch: + | (( + response: Awaited< + ReturnType + >, + ) => void) + | undefined; + const searchResult = new Promise< + Awaited> + >((resolve) => { + resolveSearch = resolve; + }); + const service = createSqlLanguageService({ + catalog: catalogProvider(() => searchResult), + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], }); - expect(session.cachedStatementIndexForTesting).toBeNull(); - expect(session.getStatementIndexForTesting()).not.toBe(index); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + resolveSearch?.({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toEqual(["catalog-availability"]); + service.dispose(); }); - it("reuses the index across no-op document mutations", () => { - const { session } = openSession("SELECT 1"); - const initialSource = session.snapshotForTesting.source; - const index = session.getStatementIndexForTesting(); - - session.update({ - embeddedRegions: [], - baseRevision: session.revision, - document: { kind: "changes", changes: [] }, + it("supersedes an older same-key request without restarting work", async () => { + let searchCount = 0; + let resolveSearch: + | (( + response: Awaited< + ReturnType + >, + ) => void) + | undefined; + const searchResult = new Promise< + Awaited> + >((resolve) => { + resolveSearch = resolve; + }); + const service = createSqlLanguageService({ + catalog: catalogProvider(() => { + searchCount += 1; + return searchResult; + }), + dialects: [duckdb], }); - expect(session.snapshotForTesting.source).toBe(initialSource); - expect(session.cachedStatementIndexForTesting).toBe(index); - - session.update({ - embeddedRegions: [], - baseRevision: session.revision, - document: { kind: "replace", text: "SELECT 1" }, + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", }); - expect(session.cachedStatementIndexForTesting).toBe(index); + const first = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await Promise.resolve(); + const second = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + resolveSearch?.({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }); + await expect(first).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + await expect(second).resolves.toMatchObject({ + status: "ready", + }); + expect(searchCount).toBe(1); + service.dispose(); }); - it("updates incrementally to the full-scan oracle", () => { - const { session } = openSession("SELECT 1; SELECT 2; SELECT 3"); - const previous = session.getStatementIndexForTesting(); - session.update({ - embeddedRegions: [], - baseRevision: session.revision, - document: { - kind: "changes", - changes: [{ from: 17, insert: "20", to: 18 }], + it("does not supersede valid work for a malformed request", async () => { + let resolveSearch: + | (( + response: Awaited< + ReturnType + >, + ) => void) + | undefined; + const searchResult = new Promise< + Awaited> + >((resolve) => { + resolveSearch = resolve; + }); + const service = createSqlLanguageService({ + catalog: catalogProvider(() => searchResult), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", }, + text: "SELECT * FROM ", + }); + const valid = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await expect( + session.complete({ + position: 14, + trigger: { + character: ".", + kind: "invoked", + }, + } as never), + ).rejects.toMatchObject({ + code: "invalid-completion-request", + }); + resolveSearch?.({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }); + await expect(valid).resolves.toMatchObject({ + status: "ready", }); + service.dispose(); + }); - const cached = session.cachedStatementIndexForTesting; - expect(cached).not.toBeNull(); - expect(cached).toEqual( - buildSqlStatementIndex( - "SELECT 1; SELECT 20; SELECT 3", - DUCKDB_SQL_LEXICAL_PROFILE, + it("supersedes pending work when a higher catalog epoch arrives", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // Intentionally unsettled; invalidation owns cancellation. + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, ), - ); - expect(cached).not.toBe(previous); - expect(cached?.slots[0]).toBe(previous.slots[0]); + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + }); + const result = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + await expect(result).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + expect(events).toEqual(["catalog"]); + service.dispose(); }); - it("clears changed replacements and preserves the cache on failure", () => { - const { session } = openSession("SELECT 1"); - const index = session.getStatementIndexForTesting(); - const revision = session.revision; - expectSessionError("stale-revision", () => { - session.update({ - embeddedRegions: [], - baseRevision: {} as never, - document: { kind: "replace", text: "SELECT 2" }, + it("settles pending completion on caller abort, update, and disposal", async () => { + const createPendingSession = () => { + const service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // Intentionally unsettled; session cancellation owns completion. + }), + ), + dialects: [duckdb], }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + return { service, session }; + }; + + const caller = createPendingSession(); + const controller = new AbortController(); + const callerResult = caller.session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + controller.abort(); + await expect(callerResult).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + caller.service.dispose(); + + const updated = createPendingSession(); + const updatedResult = updated.session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + updated.session.update({ + baseRevision: updated.session.revision, + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "remote", + }, }); - expect(session.revision).toBe(revision); - expect(session.cachedStatementIndexForTesting).toBe(index); + await expect(updatedResult).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + updated.service.dispose(); + + const disposed = createPendingSession(); + const disposedResult = disposed.session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + disposed.session.dispose(); + await expect(disposedResult).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + disposed.service.dispose(); + }); + it("replaces catalog ownership when the scope changes", async () => { + const scopes: string[] = []; + const service = createSqlLanguageService({ + catalog: catalogProvider(async (request) => { + scopes.push(request.scope); + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: request.scope }, + relations: [], + status: "ready", + }; + }), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); session.update({ - embeddedRegions: [], baseRevision: session.revision, - document: { kind: "replace", text: "SELECT 2" }, + context: { + catalog: { scope: "connection:2" }, + dialect: "duckdb", + engine: "local", + }, }); - expect(session.cachedStatementIndexForTesting).toBeNull(); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(scopes).toEqual(["connection:1", "connection:2"]); + service.dispose(); + }); + + it("advances revision and isolates listeners on catalog invalidation", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const previous = session.revision; + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + throw new Error("isolated"); + }); + session.onDidChange((event) => { + events.push(`${event.reason}:second`); + }); + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + expect(session.revision).not.toBe(previous); + expect(events).toEqual(["catalog", "catalog:second"]); + service.dispose(); + }); + + it("stops a stale catalog event after a listener updates the session", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlRevision[] = []; + session.onDidChange((event) => { + events.push(event.revision); + session.update({ + baseRevision: event.revision, + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "remote", + }, + }); + }); + session.onDidChange((event) => { + events.push(event.revision); + }); + + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + + expect(events).toHaveLength(1); + expect(session.revision).not.toBe(events[0]); + service.dispose(); + }); + + it("does not deliver an outer catalog event after nested invalidation", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlRevision[] = []; + let nested = false; + session.onDidChange((event) => { + events.push(event.revision); + if (!nested) { + nested = true; + invalidate?.({ + epoch: { generation: 2, token: "nested" }, + }); + } + }); + session.onDidChange((event) => { + events.push(event.revision); + }); + + invalidate?.({ + epoch: { generation: 1, token: "outer" }, + }); + + expect(events).toHaveLength(4); + expect(events[0]).toBe(events[1]); + expect(events[1]).not.toBe(events[2]); + expect(events[2]).toBe(events[3]); + expect(session.revision).toBe(events[2]); + service.dispose(); + }); + + it("keeps duplicate listener subscriptions independent", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + const listener = (event: SqlSessionChangeEvent): void => { + events.push(event.reason); + }; + const first = session.onDidChange(listener); + const second = session.onDidChange(listener); + + first.dispose(); + invalidate?.({ + epoch: { generation: 1, token: "first" }, + }); + expect(events).toEqual(["catalog"]); + + second.dispose(); + invalidate?.({ + epoch: { generation: 2, token: "second" }, + }); + expect(events).toEqual(["catalog"]); + service.dispose(); + }); + + it("validates completion configuration and request input", async () => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService({ + completion: { catalogResponseBudgetMs: 51 }, + dialects: [duckdb], + }); + }); + const { service, session } = openSession(); + await expect( + session.complete(null as never), + ).rejects.toMatchObject({ + code: "invalid-completion-request", + }); + service.dispose(); + }); + + it("rejects catalog context without a configured provider atomically", () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + }); + const session = service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const revision = session.revision; + expectSessionError("invalid-context", () => { + session.update({ + baseRevision: revision, + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + }); + }); + expect(session.revision).toBe(revision); + service.dispose(); + }); +}); + +describe("statement-index session cache", () => { + it("binds lexical behavior through authentic dialect handles", () => { + const service = new DefaultSqlLanguageService({ + dialects: [ + bigQueryDialect(), + dremioDialect(), + duckdb, + postgres, + ], + }); + const cases = [ + { + dialect: "bigquery", + profile: BIGQUERY_SQL_LEXICAL_PROFILE, + text: "# hidden; still hidden\nSELECT r'''a;b''';", + }, + { + dialect: "dremio", + profile: DREMIO_SQL_LEXICAL_PROFILE, + text: "# code; SELECT $$a;b$$;", + }, + { + dialect: "duckdb", + profile: DUCKDB_SQL_LEXICAL_PROFILE, + text: "SELECT $$a;b$$; /* outer /* inner; */ done */", + }, + { + dialect: "postgresql", + profile: POSTGRESQL_SQL_LEXICAL_PROFILE, + text: "SELECT E'a\\';b'; SELECT $tag$c;d$tag$;", + }, + ] as const; + + for (const testCase of cases) { + const session = service.openDocument({ + context: { + dialect: testCase.dialect, + engine: "warehouse", + }, + text: testCase.text, + }); + expect(session.getStatementIndexForTesting()).toEqual( + buildSqlStatementIndex(testCase.text, testCase.profile), + ); + } + }); + + it("builds lazily and reuses the current cache", () => { + const { session } = openSession("SELECT 1; SELECT 2"); + expect(session.cachedStatementIndexForTesting).toBeNull(); + + const index = session.getStatementIndexForTesting(); + expect(session.cachedStatementIndexForTesting).toBe(index); + expect(session.getStatementIndexForTesting()).toBe(index); + }); + + it("retains the index for unrelated context changes", () => { + const { session } = openSession("SELECT 1; SELECT 2"); + const index = session.getStatementIndexForTesting(); + session.update({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "remote" }, + }); + expect(session.cachedStatementIndexForTesting).toBe(index); + }); + + it("invalidates the index when lexical profile identity changes", () => { + const { session } = openSession("SELECT $$a;b$$;"); + const index = session.getStatementIndexForTesting(); + session.update({ + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expect(session.getStatementIndexForTesting()).not.toBe(index); + }); + + it("reuses the index across no-op document mutations", () => { + const { session } = openSession("SELECT 1"); + const initialSource = session.snapshotForTesting.source; + const index = session.getStatementIndexForTesting(); + + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "changes", changes: [] }, + }); + expect(session.snapshotForTesting.source).toBe(initialSource); + expect(session.cachedStatementIndexForTesting).toBe(index); + + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, + }); + expect(session.cachedStatementIndexForTesting).toBe(index); + }); + + it("updates incrementally to the full-scan oracle", () => { + const { session } = openSession("SELECT 1; SELECT 2; SELECT 3"); + const previous = session.getStatementIndexForTesting(); + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 17, insert: "20", to: 18 }], + }, + }); + + const cached = session.cachedStatementIndexForTesting; + expect(cached).not.toBeNull(); + expect(cached).toEqual( + buildSqlStatementIndex( + "SELECT 1; SELECT 20; SELECT 3", + DUCKDB_SQL_LEXICAL_PROFILE, + ), + ); + expect(cached).not.toBe(previous); + expect(cached?.slots[0]).toBe(previous.slots[0]); + }); + + it("clears changed replacements and preserves the cache on failure", () => { + const { session } = openSession("SELECT 1"); + const index = session.getStatementIndexForTesting(); + const revision = session.revision; + expectSessionError("stale-revision", () => { + session.update({ + embeddedRegions: [], + baseRevision: {} as never, + document: { kind: "replace", text: "SELECT 2" }, + }); + }); + expect(session.revision).toBe(revision); + expect(session.cachedStatementIndexForTesting).toBe(index); + + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 2" }, + }); + expect(session.cachedStatementIndexForTesting).toBeNull(); }); it("releases the cache on disposal", () => { @@ -1937,3 +2765,1507 @@ describe("lifecycle", () => { }); }); }); + +describe("session coverage hardening", () => { + const readyCatalog: SqlRelationCatalogProvider = { + id: "coverage-catalog", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + }; + + function catalogService( + provider: SqlRelationCatalogProvider = readyCatalog, + ) { + return createSqlLanguageService({ + catalog: provider, + dialects: [duckdb, postgres], + }); + } + + it.each([ + { scope: "" }, + { scope: "x".repeat(513) }, + { scope: 1 }, + { scope: "valid", searchPath: "main" }, + { + scope: "valid", + searchPath: Array.from({ length: 33 }, () => [ + { quoted: false, value: "main" }, + ]), + }, + { scope: "valid", searchPath: ["main"] }, + { scope: "valid", searchPath: [[]] }, + { + scope: "valid", + searchPath: [ + Array.from({ length: 5 }, () => ({ + quoted: false, + value: "main", + })), + ], + }, + { scope: "valid", searchPath: [[null]] }, + { + scope: "valid", + searchPath: [[{ quoted: false, value: 1 }]], + }, + { + scope: "valid", + searchPath: [[{ quoted: false, value: "" }]], + }, + { + scope: "valid", + searchPath: [ + [{ quoted: false, value: "x".repeat(257) }], + ], + }, + { + scope: "valid", + searchPath: [[{ quoted: "no", value: "main" }]], + }, + ])("rejects invalid catalog context %# atomically", (catalog) => { + const service = catalogService(); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + catalog, + dialect: "duckdb", + engine: "local", + } as never, + text: "SELECT 1", + }); + }); + service.dispose(); + }); + + it.each([ + { position: Number.NaN, trigger: { kind: "invoked" } }, + { position: -1, trigger: { kind: "invoked" } }, + { position: 9, trigger: { kind: "invoked" } }, + { position: 0, trigger: null }, + { position: 0, trigger: { kind: "unknown" } }, + { + position: 0, + trigger: { character: 1, kind: "trigger-character" }, + }, + { + position: 0, + trigger: { character: "", kind: "trigger-character" }, + }, + { + position: 0, + trigger: { character: "ab", kind: "trigger-character" }, + }, + { + position: 0, + trigger: { character: "😀x", kind: "trigger-character" }, + }, + { + position: 0, + signal: {}, + trigger: { kind: "invoked" }, + }, + ])("rejects malformed completion request %#", async (request) => { + const { service, session } = openSession("SELECT 1"); + await expect( + session.complete(request as never), + ).rejects.toMatchObject({ + code: "invalid-completion-request", + }); + service.dispose(); + }); + + it("accepts a one-code-point trigger and rejects listeners after disposal", async () => { + const { service, session } = openSession( + "SELECT * FROM ", + ); + await expect( + session.complete({ + position: 14, + trigger: { character: "😀", kind: "trigger-character" }, + }), + ).resolves.toMatchObject({ status: "ready" }); + expectSessionError("invalid-completion-request", () => { + session.onDidChange(null as never); + }); + const subscription = session.onDidChange(() => {}); + subscription.dispose(); + subscription.dispose(); + session.dispose(); + expectSessionError("session-disposed", () => { + session.onDidChange(() => {}); + }); + await expect( + session.complete({ + position: 0, + trigger: { kind: "invoked" }, + }), + ).rejects.toMatchObject({ code: "session-disposed" }); + service.dispose(); + }); + + it.each([ + { + reason: "opaque-statement", + text: "DELIMITER $$", + }, + { + reason: "resource-limit", + text: `SELECT * FROM ${" ".repeat(65_537)}`, + }, + { + reason: "inactive", + text: "SELECT 1", + }, + ] as const)("returns $reason local unavailability", async ({ reason, text }) => { + const service = createSqlLanguageService({ + dialects: [postgres], + }); + const session = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text, + }); + await expect( + session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason, + retryable: false, + status: "unavailable", + }); + service.dispose(); + }); + + it.each([ + null, + "bad", + { catalogResponseBudgetMs: "fast" }, + { catalogResponseBudgetMs: Number.NaN }, + { catalogResponseBudgetMs: -1 }, + ])("rejects invalid completion options %#", (completion) => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService({ + completion, + dialects: [duckdb], + } as never); + }); + }); + + it("rejects a malformed catalog provider", () => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService({ + catalog: { id: "", search: async () => ({}) }, + dialects: [duckdb], + } as never); + }); + }); + + it.each([ + { + expectedIssue: "catalog-failed", + response: { + code: "unavailable", + epoch: { generation: 0, token: "failed" }, + retry: "next-request", + status: "failed", + }, + }, + { + expectedIssue: "catalog-malformed", + response: { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "bad" }, + relations: [{ bad: true }], + status: "ready", + }, + }, + ])("maps catalog outcome $expectedIssue", async ({ + expectedIssue, + response, + }) => { + const service = catalogService({ + id: `catalog-${expectedIssue}`, + search: async () => response as never, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:coverage" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: expectedIssue }], + }, + }); + service.dispose(); + }); + + it("maps provider rejection and already-aborted requests", async () => { + const service = catalogService({ + id: "rejecting", + search: async () => { + throw new Error("private provider failure"); + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:coverage" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { issues: [{ reason: "catalog-failed" }] }, + }); + const controller = new AbortController(); + controller.abort(); + await expect( + session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + service.dispose(); + }); + + it("expires terminal loading intent and clears it idempotently", async () => { + vi.useFakeTimers(); + let service: + | ReturnType> + | undefined; + try { + service = catalogService({ + id: "loading", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:coverage" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const revision = session.revision; + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + }); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 1_000, + }, + ], + }, + }); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(vi.getTimerCount()).toBe(1); + expect(session.revision).toBe(revision); + expect(events).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(vi.getTimerCount()).toBe(0); + expect(session.revision).toBe(revision); + expect(events).toEqual([]); + await vi.advanceTimersByTimeAsync(1_000); + session.update({ + baseRevision: session.revision, + context: { + catalog: { scope: "connection:coverage" }, + dialect: "duckdb", + engine: "changed", + }, + }); + expect(vi.getTimerCount()).toBe(0); + } finally { + service?.dispose(); + vi.useRealTimers(); + } + }); + + it("enforces context node and property aggregate limits", () => { + const service = createService(); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + nodes: Array.from({ length: 10_001 }, () => ({})), + } as never, + text: "", + }); + }); + const manyProperties = Object.fromEntries( + Array.from({ length: 50_001 }, (_, index) => [ + `p${index}`, + null, + ]), + ); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + manyProperties, + } as never, + text: "", + }); + }); + service.dispose(); + }); + + it("keeps listener subscriptions independently disposable during dispatch", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + ...readyCatalog, + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:listeners" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT 1", + }); + const calls: string[] = []; + let second = session.onDidChange(() => { + calls.push("second"); + }); + const first = session.onDidChange(() => { + calls.push("first"); + second.dispose(); + }); + second.dispose(); + second = session.onDidChange(() => { + calls.push("second"); + }); + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + expect(calls).toEqual(["first"]); + first.dispose(); + service.dispose(); + }); + + it.each(["update", "dispose"] as const)( + "releases a retained refresh intent on %s", + async (action) => { + let aborted = 0; + const service = createSqlLanguageService({ + catalog: { + id: `pending-${action}`, + search: async (_request, signal) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + aborted += 1; + resolve({ + epoch: { generation: 0, token: "late" }, + status: "loading", + }); + }, + { once: true }, + ); + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: `connection:${action}` }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + if (action === "update") { + session.update({ + baseRevision: session.revision, + context: { + catalog: { scope: `connection:${action}` }, + dialect: "duckdb", + engine: "changed", + }, + }); + } else { + session.dispose(); + } + await Promise.resolve(); + expect(aborted).toBe(1); + service.dispose(); + }, + ); + + it("hands a retained intent to a different-key completion", async () => { + const aborted: string[] = []; + const service = createSqlLanguageService({ + catalog: { + id: "different-key", + search: async (request, signal) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + aborted.push(request.prefix.value); + resolve({ + epoch: { generation: 0, token: "late" }, + status: "loading", + }); + }, + { once: true }, + ); + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = "SELECT * FROM a; SELECT * FROM b"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:different-key" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + await session.complete({ + position: text.indexOf("a;") + 1, + trigger: { kind: "invoked" }, + }); + await session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + await Promise.resolve(); + expect(aborted).toContain("a"); + service.dispose(); + }); + + it("maps synchronous provider budget exhaustion to catalog timeout", async () => { + const service = catalogService({ + id: "synchronous-timeout", + search: async () => { + const deadline = performance.now() + 10; + while (performance.now() < deadline) { + // Deliberately exceed the coordinator's synchronous budget. + } + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "late" }, + relations: [], + status: "ready", + }; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:sync-timeout" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { issues: [{ reason: "catalog-timeout" }] }, + }); + service.dispose(); + }); + + it("rejects non-string and unknown dialect context values", () => { + const service = createService(); + for (const dialect of [1, "unknown"]) { + expectSessionError("invalid-dialect", () => { + service.openDocument({ + context: { dialect, engine: "local" } as never, + text: "", + }); + }); + } + service.dispose(); + }); + + it.each([ + ["SELECT * FROM users ON ", "unsupported-query-site"], + [ + "SELECT * FROM users NATURAL WHERE ", + "ambiguous-query-site", + ], + ])("maps query-site uncertainty in %s", async (text, reason) => { + const service = createSqlLanguageService({ + dialects: [postgres], + }); + const session = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text, + }); + await expect( + session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason, + retryable: false, + status: "unavailable", + }); + service.dispose(); + }); + + it("cancels previous active work when the replacement site is inactive", async () => { + const service = createSqlLanguageService({ + catalog: { + id: "inactive-replacement", + search: async () => + new Promise(() => { + // Cancelled by the second completion. + }), + }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:inactive-replacement" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const first = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await Promise.resolve(); + await expect( + session.complete({ + position: 0, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason: "inactive", + status: "unavailable", + }); + await expect(first).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + service.dispose(); + }); + + it("cancels a retained intent when the replacement site is inactive", async () => { + let aborts = 0; + const service = createSqlLanguageService({ + catalog: { + id: "intent-inactive-replacement", + search: async (_request, signal) => + new Promise((resolve) => { + signal.addEventListener("abort", () => { + aborts += 1; + resolve({ + epoch: { generation: 0, token: "late" }, + status: "loading", + }); + }); + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:intent-inactive-replacement", + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await session.complete({ + position: 0, + trigger: { kind: "invoked" }, + }); + await Promise.resolve(); + expect(aborts).toBe(1); + service.dispose(); + }); + + it("preserves FIFO nested dispatch across shared-scope sessions", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + ...readyCatalog, + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const open = () => + service.openDocument({ + context: { + catalog: { scope: "connection:shared-dispatch" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT 1", + }); + const first = open(); + const second = open(); + const events: string[] = []; + first.onDidChange(() => { + events.push("first"); + if (events.length === 1) { + invalidate?.({ + epoch: { generation: 2, token: "nested" }, + }); + } + }); + second.onDidChange(() => { + events.push("second"); + }); + invalidate?.({ + epoch: { generation: 1, token: "outer" }, + }); + expect(events).toEqual([ + "first", + "second", + "first", + "second", + ]); + service.dispose(); + }); + + it("fails catalog ownership closed after live-scope capacity", async () => { + const service = catalogService(); + const sessions = Array.from({ length: 129 }, (_, index) => + service.openDocument({ + context: { + catalog: { scope: `connection:capacity:${index}` }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }), + ); + await expect( + sessions[128]!.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-overloaded" }], + }, + }); + service.dispose(); + }); + + it("accepts omitted and explicit-undefined completion budgets", () => { + const first = createSqlLanguageService({ + completion: {}, + dialects: [duckdb], + }); + const second = createSqlLanguageService({ + completion: { catalogResponseBudgetMs: undefined }, + dialects: [duckdb], + }); + first.dispose(); + second.dispose(); + }); + + it("contains synchronous invalidation while replacing a refresh intent", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + let searchCount = 0; + const service = createSqlLanguageService({ + catalog: { + id: "reentrant-invalidation", + search: async () => { + searchCount += 1; + if (searchCount === 1) { + return new Promise(() => { + // Retained as the first completion's refresh intent. + }); + } + invalidate?.({ + epoch: { generation: 1, token: "synchronous" }, + }); + return { + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "synchronous" }, + relations: [], + status: "ready", + }; + }, + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = "SELECT * FROM a; SELECT * FROM b"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:reentrant" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + await session.complete({ + position: text.indexOf("a;") + 1, + trigger: { kind: "invoked" }, + }); + await expect( + session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + expect(searchCount).toBe(2); + service.dispose(); + }); + + it("contains a synchronous document update from provider search", async () => { + let session: + | ReturnType["openDocument"]> + | undefined; + const service = catalogService({ + id: "reentrant-update", + search: async () => { + if (session) { + session.update({ + baseRevision: session.revision, + context: { + catalog: { scope: "connection:reentrant-update" }, + dialect: "duckdb", + engine: "updated", + }, + }); + } + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + session = service.openDocument({ + context: { + catalog: { scope: "connection:reentrant-update" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + service.dispose(); + }); + + it("ignores a hostile late abort callback after completion", async () => { + let searchCount = 0; + let resolveSecond: + | (( + response: Awaited< + ReturnType + >, + ) => void) + | undefined; + let secondSignal: AbortSignal | undefined; + const secondResult = new Promise< + Awaited> + >((resolve) => { + resolveSecond = resolve; + }); + const service = catalogService({ + id: "late-abort", + search: async (_request, signal) => { + searchCount += 1; + if (searchCount === 1) { + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + } + secondSignal = signal; + return secondResult; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:late-abort" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + let captured: + | EventListenerOrEventListenerObject + | undefined; + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + if (type === "abort") captured = listener; + nativeAdd(type, listener, options); + }, + }); + await session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + session.update({ + baseRevision: session.revision, + document: { + kind: "replace", + text: "SELECT * FROM u", + }, + embeddedRegions: [], + }); + const current = session.complete({ + position: 15, + trigger: { kind: "invoked" }, + }); + await Promise.resolve(); + if (typeof captured === "function") { + captured(new Event("abort")); + } else { + captured?.handleEvent(new Event("abort")); + } + expect(secondSignal?.aborted).toBe(false); + resolveSecond?.({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }); + await expect(current).resolves.toMatchObject({ + status: "ready", + }); + service.dispose(); + }); + + it("normalizes a hostile range-inspection failure", () => { + const { service, session } = openSession(""); + const hostileChange = new Proxy( + { from: 0, insert: "x", to: 0 }, + { + getOwnPropertyDescriptor() { + throw new Error("hostile range"); + }, + }, + ); + expectSessionError("invalid-change", () => { + session.update({ + baseRevision: session.revision, + document: { + changes: [hostileChange], + kind: "changes", + }, + embeddedRegions: [], + }); + }); + service.dispose(); + }); + + it("accepts a host timer whose opaque handle is null", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + let scheduled = 0; + const cleared: unknown[] = []; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: () => { + scheduled += 1; + return null; + }, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (handle: unknown) => { + cleared.push(handle); + scheduled -= 1; + }, + writable: true, + }); + try { + const service = catalogService(); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:null-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect( + session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ status: "ready" }); + expect(cleared).toContain(null); + service.dispose(); + expect(scheduled).toBe(0); + + const loadingService = catalogService({ + id: "null-loading-timer", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const loadingSession = loadingService.openDocument({ + context: { + catalog: { scope: "connection:null-loading-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect(loadingSession.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + const clearedBeforeDispose = cleared.length; + loadingService.dispose(); + expect(cleared.length).toBeGreaterThan(clearedBeforeDispose); + expect(cleared.at(-1)).toBe(null); + expect(scheduled).toBe(0); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: nativeClearTimeout, + writable: true, + }); + } + }); + + it("handles synchronous terminal-intent timer completion", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const cleared: unknown[] = []; + let synchronousCalls = 0; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay === 1_000) { + synchronousCalls += 1; + if (typeof callback === "function") { + Reflect.apply(callback, undefined, arguments_); + } + return null; + } + return nativeSetTimeout(callback, delay, ...arguments_); + }, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (handle: ReturnType) => { + cleared.push(handle); + nativeClearTimeout(handle); + }, + writable: true, + }); + try { + const service = catalogService({ + id: "synchronous-intent-timer", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:synchronous-intent-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + expect(synchronousCalls).toBe(1); + expect(cleared).toContain(null); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: nativeClearTimeout, + writable: true, + }); + } + }); + + it("does not let a stale intent callback erase a newer timer", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const callbacks: Array<() => void> = []; + const handles: object[] = []; + const cleared: number[] = []; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay !== 1_000) { + return nativeSetTimeout(callback, delay, ...arguments_); + } + if (typeof callback !== "function") { + throw new Error("Intent timer callback must be a function"); + } + callbacks.push(() => { + Reflect.apply(callback, undefined, arguments_); + }); + const handle = Object.freeze({ id: handles.length }); + handles.push(handle); + return handle; + }, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (handle: unknown) => { + const index = handles.findIndex( + (candidate) => candidate === handle, + ); + if (index >= 0) { + cleared.push(index); + } else { + Reflect.apply(nativeClearTimeout, globalThis, [handle]); + } + }, + writable: true, + }); + try { + const service = catalogService({ + id: "stale-intent-timer", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:stale-intent-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(callbacks).toHaveLength(2); + expect(cleared).toContain(0); + + callbacks[0]?.(); + session.dispose(); + + expect(cleared).toContain(1); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: nativeClearTimeout, + writable: true, + }); + } + }); + + it("consumes settlement racing the soft response timer", async () => { + let resolveSearch: + | (( + response: Awaited< + ReturnType + >, + ) => void) + | undefined; + const searchResult = new Promise< + Awaited> + >((resolve) => { + resolveSearch = resolve; + }); + const nativeSetTimeout = globalThis.setTimeout; + let injected = false; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => + nativeSetTimeout( + (...timerArguments: unknown[]) => { + if (delay === 0 && !injected) { + injected = true; + resolveSearch?.({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }); + } + if (typeof callback === "function") { + Reflect.apply(callback, undefined, timerArguments); + } + }, + delay, + ...arguments_, + ), + writable: true, + }); + try { + const service = createSqlLanguageService({ + catalog: { + id: "settlement-race", + search: async () => searchResult, + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:settlement-race" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + }); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [ + { + coverage: "complete", + outcome: "ready", + providerId: "settlement-race", + }, + ], + status: "ready", + value: { + isIncomplete: false, + issues: [], + }, + }); + expect(events).toEqual([]); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + } + }); + + it("fails catalog ownership closed after per-scope membership capacity", async () => { + const service = catalogService(); + const sessions = Array.from({ length: 257 }, () => + service.openDocument({ + context: { + catalog: { scope: "connection:membership-capacity" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }), + ); + await expect( + sessions[256]!.complete({ + position: 14, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-overloaded" }], + }, + }); + service.dispose(); + }); + + it("forwards a validated ordered catalog search path", async () => { + let observed: + | Parameters[0] + | undefined; + const service = catalogService({ + id: "search-path", + search: async (request) => { + observed = request; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + const searchPath = [ + [ + { quoted: false, value: "memory" }, + { quoted: true, value: "Main Schema" }, + ], + ] as const; + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:search-path", + searchPath, + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(observed?.searchPaths).toEqual(searchPath); + expect(observed?.searchPaths).not.toBe(searchPath); + service.dispose(); + }); + + it("contains reentrant completion during signal registration", async () => { + const service = createSqlLanguageService({ + catalog: { + id: "reentrant-signal", + search: async () => + new Promise(() => { + // Settled by session disposal. + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:reentrant-signal" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + const controller = new AbortController(); + let nested: + | ReturnType + | undefined; + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + if (!nested) { + nested = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + } + nativeAdd(type, listener, options); + }, + }); + await expect( + session.complete({ + position: 0, + signal: controller.signal, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + reason: "inactive", + status: "unavailable", + }); + service.dispose(); + await expect(nested).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + }); + + it("stops catalog event delivery when a listener disposes the session", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "dispose-listener", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:dispose-listener" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + session.onDidChange((event) => { + events.push(event.reason); + session.dispose(); + }); + session.onDidChange((event) => { + events.push(`${event.reason}:late`); + }); + + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + + expect(events).toEqual(["catalog"]); + service.dispose(); + }); +}); diff --git a/src/vnext/codemirror/relation-completion-types.ts b/src/vnext/codemirror/relation-completion-types.ts index 8c736d7..53aac2f 100644 --- a/src/vnext/codemirror/relation-completion-types.ts +++ b/src/vnext/codemirror/relation-completion-types.ts @@ -1,4 +1,4 @@ -import type { SqlRelationCompletionItem } from "../relation-completion-types.js"; +import type { SqlCompletionItem } from "../relation-completion-types.js"; // Provisional CodeMirror-only boundary; the framework-independent core stays DOM-free. export interface SqlCompletionInfoResolverContext { @@ -11,7 +11,7 @@ export interface SqlDisposableCompletionInfo { } export type SqlCompletionInfoResolver = ( - item: SqlRelationCompletionItem, + item: SqlCompletionItem, context: SqlCompletionInfoResolverContext, ) => | SqlDisposableCompletionInfo diff --git a/src/vnext/index.ts b/src/vnext/index.ts index d850c9f..8204d7b 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -28,3 +28,26 @@ export type { SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; +export type { + SqlCanonicalRelationPath, + SqlCatalogEpoch, + SqlCatalogFailureCode, + SqlCatalogProviderReport, + SqlCatalogReadyCoverage, + SqlCatalogRelation, + SqlCatalogRelationKind, + SqlCatalogRetryPolicy, + SqlCatalogSearchRequest, + SqlCatalogSearchResponse, + SqlCompletionCancellationReason, + SqlCompletionIssue, + SqlCompletionRequest, + SqlCompletionTrigger, + SqlDisposable, + SqlRelationCatalogProvider, + SqlCompletionItem, + SqlCompletionList, + SqlCompletionResult, + SqlSessionChangeEvent, + SqlSessionChangeReason, +} from "./relation-completion-types.js"; diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 1222d10..56c8952 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -1,6 +1,4 @@ import type { - SqlDocumentContext, - SqlDocumentUpdate, SqlIdentifierComponent, SqlIdentifierPath, SqlRevision, @@ -196,6 +194,7 @@ export interface SqlSessionChangeEvent { export type SqlCompletionTrigger = | { + readonly character?: never; readonly kind: "invoked"; } | { @@ -206,7 +205,7 @@ export type SqlCompletionTrigger = export interface SqlCompletionRequest { readonly position: number; readonly trigger: SqlCompletionTrigger; - readonly signal?: AbortSignal; + readonly signal?: AbortSignal | undefined; } export interface SqlCteCompletionProvenance { @@ -226,12 +225,14 @@ interface SqlCompletionItemBase { readonly detail?: string; } -export type SqlRelationCompletionItem = +export type SqlCompletionItem = | (SqlCompletionItemBase & { + readonly kind: "relation"; readonly relationKind: "cte"; readonly provenance: SqlCteCompletionProvenance; }) | (SqlCompletionItemBase & { + readonly kind: "relation"; readonly relationKind: SqlCatalogRelationKind; readonly provenance: SqlCatalogCompletionProvenance; }); @@ -257,14 +258,14 @@ export type SqlCompletionIssue = | "result-limit"; }; -export type SqlRelationCompletionList = +export type SqlCompletionList = | { - readonly items: readonly SqlRelationCompletionItem[]; + readonly items: readonly SqlCompletionItem[]; readonly isIncomplete: false; readonly issues: readonly []; } | { - readonly items: readonly SqlRelationCompletionItem[]; + readonly items: readonly SqlCompletionItem[]; readonly isIncomplete: true; readonly issues: readonly [ SqlCompletionIssue, @@ -288,7 +289,6 @@ export type SqlCatalogProviderUnavailableReason = | "queue-overloaded" | "queue-timeout" | "execution-timeout" - | "synchronous-timeout" | "provider-rejected" | "malformed-response"; @@ -320,11 +320,11 @@ export interface SqlServiceFailure { readonly retryable: boolean; } -export type SqlRelationCompletionResult = +export type SqlCompletionResult = | { readonly status: "ready"; readonly revision: SqlRevision; - readonly value: SqlRelationCompletionList; + readonly value: SqlCompletionList; readonly sources: readonly SqlCatalogProviderReport[]; } | { @@ -343,20 +343,3 @@ export type SqlRelationCompletionResult = readonly revision: SqlRevision; readonly failure: SqlServiceFailure; }; - -export interface SqlRelationCompletionSession< - Context extends SqlDocumentContext, -> { - readonly revision: SqlRevision; - readonly update: ( - transaction: SqlDocumentUpdate, - ) => SqlRevision; - readonly complete: ( - request: SqlCompletionRequest, - ) => Promise; - readonly onDidChange: ( - listener: (event: SqlSessionChangeEvent) => void, - ) => SqlDisposable; - readonly isCurrent: (revision: SqlRevision) => boolean; - readonly dispose: () => void; -} diff --git a/src/vnext/relation-completion.ts b/src/vnext/relation-completion.ts new file mode 100644 index 0000000..a0d2668 --- /dev/null +++ b/src/vnext/relation-completion.ts @@ -0,0 +1,522 @@ +import type { + SqlCatalogProviderReport, + SqlCompletionIssue, + SqlCompletionItem, + SqlCompletionList, +} from "./relation-completion-types.js"; +import type { + SqlCatalogSearchWorkOutcome, +} from "./relation-catalog-search-work.js"; +import type { + SqlRelationDialectRuntime, +} from "./relation-dialect.js"; +import type { + SqlLocalRelationSiteResult, +} from "./local-relation-site.js"; +import type { + SqlIdentifierComponent, + SqlTextRange, +} from "./types.js"; + +export const MAX_RELATION_COMPLETION_RESULTS = 100; + +type SqlReadyLocalRelationSite = Extract< + SqlLocalRelationSiteResult, + { readonly status: "ready" } +>; + +type SqlComposableCatalogUnavailableReason = + | "execution-timeout" + | "malformed-response" + | "overloaded" + | "provider-failed" + | "queue-timeout"; + +export type SqlComposableCatalogOutcome = + | Extract< + SqlCatalogSearchWorkOutcome, + { readonly status: "usable" } + > + | { + readonly status: "loading"; + } + | { + readonly status: "unavailable"; + readonly reason: SqlComposableCatalogUnavailableReason; + } + | null; + +export interface SqlRelationCompletionCompositionInput { + readonly catalogOutcome: SqlComposableCatalogOutcome; + readonly dialect: SqlRelationDialectRuntime; + readonly localSite: SqlReadyLocalRelationSite; + readonly providerId: string | null; + readonly remainingIntentLeaseMs: number; + readonly replacementRange: SqlTextRange; + readonly statementOffset: number; +} + +export interface SqlRelationCompletionComposition { + readonly sources: readonly SqlCatalogProviderReport[]; + readonly value: SqlCompletionList; +} + +interface RankedCteItem { + readonly item: Extract< + SqlCompletionItem, + { readonly relationKind: "cte" } + >; + readonly path: string; +} + +interface RankedCatalogItem { + readonly completionPathLength: number; + readonly item: Exclude< + SqlCompletionItem, + { readonly relationKind: "cte" } + >; + readonly label: string; + readonly matchQuality: "exact" | "equivalent"; + readonly path: string; +} + +const CATALOG_KIND_ORDER = Object.freeze({ + "temporary-table": 0, + table: 1, + view: 2, + "materialized-view": 3, + "external-relation": 4, +} as const); + +const ISSUE_ORDER = Object.freeze([ + "query-site-recovery", + "opaque-template-context", + "cte-scope-uncertainty", + "recursive-cte-uncertainty", + "catalog-loading", + "catalog-partial", + "catalog-paginated", + "catalog-failed", + "catalog-malformed", + "catalog-overloaded", + "catalog-queue-timeout", + "catalog-timeout", + "result-limit", +] as const); + +type OrderedIssueReason = (typeof ISSUE_ORDER)[number]; + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareCteItems( + left: RankedCteItem, + right: RankedCteItem, +): number { + return ( + compareCodeUnits(left.item.label, right.item.label) || + compareCodeUnits(left.path, right.path) || + left.item.provenance.declarationPosition - + right.item.provenance.declarationPosition + ); +} + +function compareCatalogItems( + left: RankedCatalogItem, + right: RankedCatalogItem, +): number { + return ( + (left.matchQuality === right.matchQuality + ? 0 + : left.matchQuality === "exact" + ? -1 + : 1) || + left.completionPathLength - right.completionPathLength || + CATALOG_KIND_ORDER[left.item.relationKind] - + CATALOG_KIND_ORDER[right.item.relationKind] || + compareCodeUnits(left.label, right.label) || + compareCodeUnits(left.path, right.path) || + compareCodeUnits( + left.item.provenance.entityId, + right.item.provenance.entityId, + ) + ); +} + +function addLocalIssues( + input: SqlRelationCompletionCompositionInput, + issues: Set, +): void { + const { localSite } = input; + if (localSite.querySite.recognition.quality === "recovered") { + issues.add("query-site-recovery"); + } + if ( + localSite.querySite.recognition.issues.some( + (issue) => issue === "opaque-template-context", + ) + ) { + issues.add("opaque-template-context"); + } + if (localSite.local.kind !== "unqualified") return; + const visibility = localSite.local.cteVisibility; + if ( + visibility.quality === "recovered" || + visibility.shadowing.coverage === "unknown" || + visibility.issues.length > 0 + ) { + issues.add("cte-scope-uncertainty"); + } + if ( + visibility.issues.includes("opaque-template-context") + ) { + issues.add("opaque-template-context"); + } + if ( + visibility.issues.includes("recursive-cte-position") + ) { + issues.add("recursive-cte-uncertainty"); + } +} + +function createCteItems( + input: SqlRelationCompletionCompositionInput, + issues: Set, +): RankedCteItem[] { + if (input.localSite.local.kind !== "unqualified") { + return []; + } + const items: RankedCteItem[] = []; + const prefix = input.localSite.querySite.prefix; + for (const cte of input.localSite.local.cteVisibility.ctes) { + let match: ReturnType< + SqlRelationDialectRuntime["completion"]["cteIdentifierMatchesPrefix"] + >; + try { + match = + input.dialect.completion.cteIdentifierMatchesPrefix( + cte.name, + prefix, + ); + } catch { + match = "unknown"; + } + if (match === "unknown") { + issues.add("cte-scope-uncertainty"); + continue; + } + if (match === "no-match") continue; + const declarationPosition = + input.statementOffset + cte.declarationPosition; + const item = Object.freeze({ + edit: Object.freeze({ + from: input.replacementRange.from, + insert: cte.sourceSpelling, + to: input.replacementRange.to, + }), + label: cte.name.value, + kind: "relation" as const, + provenance: Object.freeze({ + declarationPosition, + kind: "cte" as const, + }), + relationKind: "cte" as const, + }); + items.push({ item, path: cte.sourceSpelling }); + } + items.sort(compareCteItems); + return items; +} + +function cteShadowsCatalogRelation( + input: SqlRelationCompletionCompositionInput, + relationName: SqlIdentifierComponent, + issues: Set, +): boolean { + if (input.localSite.local.kind !== "unqualified") { + return false; + } + const shadowing = + input.localSite.local.cteVisibility.shadowing; + if (shadowing.coverage === "unknown") { + issues.add("cte-scope-uncertainty"); + return false; + } + for (const name of shadowing.names) { + let comparison: ReturnType< + SqlRelationDialectRuntime["completion"]["compareCteIdentifiers"] + >; + try { + comparison = + input.dialect.completion.compareCteIdentifiers( + name, + relationName, + ); + } catch { + comparison = "unknown"; + } + if (comparison === "equal") return true; + if (comparison === "unknown") { + issues.add("cte-scope-uncertainty"); + } + } + return false; +} + +function createCatalogItems( + input: SqlRelationCompletionCompositionInput, + issues: Set, +): RankedCatalogItem[] { + const outcome = input.catalogOutcome; + if ( + outcome === null || + outcome.status !== "usable" || + outcome.response.status !== "ready" || + input.providerId === null + ) { + return []; + } + const items: RankedCatalogItem[] = []; + for (const relation of outcome.response.relations) { + const relationName = + relation.canonicalPath[ + relation.canonicalPath.length - 1 + ]; + if (!relationName || relationName.role !== "relation") continue; + if ( + relation.completionPath.length === 1 && + cteShadowsCatalogRelation( + input, + relationName, + issues, + ) + ) { + continue; + } + const base = { + edit: Object.freeze({ + from: input.replacementRange.from, + insert: relation.completionText, + to: input.replacementRange.to, + }), + label: relationName.value, + kind: "relation" as const, + provenance: Object.freeze({ + entityId: relation.entityId, + kind: "catalog" as const, + providerId: input.providerId, + }), + relationKind: relation.relationKind, + }; + const item = Object.freeze( + relation.detail === undefined + ? base + : { ...base, detail: relation.detail }, + ); + const renderedLabel = + input.dialect.completion.renderRelationPath( + Object.freeze([relationName]), + ); + items.push({ + completionPathLength: relation.completionPath.length, + item, + label: + renderedLabel.status === "rendered" + ? renderedLabel.text + : relationName.value, + matchQuality: relation.matchQuality, + path: relation.completionText, + }); + } + items.sort(compareCatalogItems); + return items; +} + +function unavailableIssue( + reason: SqlComposableCatalogUnavailableReason, +): OrderedIssueReason { + switch (reason) { + case "execution-timeout": + return "catalog-timeout"; + case "malformed-response": + return "catalog-malformed"; + case "overloaded": + return "catalog-overloaded"; + case "provider-failed": + return "catalog-failed"; + case "queue-timeout": + return "catalog-queue-timeout"; + } +} + +function unavailableReportReason( + reason: SqlComposableCatalogUnavailableReason, +): Extract< + SqlCatalogProviderReport, + { readonly outcome: "unavailable" } +>["reason"] { + switch (reason) { + case "execution-timeout": + return "execution-timeout"; + case "malformed-response": + return "malformed-response"; + case "overloaded": + return "queue-overloaded"; + case "provider-failed": + return "provider-rejected"; + case "queue-timeout": + return "queue-timeout"; + } +} + +function addCatalogEvidence( + input: SqlRelationCompletionCompositionInput, + issues: Set, +): readonly SqlCatalogProviderReport[] { + const outcome = input.catalogOutcome; + const providerId = input.providerId; + if (outcome === null || providerId === null) return Object.freeze([]); + if (outcome.status === "loading") { + issues.add("catalog-loading"); + return Object.freeze([ + Object.freeze({ + feature: "relation-catalog" as const, + outcome: "loading" as const, + providerId, + }), + ]); + } + if (outcome.status === "unavailable") { + issues.add(unavailableIssue(outcome.reason)); + return Object.freeze([ + Object.freeze({ + feature: "relation-catalog" as const, + outcome: "unavailable" as const, + providerId, + reason: unavailableReportReason(outcome.reason), + }), + ]); + } + const response = outcome.response; + if (response.status === "loading") { + issues.add("catalog-loading"); + return Object.freeze([ + Object.freeze({ + feature: "relation-catalog" as const, + outcome: "loading" as const, + providerId, + }), + ]); + } + if (response.status === "failed") { + issues.add("catalog-failed"); + return Object.freeze([ + Object.freeze({ + code: response.code, + feature: "relation-catalog" as const, + outcome: "failed" as const, + providerId, + retry: response.retry, + }), + ]); + } + if (response.coverage.kind === "partial") { + issues.add("catalog-partial"); + } else if (response.coverage.kind === "paginated") { + issues.add("catalog-paginated"); + } + return Object.freeze([ + Object.freeze({ + coverage: response.coverage.kind, + feature: "relation-catalog" as const, + outcome: "ready" as const, + providerId, + }), + ]); +} + +function freezeIssues( + reasons: ReadonlySet, + remainingIntentLeaseMs: number, +): readonly SqlCompletionIssue[] { + const issues: SqlCompletionIssue[] = []; + for (const reason of ISSUE_ORDER) { + if (!reasons.has(reason)) continue; + issues.push( + reason === "catalog-loading" + ? Object.freeze({ + reason, + remainingIntentLeaseMs, + }) + : Object.freeze({ reason }), + ); + } + return Object.freeze(issues); +} + +function completeList( + items: readonly SqlCompletionItem[], +): Extract< + SqlCompletionList, + { readonly isIncomplete: false } +> { + const noIssues: readonly [] = Object.freeze([]); + return Object.freeze({ + isIncomplete: false, + issues: noIssues, + items, + }); +} + +function incompleteList( + items: readonly SqlCompletionItem[], + firstIssue: SqlCompletionIssue, + remainingIssues: readonly SqlCompletionIssue[], +): Extract< + SqlCompletionList, + { readonly isIncomplete: true } +> { + const issues: readonly [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ] = Object.freeze([firstIssue, ...remainingIssues]); + return Object.freeze({ + isIncomplete: true, + issues, + items, + }); +} + +export function composeSqlRelationCompletion( + input: SqlRelationCompletionCompositionInput, +): SqlRelationCompletionComposition { + const issues = new Set(); + addLocalIssues(input, issues); + const ctes = createCteItems(input, issues); + const catalog = createCatalogItems(input, issues); + const ranked = [ + ...ctes.map(({ item }) => item), + ...catalog.map(({ item }) => item), + ]; + if (ranked.length > MAX_RELATION_COMPLETION_RESULTS) { + ranked.length = MAX_RELATION_COMPLETION_RESULTS; + issues.add("result-limit"); + } + const sources = addCatalogEvidence(input, issues); + const frozenItems = Object.freeze(ranked); + const frozenIssues = freezeIssues( + issues, + input.remainingIntentLeaseMs, + ); + const firstIssue = frozenIssues[0]; + const value = + firstIssue === undefined + ? completeList(frozenItems) + : incompleteList( + frozenItems, + firstIssue, + frozenIssues.slice(1), + ); + return Object.freeze({ sources, value }); +} diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 7306d16..80b1169 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -6,15 +6,50 @@ import type { SqlLanguageService, SqlLanguageServiceOptions, SqlRevision, + SqlIdentifierPath, SqlTextChange, SqlTextRange, } from "./types.js"; import { buildSqlStatementIndex, + findSqlStatementSlot, type SqlLexicalProfile, type SqlStatementIndex, + type SqlStatementSlot, updateSqlStatementIndex, } from "./statement-index.js"; +import { + captureSqlRelationCatalogProvider, + MAX_CATALOG_IDENTIFIER_LENGTH, + MAX_CATALOG_SCOPE_LENGTH, + MAX_CATALOG_SEARCH_PATH_COMPONENTS, + MAX_CATALOG_SEARCH_PATHS, +} from "./relation-catalog-boundary.js"; +import { + createSqlCatalogSearchWorkCoordinator, + type SqlCatalogSearchWorkCoordinator, + type SqlCatalogSearchWorkOwner, + type SqlCatalogSearchWorkOutcome, + type SqlCatalogSearchWorkTicket, +} from "./relation-catalog-search-work.js"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationStatementPreparation, + type SqlLocalRelationSiteResult, +} from "./local-relation-site.js"; +import { + composeSqlRelationCompletion, + MAX_RELATION_COMPLETION_RESULTS, + type SqlComposableCatalogOutcome, +} from "./relation-completion.js"; +import type { + SqlCompletionRequest, + SqlDisposable, + SqlCompletionResult, + SqlSessionChangeEvent, + SqlSessionChangeReason, +} from "./relation-completion-types.js"; import { BIGQUERY_SQL_RELATION_DIALECT, DREMIO_SQL_RELATION_DIALECT, @@ -45,6 +80,35 @@ const MAX_CONTEXT_STRING_LENGTH = 1_000_000; const MAX_CONTEXT_ARRAY_LENGTH = 50_000; const MAX_CHANGES_PER_UPDATE = 10_000; const MAX_DIALECTS = 1_000; +const DEFAULT_CATALOG_RESPONSE_BUDGET_MS = 40; +const MAX_CATALOG_RESPONSE_BUDGET_MS = 50; +const TERMINAL_LOADING_INTENT_LEASE_MS = 1_000; + +interface ResolvedCatalogContext { + readonly scope: string; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +interface CompletionRequestState { + cancelReason: "caller" | "disposed" | "superseded" | null; + readonly revision: SqlRevision; + ticket: SqlCatalogSearchWorkTicket | null; + readonly token: object; +} + +interface SessionChangeSubscription { + active: boolean; + readonly listener: (event: SqlSessionChangeEvent) => void; +} + +interface SessionTimerCell { + active: boolean; + handle: ReturnType | undefined; +} + +interface CompletionConfiguration { + readonly catalogResponseBudgetMs: number; +} interface SqlDialectRuntime { readonly dialect: SqlDialect; @@ -352,6 +416,89 @@ function resolveDialectRuntime( return runtime; } +function resolveCatalogContext( + context: SqlDocumentContext, +): ResolvedCatalogContext | null { + const catalog = context.catalog; + if (!catalog) return null; + if ( + typeof catalog !== "object" || + typeof catalog.scope !== "string" || + catalog.scope.length === 0 || + catalog.scope.length > MAX_CATALOG_SCOPE_LENGTH + ) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog context has an invalid scope", + ); + } + const candidatePaths = catalog.searchPath ?? []; + if ( + !Array.isArray(candidatePaths) || + candidatePaths.length > MAX_CATALOG_SEARCH_PATHS + ) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog search paths are invalid", + ); + } + for (const path of candidatePaths) { + if ( + !Array.isArray(path) || + path.length === 0 || + path.length > MAX_CATALOG_SEARCH_PATH_COMPONENTS + ) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog search path is invalid", + ); + } + for (const component of path) { + if ( + !component || + typeof component !== "object" || + typeof component.value !== "string" || + component.value.length === 0 || + component.value.length > MAX_CATALOG_IDENTIFIER_LENGTH || + typeof component.quoted !== "boolean" + ) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog search path component is invalid", + ); + } + } + } + return Object.freeze({ + scope: catalog.scope, + searchPaths: candidatePaths, + }); +} + +function unavailableCompletionReason( + local: Exclude, +): "inactive" | "unsupported-query-site" | "opaque-statement" | + "ambiguous-query-site" | "resource-limit" { + if (local.status === "inactive") return "inactive"; + switch (local.reason) { + case "opaque-statement": + return "opaque-statement"; + case "resource-limit": + return "resource-limit"; + case "unsupported-query-site": + return "unsupported-query-site"; + case "ambiguous-query-site": + return "ambiguous-query-site"; + } +} + +function completionCancellation( + revision: SqlRevision, + reason: "caller" | "disposed" | "superseded", +): SqlCompletionResult { + return Object.freeze({ reason, revision, status: "cancelled" }); +} + interface MissingDataProperty { readonly found: false; } @@ -588,22 +735,47 @@ interface StatementIndexCache { readonly sourceSequence: number; } +interface LocalRelationStatementCache { + readonly dialect: SqlRelationDialectRuntime; + readonly index: SqlStatementIndex; + readonly preparation: SqlLocalRelationStatementPreparation; + readonly slot: SqlStatementSlot; + readonly sourceSequence: number; +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { + readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; + readonly #catalogResponseBudgetMs: number; readonly #dialects: ReadonlyMap; readonly #onDispose: () => void; + readonly #listeners = new Set(); + #activeCompletion: CompletionRequestState | null = null; + #catalogOwner: SqlCatalogSearchWorkOwner | null = null; + #catalogOwnerDialect: SqlRelationDialectRuntime | null = null; + #catalogOwnerScope: string | null = null; #disposed = false; + #localRelationStatementCache: + | LocalRelationStatementCache + | null = null; + #refreshIntent: CompletionRequestState | null = null; #snapshot: SessionSnapshot; #statementIndexCache: StatementIndexCache | null = null; + #terminalIntentTimer: SessionTimerCell | null = null; #updating = false; constructor( source: SqlSourceSnapshot, context: Context, dialects: ReadonlyMap, + catalogCoordinator: SqlCatalogSearchWorkCoordinator | null, + completion: CompletionConfiguration, onDispose: () => void, ) { + this.#catalogCoordinator = catalogCoordinator; + this.#catalogResponseBudgetMs = + completion.catalogResponseBudgetMs; this.#dialects = dialects; this.#onDispose = onDispose; const sequence = 0; @@ -621,6 +793,7 @@ export class DefaultSqlDocumentSession source, sourceSequence, }); + this.#replaceCatalogOwner(); } get revision(): SqlRevision { @@ -662,6 +835,569 @@ export class DefaultSqlDocumentSession return index; } + #clearTerminalIntent(): void { + const cell = this.#terminalIntentTimer; + if (!cell) return; + this.#terminalIntentTimer = null; + cell.active = false; + clearTimeout(cell.handle); + } + + #dispatchChange( + revision: SqlRevision, + reason: SqlSessionChangeReason, + ): void { + if (this.#disposed || revision !== this.#snapshot.revision) { + return; + } + const event = Object.freeze({ reason, revision }); + for (const subscription of Array.from(this.#listeners)) { + if ( + this.#disposed || + revision !== this.#snapshot.revision + ) { + return; + } + if ( + !subscription.active || + !this.#listeners.has(subscription) + ) { + continue; + } + try { + Reflect.apply(subscription.listener, undefined, [event]); + } catch { + // Listener failures do not interrupt coordinator state changes. + } + } + } + + #prepareServiceChange( + reason: SqlSessionChangeReason, + expected?: CompletionRequestState, + ): (() => undefined) | null { + if ( + this.#disposed || + (expected !== undefined && + (this.#activeCompletion !== expected && + this.#refreshIntent !== expected || + expected.cancelReason !== null || + expected.revision !== this.#snapshot.revision)) + ) { + return null; + } + const previous = this.#snapshot; + const revision = createSqlRevisionToken(); + this.#snapshot = Object.freeze({ + ...previous, + revision, + sequence: previous.sequence + 1, + }); + const active = this.#activeCompletion; + const intent = this.#refreshIntent; + this.#activeCompletion = null; + this.#refreshIntent = null; + this.#clearTerminalIntent(); + if (active && active.cancelReason === null) { + active.cancelReason = "superseded"; + } + if (intent && intent !== active) { + if (intent.cancelReason === null) { + intent.cancelReason = "superseded"; + } + } + return (): undefined => { + active?.ticket?.cancel(); + if (intent !== active) intent?.ticket?.cancel(); + this.#dispatchChange(revision, reason); + return undefined; + }; + } + + #replaceCatalogOwner(): void { + const catalog = resolveCatalogContext(this.#snapshot.context); + const dialect = this.#snapshot.dialect.relationDialect; + if ( + this.#catalogOwner && + catalog && + this.#catalogOwnerScope === catalog.scope && + this.#catalogOwnerDialect === dialect + ) { + return; + } + this.#catalogOwner?.dispose(); + this.#catalogOwner = null; + this.#catalogOwnerDialect = null; + this.#catalogOwnerScope = null; + if (!catalog || !this.#catalogCoordinator || this.#disposed) { + return; + } + const prepared = this.#catalogCoordinator.prepareOwner( + catalog.scope, + dialect, + Object.freeze({ + prepareCatalogChange: (): (() => undefined) | null => + this.#prepareServiceChange("catalog"), + }), + ); + if (prepared.status !== "prepared") return; + this.#catalogOwner = prepared.owner; + this.#catalogOwnerDialect = dialect; + this.#catalogOwnerScope = catalog.scope; + const activation = prepared.owner.activate(); + if (activation.status !== "active") { + prepared.owner.dispose(); + if (this.#catalogOwner === prepared.owner) { + this.#catalogOwner = null; + this.#catalogOwnerDialect = null; + this.#catalogOwnerScope = null; + } + } + } + + readonly onDidChange = ( + listener: (event: SqlSessionChangeEvent) => void, + ): SqlDisposable => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + if (typeof listener !== "function") { + throw new SqlSessionError( + "invalid-completion-request", + "SQL session change listener must be a function", + ); + } + const subscription: SessionChangeSubscription = { + active: true, + listener, + }; + this.#listeners.add(subscription); + return Object.freeze({ + dispose: (): void => { + if (!subscription.active) return; + subscription.active = false; + this.#listeners.delete(subscription); + }, + }); + }; + + readonly complete = async ( + request: SqlCompletionRequest, + ): Promise => { + const completionStartedAt = performance.now(); + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + let position: number; + let signal: AbortSignal | undefined; + try { + if (request === null || typeof request !== "object") { + throw new Error(); + } + position = readRequiredDataProperty( + request, + "position", + "invalid-completion-request", + "SQL completion request", + ); + const trigger = readRequiredDataProperty( + request, + "trigger", + "invalid-completion-request", + "SQL completion request", + ); + if ( + !Number.isSafeInteger(position) || + position < 0 || + position > this.#snapshot.source.originalText.length || + trigger === null || + typeof trigger !== "object" + ) { + throw new Error(); + } + const triggerKind = readRequiredDataProperty( + trigger, + "kind", + "invalid-completion-request", + "SQL completion trigger", + ); + if ( + triggerKind !== "invoked" && + triggerKind !== "trigger-character" + ) { + throw new Error(); + } + if (triggerKind === "trigger-character") { + const character = readRequiredDataProperty( + trigger, + "character", + "invalid-completion-request", + "SQL completion trigger", + ); + if ( + typeof character !== "string" || + character.length === 0 || + character.length > 2 || + Array.from(character).length !== 1 + ) { + throw new Error(); + } + } else { + rejectOwnProperty( + trigger, + "character", + "invalid-completion-request", + "SQL completion trigger", + ); + } + const signalProperty = readOwnDataProperty( + request, + "signal", + "invalid-completion-request", + "SQL completion request", + ); + if ( + signalProperty.found && + signalProperty.value !== undefined + ) { + if (!(signalProperty.value instanceof AbortSignal)) { + throw new Error(); + } + signal = signalProperty.value; + } + } catch (error) { + if ( + error instanceof SqlSessionError && + error.code === "invalid-completion-request" + ) { + throw error; + } + throw new SqlSessionError( + "invalid-completion-request", + "SQL completion request is invalid", + ); + } + + const previousActive = this.#activeCompletion; + const previousIntent = this.#refreshIntent; + if ( + previousActive && + previousActive.cancelReason === null + ) { + previousActive.cancelReason = "superseded"; + } + if ( + previousIntent && + previousIntent !== previousActive && + previousIntent.cancelReason === null + ) { + previousIntent.cancelReason = "superseded"; + } + const cancelPrevious = (): void => { + previousActive?.ticket?.cancel(); + if (previousIntent !== previousActive) { + previousIntent?.ticket?.cancel(); + } + if (this.#refreshIntent === previousIntent) { + this.#refreshIntent = null; + } + }; + this.#clearTerminalIntent(); + const snapshot = this.#snapshot; + const active: CompletionRequestState = { + cancelReason: null, + revision: snapshot.revision, + ticket: null, + token: {}, + }; + this.#activeCompletion = active; + if (signal?.aborted) { + active.cancelReason = "caller"; + this.#activeCompletion = null; + cancelPrevious(); + return completionCancellation(snapshot.revision, "caller"); + } + const onAbort = (): void => { + if ( + this.#activeCompletion === active && + active.cancelReason === null + ) { + active.cancelReason = "caller"; + active.ticket?.cancel(); + } + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + try { + const index = this.getStatementIndexForTesting(); + const slot = findSqlStatementSlot(index, position, "left"); + const cachedLocal = this.#localRelationStatementCache; + const prepared = + cachedLocal && + cachedLocal.sourceSequence === snapshot.sourceSequence && + cachedLocal.index === index && + cachedLocal.slot === slot && + cachedLocal.dialect === snapshot.dialect.relationDialect + ? cachedLocal.preparation + : prepareSqlLocalRelationStatement( + snapshot.source, + index, + slot, + snapshot.dialect.relationDialect, + ); + if (cachedLocal?.preparation !== prepared) { + this.#localRelationStatementCache = Object.freeze({ + dialect: snapshot.dialect.relationDialect, + index, + preparation: prepared, + slot, + sourceSequence: snapshot.sourceSequence, + }); + } + if (prepared.status !== "ready") { + cancelPrevious(); + return Object.freeze({ + reason: unavailableCompletionReason(prepared), + retryable: false, + revision: snapshot.revision, + status: "unavailable", + }); + } + const localSite = analyzeSqlLocalRelationSite( + prepared.statement, + position, + ); + if (localSite.status !== "ready") { + cancelPrevious(); + return Object.freeze({ + reason: unavailableCompletionReason(localSite), + retryable: false, + revision: snapshot.revision, + status: "unavailable", + }); + } + if ( + this.#activeCompletion !== active || + active.cancelReason !== null || + snapshot.revision !== this.#snapshot.revision + ) { + return completionCancellation( + snapshot.revision, + active.cancelReason ?? "superseded", + ); + } + + const querySite = localSite.querySite; + const statementOffset = + slot.boundaryQuality === "exact" ? slot.source.from : 0; + const replacementRange = Object.freeze({ + from: statementOffset + querySite.typedPathRange.from, + to: statementOffset + querySite.typedPathRange.to, + }); + const catalog = resolveCatalogContext(snapshot.context); + let catalogOutcome: SqlComposableCatalogOutcome = null; + let remainingIntentLeaseMs = 0; + const owner = this.#catalogOwner; + + if (catalog && this.#catalogCoordinator) { + if (!owner) { + cancelPrevious(); + catalogOutcome = Object.freeze({ + reason: "overloaded", + status: "unavailable", + }); + } else { + const ticket = owner.request({ + continuationToken: null, + limit: MAX_RELATION_COMPLETION_RESULTS, + prefix: querySite.prefix, + qualifier: querySite.qualifier, + searchPaths: catalog.searchPaths, + }); + if (this.#refreshIntent === previousIntent) { + this.#refreshIntent = null; + } + active.ticket = ticket; + const elapsed = Math.max( + 0, + performance.now() - completionStartedAt, + ); + const remaining = Math.max( + 0, + this.#catalogResponseBudgetMs - elapsed, + ); + let responseTimer: + | ReturnType + | undefined; + const raced = await Promise.race([ + ticket.result.then((outcome) => + Object.freeze({ + kind: "outcome" as const, + outcome, + }), + ), + new Promise<{ readonly kind: "timeout" }>((resolve) => { + responseTimer = setTimeout( + () => resolve(Object.freeze({ kind: "timeout" })), + remaining, + ); + }), + ]); + clearTimeout(responseTimer); + if ( + this.#activeCompletion !== active || + active.cancelReason !== null || + snapshot.revision !== this.#snapshot.revision + ) { + ticket.cancel(); + return completionCancellation( + snapshot.revision, + active.cancelReason ?? "superseded", + ); + } + let providerOutcome: SqlCatalogSearchWorkOutcome | null = + raced.kind === "outcome" ? raced.outcome : null; + if (raced.kind === "timeout") { + const retained = ticket.retainForRefresh( + (): (() => undefined) | null => + this.#prepareServiceChange( + "catalog-availability", + active, + ), + ); + if (retained.status === "retained") { + remainingIntentLeaseMs = retained.remainingLeaseMs; + this.#refreshIntent = active; + catalogOutcome = Object.freeze({ + status: "loading", + }); + } else if ( + retained.reason === "not-retainable" + ) { + providerOutcome = await ticket.result; + } else { + catalogOutcome = Object.freeze({ + reason: "execution-timeout", + status: "unavailable", + }); + } + } + if (providerOutcome) { + const outcome = providerOutcome; + if ( + outcome.status === "cancelled" || + outcome.status === "superseded" + ) { + return completionCancellation( + snapshot.revision, + active.cancelReason ?? + (outcome.status === "cancelled" + ? "caller" + : "superseded"), + ); + } + if (outcome.status === "unavailable") { + if ( + outcome.reason === "disposed" || + outcome.reason === "inactive" + ) { + return completionCancellation( + snapshot.revision, + this.#disposed ? "disposed" : "superseded", + ); + } + switch (outcome.reason) { + case "invalid-request": + catalogOutcome = Object.freeze({ + reason: "provider-failed", + status: "unavailable", + }); + break; + case "execution-timeout": + case "malformed-response": + case "overloaded": + case "provider-failed": + case "queue-timeout": + catalogOutcome = Object.freeze({ + reason: outcome.reason, + status: "unavailable", + }); + break; + } + } else { + catalogOutcome = outcome; + if (outcome.response.status === "loading") { + remainingIntentLeaseMs = + TERMINAL_LOADING_INTENT_LEASE_MS; + const cell: SessionTimerCell = { + active: true, + handle: undefined, + }; + this.#terminalIntentTimer = cell; + const handle = setTimeout(() => { + if (!cell.active) return; + cell.active = false; + if (this.#terminalIntentTimer === cell) { + this.#terminalIntentTimer = null; + } + }, remainingIntentLeaseMs); + cell.handle = handle; + if ( + !cell.active || + this.#terminalIntentTimer !== cell + ) { + clearTimeout(handle); + } + } + } + } + } + } else { + cancelPrevious(); + } + + const composition = composeSqlRelationCompletion({ + catalogOutcome, + dialect: snapshot.dialect.relationDialect, + localSite, + providerId: + catalog && this.#catalogCoordinator + ? this.#catalogCoordinator.providerId + : null, + remainingIntentLeaseMs, + replacementRange, + statementOffset, + }); + if ( + this.#activeCompletion !== active || + active.cancelReason !== null || + snapshot.revision !== this.#snapshot.revision + ) { + return completionCancellation( + snapshot.revision, + active.cancelReason ?? "superseded", + ); + } + return Object.freeze({ + revision: snapshot.revision, + sources: composition.sources, + status: "ready", + value: composition.value, + }); + } finally { + signal?.removeEventListener("abort", onAbort); + if (this.#activeCompletion === active) { + this.#activeCompletion = null; + } + } + }; + readonly update = (update: SqlDocumentUpdate): SqlRevision => { if (this.#disposed) { throw new SqlSessionError("session-disposed", "SQL document session is disposed"); @@ -866,6 +1602,13 @@ export class DefaultSqlDocumentSession nextContext, this.#dialects, ); + const nextCatalog = resolveCatalogContext(nextContext); + if (nextCatalog && !this.#catalogCoordinator) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog context requires a configured catalog provider", + ); + } const nextLexicalProfile = nextDialect.lexicalProfile; if (this.#disposed) { throw new SqlSessionError( @@ -919,9 +1662,39 @@ export class DefaultSqlDocumentSession }) : null; } + const activeCompletion = this.#activeCompletion; + const refreshIntent = this.#refreshIntent; + const invalidatesLocalRelationCache = + nextSourceSequence !== this.#snapshot.sourceSequence || + nextDialect.relationDialect !== + this.#snapshot.dialect.relationDialect; this.#snapshot = nextSnapshot; this.#statementIndexCache = nextStatementIndexCache; - return revision; + if (invalidatesLocalRelationCache) { + this.#localRelationStatementCache = null; + } + this.#activeCompletion = null; + this.#refreshIntent = null; + this.#clearTerminalIntent(); + if ( + activeCompletion && + activeCompletion.cancelReason === null + ) { + activeCompletion.cancelReason = "superseded"; + } + if ( + refreshIntent && + refreshIntent !== activeCompletion && + refreshIntent.cancelReason === null + ) { + refreshIntent.cancelReason = "superseded"; + } + activeCompletion?.ticket?.cancel(); + if (refreshIntent !== activeCompletion) { + refreshIntent?.ticket?.cancel(); + } + this.#replaceCatalogOwner(); + return this.#snapshot.revision; } readonly isCurrent = (revision: SqlRevision): boolean => { @@ -933,7 +1706,34 @@ export class DefaultSqlDocumentSession return; } this.#disposed = true; + const activeCompletion = this.#activeCompletion; + const refreshIntent = this.#refreshIntent; + const catalogOwner = this.#catalogOwner; + this.#activeCompletion = null; + this.#refreshIntent = null; + this.#catalogOwner = null; + this.#clearTerminalIntent(); + this.#listeners.clear(); + this.#localRelationStatementCache = null; this.#statementIndexCache = null; + if ( + activeCompletion && + activeCompletion.cancelReason === null + ) { + activeCompletion.cancelReason = "disposed"; + } + if ( + refreshIntent && + refreshIntent !== activeCompletion && + refreshIntent.cancelReason === null + ) { + refreshIntent.cancelReason = "disposed"; + } + activeCompletion?.ticket?.cancel(); + if (refreshIntent !== activeCompletion) { + refreshIntent?.ticket?.cancel(); + } + catalogOwner?.dispose(); this.#onDispose(); }; } @@ -941,6 +1741,8 @@ export class DefaultSqlDocumentSession export class DefaultSqlLanguageService implements SqlLanguageService { + readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; + readonly #completion: CompletionConfiguration; readonly #dialects: ReadonlyMap; readonly #sessions = new Set>(); #disposed = false; @@ -1001,6 +1803,78 @@ export class DefaultSqlLanguageService dialects.set(runtime.dialect.id, runtime); } this.#dialects = dialects; + + let catalogResponseBudgetMs = + DEFAULT_CATALOG_RESPONSE_BUDGET_MS; + const completion = readOwnDataProperty( + options, + "completion", + "invalid-service-options", + "SQL language service options", + ); + if (completion.found && completion.value !== undefined) { + if ( + completion.value === null || + typeof completion.value !== "object" + ) { + throw new SqlSessionError( + "invalid-service-options", + "SQL completion options must be an object", + ); + } + const budget = readOwnDataProperty( + completion.value, + "catalogResponseBudgetMs", + "invalid-service-options", + "SQL completion options", + ); + if (budget.found && budget.value !== undefined) { + if ( + typeof budget.value !== "number" || + !Number.isFinite(budget.value) || + budget.value < 0 || + budget.value > MAX_CATALOG_RESPONSE_BUDGET_MS + ) { + throw new SqlSessionError( + "invalid-service-options", + `Catalog response budget must be between 0 and ${MAX_CATALOG_RESPONSE_BUDGET_MS} milliseconds`, + ); + } + catalogResponseBudgetMs = budget.value; + } + } + this.#completion = Object.freeze({ + catalogResponseBudgetMs, + }); + + const catalog = readOwnDataProperty( + options, + "catalog", + "invalid-service-options", + "SQL language service options", + ); + if (!catalog.found || catalog.value === undefined) { + this.#catalogCoordinator = null; + } else { + const captured = captureSqlRelationCatalogProvider( + catalog.value, + ); + if (captured.status !== "accepted") { + throw new SqlSessionError( + "invalid-service-options", + "SQL relation catalog provider is invalid", + ); + } + const coordinator = + createSqlCatalogSearchWorkCoordinator(captured.value); + if (coordinator.status !== "created") { + throw new SqlSessionError( + "invalid-service-options", + "SQL relation catalog coordinator could not be created", + ); + } + this.#catalogCoordinator = coordinator.coordinator; + } } catch (error) { if (error instanceof SqlSessionError) { throw error; @@ -1062,12 +1936,21 @@ export class DefaultSqlLanguageService } const context = cloneContext(candidateContext); resolveDialectRuntime(context, this.#dialects); + const catalogContext = resolveCatalogContext(context); + if (catalogContext && !this.#catalogCoordinator) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog context requires a configured catalog provider", + ); + } let session: DefaultSqlDocumentSession; session = new DefaultSqlDocumentSession( source, context, this.#dialects, + this.#catalogCoordinator, + this.#completion, () => { this.#sessions.delete(session); }, @@ -1107,6 +1990,7 @@ export class DefaultSqlLanguageService session.dispose(); } this.#sessions.clear(); + this.#catalogCoordinator?.dispose(); }; } diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 8e732ac..18d11e8 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -136,6 +136,12 @@ export interface OpenSqlDocument { export interface SqlDocumentSession { readonly revision: SqlRevision; readonly update: (update: SqlDocumentUpdate) => SqlRevision; + readonly complete: ( + request: SqlCompletionRequest, + ) => Promise; + readonly onDidChange: ( + listener: (event: SqlSessionChangeEvent) => void, + ) => SqlDisposable; readonly isCurrent: (revision: SqlRevision) => boolean; readonly dispose: () => void; } @@ -149,6 +155,10 @@ export interface SqlLanguageService { } export interface SqlLanguageServiceOptions { + readonly catalog?: SqlRelationCatalogProvider | undefined; + readonly completion?: { + readonly catalogResponseBudgetMs?: number | undefined; + } | undefined; readonly dialects: readonly SqlDialect[]; } @@ -156,6 +166,7 @@ export type SqlSessionErrorCode = | "duplicate-dialect" | "invalid-change" | "invalid-context" + | "invalid-completion-request" | "invalid-dialect" | "invalid-document" | "invalid-service-options" @@ -174,3 +185,10 @@ export class SqlSessionError extends Error { this.code = code; } } +import type { + SqlCompletionRequest, + SqlDisposable, + SqlRelationCatalogProvider, + SqlCompletionResult, + SqlSessionChangeEvent, +} from "./relation-completion-types.js"; diff --git a/state/artifacts/reports/pr-197-browser-ci.md b/state/artifacts/reports/pr-197-browser-ci.md new file mode 100644 index 0000000..56da840 --- /dev/null +++ b/state/artifacts/reports/pr-197-browser-ci.md @@ -0,0 +1,44 @@ +# PR #197 browser CI triage + +## Investigation + +1. Inspected the failing browser job before forming a hypothesis. +2. Confirmed browser tests and worker builds were not the failing step. +3. Located the first failure in worker-placement bundle verification. +4. Compared the emitted bundle with the checked-in capability charter. + +## Root cause + +The framework-independent vNext bundle grew from the session walking skeleton +to the completed relation service and catalog scheduler. The exact local +packed-consumer measurement is 40,121 gzip/143,265 raw bytes; hosted Linux CI +measured 40,214 gzip bytes. It still contains no parser modules. + +The worker-placement script retained the obsolete 16 KiB skeleton limit, while +the capability charter already budgets 75 KiB for core plus the future +CodeMirror adapter. The complete worker fixture also embeds the page-side core, +so after the first correction its obsolete aggregate ceiling failed at 150,847 +gzip bytes. The exact local aggregate is 150,985 gzip/669,106 raw bytes; its +PostgreSQL and BigQuery lazy grammar closures are unchanged. + +## Fix + +Allocate 48 KiB gzip and 180 KiB raw to the framework-independent core. This +passes the current measured bundle with bounded headroom and reserves 27 KiB of +the declared compressed budget for the separate CodeMirror adapter. + +Allocate 160 KiB gzip and 700 KiB raw to the complete worker evidence fixture. +This is an aggregate test-fixture ceiling, not a product bundle promise: it +includes the page-side core plus both lazy parser grammars. The dedicated +dialect-closure ceilings remain unchanged and continue to detect parser growth. + +## Friction and future guidance + +The local isolated worktree shares dependencies by symlink, so its root install +must run with CI semantics to avoid an interactive module-directory prompt. +Hosted CI remains authoritative for platform-sensitive compressed sizes; the +local exact-tarball report records the raw sizes and dependency graph. + +Future vertical slices that change public entry-point reachability should +compare emitted bundle composition with both the per-entry allocation and the +combined 75 KiB capability budget. diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts index 57ef37f..edc16c8 100644 --- a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts +++ b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts @@ -4,7 +4,7 @@ import type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, } from "../../src/vnext/codemirror/relation-completion-types.js"; -import type { SqlRelationCompletionItem } from "../../src/vnext/relation-completion-types.js"; +import type { SqlCompletionItem } from "../../src/vnext/relation-completion-types.js"; interface ReactRootLike { readonly render: (value: unknown) => void; @@ -28,7 +28,7 @@ const resolveInfo: SqlCompletionInfoResolver = async (item, { signal }) => { }; }; -declare const item: SqlRelationCompletionItem; +declare const item: SqlCompletionItem; const resolved = resolveInfo(item, { signal: new AbortController().signal, }); @@ -42,7 +42,7 @@ if (item.provenance.kind === "catalog") { // @ts-expect-error live editor state is not a resolver parameter const resolverWithView: SqlCompletionInfoResolver = ( - _item: SqlRelationCompletionItem, + _item: SqlCompletionItem, _context: SqlCompletionInfoResolverContext, _view: EditorView, ) => null; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 721dfa4..9a8e74d 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -1,6 +1,7 @@ import type { SqlCatalogContext, SqlDocumentContext, + SqlDocumentSession, SqlDocumentEdit, SqlDocumentUpdate, SqlEmbeddedRegion, @@ -18,10 +19,9 @@ import type { SqlCompletionCancellationReason, SqlCompletionIssue, SqlDisposable, - SqlRelationCompletionItem, - SqlRelationCompletionList, + SqlCompletionItem, + SqlCompletionList, SqlRelationCatalogProvider, - SqlRelationCompletionSession, } from "../../src/vnext/relation-completion-types.js"; import type { SqlCatalogEpochTransitionTarget, @@ -60,7 +60,7 @@ const openWithRegions: OpenSqlDocument = { text: "SELECT * FROM {df}", }; -declare const session: SqlRelationCompletionSession; +declare const session: SqlDocumentSession; session.update({ baseRevision: session.revision, document: { kind: "replace", text: "SELECT * FROM {next_df}" }, @@ -325,19 +325,19 @@ const mismatchedItem = { providerId: "marimo", }, relationKind: "cte", -} satisfies SqlRelationCompletionItem; +} satisfies SqlCompletionItem; const contradictoryCompleteList = { isIncomplete: false, // @ts-expect-error complete lists cannot carry incomplete issues issues: [{ reason: "catalog-partial" }], items: [], -} satisfies SqlRelationCompletionList; +} satisfies SqlCompletionList; const contradictoryIncompleteList = { isIncomplete: true, issues: [], items: [], // @ts-expect-error incomplete lists require at least one issue -} satisfies SqlRelationCompletionList; +} satisfies SqlCompletionList; // @ts-expect-error timeouts are unavailable evidence, not cancellation const invalidCancellation: SqlCompletionCancellationReason = "timeout"; const undefinedContext: SqlDocumentUpdate = { diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 13c7020..520056c 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -7,6 +7,7 @@ import { type SqlDocumentSession, type SqlEmbeddedRegion, type SqlLanguageService, + type SqlRelationCatalogProvider, type SqlRevision, type SqlTextChange, type SqlTextRange, @@ -33,6 +34,21 @@ void typedDialect.grammar; // @ts-expect-error dialect rendering policy is package-private void typedDialect.renderRelationPath; const service = createSqlLanguageService({ dialects: [dialect] }); +const relationCatalog = { + id: "host-catalog", + search: async () => ({ + coverage: { kind: "complete" as const }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready" as const, + }), +} satisfies SqlRelationCatalogProvider; +const catalogService = createSqlLanguageService({ + catalog: relationCatalog, + completion: { catalogResponseBudgetMs: 40 }, + dialects: [dialect], +}); +void catalogService; const session = service.openDocument({ context: { dialect: "duckdb", engine: "local" }, embeddedRegions: [{ from: 0, language: "python", to: 1 }], @@ -53,6 +69,25 @@ const hostOpen = { }; service.openDocument(hostOpen); const revision: SqlRevision = session.revision; +void session.complete({ + position: 0, + trigger: { kind: "invoked" }, +}); +void session.complete({ + position: 0, + signal: undefined, + trigger: { kind: "invoked" }, +}); +void session.complete({ + position: 0, + // @ts-expect-error invoked triggers cannot also carry a character + trigger: { character: ".", kind: "invoked" }, +}); +const changeSubscription = session.onDidChange((event) => { + const changedRevision: SqlRevision = event.revision; + void changedRevision; +}); +changeSubscription.dispose(); declare const maybeContext: HostContext | undefined; declare const maybeDocument: SqlDocumentEdit | undefined; diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index b1dbf6c..564c035 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -9,23 +9,23 @@ package's dependency. The fixture workspace pins Vite's floating transitive versions to the exact versions in the root lock, so the nested frozen install can run offline after a clean root CI install. -The minified Vite 8 packed-consumer baseline is 15,028 gzip/47,141 raw +The minified Vite 8 packed-consumer baseline is 40,121 gzip/143,265 raw bytes for the complete parser-free core, 67,573 gzip bytes for the PostgreSQL transitive graph, 50,470 gzip bytes for the BigQuery transitive graph, and -125,937 gzip/572,982 raw bytes for the complete worker build output. The core -measurement includes the four authenticated relation-dialect runtimes and -their reserved-word tables. The PostgreSQL and BigQuery figures each include -their transitive shared chunks; the report also identifies those shared chunks -explicitly. +150,985 gzip/669,106 raw bytes for the complete worker build output. The core +measurement includes the four authenticated relation-dialect runtimes, their +reserved-word tables, and relation-completion/session orchestration. The +PostgreSQL and BigQuery figures each include their transitive shared chunks; +the report also identifies those shared chunks explicitly. -The fail-closed ceilings retain approximately 9% gzip and 13% raw headroom for -the core, 4% gzip and 2% raw headroom for the complete worker output, and the -existing tight dialect-graph headroom: +The fail-closed ceilings retain approximately 18% gzip and 22% raw headroom +for the core, 8% gzip and 7% raw headroom for the complete worker output, and +the existing tight dialect-graph headroom: -- Complete parser-free core: 16 KiB gzip and 52 KiB raw +- Complete parser-free core: 48 KiB gzip and 180 KiB raw - PostgreSQL transitive graph: 68 KiB gzip - BigQuery transitive graph: 50 KiB gzip -- Complete worker build output: 128 KiB gzip and 570 KiB raw +- Complete worker build output: 160 KiB gzip and 700 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no From 1b4c1af70c5d54d74cad4467c5c937101b565571 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 01:06:22 +0800 Subject: [PATCH 29/42] feat(vnext): correlate completion refresh intent (#198) ## Summary - add an opaque completion refresh token that correlates loading results, follow-up catalog events, and the originating completion task - make completion tasks expose their refresh token synchronously so CodeMirror consumers can safely latch intent before caller-controlled reentrancy - define precise token lifetimes for soft and terminal catalog-loading leases, including expiry, cancellation, update, and disposal - harden `AbortSignal` handling against reentrant and throwing getters/listener methods without allowing an older invocation to erase newer work ## Why The upcoming CodeMirror adapter must refresh completion only for the request that originally produced a loading result. Revision checks alone cannot distinguish multiple requests at the same document revision, and asynchronous token publication leaves a race where catalog events can arrive before the consumer knows what to match. This contract makes that identity explicit and keeps latest-request-wins behavior correct across hostile caller boundaries. ## Validation - 2,003 tests pass; 1 intentional expected failure - changed-file coverage: 97.92% statements, 96.01% branches, 100% functions, 98.01% lines - all strict and loose-optional TypeScript configurations pass - oxlint and test-integrity checks pass - session completion benchmarks remain within budget - two independent exact-commit adversarial reviews approved `a0522c4` --- ## Summary by cubic Adds an opaque completion refresh token to correlate loading results, follow-up catalog events, and the originating request. `session.complete()` now returns a `SqlCompletionTask` that exposes the token synchronously, and `onDidChange` events include the token when relevant. - **New Features** - Introduced `SqlCompletionRefreshToken` and `SqlCompletionTask` (`complete()` returns a task with `refreshToken`). - `SqlSessionChangeEvent` now carries `refreshToken`: - `catalog-availability`: always includes the exact token for the active soft intent. - `catalog`: includes the matching token during an active intent lease, otherwise `null`. - `provider-configuration`: `refreshToken` is always `null`. - Ready results include `refreshToken` when a `catalog-loading` lease is active; otherwise `null`. - Hardened `AbortSignal` and timer handling to avoid reentrancy issues and prevent stale callbacks from erasing newer work. - **Migration** - Update callers to use the new task: - `const task = session.complete(...); const result = await task;` - Use `task.refreshToken` immediately to latch intent. - Update `onDidChange` listeners: - Compare `event.refreshToken` to the latched `task.refreshToken` before refreshing. - Treat `catalog` events with `refreshToken: null` as unrelated to the current intent. - Adjust types: - `complete()` now returns `SqlCompletionTask` (still awaitable). - Handle the refined `SqlSessionChangeEvent` union with per-reason `refreshToken` semantics. - Do not serialize or introspect tokens; compare by identity only. Written for commit a0522c47931ef1ee0d417adfb6336c207cc38add. Summary will update on new commits. Review in cubic --- docs/vnext/session-primitives.md | 32 +- src/vnext/__tests__/session.test.ts | 1007 ++++++++++++++++- src/vnext/index.ts | 2 + src/vnext/relation-completion-types.ts | 46 +- src/vnext/session.ts | 301 ++++- src/vnext/types.ts | 4 +- .../marimo-relation-completion.test-d.ts | 54 +- 7 files changed, 1340 insertions(+), 106 deletions(-) diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 6018722..1e0735b 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -88,18 +88,25 @@ const session = service.openDocument({ text: "SELECT * FROM ", }); -const subscription = session.onDidChange(({ reason }) => { - if (reason === "catalog" || reason === "catalog-availability") { +let completionRefreshToken: SqlCompletionRefreshToken | null = null; +const subscription = session.onDidChange(({ refreshToken }) => { + if ( + refreshToken !== null && + refreshToken === completionRefreshToken + ) { // Ask the editor adapter to request completion again. } }); -const result = await session.complete({ +const completionTask = session.complete({ position: 14, trigger: { kind: "invoked" }, }); +completionRefreshToken = completionTask.refreshToken; +const result = await completionTask; if (result.status === "ready" && session.isCurrent(result.revision)) { + completionRefreshToken = result.refreshToken; for (const item of result.value.items) { // Apply item.edit in the original document's UTF-16 coordinates. } @@ -118,10 +125,21 @@ exposing provider errors or internal epochs. The interactive catalog wait is bounded from the start of `complete()`. When the budget expires, the session returns local evidence with a -`catalog-loading` issue and a checked remaining intent lease. Compatible -readiness advances the session revision and emits `catalog-availability`; -higher provider epochs emit `catalog`. Consumers must request completion again -and apply only results whose revision remains current. +`catalog-loading` issue, a checked remaining intent lease, and an opaque +`refreshToken`. Compatible readiness advances the session revision and emits +`catalog-availability` with that exact token. A higher provider epoch emits +`catalog` with the token only while the same soft or terminal loading intent +remains leased; otherwise its token is `null`. + +Consumers compare refresh tokens by identity. A matching token is necessary, +not sufficient, to request completion again: the document, selection, context, +and adapter-owned intent must also remain unchanged. Tokens are in-process, +non-serializable control identities; they expose no provider epoch, query, or +work identity. The completion task exposes its token synchronously before +provider work starts, so consumers can latch a matching catalog event that +races the result. Ready results retain that token only while +`catalog-loading` has an unexpired lease; all other ready results use `null`. +Consumers apply only results whose revision remains current. ## Dialect registration diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 130b5c4..631f003 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -173,6 +173,7 @@ describe("relation completion session integration", () => { trigger: { kind: "invoked" }, }); expect(result).toMatchObject({ + refreshToken: null, status: "ready", value: { items: [ @@ -312,6 +313,7 @@ describe("relation completion session integration", () => { ], }, }); + expect(result).toMatchObject({ refreshToken: null }); service.dispose(); }); @@ -410,6 +412,25 @@ describe("relation completion session integration", () => { ], }, }); + if (result.status !== "ready") { + throw new Error("Expected a ready completion"); + } + expect(result.refreshToken).not.toBeNull(); + expect(Object.isFrozen(result.refreshToken)).toBe(true); + const second = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(second).toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + if (second.status !== "ready") { + throw new Error("Expected a ready completion"); + } + expect(second.refreshToken).not.toBe(result.refreshToken); service.dispose(); }); @@ -439,9 +460,9 @@ describe("relation completion session integration", () => { }, text: "SELECT * FROM ", }); - const events: string[] = []; + const events: SqlSessionChangeEvent[] = []; session.onDidChange((event) => { - events.push(event.reason); + events.push(event); }); const result = await session.complete({ position: 14, @@ -453,6 +474,9 @@ describe("relation completion session integration", () => { issues: [{ reason: "catalog-loading" }], }, }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected a refreshable ready completion"); + } resolveSearch?.({ coverage: { kind: "complete" }, epoch: { generation: 0, token: "initial" }, @@ -460,7 +484,241 @@ describe("relation completion session integration", () => { status: "ready", }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(events).toEqual(["catalog-availability"]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + reason: "catalog-availability", + }); + expect(events[0]?.refreshToken).toBe(result.refreshToken); + service.dispose(); + }); + + it("correlates terminal loading with catalog invalidation during its lease", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected a refreshable ready completion"); + } + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ reason: "catalog" }); + expect(events[0]?.refreshToken).toBe(result.refreshToken); + service.dispose(); + }); + + it("does not correlate catalog invalidation after a terminal lease expires", async () => { + vi.useFakeTimers(); + let service: + | ReturnType> + | undefined; + try { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const result = await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + status: "ready", + value: { + issues: [{ remainingIntentLeaseMs: 1_000 }], + }, + }); + await vi.advanceTimersByTimeAsync(1_000); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + } finally { + service?.dispose(); + vi.useRealTimers(); + } + }); + + it("does not correlate catalog invalidation after a soft lease expires", async () => { + vi.useFakeTimers(); + let service: + | ReturnType> + | undefined; + try { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // The refresh lease owns this unsettled search. + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const task = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + const result = await task; + expect(result).toMatchObject({ + refreshToken: task.refreshToken, + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + if (result.status !== "ready") { + throw new Error("Expected a ready completion"); + } + const loading = result.value.issues.find( + (issue) => issue.reason === "catalog-loading", + ); + if (!loading || loading.reason !== "catalog-loading") { + throw new Error("Expected a loading issue"); + } + expect(loading.remainingIntentLeaseMs).toBeGreaterThan(0); + await vi.advanceTimersByTimeAsync( + Math.ceil(loading.remainingIntentLeaseMs), + ); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + } finally { + service?.dispose(); + vi.useRealTimers(); + } + }); + + it("correlates catalog invalidation during a soft lease", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + () => + new Promise(() => { + // Catalog invalidation settles the retained intent. + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const task = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + await expect(task).resolves.toMatchObject({ + refreshToken: task.refreshToken, + status: "ready", + value: { + issues: [{ reason: "catalog-loading" }], + }, + }); + invalidate?.({ + epoch: { generation: 1, token: "ready" }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.refreshToken).toBe(task.refreshToken); service.dispose(); }); @@ -596,9 +854,9 @@ describe("relation completion session integration", () => { }, text: "SELECT * FROM ", }); - const events: string[] = []; + const events: SqlSessionChangeEvent[] = []; session.onDidChange((event) => { - events.push(event.reason); + events.push(event); }); const result = session.complete({ position: 14, @@ -611,7 +869,12 @@ describe("relation completion session integration", () => { reason: "superseded", status: "cancelled", }); - expect(events).toEqual(["catalog"]); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: result.refreshToken, + }), + ]); service.dispose(); }); @@ -3581,20 +3844,29 @@ describe("session coverage hardening", () => { }, text, }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); await session.complete({ position: text.indexOf("a;") + 1, trigger: { kind: "invoked" }, }); - await expect( - session.complete({ - position: text.length, - trigger: { kind: "invoked" }, - }), - ).resolves.toMatchObject({ + const replacement = session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + expect(replacement).toBeInstanceOf(Promise); + expect(Object.isFrozen(replacement)).toBe(true); + await expect(replacement).resolves.toMatchObject({ reason: "superseded", status: "cancelled", }); expect(searchCount).toBe(2); + expect(events).toHaveLength(1); + expect(events[0]?.refreshToken).toBe( + replacement.refreshToken, + ); service.dispose(); }); @@ -3894,9 +4166,15 @@ describe("session coverage hardening", () => { position: 14, trigger: { kind: "invoked" }, })).resolves.toMatchObject({ + refreshToken: null, status: "ready", value: { - issues: [{ reason: "catalog-loading" }], + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 0, + }, + ], }, }); expect(synchronousCalls).toBe(1); @@ -3916,12 +4194,13 @@ describe("session coverage hardening", () => { } }); - it("does not let a stale intent callback erase a newer timer", async () => { + it("handles synchronous soft-intent timer completion", async () => { const nativeSetTimeout = globalThis.setTimeout; const nativeClearTimeout = globalThis.clearTimeout; - const callbacks: Array<() => void> = []; - const handles: object[] = []; - const cleared: number[] = []; + const cleared: unknown[] = []; + let responseTimerSeen = false; + let positiveTimersAfterResponse = 0; + let synchronousCalls = 0; Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: ( @@ -3929,52 +4208,216 @@ describe("session coverage hardening", () => { delay?: number, ...arguments_: unknown[] ) => { - if (delay !== 1_000) { - return nativeSetTimeout(callback, delay, ...arguments_); - } - if (typeof callback !== "function") { - throw new Error("Intent timer callback must be a function"); + if (delay === 0) { + responseTimerSeen = true; + } else if ( + responseTimerSeen && + typeof delay === "number" && + delay > 0 + ) { + positiveTimersAfterResponse += 1; + if (positiveTimersAfterResponse === 1) { + synchronousCalls += 1; + if (typeof callback === "function") { + Reflect.apply(callback, undefined, arguments_); + } + return null; + } } - callbacks.push(() => { - Reflect.apply(callback, undefined, arguments_); - }); - const handle = Object.freeze({ id: handles.length }); - handles.push(handle); - return handle; + return nativeSetTimeout(callback, delay, ...arguments_); }, writable: true, }); Object.defineProperty(globalThis, "clearTimeout", { configurable: true, - value: (handle: unknown) => { - const index = handles.findIndex( - (candidate) => candidate === handle, - ); - if (index >= 0) { - cleared.push(index); - } else { - Reflect.apply(nativeClearTimeout, globalThis, [handle]); - } + value: (handle: ReturnType) => { + cleared.push(handle); + nativeClearTimeout(handle); }, writable: true, }); try { - const service = catalogService({ - id: "stale-intent-timer", - search: async () => ({ - epoch: { generation: 0, token: "loading" }, - status: "loading", - }), + const service = createSqlLanguageService({ + catalog: { + id: "synchronous-soft-timer", + search: () => + new Promise(() => { + // The synthetic soft-intent timer owns settlement. + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], }); const session = service.openDocument({ context: { - catalog: { scope: "connection:stale-intent-timer" }, + catalog: { scope: "connection:synchronous-soft-timer" }, dialect: "duckdb", engine: "local", }, text: "SELECT * FROM ", }); - await session.complete({ + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + value: { + issues: [ + { + reason: "catalog-loading", + remainingIntentLeaseMs: 0, + }, + ], + }, + }); + expect(synchronousCalls).toBe(1); + expect(cleared).toContain(null); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: nativeClearTimeout, + writable: true, + }); + } + }); + + it("ignores a cleared soft-intent timer callback", async () => { + const nativeSetTimeout = globalThis.setTimeout; + let responseTimerSeen = false; + let staleCallback: (() => void) | undefined; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay === 0) { + responseTimerSeen = true; + } else if ( + responseTimerSeen && + staleCallback === undefined && + typeof callback === "function" + ) { + staleCallback = () => { + Reflect.apply(callback, undefined, arguments_); + }; + } + return nativeSetTimeout(callback, delay, ...arguments_); + }, + writable: true, + }); + try { + const service = createSqlLanguageService({ + catalog: { + id: "stale-soft-timer", + search: () => + new Promise(() => { + // Session lifecycle owns this unsettled search. + }), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:stale-soft-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + expect(staleCallback).toBeTypeOf("function"); + const replacement = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + staleCallback?.(); + session.dispose(); + await expect(replacement).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + service.dispose(); + } finally { + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: nativeSetTimeout, + writable: true, + }); + } + }); + + it("does not let a stale intent callback erase a newer timer", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const callbacks: Array<() => void> = []; + const handles: object[] = []; + const cleared: number[] = []; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + if (delay !== 1_000) { + return nativeSetTimeout(callback, delay, ...arguments_); + } + if (typeof callback !== "function") { + throw new Error("Intent timer callback must be a function"); + } + callbacks.push(() => { + Reflect.apply(callback, undefined, arguments_); + }); + const handle = Object.freeze({ id: handles.length }); + handles.push(handle); + return handle; + }, + writable: true, + }); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + value: (handle: unknown) => { + const index = handles.findIndex( + (candidate) => candidate === handle, + ); + if (index >= 0) { + cleared.push(index); + } else { + Reflect.apply(nativeClearTimeout, globalThis, [handle]); + } + }, + writable: true, + }); + try { + const service = catalogService({ + id: "stale-intent-timer", + search: async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:stale-intent-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + await session.complete({ position: 14, trigger: { kind: "invoked" }, }); @@ -4217,8 +4660,8 @@ describe("session coverage hardening", () => { trigger: { kind: "invoked" }, }), ).resolves.toMatchObject({ - reason: "inactive", - status: "unavailable", + reason: "superseded", + status: "cancelled", }); service.dispose(); await expect(nested).resolves.toMatchObject({ @@ -4227,6 +4670,476 @@ describe("session coverage hardening", () => { }); }); + it("publishes the task token before reentrant signal invalidation", async () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: { + id: "signal-invalidation", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }, + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:signal-invalidation" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + let task: + | ReturnType + | undefined; + let matchedPublishedToken = false; + session.onDidChange((event) => { + matchedPublishedToken = + task !== undefined && + event.refreshToken === task.refreshToken; + }); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + invalidate?.({ + epoch: { generation: 1, token: "signal" }, + }); + nativeAdd(type, listener, options); + }, + }); + task = session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + await expect(task).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + expect(matchedPublishedToken).toBe(true); + service.dispose(); + }); + + it("handles abort reentrancy during signal registration", async () => { + const service = catalogService({ + id: "signal-abort", + search: async () => + new Promise(() => { + // Abort occurs before provider invocation. + }), + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:signal-abort" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + nativeAdd(type, listener, options); + controller.abort(); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + service.dispose(); + }); + + it("rechecks abort after signal registration", async () => { + let searchCount = 0; + const service = catalogService({ + id: "signal-abort-before-registration", + search: async () => { + searchCount += 1; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:signal-abort-before-registration", + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + const nativeAdd = controller.signal.addEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ) => { + controller.abort(); + nativeAdd(type, listener, options); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + expect(searchCount).toBe(0); + service.dispose(); + }); + + it.each(["before", "after"] as const)( + "preserves a nested completion when the signal becomes aborted %s registration", + async (phase) => { + let searchCount = 0; + const service = catalogService({ + id: `signal-aborted-getter-${phase}`, + search: async () => { + searchCount += 1; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: `connection:signal-aborted-getter-${phase}`, + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const controller = new AbortController(); + let reads = 0; + let nested: + | ReturnType + | undefined; + Object.defineProperty(controller.signal, "aborted", { + configurable: true, + get: () => { + reads += 1; + if ( + !nested && + (phase === "before" || reads === 2) + ) { + nested = session.complete({ + position: 14, + trigger: { kind: "invoked" }, + }); + return true; + } + return false; + }, + }); + + const outer = session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + }); + await expect(outer).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + await expect(nested).resolves.toMatchObject({ + status: "ready", + }); + expect(searchCount).toBe(1); + service.dispose(); + }, + ); + + it("cleans up when signal registration throws", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-add", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:throwing-signal-add" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: () => { + throw new Error("registration failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).rejects.toThrow("registration failed"); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + const reentrantController = new AbortController(); + Object.defineProperty( + reentrantController.signal, + "addEventListener", + { + configurable: true, + value: () => { + generation = 2; + invalidate?.({ + epoch: { + generation, + token: `generation:${generation}`, + }, + }); + throw new Error("reentrant registration failed"); + }, + }, + ); + const reentrantTask = session.complete({ + position: 14, + signal: reentrantController.signal, + trigger: { kind: "invoked" }, + }); + await expect(reentrantTask).rejects.toThrow( + "reentrant registration failed", + ); + expect(events[1]?.refreshToken).toBe( + reentrantTask.refreshToken, + ); + generation = 3; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events[2]).toMatchObject({ + reason: "catalog", + refreshToken: null, + }); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + + it("cleans up when the signal aborted getter throws", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-aborted", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:throwing-signal-aborted", + }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "aborted", { + configurable: true, + get: () => { + throw new Error("aborted read failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).rejects.toThrow("aborted read failed"); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + + it("contains signal cleanup failures after making the task inert", async () => { + let generation = 0; + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = catalogService({ + id: "throwing-signal-remove", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { + generation, + token: `generation:${generation}`, + }, + relations: [], + status: "ready", + }), + subscribe: (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:throwing-signal-remove" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + const controller = new AbortController(); + Object.defineProperty(controller.signal, "removeEventListener", { + configurable: true, + value: () => { + throw new Error("cleanup failed"); + }, + }); + await expect(session.complete({ + position: 14, + signal: controller.signal, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + generation = 1; + invalidate?.({ + epoch: { generation, token: `generation:${generation}` }, + }); + expect(events).toEqual([ + expect.objectContaining({ + reason: "catalog", + refreshToken: null, + }), + ]); + await expect(session.complete({ + position: 14, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + refreshToken: null, + status: "ready", + }); + service.dispose(); + }); + it("stops catalog event delivery when a listener disposes the session", () => { let invalidate: | ((event: SqlCatalogInvalidation) => void) diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 8204d7b..7628924 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -42,12 +42,14 @@ export type { SqlCompletionCancellationReason, SqlCompletionIssue, SqlCompletionRequest, + SqlCompletionRefreshToken, SqlCompletionTrigger, SqlDisposable, SqlRelationCatalogProvider, SqlCompletionItem, SqlCompletionList, SqlCompletionResult, + SqlCompletionTask, SqlSessionChangeEvent, SqlSessionChangeReason, } from "./relation-completion-types.js"; diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 56c8952..ce950fd 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -187,11 +187,41 @@ export type SqlSessionChangeReason = | "catalog-availability" | "provider-configuration"; -export interface SqlSessionChangeEvent { - readonly revision: SqlRevision; - readonly reason: SqlSessionChangeReason; +const completionRefreshTokenBrand: unique symbol = Symbol( + "SqlCompletionRefreshToken", +); + +/** Opaque, in-process identity for one completion refresh intent. */ +export interface SqlCompletionRefreshToken { + readonly [completionRefreshTokenBrand]: "SqlCompletionRefreshToken"; +} + +/** @internal */ +export function createSqlCompletionRefreshToken(): SqlCompletionRefreshToken { + const token: SqlCompletionRefreshToken = { + [completionRefreshTokenBrand]: "SqlCompletionRefreshToken", + }; + Object.freeze(token); + return token; } +export type SqlSessionChangeEvent = + | { + readonly revision: SqlRevision; + readonly reason: "catalog-availability"; + readonly refreshToken: SqlCompletionRefreshToken; + } + | { + readonly revision: SqlRevision; + readonly reason: "catalog"; + readonly refreshToken: SqlCompletionRefreshToken | null; + } + | { + readonly revision: SqlRevision; + readonly reason: "provider-configuration"; + readonly refreshToken: null; + }; + export type SqlCompletionTrigger = | { readonly character?: never; @@ -324,6 +354,7 @@ export type SqlCompletionResult = | { readonly status: "ready"; readonly revision: SqlRevision; + readonly refreshToken: SqlCompletionRefreshToken | null; readonly value: SqlCompletionList; readonly sources: readonly SqlCatalogProviderReport[]; } @@ -343,3 +374,12 @@ export type SqlCompletionResult = readonly revision: SqlRevision; readonly failure: SqlServiceFailure; }; + +/** + * A completion invocation whose identity is available before provider work + * starts. + */ +export interface SqlCompletionTask + extends Promise { + readonly refreshToken: SqlCompletionRefreshToken; +} diff --git a/src/vnext/session.ts b/src/vnext/session.ts index 80b1169..fa41d79 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -43,12 +43,14 @@ import { MAX_RELATION_COMPLETION_RESULTS, type SqlComposableCatalogOutcome, } from "./relation-completion.js"; -import type { - SqlCompletionRequest, - SqlDisposable, - SqlCompletionResult, - SqlSessionChangeEvent, - SqlSessionChangeReason, +import { + createSqlCompletionRefreshToken, + type SqlCompletionRequest, + type SqlCompletionRefreshToken, + type SqlDisposable, + type SqlCompletionResult, + type SqlCompletionTask, + type SqlSessionChangeEvent, } from "./relation-completion-types.js"; import { BIGQUERY_SQL_RELATION_DIALECT, @@ -93,9 +95,18 @@ interface CompletionRequestState { cancelReason: "caller" | "disposed" | "superseded" | null; readonly revision: SqlRevision; ticket: SqlCatalogSearchWorkTicket | null; - readonly token: object; + readonly token: SqlCompletionRefreshToken; } +type ServiceChange = + | { + readonly reason: "catalog-availability"; + readonly expected: CompletionRequestState; + } + | { + readonly reason: "catalog"; + }; + interface SessionChangeSubscription { active: boolean; readonly listener: (event: SqlSessionChangeEvent) => void; @@ -106,6 +117,11 @@ interface SessionTimerCell { handle: ReturnType | undefined; } +interface TerminalRefreshIntent { + readonly timer: SessionTimerCell; + readonly token: SqlCompletionRefreshToken; +} + interface CompletionConfiguration { readonly catalogResponseBudgetMs: number; } @@ -499,6 +515,12 @@ function completionCancellation( return Object.freeze({ reason, revision, status: "cancelled" }); } +function completionCancellationReason( + request: CompletionRequestState, +): "caller" | "disposed" | "superseded" { + return request.cancelReason ?? "superseded"; +} + interface MissingDataProperty { readonly found: false; } @@ -743,6 +765,13 @@ interface LocalRelationStatementCache { readonly sourceSequence: number; } +function createCompletionTask( + refreshToken: SqlCompletionRefreshToken, + result: Promise, +): SqlCompletionTask { + return Object.freeze(Object.assign(result, { refreshToken })); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -761,8 +790,9 @@ export class DefaultSqlDocumentSession | null = null; #refreshIntent: CompletionRequestState | null = null; #snapshot: SessionSnapshot; + #softRefreshIntentTimer: SessionTimerCell | null = null; #statementIndexCache: StatementIndexCache | null = null; - #terminalIntentTimer: SessionTimerCell | null = null; + #terminalRefreshIntent: TerminalRefreshIntent | null = null; #updating = false; constructor( @@ -836,25 +866,32 @@ export class DefaultSqlDocumentSession } #clearTerminalIntent(): void { - const cell = this.#terminalIntentTimer; - if (!cell) return; - this.#terminalIntentTimer = null; - cell.active = false; - clearTimeout(cell.handle); + const intent = this.#terminalRefreshIntent; + if (!intent) return; + this.#terminalRefreshIntent = null; + intent.timer.active = false; + clearTimeout(intent.timer.handle); + } + + #clearSoftRefreshIntentTimer(): void { + const timer = this.#softRefreshIntentTimer; + if (!timer) return; + this.#softRefreshIntentTimer = null; + timer.active = false; + clearTimeout(timer.handle); } - #dispatchChange( - revision: SqlRevision, - reason: SqlSessionChangeReason, - ): void { - if (this.#disposed || revision !== this.#snapshot.revision) { + #dispatchChange(event: SqlSessionChangeEvent): void { + if ( + this.#disposed || + event.revision !== this.#snapshot.revision + ) { return; } - const event = Object.freeze({ reason, revision }); for (const subscription of Array.from(this.#listeners)) { if ( this.#disposed || - revision !== this.#snapshot.revision + event.revision !== this.#snapshot.revision ) { return; } @@ -873,9 +910,12 @@ export class DefaultSqlDocumentSession } #prepareServiceChange( - reason: SqlSessionChangeReason, - expected?: CompletionRequestState, + change: ServiceChange, ): (() => undefined) | null { + const expected = + change.reason === "catalog-availability" + ? change.expected + : undefined; if ( this.#disposed || (expected !== undefined && @@ -886,6 +926,19 @@ export class DefaultSqlDocumentSession ) { return null; } + const terminal = this.#terminalRefreshIntent; + const soft = this.#refreshIntent; + const activeIntent = this.#activeCompletion; + const refreshToken = + change.reason === "catalog-availability" + ? change.expected.token + : soft?.cancelReason === null + ? soft.token + : terminal?.timer.active === true + ? terminal.token + : activeIntent?.cancelReason === null + ? activeIntent.token + : null; const previous = this.#snapshot; const revision = createSqlRevisionToken(); this.#snapshot = Object.freeze({ @@ -893,10 +946,23 @@ export class DefaultSqlDocumentSession revision, sequence: previous.sequence + 1, }); + const event: SqlSessionChangeEvent = + change.reason === "catalog-availability" + ? Object.freeze({ + reason: change.reason, + refreshToken: change.expected.token, + revision, + }) + : Object.freeze({ + reason: change.reason, + refreshToken, + revision, + }); const active = this.#activeCompletion; const intent = this.#refreshIntent; this.#activeCompletion = null; this.#refreshIntent = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); if (active && active.cancelReason === null) { active.cancelReason = "superseded"; @@ -909,7 +975,7 @@ export class DefaultSqlDocumentSession return (): undefined => { active?.ticket?.cancel(); if (intent !== active) intent?.ticket?.cancel(); - this.#dispatchChange(revision, reason); + this.#dispatchChange(event); return undefined; }; } @@ -937,7 +1003,7 @@ export class DefaultSqlDocumentSession dialect, Object.freeze({ prepareCatalogChange: (): (() => undefined) | null => - this.#prepareServiceChange("catalog"), + this.#prepareServiceChange({ reason: "catalog" }), }), ); if (prepared.status !== "prepared") return; @@ -984,9 +1050,18 @@ export class DefaultSqlDocumentSession }); }; - readonly complete = async ( + readonly complete = ( request: SqlCompletionRequest, - ): Promise => { + ): SqlCompletionTask => { + const refreshToken = createSqlCompletionRefreshToken(); + const result = this.#runCompletion(request, refreshToken); + return createCompletionTask(refreshToken, result); + }; + + async #runCompletion( + request: SqlCompletionRequest, + refreshToken: SqlCompletionRefreshToken, + ): Promise { const completionStartedAt = performance.now(); if (this.#disposed) { throw new SqlSessionError( @@ -1107,22 +1182,36 @@ export class DefaultSqlDocumentSession if (this.#refreshIntent === previousIntent) { this.#refreshIntent = null; } + this.#clearSoftRefreshIntentTimer(); }; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); const snapshot = this.#snapshot; const active: CompletionRequestState = { cancelReason: null, revision: snapshot.revision, ticket: null, - token: {}, + token: refreshToken, }; this.#activeCompletion = active; - if (signal?.aborted) { - active.cancelReason = "caller"; - this.#activeCompletion = null; - cancelPrevious(); - return completionCancellation(snapshot.revision, "caller"); - } + const cancellationIfNotCurrent = + (): SqlCompletionResult | null => { + if ( + this.#activeCompletion === active && + active.cancelReason === null && + snapshot.revision === this.#snapshot.revision + ) { + return null; + } + cancelPrevious(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + }; + await Promise.resolve(); + const publicationCancellation = cancellationIfNotCurrent(); + if (publicationCancellation) return publicationCancellation; const onAbort = (): void => { if ( this.#activeCompletion === active && @@ -1132,9 +1221,66 @@ export class DefaultSqlDocumentSession active.ticket?.cancel(); } }; - signal?.addEventListener("abort", onAbort, { once: true }); - + const makeInvocationInert = (): void => { + if (this.#activeCompletion === active) { + this.#activeCompletion = null; + } + if (active.cancelReason === null) { + active.cancelReason = "superseded"; + } + cancelPrevious(); + }; + const readSignalAborted = ( + currentSignal: AbortSignal, + ): boolean => { + try { + return currentSignal.aborted; + } catch (error) { + makeInvocationInert(); + throw error; + } + }; + let signalRegistrationAttempted = false; try { + if (signal) { + const abortedBeforeRegistration = + readSignalAborted(signal); + const readCancellation = cancellationIfNotCurrent(); + if (readCancellation) return readCancellation; + if (abortedBeforeRegistration) { + active.cancelReason = "caller"; + this.#activeCompletion = null; + cancelPrevious(); + return completionCancellation( + snapshot.revision, + "caller", + ); + } + signalRegistrationAttempted = true; + try { + signal.addEventListener("abort", onAbort, { once: true }); + } catch (error) { + makeInvocationInert(); + throw error; + } + const registrationCancellation = + cancellationIfNotCurrent(); + if (registrationCancellation) { + return registrationCancellation; + } + const abortedAfterRegistration = + readSignalAborted(signal); + const rereadCancellation = cancellationIfNotCurrent(); + if (rereadCancellation) return rereadCancellation; + if (abortedAfterRegistration) { + onAbort(); + cancelPrevious(); + return completionCancellation( + snapshot.revision, + "caller", + ); + } + } const index = this.getStatementIndexForTesting(); const slot = findSqlStatementSlot(index, position, "left"); const cachedLocal = this.#localRelationStatementCache; @@ -1189,7 +1335,7 @@ export class DefaultSqlDocumentSession ) { return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } @@ -1213,6 +1359,17 @@ export class DefaultSqlDocumentSession status: "unavailable", }); } else { + if ( + this.#activeCompletion !== active || + active.cancelReason !== null || + snapshot.revision !== this.#snapshot.revision + ) { + cancelPrevious(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } const ticket = owner.request({ continuationToken: null, limit: MAX_RELATION_COMPLETION_RESULTS, @@ -1258,7 +1415,7 @@ export class DefaultSqlDocumentSession ticket.cancel(); return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } let providerOutcome: SqlCatalogSearchWorkOutcome | null = @@ -1267,13 +1424,42 @@ export class DefaultSqlDocumentSession const retained = ticket.retainForRefresh( (): (() => undefined) | null => this.#prepareServiceChange( - "catalog-availability", - active, + { + expected: active, + reason: "catalog-availability", + }, ), ); if (retained.status === "retained") { remainingIntentLeaseMs = retained.remainingLeaseMs; this.#refreshIntent = active; + const cell: SessionTimerCell = { + active: true, + handle: undefined, + }; + this.#softRefreshIntentTimer = cell; + const handle = setTimeout(() => { + if ( + !cell.active || + this.#softRefreshIntentTimer !== cell + ) { + return; + } + cell.active = false; + this.#softRefreshIntentTimer = null; + this.#refreshIntent = null; + ticket.cancel(); + }, remainingIntentLeaseMs); + cell.handle = handle; + if ( + !cell.active || + this.#softRefreshIntentTimer !== cell + ) { + clearTimeout(handle); + } + if (this.#refreshIntent !== active) { + remainingIntentLeaseMs = 0; + } catalogOutcome = Object.freeze({ status: "loading", }); @@ -1339,21 +1525,28 @@ export class DefaultSqlDocumentSession active: true, handle: undefined, }; - this.#terminalIntentTimer = cell; + const intent: TerminalRefreshIntent = { + timer: cell, + token: active.token, + }; + this.#terminalRefreshIntent = intent; const handle = setTimeout(() => { if (!cell.active) return; cell.active = false; - if (this.#terminalIntentTimer === cell) { - this.#terminalIntentTimer = null; + if (this.#terminalRefreshIntent === intent) { + this.#terminalRefreshIntent = null; } }, remainingIntentLeaseMs); cell.handle = handle; if ( !cell.active || - this.#terminalIntentTimer !== cell + this.#terminalRefreshIntent !== intent ) { clearTimeout(handle); } + if (this.#terminalRefreshIntent !== intent) { + remainingIntentLeaseMs = 0; + } } } } @@ -1381,22 +1574,36 @@ export class DefaultSqlDocumentSession ) { return completionCancellation( snapshot.revision, - active.cancelReason ?? "superseded", + completionCancellationReason(active), ); } return Object.freeze({ + refreshToken: + (catalogOutcome?.status === "loading" || + (catalogOutcome?.status === "usable" && + catalogOutcome.response.status === "loading")) && + (this.#refreshIntent === active || + this.#terminalRefreshIntent?.token === active.token) + ? active.token + : null, revision: snapshot.revision, sources: composition.sources, status: "ready", value: composition.value, }); } finally { - signal?.removeEventListener("abort", onAbort); if (this.#activeCompletion === active) { this.#activeCompletion = null; } + if (signal && signalRegistrationAttempted) { + try { + signal.removeEventListener("abort", onAbort); + } catch { + // Signal cleanup cannot retain or fail the completion. + } + } } - }; + } readonly update = (update: SqlDocumentUpdate): SqlRevision => { if (this.#disposed) { @@ -1675,6 +1882,7 @@ export class DefaultSqlDocumentSession } this.#activeCompletion = null; this.#refreshIntent = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); if ( activeCompletion && @@ -1712,6 +1920,7 @@ export class DefaultSqlDocumentSession this.#activeCompletion = null; this.#refreshIntent = null; this.#catalogOwner = null; + this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); this.#listeners.clear(); this.#localRelationStatementCache = null; diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 18d11e8..1931e7e 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -138,7 +138,7 @@ export interface SqlDocumentSession { readonly update: (update: SqlDocumentUpdate) => SqlRevision; readonly complete: ( request: SqlCompletionRequest, - ) => Promise; + ) => SqlCompletionTask; readonly onDidChange: ( listener: (event: SqlSessionChangeEvent) => void, ) => SqlDisposable; @@ -187,8 +187,8 @@ export class SqlSessionError extends Error { } import type { SqlCompletionRequest, + SqlCompletionTask, SqlDisposable, SqlRelationCatalogProvider, - SqlCompletionResult, SqlSessionChangeEvent, } from "./relation-completion-types.js"; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 9a8e74d..30411a3 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -6,6 +6,8 @@ import type { SqlDocumentUpdate, SqlEmbeddedRegion, SqlIdentifierComponent, + SqlCompletionRefreshToken, + SqlCompletionTask, OpenSqlDocument, } from "../../src/vnext/index.js"; import type { @@ -22,6 +24,7 @@ import type { SqlCompletionItem, SqlCompletionList, SqlRelationCatalogProvider, + SqlSessionChangeEvent, } from "../../src/vnext/relation-completion-types.js"; import type { SqlCatalogEpochTransitionTarget, @@ -96,25 +99,74 @@ const subscription = session.onDidChange((event) => { const reason: "catalog" | "catalog-availability" | "provider-configuration" = event.reason; session.isCurrent(event.revision); + if (event.reason === "catalog-availability") { + const token: SqlCompletionRefreshToken = event.refreshToken; + void token; + } void reason; }); subscription.dispose(); subscription.dispose(); -void session.complete({ +const completionTask: SqlCompletionTask = session.complete({ position: 14, signal: new AbortController().signal, trigger: { kind: "invoked" }, }); +const invocationToken: SqlCompletionRefreshToken = + completionTask.refreshToken; +void invocationToken; void session.complete({ position: 14, trigger: { character: ".", kind: "trigger-character" }, }).then((result) => { + if (result.status === "ready") { + const token: SqlCompletionRefreshToken | null = + result.refreshToken; + void token; + } // @ts-expect-error scheduler work identities never enter consumer results void result.workId; // @ts-expect-error catalog epochs never enter consumer results void result.epoch; }); +declare const refreshToken: SqlCompletionRefreshToken; +const catalogAvailabilityEvent = { + reason: "catalog-availability", + refreshToken, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const catalogEventWithoutIntent = { + reason: "catalog", + refreshToken: null, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const providerConfigurationEvent = { + reason: "provider-configuration", + refreshToken: null, + revision: session.revision, +} satisfies SqlSessionChangeEvent; +const invalidAvailabilityEvent = { + reason: "catalog-availability", + refreshToken: null, + revision: session.revision, + // @ts-expect-error availability always identifies the exact refresh intent +} satisfies SqlSessionChangeEvent; +const invalidProviderConfigurationEvent = { + reason: "provider-configuration", + refreshToken, + revision: session.revision, + // @ts-expect-error provider configuration is never a completion refresh +} satisfies SqlSessionChangeEvent; +// @ts-expect-error completion refresh identities are service-issued +const fabricatedRefreshToken: SqlCompletionRefreshToken = {}; +void catalogAvailabilityEvent; +void catalogEventWithoutIntent; +void providerConfigurationEvent; +void invalidAvailabilityEvent; +void invalidProviderConfigurationEvent; +void fabricatedRefreshToken; + const provider: SqlRelationCatalogProvider = { id: "marimo", search: async (request, signal) => { From 246fa6fc255b4223b8f7aaf42164f21940c6a567 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 01:28:09 +0800 Subject: [PATCH 30/42] feat(vnext): add CodeMirror completion adapter (#199) ## Summary - add an explicit `@marimo-team/codemirror-sql/vnext/codemirror` entry point with one caller-owned service and one session per `EditorView` - translate composite CodeMirror document, context, and embedded-region effects into one atomic session update - map current relation results to exact CodeMirror edits with revision, document, selection, context, and embedded-region guards - coalesce exact-token catalog refreshes across active menus and empty loading results without synchronous provider-driven dispatch - contain Escape, close, blur, visibility, expiry, service-first disposal, stale rejection, and repeated teardown races ## Why The framework-independent service now has the identity and lease contracts needed for a thin editor boundary. This adapter makes those contracts usable without leaking CodeMirror into the core, while preserving marimo's external completion sources and treating transformed-document regions explicitly rather than guessing their mapping. ## Validation - 2,028 tests pass; 1 intentional expected failure - adapter coverage: 97.70% statements, 95.86% branches, 100% functions, 99.01% lines - all strict and loose-optional TypeScript configurations pass - oxlint and test-integrity checks pass - packed-consumer source covers the new package subpath; hosted package CI will run the isolated install - two independent exact-commit adversarial reviews approved `8d72a4c` --- ## Summary by cubic Adds a CodeMirror 6 adapter for the vNext SQL language service, exposed as `@marimo-team/codemirror-sql/vnext/codemirror`. This enables exact, guarded completion edits and atomic document/context/region updates without leaking CodeMirror into the core. - **New Features** - New `sqlEditor` adapter with one document session per `EditorView`; caller owns the shared `service`. - Atomic updates: merges document changes with `support.contextEffect` and `support.embeddedRegionsEffect`. - Exact completion edits with revision/context/selection guards; maps unrelated embedded regions through its own edit; ignores overlaps. - Coalesced refresh intents keyed by service tokens; cancels on Escape, blur (configurable), visibility loss, selection change, or lease expiry; no synchronous provider-driven dispatch. - Single `autocompletion` configuration; supports `externalSources` for extra providers. - New docs: `docs/vnext/codemirror-adapter.md`; subpath export added in `package.json`; smoke test updated. - **Migration** - Import from `@marimo-team/codemirror-sql/vnext/codemirror`, create a service from `@marimo-team/codemirror-sql/vnext`, then add `support.extension` to the editor. - Do not install a second `autocompletion` extension; pass extra providers via `externalSources`. - Update context/regions via `support.setContext` or by dispatching `support.contextEffect` and `support.embeddedRegionsEffect`. If embedded regions exist, include the complete resulting set on every document-changing transaction. Written for commit 8d72a4ceb895b6614c36d35b3457fea4be5e9bb0. Summary will update on new commits. Review in cubic --- docs/vnext/codemirror-adapter.md | 95 ++ docs/vnext/session-primitives.md | 3 + package.json | 6 + scripts/package-smoke.mjs | 10 + .../codemirror/__tests__/sql-editor.test.ts | 1172 +++++++++++++++++ src/vnext/codemirror/index.ts | 6 + src/vnext/codemirror/sql-editor.ts | 668 ++++++++++ 7 files changed, 1960 insertions(+) create mode 100644 docs/vnext/codemirror-adapter.md create mode 100644 src/vnext/codemirror/__tests__/sql-editor.test.ts create mode 100644 src/vnext/codemirror/index.ts create mode 100644 src/vnext/codemirror/sql-editor.ts diff --git a/docs/vnext/codemirror-adapter.md b/docs/vnext/codemirror-adapter.md new file mode 100644 index 0000000..29c522d --- /dev/null +++ b/docs/vnext/codemirror-adapter.md @@ -0,0 +1,95 @@ +# vNext CodeMirror Adapter + +Status: experimental + +Import: `@marimo-team/codemirror-sql/vnext/codemirror` + +`sqlEditor` connects one caller-owned vNext language service to one or more +CodeMirror 6 views. Each view owns a document session, cancellation state, +catalog-refresh intent, and revision subscription. Destroying a view disposes +that view's session but never disposes the shared service. + +```ts +import { EditorView } from "@codemirror/view"; +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql/vnext"; +import { + sqlEditor, +} from "@marimo-team/codemirror-sql/vnext/codemirror"; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], +}); +const support = sqlEditor({ + initialContext: { + catalog: { scope: "connection-incarnation:1" }, + dialect: "duckdb", + }, + service, +}); +const view = new EditorView({ + doc: "SELECT * FROM ", + extensions: [support.extension], +}); + +support.setContext(view, { + catalog: { scope: "connection-incarnation:2" }, + dialect: "duckdb", +}); + +view.destroy(); +service.dispose(); +``` + +The adapter installs one coherent `autocompletion` configuration. Additional +sources belong in `autocomplete.externalSources`; consumers should not install +a second independently configured `autocompletion` extension. + +## Atomic inputs + +Context and document changes can share one CodeMirror transaction: + +```ts +view.dispatch({ + changes, + effects: support.contextEffect.of(nextContext), +}); +``` + +CodeMirror's composite `ChangeSet` is converted to sorted, non-overlapping +pre-update UTF-16 changes and sent to the session exactly once. + +Embedded-language ranges are explicit. Their effect payload is the complete +region set in the resulting document's coordinates: + +```ts +view.dispatch({ + changes, + effects: support.embeddedRegionsEffect.of(nextRegions), +}); +``` + +When the current document contains embedded regions, every document-changing +transaction must include the complete resulting region set. The adapter fails +closed instead of guessing how host-language delimiters map through edits. +Region-only updates may use `setEmbeddedRegions`. + +The adapter's own completion edit is the narrow exception: it maps unrelated +regions through the exact edit and includes the complete mapped set in the same +transaction. A completion edit that overlaps an embedded region is ignored. + +## Completion currency + +The adapter does not use `validFor` or result mapping in this first slice. +Every result and completion application rechecks the session revision, +document identity, selection, and context generation. + +Loading completion results retain one bounded, token-correlated refresh intent, +including empty results that open no menu. Only a session event carrying the +exact task token can schedule a refresh. A newer request, document/context/ +region/selection change, Escape, configured blur, visibility loss, lease +expiry, or view destruction cancels the intent. Refresh and close operations +are always queued; provider and session callbacks never synchronously dispatch +CodeMirror transactions. diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 1e0735b..550c5c1 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -8,6 +8,9 @@ opaque revisions, dialect registration, lifecycle management, and parser-independent relation completion. Diagnostics, hover, navigation, and general expression completion are not yet available. +CodeMirror consumers should use the separate +[vNext CodeMirror adapter](./codemirror-adapter.md). + See [source coordinates](./source-coordinates.md) for the shared UTF-16 range contract and the internal immutable source-snapshot model. diff --git a/package.json b/package.json index 989463c..84b77c1 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,12 @@ "default": "./dist/vnext/index.js" } }, + "./vnext/codemirror": { + "import": { + "types": "./dist/vnext/codemirror/index.d.ts", + "default": "./dist/vnext/codemirror/index.js" + } + }, "./data/common-keywords.json": "./src/data/common-keywords.json", "./data/duckdb-keywords.json": "./src/data/duckdb-keywords.json" }, diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index fec61df..a6aa13c 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -231,6 +231,7 @@ import { type SqlEmbeddedRegion, type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; +import { sqlEditor } from "@marimo-team/codemirror-sql/vnext/codemirror"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; @@ -246,6 +247,10 @@ const parser = new NodeSqlParser(); const service = createSqlLanguageService({ dialects: [duckdbDialect()], }); +const editorSupport = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, +}); const embeddedRegions: readonly SqlEmbeddedRegion[] = [ { from: 14, language: "python", to: 18 }, ]; @@ -262,6 +267,7 @@ session.update({ }); void extensions; +void editorSupport.extension; void parser; void range; void session; @@ -278,6 +284,7 @@ void duckdbKeywords; import * as api from "@marimo-team/codemirror-sql"; import * as dialects from "@marimo-team/codemirror-sql/dialects"; import * as vnext from "@marimo-team/codemirror-sql/vnext"; +import * as vnextCodeMirror from "@marimo-team/codemirror-sql/vnext/codemirror"; import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; @@ -296,6 +303,9 @@ if ( ) { throw new Error("vNext package exports are incomplete"); } +if (typeof vnextCodeMirror.sqlEditor !== "function") { + throw new Error("vNext CodeMirror package exports are incomplete"); +} for (const dialect of [ vnext.bigQueryDialect(), vnext.dremioDialect(), diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts new file mode 100644 index 0000000..a119040 --- /dev/null +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -0,0 +1,1172 @@ +import { + closeCompletion, + completionStatus, + currentCompletions, + startCompletion, +} from "@codemirror/autocomplete"; +import { EditorSelection, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, + type SqlCatalogSearchRequest, + type SqlCatalogSearchResponse, + type SqlCompletionItem, + type SqlCompletionRefreshToken, + type SqlCompletionResult, + type SqlCompletionTask, + type SqlContextInput, + type SqlDocumentContext, + type SqlDocumentSession, + type SqlDocumentUpdate, + type SqlLanguageService, + type SqlRelationCatalogProvider, + type SqlRevision, + type SqlSessionChangeEvent, +} from "../../index.js"; +import { + createSqlCompletionRefreshToken, +} from "../../relation-completion-types.js"; +import { createSqlRevisionToken } from "../../types.js"; +import { + createSqlEditorInternal, + sqlEditor, + type SqlEditorRuntime, +} from "../sql-editor.js"; + +interface TestContext extends SqlDocumentContext { + readonly engine: string; +} + +interface FakeServiceHarness { + readonly completeSignals: AbortSignal[]; + readonly emit: (event: SqlSessionChangeEvent) => void; + readonly getLastToken: () => SqlCompletionRefreshToken | null; + readonly service: SqlLanguageService; + readonly sessionDisposals: () => number; + readonly updates: readonly SqlDocumentUpdate[]; +} + +const views: EditorView[] = []; + +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); +}); + +function relationResponse( + relation = "users", +): SqlCatalogSearchResponse { + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [ + { + quoted: false, + role: "schema", + value: "main", + }, + { + quoted: false, + role: "relation", + value: relation, + }, + ], + completionPathStart: 1, + entityId: `main.${relation}`, + matchQuality: "exact", + relationKind: "table", + }], + status: "ready", + }; +} + +function completionItem( + from = 14, + to = 16, + relationKind: "cte" | "table" = "table", +): SqlCompletionItem { + const edit = { + from, + insert: "users", + to, + }; + return relationKind === "cte" + ? { + edit, + kind: "relation", + label: "users", + provenance: { + declarationPosition: 0, + kind: "cte", + }, + relationKind, + } + : { + detail: "Table", + edit, + kind: "relation", + label: "users", + provenance: { + entityId: "main.users", + kind: "catalog", + providerId: "fake", + }, + relationKind, + }; +} + +function fakeService( + result: ( + revision: SqlRevision, + token: SqlCompletionRefreshToken, + ) => SqlCompletionResult | Promise, + rejectUpdates = false, +): FakeServiceHarness { + const completeSignals: AbortSignal[] = []; + const updates: SqlDocumentUpdate[] = []; + let listener: + | ((event: SqlSessionChangeEvent) => void) + | null = null; + let lastToken: SqlCompletionRefreshToken | null = null; + let sessionDisposalCount = 0; + const service: SqlLanguageService = { + dispose: () => undefined, + openDocument: () => { + let disposed = false; + let revision = createSqlRevisionToken(); + const session: SqlDocumentSession = { + complete: (request): SqlCompletionTask => { + completeSignals.push(request.signal ?? new AbortController().signal); + const token = createSqlCompletionRefreshToken(); + lastToken = token; + return Object.assign( + Promise.resolve(result(revision, token)), + { refreshToken: token }, + ); + }, + dispose: () => { + if (disposed) return; + disposed = true; + sessionDisposalCount += 1; + }, + isCurrent: (candidate) => !disposed && candidate === revision, + onDidChange: (nextListener) => { + listener = nextListener; + return { + dispose: () => { + if (listener === nextListener) listener = null; + }, + }; + }, + get revision() { + return revision; + }, + update: (update) => { + updates.push(update); + if (rejectUpdates) { + throw new Error("update rejected"); + } + revision = createSqlRevisionToken(); + return revision; + }, + }; + return session; + }, + }; + return { + completeSignals, + emit: (event) => listener?.(event), + getLastToken: () => lastToken, + service, + sessionDisposals: () => sessionDisposalCount, + updates, + }; +} + +function readyResult( + revision: SqlRevision, + items: readonly SqlCompletionItem[], + refreshToken: SqlCompletionRefreshToken | null = null, +): SqlCompletionResult { + return { + refreshToken, + revision, + sources: [], + status: "ready", + value: refreshToken === null + ? { + isIncomplete: false, + issues: [], + items, + } + : { + isIncomplete: true, + issues: [{ + reason: "catalog-loading", + remainingIntentLeaseMs: 1_000, + }], + items, + }, + }; +} + +function context( + scope = "connection:fake", +): SqlContextInput { + return { + catalog: { scope }, + dialect: "duckdb", + engine: "local", + }; +} + +function controlledRuntime(): { + readonly closes: EditorView[]; + readonly queued: Array<() => void>; + readonly runtime: SqlEditorRuntime; + readonly starts: EditorView[]; + readonly timerDelays: number[]; + readonly timers: Array<() => void>; +} { + const closes: EditorView[] = []; + const queued: Array<() => void> = []; + const starts: EditorView[] = []; + const timerDelays: number[] = []; + const timers: Array<() => void> = []; + return { + closes, + queued, + runtime: { + clearTimeout: (handle) => clearTimeout(handle), + closeCompletion: (view) => { + closes.push(view); + return true; + }, + queueMicrotask: (callback) => { + queued.push(callback); + }, + setTimeout: (callback, delayMs) => { + timerDelays.push(delayMs); + timers.push(callback); + return setTimeout(() => undefined, 60_000); + }, + startCompletion: (view) => { + starts.push(view); + return true; + }, + }, + starts, + timerDelays, + timers, + }; +} + +function createView( + extension: Extension, + doc = "SELECT * FROM us", +): EditorView { + const view = new EditorView({ + doc, + extensions: extension, + parent: document.body, + selection: EditorSelection.cursor(doc.length), + }); + views.push(view); + return view; +} + +async function waitForActiveCompletion(view: EditorView): Promise { + await vi.waitFor(() => { + expect(completionStatus(view.state)).toBe("active"); + }); +} + +describe("sqlEditor", () => { + it("maps current service completions and applies the exact core edit", async () => { + const service = createSqlLanguageService({ + catalog: { + id: "catalog", + search: async () => relationResponse(), + }, + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { + catalog: { + scope: "connection:1", + searchPath: [[{ + quoted: false, + value: "main", + }]], + }, + dialect: "duckdb", + engine: "local", + }, + service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state).find( + (item) => item.label === "users", + ); + expect(completion).toBeDefined(); + if (!completion || typeof completion.apply !== "function") { + throw new Error("Expected an exact completion apply callback"); + } + completion.apply(view, completion, 14, 16); + expect(view.state.doc.toString()).toBe("SELECT * FROM users"); + + view.destroy(); + service.dispose(); + }); + + it("maps embedded regions through its own non-overlapping apply edit", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem(16, 18)]) + ); + const support = sqlEditor({ + initialContext: context(), + initialEmbeddedRegions: [{ + from: 0, + language: "host", + to: 2, + }, { + from: 18, + language: "host", + to: 20, + }], + service: harness.service, + }); + const view = createView(support.extension, "xxSELECT * FROM usYY"); + view.dispatch({ selection: { anchor: 18 } }); + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion || typeof completion.apply !== "function") { + throw new Error("Expected completion apply callback"); + } + + completion.apply(view, completion, 16, 18); + expect(view.state.doc.toString()).toBe("xxSELECT * FROM usersYY"); + expect(harness.updates.at(-1)).toMatchObject({ + document: { + changes: [{ from: 16, insert: "users", to: 18 }], + }, + embeddedRegions: [{ + from: 0, + language: "host", + to: 2, + }, { + from: 21, + language: "host", + to: 23, + }], + }); + expect(harness.sessionDisposals()).toBe(0); + }); + + it("combines document and final context effects into one current input", async () => { + const requests: SqlCatalogSearchRequest[] = []; + const service = createSqlLanguageService({ + catalog: { + id: "catalog", + search: async (request) => { + requests.push(request); + return relationResponse("orders"); + }, + }, + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { + catalog: { scope: "connection:old" }, + dialect: "duckdb", + engine: "local", + }, + service, + }); + const view = createView(support.extension, "SELECT * FROM "); + view.dispatch( + { + changes: { from: 14, insert: "or" }, + effects: support.contextEffect.of({ + catalog: { scope: "connection:middle" }, + dialect: "duckdb", + engine: "remote", + }), + }, + { + changes: { from: 16, insert: "d" }, + effects: support.contextEffect.of({ + catalog: { scope: "connection:final" }, + dialect: "duckdb", + engine: "remote", + }), + selection: { anchor: 17 }, + sequential: true, + }, + ); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(requests.at(-1)).toMatchObject({ + prefix: { value: "ord" }, + scope: "connection:final", + }); + + view.destroy(); + service.dispose(); + }); + + it("refreshes an empty loading result once when its exact work becomes ready", async () => { + let resolveSearch: + | ((response: SqlCatalogSearchResponse) => void) + | undefined; + const pending = new Promise((resolve) => { + resolveSearch = resolve; + }); + let searches = 0; + const provider: SqlRelationCatalogProvider = { + id: "deferred", + search: async () => { + searches += 1; + return pending; + }, + }; + const service = createSqlLanguageService({ + catalog: provider, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + autocomplete: { closeOnBlur: false }, + initialContext: { + catalog: { + scope: "connection:deferred", + searchPath: [[{ + quoted: false, + value: "main", + }]], + }, + dialect: "duckdb", + engine: "local", + }, + service, + }); + const view = createView(support.extension, "SELECT * FROM "); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(searches).toBe(1); + expect(completionStatus(view.state)).toBeNull(); + }); + resolveSearch?.(relationResponse()); + await vi.waitFor(() => { + expect(currentCompletions(view.state).map((item) => item.label)) + .toContain("users"); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(currentCompletions(view.state).map((item) => item.label)) + .toEqual(["users"]); + + view.destroy(); + service.dispose(); + }); + + it("keeps shared-service ownership with the caller", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { + dialect: "duckdb", + engine: "local", + }, + service, + }); + const first = createView(support.extension); + const second = createView(support.extension); + first.destroy(); + second.destroy(); + + const session = service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + }, + text: "SELECT 1", + }); + expect(session.revision).toBeDefined(); + session.dispose(); + service.dispose(); + }); + + it("contains caller service disposal before its view is destroyed", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: context(), + service, + }); + const view = createView(support.extension); + service.dispose(); + + expect(() => { + support.setContext(view, context("connection:disposed")); + }).not.toThrow(); + expect(() => view.destroy()).not.toThrow(); + }); + + it("composes external sources and maps CTE presentation", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem(14, 16, "cte")]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 14, + options: [{ label: "user_external" }], + })], + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(currentCompletions(view.state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "user_external" }), + expect.objectContaining({ label: "users", type: "type" }), + ]), + ); + }); + + it.each([ + "cancelled", + "failed", + "unavailable", + ] as const)("maps %s service results to no CodeMirror result", async (status) => { + const harness = fakeService((revision) => { + if (status === "cancelled") { + return { + reason: "caller", + revision, + status, + }; + } + if (status === "failed") { + return { + failure: { code: "internal", retryable: false }, + revision, + status, + }; + } + return { + reason: "inactive", + retryable: false, + revision, + status, + }; + }); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals).toHaveLength(1); + expect(completionStatus(view.state)).toBeNull(); + }); + }); + + it("fails closed for mixed edit ranges and token/list mismatches", async () => { + const runtime = controlledRuntime(); + let malformed = false; + const harness = fakeService((revision, token) => + malformed + ? { + refreshToken: token, + revision, + sources: [], + status: "ready", + value: { + isIncomplete: true, + issues: [{ + reason: "catalog-partial", + }], + items: [completionItem()], + }, + } + : readyResult( + revision, + [ + completionItem(), + completionItem(13, 16), + ], + token, + ) + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals).toHaveLength(1); + expect(completionStatus(view.state)).toBeNull(); + }); + const mixedToken = harness.getLastToken(); + if (!mixedToken) throw new Error("Expected mixed-result token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: mixedToken, + revision: createSqlRevisionToken(), + }); + expect(runtime.timers).toHaveLength(0); + expect(runtime.queued).toHaveLength(0); + malformed = true; + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals).toHaveLength(2); + expect(completionStatus(view.state)).toBeNull(); + }); + }); + + it("rejects a refresh token that does not match its task", async () => { + const runtime = controlledRuntime(); + const mismatchedToken = createSqlCompletionRefreshToken(); + const harness = fakeService((revision) => + readyResult(revision, [], mismatchedToken) + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals).toHaveLength(1); + expect(completionStatus(view.state)).toBeNull(); + }); + const taskToken = harness.getLastToken(); + if (!taskToken) throw new Error("Expected task token"); + + for (const refreshToken of [taskToken, mismatchedToken]) { + harness.emit({ + reason: "catalog-availability", + refreshToken, + revision: createSqlRevisionToken(), + }); + } + expect(runtime.timers).toHaveLength(0); + expect(runtime.queued).toHaveLength(0); + }); + + it("uses the service-reported remaining refresh lease", async () => { + const runtime = controlledRuntime(); + let resolveResult: + | ((result: SqlCompletionResult) => void) + | undefined; + let resultRevision: SqlRevision | null = null; + let resultToken: SqlCompletionRefreshToken | null = null; + const harness = fakeService((revision, token) => { + resultRevision = revision; + resultToken = token; + return new Promise((resolve) => { + resolveResult = resolve; + }); + }); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + if (!resultRevision || !resultToken) { + throw new Error("Expected deferred completion identity"); + } + resolveResult?.(readyResult(resultRevision, [], resultToken)); + + await vi.waitFor(() => { + expect(runtime.timerDelays).toEqual([1_000]); + }); + }); + + it("rejects a stale completion apply after a context update", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion || typeof completion.apply !== "function") { + throw new Error("Expected completion apply callback"); + } + + support.setContext(view, context("connection:new")); + completion.apply(view, completion, 14, 16); + expect(view.state.doc.toString()).toBe("SELECT * FROM us"); + expect(harness.updates).toHaveLength(1); + expect(harness.updates[0]).not.toHaveProperty("embeddedRegions"); + }); + + it("updates regions explicitly and requires them for transformed documents", () => { + const harness = fakeService((revision) => readyResult(revision, [])); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension, "xxSELECT 1"); + support.setEmbeddedRegions(view, [{ + from: 0, + language: "host", + to: 2, + }]); + expect(harness.updates.at(-1)).toMatchObject({ + embeddedRegions: [{ from: 0, language: "host", to: 2 }], + }); + + view.dispatch({ + effects: [ + support.contextEffect.of(context("connection:regions")), + support.embeddedRegionsEffect.of([{ + from: 0, + language: "host", + to: 2, + }]), + ], + }); + view.dispatch({ + changes: { from: 10, insert: " " }, + effects: support.embeddedRegionsEffect.of([{ + from: 0, + language: "host", + to: 2, + }]), + }); + expect(harness.updates).toHaveLength(3); + + const removeEventListener = vi.spyOn( + document, + "removeEventListener", + ); + const error = vi.spyOn(console, "error").mockImplementation( + () => undefined, + ); + view.dispatch({ changes: { from: 10, insert: "x" } }); + expect(harness.sessionDisposals()).toBe(1); + expect(removeEventListener).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + removeEventListener.mockRestore(); + error.mockRestore(); + }); + + it("destroys owned resources when a session update is rejected", () => { + const harness = fakeService( + (revision) => readyResult(revision, []), + true, + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + const error = vi.spyOn(console, "error").mockImplementation( + () => undefined, + ); + + support.setContext(view, context("connection:rejected")); + expect(harness.sessionDisposals()).toBe(1); + expect(harness.updates).toHaveLength(1); + expect(error).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it.each(["sync", "async"] as const)( + "contains a %s completion service failure", + async (failureKind) => { + const harness = fakeService(() => { + if (failureKind === "sync") { + throw new Error("synchronous service failure"); + } + return Promise.reject(new Error("asynchronous service failure")); + }); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(completionStatus(view.state)).toBeNull(); + expect(harness.sessionDisposals()).toBe(1); + }); + view.destroy(); + expect(harness.sessionDisposals()).toBe(1); + }, + ); + + it("cancels invisible intent on selection, Escape, expiry, and visibility", async () => { + const runtime = controlledRuntime(); + const harness = fakeService((revision, token) => + readyResult(revision, [], token) + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(1)); + const selectionToken = harness.getLastToken(); + view.dispatch({ selection: { anchor: 0 } }); + if (!selectionToken) throw new Error("Expected completion token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: selectionToken, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(0); + + view.dispatch({ selection: { anchor: 14 } }); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(2)); + view.focus(); + view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + key: "Escape", + })); + const escapeToken = harness.getLastToken(); + if (!escapeToken) throw new Error("Expected completion token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: escapeToken, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(0); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(3)); + runtime.timers.at(-1)?.(); + const expiredToken = harness.getLastToken(); + if (!expiredToken) throw new Error("Expected completion token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: expiredToken, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(0); + + let resolveResult: + | ((result: SqlCompletionResult) => void) + | undefined; + const deferredHarness = fakeService( + () => new Promise((resolve) => { + resolveResult = resolve; + }), + ); + const deferredSupport = sqlEditor({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: deferredHarness.service, + }); + const deferredView = createView(deferredSupport.extension); + expect(startCompletion(deferredView)).toBe(true); + await vi.waitFor(() => + expect(deferredHarness.completeSignals).toHaveLength(1) + ); + const visibilityDescriptor = Object.getOwnPropertyDescriptor( + document, + "visibilityState", + ); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(deferredHarness.completeSignals[0]?.aborted).toBe(true); + if (visibilityDescriptor) { + Object.defineProperty( + document, + "visibilityState", + visibilityDescriptor, + ); + } else { + Reflect.deleteProperty(document, "visibilityState"); + } + resolveResult?.({ + reason: "caller", + revision: createSqlRevisionToken(), + status: "cancelled", + }); + }); + + it("coalesces matching refreshes and suppresses queued work after destroy", async () => { + const runtime = controlledRuntime(); + const harness = fakeService((revision, token) => + readyResult(revision, [], token) + ); + const support = createSqlEditorInternal({ + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + view.focus(); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(1)); + const token = harness.getLastToken(); + if (!token) throw new Error("Expected completion token"); + const event: SqlSessionChangeEvent = { + reason: "catalog-availability", + refreshToken: token, + revision: createSqlRevisionToken(), + }; + harness.emit(event); + harness.emit(event); + expect(runtime.queued).toHaveLength(1); + runtime.queued[0]?.(); + expect(runtime.starts).toEqual([view]); + + harness.emit({ + reason: "provider-configuration", + refreshToken: null, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(2); + runtime.queued[1]?.(); + expect(runtime.closes).toEqual([view]); + + harness.emit({ + reason: "provider-configuration", + refreshToken: null, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(3); + view.destroy(); + runtime.queued[2]?.(); + expect(runtime.closes).toEqual([view]); + view.destroy(); + expect(harness.sessionDisposals()).toBe(1); + }); + + it("invalidates active and scheduled work across hostile lifecycle timing", async () => { + const runtime = controlledRuntime(); + let resolveResult: + | ((result: SqlCompletionResult) => void) + | undefined; + const harness = fakeService( + () => new Promise((resolve) => { + resolveResult = resolve; + }), + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + const token = harness.getLastToken(); + if (!token) throw new Error("Expected active token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: token, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(1); + expect(harness.completeSignals[0]?.aborted).toBe(true); + + support.setContext(view, context("connection:new")); + runtime.queued[0]?.(); + expect(runtime.starts).toHaveLength(0); + document.dispatchEvent(new Event("visibilitychange")); + + resolveResult?.({ + reason: "caller", + revision: createSqlRevisionToken(), + status: "cancelled", + }); + }); + + it("does not let a stale rejected task destroy its queued replacement", async () => { + const runtime = controlledRuntime(); + let rejectResult: + | ((reason: Error) => void) + | undefined; + const harness = fakeService( + () => new Promise((_resolve, reject) => { + rejectResult = reject; + }), + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + const token = harness.getLastToken(); + if (!token) throw new Error("Expected active token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: token, + revision: createSqlRevisionToken(), + }); + expect(harness.completeSignals[0]?.aborted).toBe(true); + expect(runtime.queued).toHaveLength(1); + + rejectResult?.(new Error("late aborted rejection")); + await vi.waitFor(() => { + expect(completionStatus(view.state)).toBeNull(); + }); + expect(harness.sessionDisposals()).toBe(0); + runtime.queued[0]?.(); + expect(runtime.starts).toEqual([view]); + }); + + it("keeps a replacement intent when an older lease timer fires", async () => { + const runtime = controlledRuntime(); + const harness = fakeService((revision, token) => + readyResult(revision, [], token) + ); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(1)); + const oldTimer = runtime.timers[0]; + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => expect(runtime.timers).toHaveLength(2)); + oldTimer?.(); + const token = harness.getLastToken(); + if (!token) throw new Error("Expected replacement token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: token, + revision: createSqlRevisionToken(), + }); + expect(runtime.queued).toHaveLength(1); + }); + + it("does not retain a zero-duration loading intent", async () => { + const runtime = controlledRuntime(); + const harness = fakeService((revision, token) => ({ + refreshToken: token, + revision, + sources: [], + status: "ready", + value: { + isIncomplete: true, + issues: [{ + reason: "catalog-loading", + remainingIntentLeaseMs: 0, + }], + items: [], + }, + })); + const support = createSqlEditorInternal({ + autocomplete: { closeOnBlur: false }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension, "SELECT * FROM "); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + const token = harness.getLastToken(); + if (!token) throw new Error("Expected completion token"); + harness.emit({ + reason: "catalog-availability", + refreshToken: token, + revision: createSqlRevisionToken(), + }); + expect(runtime.timers).toHaveLength(0); + expect(runtime.queued).toHaveLength(0); + }); + + it("bridges CodeMirror cancellation and blur into the service signal", async () => { + let resolveResult: + | ((result: SqlCompletionResult) => void) + | undefined; + const harness = fakeService( + () => new Promise((resolve) => { + resolveResult = resolve; + }), + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + expect(closeCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals[0]?.aborted).toBe(true); + }); + resolveResult?.({ + reason: "caller", + revision: createSqlRevisionToken(), + status: "cancelled", + }); + await vi.waitFor(() => { + expect(completionStatus(view.state)).toBeNull(); + }); + + view.focus(); + view.dispatch({ selection: { anchor: 15 } }); + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(2) + ); + view.contentDOM.blur(); + await vi.waitFor(() => { + expect(harness.completeSignals[1]?.aborted).toBe(true); + }); + resolveResult?.({ + reason: "caller", + revision: createSqlRevisionToken(), + status: "cancelled", + }); + }); +}); diff --git a/src/vnext/codemirror/index.ts b/src/vnext/codemirror/index.ts new file mode 100644 index 0000000..24a6e55 --- /dev/null +++ b/src/vnext/codemirror/index.ts @@ -0,0 +1,6 @@ +export { + sqlEditor, + type SqlEditorAutocompleteOptions, + type SqlEditorOptions, + type SqlEditorSupport, +} from "./sql-editor.js"; diff --git a/src/vnext/codemirror/sql-editor.ts b/src/vnext/codemirror/sql-editor.ts new file mode 100644 index 0000000..722d396 --- /dev/null +++ b/src/vnext/codemirror/sql-editor.ts @@ -0,0 +1,668 @@ +import { + autocompletion, + closeCompletion, + completionStatus, + pickedCompletion, + startCompletion, + type Completion, + type CompletionContext, + type CompletionResult, + type CompletionSource, +} from "@codemirror/autocomplete"; +import { + Prec, + StateEffect, + StateField, + type EditorSelection, + type Extension, + type StateEffectType, + type Text, +} from "@codemirror/state"; +import { + EditorView, + keymap, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import type { + SqlCompletionItem, + SqlCompletionRefreshToken, + SqlCompletionResult, + SqlCompletionTask, +} from "../relation-completion-types.js"; +import type { + SqlContextInput, + SqlDocumentContext, + SqlDocumentSession, + SqlEmbeddedRegion, + SqlLanguageService, + SqlRevision, + SqlTextChange, +} from "../types.js"; + +export interface SqlEditorAutocompleteOptions { + readonly activateOnTyping?: boolean; + readonly activateOnTypingDelay?: number; + readonly closeOnBlur?: boolean; + readonly defaultKeymap?: boolean; + readonly externalSources?: readonly CompletionSource[]; + readonly maxRenderedOptions?: number; + readonly selectOnOpen?: boolean; + readonly updateSyncTime?: number; +} + +export interface SqlEditorOptions< + Context extends SqlDocumentContext, +> { + readonly autocomplete?: SqlEditorAutocompleteOptions; + readonly initialContext: SqlContextInput; + readonly initialEmbeddedRegions?: + | readonly SqlEmbeddedRegion[] + | undefined; + readonly service: SqlLanguageService; +} + +export interface SqlEditorSupport< + Context extends SqlDocumentContext, +> { + readonly contextEffect: StateEffectType>; + readonly embeddedRegionsEffect: StateEffectType< + readonly SqlEmbeddedRegion[] + >; + readonly extension: Extension; + readonly setContext: ( + view: EditorView, + context: SqlContextInput, + ) => void; + readonly setEmbeddedRegions: ( + view: EditorView, + regions: readonly SqlEmbeddedRegion[], + ) => void; +} + +export interface SqlEditorRuntime { + readonly clearTimeout: ( + handle: ReturnType, + ) => void; + readonly closeCompletion: (view: EditorView) => boolean; + readonly queueMicrotask: (callback: () => void) => void; + readonly setTimeout: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + readonly startCompletion: (view: EditorView) => boolean; +} + +interface CompletionCapture { + readonly contextGeneration: number; + readonly document: Text; + readonly selection: EditorSelection; +} + +interface ActiveCompletion { + readonly capture: CompletionCapture; + readonly controller: AbortController; + readonly sequence: number; + readonly token: SqlCompletionRefreshToken; +} + +interface CompletionIntent { + readonly capture: CompletionCapture; + readonly sequence: number; + readonly token: SqlCompletionRefreshToken; +} + +const defaultRuntime: SqlEditorRuntime = Object.freeze({ + clearTimeout: (handle: ReturnType) => + clearTimeout(handle), + closeCompletion, + queueMicrotask: (callback: () => void) => queueMicrotask(callback), + setTimeout: (callback: () => void, delayMs: number) => + setTimeout(callback, delayMs), + startCompletion, +}); + +function completionTrigger(): { readonly kind: "invoked" } { + return { kind: "invoked" }; +} + +function collectChanges(update: ViewUpdate): readonly SqlTextChange[] { + const changes: SqlTextChange[] = []; + update.changes.iterChanges((from, to, _fromNew, _toNew, inserted) => { + changes.push(Object.freeze({ + from, + insert: inserted.toString(), + to, + })); + }); + return Object.freeze(changes); +} + +function loadingLeaseMs( + result: Extract, +): number | null { + for (const issue of result.value.issues) { + if (issue.reason === "catalog-loading") { + return issue.remainingIntentLeaseMs; + } + } + return null; +} + +function haveOneEditRange(items: readonly SqlCompletionItem[]): boolean { + const first = items[0]; + if (!first) return true; + for (const item of items) { + if ( + item.edit.from !== first.edit.from || + item.edit.to !== first.edit.to + ) { + return false; + } + } + return true; +} + +function completionType(item: SqlCompletionItem): string { + return item.relationKind === "cte" ? "type" : "table"; +} + +function mapEmbeddedRegions( + regions: readonly SqlEmbeddedRegion[], + change: SqlTextChange, +): readonly SqlEmbeddedRegion[] | null { + const offset = + change.insert.length - (change.to - change.from); + const mapped: SqlEmbeddedRegion[] = []; + for (const region of regions) { + if (change.to <= region.from) { + mapped.push(Object.freeze({ + from: region.from + offset, + language: region.language, + to: region.to + offset, + })); + } else if (change.from >= region.to) { + mapped.push(region); + } else { + return null; + } + } + return Object.freeze(mapped); +} + +export function createSqlEditorInternal< + Context extends SqlDocumentContext, +>( + options: SqlEditorOptions, + runtime: SqlEditorRuntime, +): SqlEditorSupport { + const contextEffect = + StateEffect.define>(); + const embeddedRegionsEffect = + StateEffect.define(); + const contextField = StateField.define>({ + create: () => options.initialContext, + update: (context, transaction) => { + let next = context; + for (const effect of transaction.effects) { + if (effect.is(contextEffect)) next = effect.value; + } + return next; + }, + }); + const embeddedRegionsField = StateField.define< + readonly SqlEmbeddedRegion[] + >({ + create: () => options.initialEmbeddedRegions ?? [], + update: (regions, transaction) => { + let next = regions; + for (const effect of transaction.effects) { + if (effect.is(embeddedRegionsEffect)) next = effect.value; + } + return next; + }, + }); + + let plugin: ViewPlugin; + let completionSource: CompletionSource; + + class SqlEditorPlugin { + readonly #view: EditorView; + readonly #session: SqlDocumentSession; + #active: ActiveCompletion | null = null; + #contextGeneration = 0; + #destroyed = false; + #hasEmbeddedRegions: boolean; + #intent: CompletionIntent | null = null; + #intentTimer: ReturnType | null = null; + #lastCompletionStatus: ReturnType; + #refreshScheduled = false; + #sequence = 0; + readonly #subscription; + readonly #visibilityListener: () => void; + + constructor(view: EditorView) { + this.#view = view; + const initialRegions = view.state.field(embeddedRegionsField); + this.#session = options.service.openDocument({ + context: view.state.field(contextField), + embeddedRegions: initialRegions, + text: view.state.doc.toString(), + }); + this.#hasEmbeddedRegions = initialRegions.length > 0; + this.#lastCompletionStatus = completionStatus(view.state); + this.#subscription = this.#session.onDidChange((event) => { + if (event.refreshToken === null) { + this.#clearCompletionState(); + this.#scheduleClose(); + return; + } + if (event.refreshToken === this.#active?.token) { + this.#scheduleRefresh(this.#active.capture); + } else if (event.refreshToken === this.#intent?.token) { + this.#scheduleRefresh(this.#intent.capture); + } + }); + this.#visibilityListener = () => { + if (view.dom.ownerDocument.visibilityState === "hidden") { + this.#clearCompletionState(); + } + }; + view.dom.ownerDocument.addEventListener( + "visibilitychange", + this.#visibilityListener, + ); + } + + #abortActive(): void { + this.#active?.controller.abort(); + this.#active = null; + } + + #clearIntent(): void { + if (this.#intentTimer !== null) { + runtime.clearTimeout(this.#intentTimer); + this.#intentTimer = null; + } + this.#intent = null; + } + + #clearCompletionState(): void { + this.#sequence += 1; + this.#abortActive(); + this.#clearIntent(); + this.#refreshScheduled = false; + } + + #captureIsCurrent(capture: CompletionCapture): boolean { + const state = this.#view.state; + return ( + !this.#destroyed && + capture.contextGeneration === this.#contextGeneration && + capture.document === state.doc && + capture.selection.eq(state.selection) + ); + } + + #scheduleClose(): void { + const sequence = this.#sequence; + runtime.queueMicrotask(() => { + if ( + this.#destroyed || + sequence !== this.#sequence + ) { + return; + } + runtime.closeCompletion(this.#view); + }); + } + + #scheduleRefresh(capture: CompletionCapture): void { + if ( + this.#refreshScheduled || + !this.#captureIsCurrent(capture) + ) { + return; + } + this.#abortActive(); + this.#clearIntent(); + this.#refreshScheduled = true; + const sequence = ++this.#sequence; + runtime.queueMicrotask(() => { + if ( + this.#destroyed || + !this.#refreshScheduled || + sequence !== this.#sequence || + !this.#captureIsCurrent(capture) || + (options.autocomplete?.closeOnBlur !== false && + !this.#view.hasFocus) + ) { + return; + } + this.#refreshScheduled = false; + runtime.startCompletion(this.#view); + }); + } + + #installIntent( + token: SqlCompletionRefreshToken, + capture: CompletionCapture, + leaseMs: number, + ): void { + this.#clearIntent(); + if (leaseMs <= 0 || !this.#captureIsCurrent(capture)) return; + const sequence = this.#sequence; + const intent: CompletionIntent = { + capture, + sequence, + token, + }; + this.#intent = intent; + this.#intentTimer = runtime.setTimeout(() => { + if ( + this.#intent === intent && + this.#sequence === intent.sequence + ) { + this.#intent = null; + this.#intentTimer = null; + } + }, leaseMs); + } + + #mapCompletion( + item: SqlCompletionItem, + revision: SqlRevision, + capture: CompletionCapture, + ): Completion { + const completion: Completion = { + apply: (view, completion) => { + const current = view.plugin(plugin); + if ( + current !== this || + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(revision) + ) { + return; + } + const regions = mapEmbeddedRegions( + view.state.field(embeddedRegionsField), + item.edit, + ); + if (regions === null) return; + view.dispatch({ + annotations: pickedCompletion.of(completion), + changes: { + from: item.edit.from, + insert: item.edit.insert, + to: item.edit.to, + }, + effects: embeddedRegionsEffect.of(regions), + }); + }, + label: item.label, + type: completionType(item), + }; + return item.detail === undefined + ? completion + : { ...completion, detail: item.detail }; + } + + readonly complete = async ( + context: CompletionContext, + ): Promise => { + this.#clearCompletionState(); + const capture: CompletionCapture = { + contextGeneration: this.#contextGeneration, + document: this.#view.state.doc, + selection: this.#view.state.selection, + }; + const controller = new AbortController(); + let task: SqlCompletionTask; + try { + task = this.#session.complete({ + position: context.pos, + signal: controller.signal, + trigger: completionTrigger(), + }); + } catch { + this.destroy(); + return null; + } + const active: ActiveCompletion = { + capture, + controller, + sequence: this.#sequence, + token: task.refreshToken, + }; + this.#active = active; + context.addEventListener( + "abort", + () => { + controller.abort(); + if (this.#active === active) this.#active = null; + }, + { onDocChange: true }, + ); + if (context.aborted) controller.abort(); + + let result: SqlCompletionResult; + try { + result = await task; + } catch { + if ( + this.#active === active && + active.sequence === this.#sequence && + !controller.signal.aborted + ) { + this.destroy(); + } + return null; + } + if ( + controller.signal.aborted || + this.#active !== active || + active.sequence !== this.#sequence || + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(result.revision) + ) { + return null; + } + this.#active = null; + if (result.status !== "ready") return null; + + if (!haveOneEditRange(result.value.items)) return null; + const leaseMs = loadingLeaseMs(result); + if (result.refreshToken !== null) { + if ( + result.refreshToken !== active.token || + leaseMs === null + ) { + return null; + } + this.#installIntent( + result.refreshToken, + capture, + leaseMs, + ); + } + const first = result.value.items[0]; + if (!first) return null; + return { + from: first.edit.from, + options: result.value.items.map((item) => + this.#mapCompletion(item, result.revision, capture) + ), + to: first.edit.to, + }; + }; + + readonly cancelCompletion = (): void => { + this.#clearCompletionState(); + }; + + readonly update = (update: ViewUpdate): void => { + let contextChanged = false; + let regionsChanged = false; + for (const transaction of update.transactions) { + for (const effect of transaction.effects) { + if (effect.is(contextEffect)) contextChanged = true; + if (effect.is(embeddedRegionsEffect)) regionsChanged = true; + } + } + if ( + update.docChanged && + this.#hasEmbeddedRegions && + !regionsChanged + ) { + this.destroy(); + throw new Error( + "SQL document changes with embedded regions require the complete resulting region set", + ); + } + if (update.docChanged || contextChanged || regionsChanged) { + this.#clearCompletionState(); + const regions = regionsChanged + ? update.state.field(embeddedRegionsField) + : []; + const baseRevision = this.#session.revision; + try { + if (update.docChanged) { + const document = { + changes: collectChanges(update), + kind: "changes" as const, + }; + this.#session.update( + contextChanged + ? { + baseRevision, + context: update.state.field(contextField), + document, + embeddedRegions: regions, + } + : { + baseRevision, + document, + embeddedRegions: regions, + }, + ); + } else if (contextChanged) { + const context = update.state.field(contextField); + this.#session.update( + regionsChanged + ? { + baseRevision, + context, + embeddedRegions: regions, + } + : { + baseRevision, + context, + }, + ); + } else { + this.#session.update({ + baseRevision, + embeddedRegions: regions, + }); + } + } catch { + this.destroy(); + return; + } + if (contextChanged) this.#contextGeneration += 1; + if (update.docChanged || regionsChanged) { + this.#hasEmbeddedRegions = regions.length > 0; + } + } else if (update.selectionSet) { + this.#clearCompletionState(); + } + if ( + update.focusChanged && + !update.view.hasFocus && + options.autocomplete?.closeOnBlur !== false + ) { + this.#clearCompletionState(); + } + const nextCompletionStatus = completionStatus(update.state); + if ( + nextCompletionStatus === null && + (this.#lastCompletionStatus === "active" || + (this.#lastCompletionStatus === "pending" && + this.#active !== null)) + ) { + this.#clearCompletionState(); + } + this.#lastCompletionStatus = nextCompletionStatus; + }; + + readonly destroy = (): void => { + if (this.#destroyed) return; + this.#destroyed = true; + this.#clearCompletionState(); + this.#view.dom.ownerDocument.removeEventListener( + "visibilitychange", + this.#visibilityListener, + ); + this.#subscription.dispose(); + this.#session.dispose(); + }; + } + + plugin = ViewPlugin.define( + (view) => new SqlEditorPlugin(view), + ); + completionSource = (context) => { + const instance = context.view?.plugin(plugin); + return instance?.complete(context) ?? null; + }; + const autocomplete = options.autocomplete ?? {}; + const { + externalSources = [], + ...autocompleteOptions + } = autocomplete; + const escapeKeymap = Prec.high( + keymap.of([{ + key: "Escape", + run: (view) => { + view.plugin(plugin)?.cancelCompletion(); + return false; + }, + }]), + ); + return Object.freeze({ + contextEffect, + embeddedRegionsEffect, + extension: [ + contextField, + embeddedRegionsField, + plugin, + escapeKeymap, + autocompletion({ + ...autocompleteOptions, + override: [completionSource, ...externalSources], + }), + ], + setContext: ( + view: EditorView, + context: SqlContextInput, + ): void => { + view.dispatch({ effects: contextEffect.of(context) }); + }, + setEmbeddedRegions: ( + view: EditorView, + regions: readonly SqlEmbeddedRegion[], + ): void => { + view.dispatch({ + effects: embeddedRegionsEffect.of(regions), + }); + }, + }); +} + +export function sqlEditor< + Context extends SqlDocumentContext, +>( + options: SqlEditorOptions, +): SqlEditorSupport { + return createSqlEditorInternal(options, defaultRuntime); +} From e052af60dfa001e753355375c9fc6c7f7430590c Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 01:40:20 +0800 Subject: [PATCH 31/42] feat(vnext): add disposable completion info (#200) ## Summary - expose an opt-in, type-safe completion info resolver on the vNext CodeMirror adapter - give every resolver an AbortSignal and require explicit DOM resource cleanup - abort pending work and destroy resolved UI on option, document, context, region, or view changes - contain resolver/cleanup failures and reject cleanup reentrancy - document the React-root integration pattern and export the public resolver types ## Verification - 43 test files; 2,036 passed and 1 expected failure - changed coverage: 98.15% statements, 95.90% branches, 100% functions, 99.20% lines - all strict/loose/demo TypeScript configurations pass - oxlint and test-integrity checks pass - build and runtime export smoke pass - two independent exact-head adversarial reviews approve `e07f8e525d6e6f2c7f73e24acd4e1f0b1ff90cc0` Stacked on #199, now merged into `dev-refactor`. --- ## Summary by cubic Adds opt-in rich completion info to the vNext CodeMirror adapter. Introduces a type-safe `infoResolver` that returns a disposable DOM panel, with automatic abort and cleanup on selection or editor changes. - **New Features** - `autocomplete.infoResolver(item, { signal })` lets you render rich info and return `{ dom, destroy }`; pending work is aborted and resolved resources are destroyed on option selection changes, document/context/region updates, or view disposal. - Stale results are ignored; null results omit the panel; resolver and cleanup failures are contained; reentrancy during cleanup is rejected. - Docs updated with a React root example; exported `SqlCompletionInfoResolver`, `SqlCompletionInfoResolverContext`, and `SqlDisposableCompletionInfo` from `src/vnext/codemirror/index.ts`. - **Migration** - Opt in by passing `sqlEditor({ autocomplete: { infoResolver } })`. - Your resolver should use the provided `AbortSignal` and return `{ dom, destroy }` or `null`. Written for commit e07f8e525d6e6f2c7f73e24acd4e1f0b1ff90cc0. Summary will update on new commits. Review in cubic --- docs/vnext/codemirror-adapter.md | 23 ++ .../codemirror/__tests__/sql-editor.test.ts | 355 ++++++++++++++++++ src/vnext/codemirror/index.ts | 5 + src/vnext/codemirror/sql-editor.ts | 125 +++++- ...o-relation-completion-codemirror.test-d.ts | 2 +- 5 files changed, 504 insertions(+), 6 deletions(-) diff --git a/docs/vnext/codemirror-adapter.md b/docs/vnext/codemirror-adapter.md index 29c522d..572d52a 100644 --- a/docs/vnext/codemirror-adapter.md +++ b/docs/vnext/codemirror-adapter.md @@ -47,6 +47,29 @@ The adapter installs one coherent `autocompletion` configuration. Additional sources belong in `autocomplete.externalSources`; consumers should not install a second independently configured `autocompletion` extension. +Rich completion details are opt-in through +`autocomplete.infoResolver`. The resolver receives the immutable core item and +an `AbortSignal`, and returns a DOM resource with an explicit `destroy` +callback. The adapter aborts pending work and destroys resolved resources when +the selected option, document input, context, regions, or view lifetime +changes. Resolver failures are contained and simply omit the detail panel. + +```ts +const support = sqlEditor({ + autocomplete: { + infoResolver: async (item, { signal }) => { + const metadata = await loadMetadata(item, { signal }); + const dom = document.createElement("div"); + const root = createRoot(dom); + root.render(renderMetadata(metadata)); + return { dom, destroy: () => root.unmount() }; + }, + }, + initialContext, + service, +}); +``` + ## Atomic inputs Context and document changes can share one CodeMirror transaction: diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts index a119040..a9bfdf7 100644 --- a/src/vnext/codemirror/__tests__/sql-editor.test.ts +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -2,7 +2,10 @@ import { closeCompletion, completionStatus, currentCompletions, + setSelectedCompletion, startCompletion, + type Completion, + type CompletionInfo, } from "@codemirror/autocomplete"; import { EditorSelection, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -283,6 +286,15 @@ async function waitForActiveCompletion(view: EditorView): Promise { }); } +async function resolveCompletionInfo( + completion: Completion, +): Promise { + if (typeof completion.info !== "function") { + throw new Error("Expected a completion info resolver"); + } + return completion.info(completion); +} + describe("sqlEditor", () => { it("maps current service completions and applies the exact core edit", async () => { const service = createSqlLanguageService({ @@ -324,6 +336,349 @@ describe("sqlEditor", () => { service.dispose(); }); + it("owns rich completion info until CodeMirror destroys it", async () => { + const item = completionItem(); + const destroys: Array> = []; + const resolver = vi.fn((resolvedItem, { signal }) => { + const dom = document.createElement("div"); + const index = destroys.length; + const destroy = vi.fn(); + destroys.push(destroy); + dom.dataset.resolverIndex = String(index); + return { dom, destroy, signal }; + }); + const harness = fakeService((revision) => + readyResult(revision, [item]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const info = await resolveCompletionInfo(completion); + + expect(resolver).toHaveBeenCalledWith( + item, + { signal: expect.any(AbortSignal) }, + ); + expect(info).toMatchObject({ dom: expect.any(Node) }); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + const index = Number( + (info.dom as HTMLElement).dataset.resolverIndex, + ); + info.destroy?.(); + info.destroy?.(); + expect(destroys[index]).toHaveBeenCalledTimes(1); + }); + + it("rejects info requests reentered during host cleanup", async () => { + let reenter = () => undefined; + const resolver = vi.fn(() => ({ + dom: document.createElement("div"), + destroy: () => reenter(), + })); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await resolveCompletionInfo(completion); + const callsBeforeReplacement = resolver.mock.calls.length; + reenter = () => { + void resolveCompletionInfo(completion); + }; + + await resolveCompletionInfo(completion); + expect(resolver).toHaveBeenCalledTimes( + callsBeforeReplacement + 1, + ); + }); + + it("aborts superseded info and destroys its late resource", async () => { + let resolveFirst: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const firstDestroy = vi.fn(); + const signals: AbortSignal[] = []; + const resolver = vi.fn((_item, { signal }) => { + signals.push(signal); + if (signals.length === 1) { + return new Promise<{ + readonly dom: Node; + readonly destroy: () => void; + }>((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve({ + dom: document.createElement("div"), + destroy: vi.fn(), + }); + }); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const first = resolveCompletionInfo(completion); + const second = resolveCompletionInfo(completion); + expect(signals[0]?.aborted).toBe(true); + + resolveFirst?.({ + dom: document.createElement("div"), + destroy: firstDestroy, + }); + expect(await first).toBeNull(); + expect(firstDestroy).toHaveBeenCalledTimes(1); + expect(await second).toMatchObject({ dom: expect.any(Node) }); + }); + + it("disposes info when selection moves to an external option", async () => { + let resolveInfo: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const destroy = vi.fn(); + let signal: AbortSignal | undefined; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 14, + options: [{ label: "users_external" }], + })], + infoResolver: (_item, context) => { + signal = context.signal; + return new Promise((resolve) => { + resolveInfo = resolve; + }); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + await vi.waitFor(() => { + expect( + currentCompletions(view.state).map((item) => item.label), + ).toContain("users_external"); + }); + const completions = currentCompletions(view.state); + const coreIndex = completions.findIndex( + (completion) => completion.label === "users", + ); + const externalIndex = completions.findIndex( + (completion) => completion.label === "users_external", + ); + const core = completions[coreIndex]; + if (!core || coreIndex < 0 || externalIndex < 0) { + throw new Error("Expected core and external completions"); + } + view.dispatch({ effects: setSelectedCompletion(coreIndex) }); + const pending = resolveCompletionInfo(core); + view.dispatch({ effects: setSelectedCompletion(externalIndex) }); + + expect(signal?.aborted).toBe(true); + resolveInfo?.({ dom: document.createElement("div"), destroy }); + await expect(pending).resolves.toBeNull(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("destroys resolved info when selection moves away", async () => { + const destroys: Array> = []; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 14, + options: [{ label: "users_external" }], + })], + infoResolver: () => { + const dom = document.createElement("div"); + const destroy = vi.fn(); + const index = destroys.push(destroy) - 1; + dom.dataset.resolverIndex = String(index); + return { dom, destroy }; + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + await vi.waitFor(() => { + expect( + currentCompletions(view.state).map((item) => item.label), + ).toContain("users_external"); + }); + const completions = currentCompletions(view.state); + const coreIndex = completions.findIndex( + (completion) => completion.label === "users", + ); + const externalIndex = completions.findIndex( + (completion) => completion.label === "users_external", + ); + const core = completions[coreIndex]; + if (!core || coreIndex < 0 || externalIndex < 0) { + throw new Error("Expected core and external completions"); + } + view.dispatch({ effects: setSelectedCompletion(coreIndex) }); + const info = await resolveCompletionInfo(core); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + const index = Number( + (info.dom as HTMLElement).dataset.resolverIndex, + ); + view.dispatch({ effects: setSelectedCompletion(externalIndex) }); + + expect(destroys[index]).toHaveBeenCalledTimes(1); + }); + + it("aborts pending info when editor input changes", async () => { + let resolveInfo: + | ((value: { + readonly dom: Node; + readonly destroy: () => void; + }) => void) + | undefined; + const destroy = vi.fn(); + let signal: AbortSignal | undefined; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + infoResolver: (_item, context) => { + signal = context.signal; + return new Promise((resolve) => { + resolveInfo = resolve; + }); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + const pending = resolveCompletionInfo(completion); + view.dispatch({ selection: { anchor: 0 } }); + expect(signal?.aborted).toBe(true); + + resolveInfo?.({ dom: document.createElement("div"), destroy }); + expect(await pending).toBeNull(); + expect(destroy).toHaveBeenCalledTimes(1); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + }); + + it("omits null completion info", async () => { + const resolver = vi.fn(() => null); + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { infoResolver: resolver }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + }); + + it("contains info resolver and cleanup failures", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + infoResolver: () => { + throw new Error("resolver failed"); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + startCompletion(view); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion) throw new Error("Expected a completion"); + await expect(resolveCompletionInfo(completion)).resolves.toBeNull(); + + const cleanupSupport = sqlEditor({ + autocomplete: { + infoResolver: () => ({ + dom: document.createElement("div"), + destroy: () => { + throw new Error("cleanup failed"); + }, + }), + }, + initialContext: context(), + service: harness.service, + }); + const cleanupView = createView(cleanupSupport.extension); + startCompletion(cleanupView); + await waitForActiveCompletion(cleanupView); + const cleanupCompletion = currentCompletions(cleanupView.state)[0]; + if (!cleanupCompletion) throw new Error("Expected a completion"); + const info = await resolveCompletionInfo(cleanupCompletion); + if (info === null || info instanceof Node) { + throw new Error("Expected disposable completion info"); + } + expect(() => info.destroy?.()).not.toThrow(); + }); + it("maps embedded regions through its own non-overlapping apply edit", async () => { const harness = fakeService((revision) => readyResult(revision, [completionItem(16, 18)]) diff --git a/src/vnext/codemirror/index.ts b/src/vnext/codemirror/index.ts index 24a6e55..e5e1d6f 100644 --- a/src/vnext/codemirror/index.ts +++ b/src/vnext/codemirror/index.ts @@ -4,3 +4,8 @@ export { type SqlEditorOptions, type SqlEditorSupport, } from "./sql-editor.js"; +export type { + SqlCompletionInfoResolver, + SqlCompletionInfoResolverContext, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; diff --git a/src/vnext/codemirror/sql-editor.ts b/src/vnext/codemirror/sql-editor.ts index 722d396..652b143 100644 --- a/src/vnext/codemirror/sql-editor.ts +++ b/src/vnext/codemirror/sql-editor.ts @@ -3,6 +3,8 @@ import { closeCompletion, completionStatus, pickedCompletion, + selectedCompletion, + selectedCompletionIndex, startCompletion, type Completion, type CompletionContext, @@ -24,6 +26,10 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; +import type { + SqlCompletionInfoResolver, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; import type { SqlCompletionItem, SqlCompletionRefreshToken, @@ -46,6 +52,7 @@ export interface SqlEditorAutocompleteOptions { readonly closeOnBlur?: boolean; readonly defaultKeymap?: boolean; readonly externalSources?: readonly CompletionSource[]; + readonly infoResolver?: SqlCompletionInfoResolver; readonly maxRenderedOptions?: number; readonly selectOnOpen?: boolean; readonly updateSyncTime?: number; @@ -112,6 +119,12 @@ interface CompletionIntent { readonly token: SqlCompletionRefreshToken; } +interface ActiveCompletionInfo { + readonly controller: AbortController; + disposed: boolean; + resource: SqlDisposableCompletionInfo | null; +} + const defaultRuntime: SqlEditorRuntime = Object.freeze({ clearTimeout: (handle: ReturnType) => clearTimeout(handle), @@ -222,6 +235,12 @@ export function createSqlEditorInternal< return next; }, }); + const autocomplete = options.autocomplete ?? {}; + const { + externalSources = [], + infoResolver, + ...autocompleteOptions + } = autocomplete; let plugin: ViewPlugin; let completionSource: CompletionSource; @@ -232,10 +251,14 @@ export function createSqlEditorInternal< #active: ActiveCompletion | null = null; #contextGeneration = 0; #destroyed = false; + #disposingInfo = false; #hasEmbeddedRegions: boolean; + #info: ActiveCompletionInfo | null = null; #intent: CompletionIntent | null = null; #intentTimer: ReturnType | null = null; #lastCompletionStatus: ReturnType; + #lastSelectedCompletion: Completion | null; + #lastSelectedCompletionIndex: number | null; #refreshScheduled = false; #sequence = 0; readonly #subscription; @@ -251,6 +274,10 @@ export function createSqlEditorInternal< }); this.#hasEmbeddedRegions = initialRegions.length > 0; this.#lastCompletionStatus = completionStatus(view.state); + this.#lastSelectedCompletion = selectedCompletion(view.state); + this.#lastSelectedCompletionIndex = selectedCompletionIndex( + view.state, + ); this.#subscription = this.#session.onDidChange((event) => { if (event.refreshToken === null) { this.#clearCompletionState(); @@ -279,6 +306,32 @@ export function createSqlEditorInternal< this.#active = null; } + #disposeInfo(info: ActiveCompletionInfo): void { + if (info.disposed) return; + info.disposed = true; + if (this.#info === info) this.#info = null; + const resource = info.resource; + info.resource = null; + const wasDisposingInfo = this.#disposingInfo; + this.#disposingInfo = true; + try { + try { + info.controller.abort(); + } catch { + // Continue to resource cleanup. + } + resource?.destroy(); + } catch { + // Host cleanup must not escape into CodeMirror lifecycle hooks. + } finally { + this.#disposingInfo = wasDisposingInfo; + } + } + + #clearInfo(): void { + if (this.#info !== null) this.#disposeInfo(this.#info); + } + #clearIntent(): void { if (this.#intentTimer !== null) { runtime.clearTimeout(this.#intentTimer); @@ -290,6 +343,7 @@ export function createSqlEditorInternal< #clearCompletionState(): void { this.#sequence += 1; this.#abortActive(); + this.#clearInfo(); this.#clearIntent(); this.#refreshScheduled = false; } @@ -402,6 +456,59 @@ export function createSqlEditorInternal< label: item.label, type: completionType(item), }; + if (infoResolver !== undefined) { + completion.info = async () => { + if (this.#disposingInfo) return null; + this.#clearInfo(); + if ( + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(revision) + ) { + return null; + } + const info: ActiveCompletionInfo = { + controller: new AbortController(), + disposed: false, + resource: null, + }; + this.#info = info; + let resource: SqlDisposableCompletionInfo | null; + try { + resource = await infoResolver(item, { + signal: info.controller.signal, + }); + } catch { + this.#disposeInfo(info); + return null; + } + if ( + info.disposed || + this.#info !== info || + !this.#captureIsCurrent(capture) || + !this.#session.isCurrent(revision) + ) { + const wasDisposingInfo = this.#disposingInfo; + this.#disposingInfo = true; + try { + resource?.destroy(); + } catch { + // Host cleanup must not escape from a stale resolver. + } finally { + this.#disposingInfo = wasDisposingInfo; + } + return null; + } + if (resource === null) { + this.#disposeInfo(info); + return null; + } + info.resource = resource; + return { + dom: resource.dom, + destroy: () => this.#disposeInfo(info), + }; + }; + } return item.detail === undefined ? completion : { ...completion, detail: item.detail }; @@ -584,6 +691,19 @@ export function createSqlEditorInternal< this.#clearCompletionState(); } const nextCompletionStatus = completionStatus(update.state); + const nextSelectedCompletion = selectedCompletion(update.state); + const nextSelectedCompletionIndex = selectedCompletionIndex( + update.state, + ); + if ( + nextSelectedCompletion !== this.#lastSelectedCompletion || + nextSelectedCompletionIndex !== + this.#lastSelectedCompletionIndex + ) { + this.#clearInfo(); + } + this.#lastSelectedCompletion = nextSelectedCompletion; + this.#lastSelectedCompletionIndex = nextSelectedCompletionIndex; if ( nextCompletionStatus === null && (this.#lastCompletionStatus === "active" || @@ -615,11 +735,6 @@ export function createSqlEditorInternal< const instance = context.view?.plugin(plugin); return instance?.complete(context) ?? null; }; - const autocomplete = options.autocomplete ?? {}; - const { - externalSources = [], - ...autocompleteOptions - } = autocomplete; const escapeKeymap = Prec.high( keymap.of([{ key: "Escape", diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts index edc16c8..33938c2 100644 --- a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts +++ b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts @@ -3,7 +3,7 @@ import type { EditorView } from "@codemirror/view"; import type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, -} from "../../src/vnext/codemirror/relation-completion-types.js"; +} from "../../src/vnext/codemirror/index.js"; import type { SqlCompletionItem } from "../../src/vnext/relation-completion-types.js"; interface ReactRootLike { From b0a76da467498cd5bbe5e96fd0a9964ab5fdbdb6 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 02:05:46 +0800 Subject: [PATCH 32/42] feat(vnext): expose statement boundaries (#201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - expose synchronous, revision-stamped `statementBoundaryAt` and `statementBoundariesIntersecting` session queries - preserve exact/no-code/opaque lexical states without leaking the private statement index - add scanner-owned `code` spans that exclude separator trivia and form a strict `hasCode` discriminated union - validate request envelopes as own data properties without invoking host accessors - retain incremental index reuse and shift code spans with exact UTF-16 coordinates - add public type, package smoke, hostile-input, affinity, opacity, masking, intersection, and documentation coverage ## Verification - 43 test files; 2,045 passed and 1 expected failure - changed coverage: 97.92% statements, 96.12% branches, 100% functions, 98.07% lines - all source/test/strict/loose/demo TypeScript configurations pass - oxlint, test integrity, build, and runtime export smoke pass - cold point lookup measured 3.59 ms at 1 MiB and 42.67 ms at the 16 MiB source ceiling; warm viewport intersection measured 0.075–0.096 ms - two independent exact-head adversarial reviews approve `c901544ff41480bb1e7ea2ddcab3af477dc8103f` --- ## Summary by cubic Expose synchronous, revision-stamped statement boundary queries in the vNext session API so editors and runners can locate executable SQL without re-lexing. Adds an exact/opaque boundary model with precise UTF-16 ranges and a `code` span for execution. - **New Features** - Added `session.statementBoundaryAt({ affinity, position })` and `session.statementBoundariesIntersecting({ from, to })`; results are frozen and include the current `revision`. - Exact boundaries expose `extent`, `source`, optional `terminator`, lexical `endState`, and a `code` span that excludes separator trivia; discriminated by `hasCode`. - Opaque boundaries report a `reason` for non-executable regions (procedural blocks, custom delimiters, resource limits). - All ranges map to the original document’s UTF-16 positions and respect masked embedded regions. - Inputs are strictly validated (own data properties only); invalid requests throw `SqlSessionError` with `invalid-statement-boundary-request`. - Exported new types via `@marimo-team/codemirror-sql/vnext`: `SqlStatementBoundary*`, `SqlStatementAffinity`, `SqlStatementLexicalEnd`, and related request/result shapes. Written for commit c901544ff41480bb1e7ea2ddcab3af477dc8103f. Summary will update on new commits. Review in cubic --- docs/vnext/session-primitives.md | 41 +++ scripts/package-smoke.mjs | 27 ++ src/vnext/__tests__/session.test.ts | 321 ++++++++++++++++++ .../codemirror/__tests__/sql-editor.test.ts | 24 ++ src/vnext/index.ts | 13 + src/vnext/session.ts | 223 +++++++++++- src/vnext/statement-boundary-types.ts | 78 +++++ src/vnext/statement-index.ts | 76 ++++- src/vnext/types.ts | 13 + test/vnext-types/session.test-d.ts | 34 ++ 10 files changed, 838 insertions(+), 12 deletions(-) create mode 100644 src/vnext/statement-boundary-types.ts diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 550c5c1..87f6424 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -14,6 +14,47 @@ CodeMirror consumers should use the separate See [source coordinates](./source-coordinates.md) for the shared UTF-16 range contract and the internal immutable source-snapshot model. +Statement boundaries are available as a synchronous structural query: + +```ts +const result = session.statementBoundaryAt({ + affinity: "left", + position: cursorOffset, +}); + +if ( + session.isCurrent(result.revision) && + result.boundary.boundaryQuality === "exact" && + result.boundary.hasCode +) { + executeRange(result.boundary.source); +} +``` + +The immutable result carries the current session revision. Exact boundaries +expose their full extent, source range, optional terminator, lexical end state, +and a nullable range from the first through last SQL code token. Source ranges +retain attached +whitespace and comments; they are factual lexical boundaries, not pre-trimmed +visual selections. The `code` range excludes leading and trailing separator +trivia so presentation layers do not need to re-lex the document. Opaque +procedural, custom-delimiter, and resource-limited +regions expose only their extent and reason, so consumers cannot mistake them +for safely executable SQL. + +Viewport consumers can retrieve every structural boundary in one half-open +range without probing or re-lexing the document: + +```ts +const visible = session.statementBoundariesIntersecting({ + from: viewport.from, + to: viewport.to, +}); +``` + +The query runs in logarithmic lookup time plus the number of intersecting +boundaries and returns a frozen, revision-stamped array. + ## Example ```ts diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index a6aa13c..2f7b965 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -207,6 +207,13 @@ session.update({ baseRevision: session.revision, document: { kind: "replace", text: "SELECT * FROM {next_df}" }, }); +const statement = session.statementBoundaryAt({ affinity: "left", position: 0 }); +if ( + statement.boundary.boundaryQuality !== "exact" || + !statement.boundary.hasCode +) { + throw new Error("The packaged vNext statement boundary was unavailable"); +} service.dispose(); `, ); @@ -229,6 +236,7 @@ import { duckdbDialect, type SqlDocumentContext, type SqlEmbeddedRegion, + type SqlStatementBoundaryAtResult, type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; import { sqlEditor } from "@marimo-team/codemirror-sql/vnext/codemirror"; @@ -265,12 +273,17 @@ session.update({ baseRevision: session.revision, document: { kind: "changes", changes: [] }, }); +const statement: SqlStatementBoundaryAtResult = session.statementBoundaryAt({ + affinity: "left", + position: 0, +}); void extensions; void editorSupport.extension; void parser; void range; void session; +void statement; void BigQueryDialect; void DremioDialect; void commonKeywords; @@ -349,6 +362,20 @@ const updatedRevision = session.update({ if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { throw new Error("The packaged vNext session violated revision identity"); } +const statement = session.statementBoundaryAt({ affinity: "left", position: 0 }); +const visible = session.statementBoundariesIntersecting({ + from: 0, + to: 23, +}); +if ( + statement.revision !== updatedRevision || + statement.boundary.boundaryQuality !== "exact" || + !statement.boundary.hasCode || + visible.revision !== updatedRevision || + visible.boundaries.length !== 1 +) { + throw new Error("The packaged vNext statement boundary is invalid"); +} service.dispose(); `, ); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 631f003..14c3ad0 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -1394,6 +1394,327 @@ describe("statement-index session cache", () => { }); }); +describe("public statement boundaries", () => { + it("projects exact boundaries with explicit cursor affinity", () => { + const { session } = openSession("SELECT 1;SELECT 2"); + + const left = session.statementBoundaryAt({ + affinity: "left", + position: 9, + }); + const right = session.statementBoundaryAt({ + affinity: "right", + position: 9, + }); + + expect(left).toEqual({ + boundary: { + boundaryQuality: "exact", + code: { from: 0, to: 8 }, + endState: { kind: "normal" }, + extent: { from: 0, to: 9 }, + hasCode: true, + source: { from: 0, to: 8 }, + terminator: { from: 8, to: 9 }, + }, + revision: session.revision, + }); + expect(right.boundary).toEqual({ + boundaryQuality: "exact", + code: { from: 9, to: 17 }, + endState: { kind: "normal" }, + extent: { from: 9, to: 17 }, + hasCode: true, + source: { from: 9, to: 17 }, + terminator: null, + }); + expect(Object.isFrozen(left)).toBe(true); + expect(Object.isFrozen(left.boundary)).toBe(true); + expect(Object.isFrozen(left.boundary.extent)).toBe(true); + if (left.boundary.boundaryQuality === "exact") { + expect(Object.isFrozen(left.boundary.code)).toBe(true); + expect(Object.isFrozen(left.boundary.source)).toBe(true); + expect(Object.isFrozen(left.boundary.terminator)).toBe(true); + expect(Object.isFrozen(left.boundary.endState)).toBe(true); + } + }); + + it("distinguishes empty, consecutive, and terminal boundaries", () => { + const empty = openSession("").session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(empty.boundary).toMatchObject({ + boundaryQuality: "exact", + extent: { from: 0, to: 0 }, + hasCode: false, + }); + + const { session } = openSession("SELECT 1;;"); + expect(session.statementBoundaryAt({ + affinity: "left", + position: 9, + }).boundary).toMatchObject({ + hasCode: true, + terminator: { from: 8, to: 9 }, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 9, + }).boundary).toMatchObject({ + hasCode: false, + terminator: { from: 9, to: 10 }, + }); + expect(session.statementBoundaryAt({ + affinity: "left", + position: 10, + }).boundary).toMatchObject({ + extent: { from: 9, to: 10 }, + hasCode: false, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 10, + }).boundary).toMatchObject({ + extent: { from: 10, to: 10 }, + hasCode: false, + }); + }); + + it("preserves explicit no-code and unterminated states", () => { + const comment = openSession("/* comment */").session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(comment.boundary).toMatchObject({ + boundaryQuality: "exact", + hasCode: false, + source: { from: 0, to: 13 }, + }); + + const unterminated = openSession("SELECT '🦆").session.statementBoundaryAt({ + affinity: "left", + position: 10, + }); + expect(unterminated.boundary).toMatchObject({ + boundaryQuality: "exact", + endState: { + construct: "single-quoted-string", + from: 7, + kind: "unterminated", + }, + }); + + const text = " /* lead */ SELECT 1 /* tail */ "; + const trimmed = openSession(text).session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(trimmed.boundary).toMatchObject({ + code: { + from: text.indexOf("SELECT"), + to: text.indexOf(" /* tail */"), + }, + source: { from: 0, to: text.length }, + }); + }); + + it("reports original coordinates across masked host regions", () => { + const service = createService(); + const text = "SELECT {x;y};SELECT 2"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ + from: 7, + language: "python", + to: 12, + }], + text, + }); + + expect(session.statementBoundaryAt({ + affinity: "left", + position: 13, + }).boundary).toMatchObject({ + extent: { from: 0, to: 13 }, + source: { from: 0, to: 12 }, + terminator: { from: 12, to: 13 }, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 13, + }).boundary).toMatchObject({ + extent: { from: 13, to: text.length }, + source: { from: 13, to: text.length }, + }); + }); + + it("reports opaque boundaries without executable source", () => { + const text = + "CREATE FUNCTION f() RETURNS int LANGUAGE SQL BEGIN ATOMIC SELECT 1; END;"; + const { session } = openSession(text); + session.update({ + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + }); + const result = session.statementBoundaryAt({ + affinity: "right", + position: 32, + }); + + expect(result.boundary).toEqual({ + boundaryQuality: "opaque", + extent: { from: 0, to: text.length }, + reason: "procedural-block", + }); + expect("source" in result.boundary).toBe(false); + }); + + it("projects custom-delimiter and resource-limit opacity", () => { + const service = createSqlLanguageService({ + dialects: [bigQueryDialect()], + }); + const custom = service.openDocument({ + context: { dialect: "bigquery", engine: "warehouse" }, + text: "DELIMITER $$\nSELECT 1$$", + }); + expect(custom.statementBoundaryAt({ + affinity: "right", + position: 0, + }).boundary).toMatchObject({ + boundaryQuality: "opaque", + reason: "custom-delimiter", + }); + + const text = ";".repeat(10_001); + const limited = service.openDocument({ + context: { dialect: "bigquery", engine: "warehouse" }, + text, + }); + expect(limited.statementBoundaryAt({ + affinity: "right", + position: text.length, + }).boundary).toMatchObject({ + boundaryQuality: "opaque", + reason: "resource-limit", + }); + }); + + it("returns the current revision after atomic updates", () => { + const { session } = openSession("SELECT 1"); + const previous = session.statementBoundaryAt({ + affinity: "left", + position: 8, + }); + session.update({ + baseRevision: session.revision, + document: { + changes: [{ from: 7, insert: "20", to: 8 }], + kind: "changes", + }, + embeddedRegions: [], + }); + + const current = session.statementBoundaryAt({ + affinity: "left", + position: 9, + }); + expect(current.revision).toBe(session.revision); + expect(current.revision).not.toBe(previous.revision); + expect(current.boundary).toMatchObject({ + source: { from: 0, to: 9 }, + }); + }); + + it("returns every boundary intersecting a viewport range", () => { + const text = ";SELECT 1;/* trailing */"; + const { session } = openSession(text); + const result = session.statementBoundariesIntersecting({ + from: 0, + to: text.length, + }); + + expect(result.revision).toBe(session.revision); + expect(result.boundaries).toHaveLength(3); + expect(result.boundaries.map((boundary) => + boundary.boundaryQuality === "exact" && boundary.hasCode + )).toEqual([false, true, false]); + expect(result.boundaries[1]).toMatchObject({ + code: { from: 1, to: 9 }, + extent: { from: 1, to: 10 }, + source: { from: 1, to: 9 }, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.boundaries)).toBe(true); + expect(session.statementBoundariesIntersecting({ + from: 1, + to: 1, + }).boundaries).toEqual([]); + }); + + it("validates requests and rejects disposed sessions", () => { + const { session } = openSession("SELECT 1"); + for (const request of [ + null, + {}, + { affinity: "center", position: 0 }, + { affinity: "left", position: -1 }, + { affinity: "left", position: Number.NaN }, + { affinity: "right", position: 9 }, + ]) { + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt(request as never); + }); + } + let getterCalls = 0; + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt( + Object.defineProperty( + { affinity: "left" }, + "position", + { + get: () => { + getterCalls += 1; + return 0; + }, + }, + ) as never, + ); + }); + expect(getterCalls).toBe(0); + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt( + Object.create({ affinity: "left", position: 0 }) as never, + ); + }); + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt(new Proxy({}, { + getOwnPropertyDescriptor: () => { + throw new Error("host descriptor trap"); + }, + }) as never); + }); + for (const range of [ + null, + {}, + { from: -1, to: 0 }, + { from: 2, to: 1 }, + { from: 0, to: 9 }, + ]) { + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundariesIntersecting(range as never); + }); + } + session.dispose(); + expectSessionError("session-disposed", () => { + session.statementBoundaryAt({ affinity: "left", position: 0 }); + }); + expectSessionError("session-disposed", () => { + session.statementBoundariesIntersecting({ from: 0, to: 0 }); + }); + }); +}); + describe("document revisions", () => { it("starts with a frozen current revision", () => { const { session } = openSession(); diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts index a9bfdf7..39ebef4 100644 --- a/src/vnext/codemirror/__tests__/sql-editor.test.ts +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -166,6 +166,30 @@ function fakeService( get revision() { return revision; }, + statementBoundaryAt: () => ({ + boundary: { + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + }, + revision, + }), + statementBoundariesIntersecting: () => ({ + boundaries: [{ + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + }], + revision, + }), update: (update) => { updates.push(update); if (rejectUpdates) { diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 7628924..5a98211 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -28,6 +28,19 @@ export type { SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; +export type { + SqlExactStatementBoundary, + SqlOpaqueStatementBoundary, + SqlStatementAffinity, + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, + SqlStatementLexicalEnd, + SqlStatementOpaqueReason, + SqlStatementUnterminatedConstruct, +} from "./statement-boundary-types.js"; export type { SqlCanonicalRelationPath, SqlCatalogEpoch, diff --git a/src/vnext/session.ts b/src/vnext/session.ts index fa41d79..bb5359d 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -13,6 +13,7 @@ import type { import { buildSqlStatementIndex, findSqlStatementSlot, + findSqlStatementSlotsIntersecting, type SqlLexicalProfile, type SqlStatementIndex, type SqlStatementSlot, @@ -63,6 +64,7 @@ import { createIdentitySqlSource, createMaskedSqlSource, isSqlSourceError, + mapAnalysisRangeToOriginal, MAX_SQL_SOURCE_LENGTH, normalizeSqlTextRange, type SqlSourceSnapshot, @@ -73,6 +75,14 @@ import { type SqlDialect, SqlSessionError, } from "./types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, + SqlStatementLexicalEnd, +} from "./statement-boundary-types.js"; const MAX_CONTEXT_DEPTH = 100; const MAX_CONTEXT_NODES = 10_000; @@ -772,6 +782,156 @@ function createCompletionTask( return Object.freeze(Object.assign(result, { refreshToken })); } +function invalidStatementBoundaryRequest(message: string): never { + throw new SqlSessionError( + "invalid-statement-boundary-request", + message, + ); +} + +function statementBoundaryRequest( + request: unknown, +): SqlStatementBoundaryAtRequest { + if (request === null || typeof request !== "object") { + return invalidStatementBoundaryRequest( + "SQL statement boundary request must be an object", + ); + } + let affinity: unknown; + let position: unknown; + try { + affinity = readRequiredDataProperty( + request, + "affinity", + "invalid-statement-boundary-request", + "SQL statement boundary request", + ); + position = readRequiredDataProperty( + request, + "position", + "invalid-statement-boundary-request", + "SQL statement boundary request", + ); + } catch { + return invalidStatementBoundaryRequest( + "SQL statement boundary request requires own data properties", + ); + } + if (affinity !== "left" && affinity !== "right") { + return invalidStatementBoundaryRequest( + "SQL statement affinity must be left or right", + ); + } + if ( + typeof position !== "number" || + !Number.isSafeInteger(position) || + position < 0 + ) { + return invalidStatementBoundaryRequest( + "SQL statement position must be a non-negative safe integer", + ); + } + return Object.freeze({ + affinity, + position, + }); +} + +function statementIntersectionRequest( + request: unknown, +): SqlStatementBoundariesIntersectingRequest { + if (request === null || typeof request !== "object") { + return invalidStatementBoundaryRequest( + "SQL statement intersection request must be an object", + ); + } + let from: unknown; + let to: unknown; + try { + from = readRequiredDataProperty( + request, + "from", + "invalid-statement-boundary-request", + "SQL statement intersection request", + ); + to = readRequiredDataProperty( + request, + "to", + "invalid-statement-boundary-request", + "SQL statement intersection request", + ); + } catch { + return invalidStatementBoundaryRequest( + "SQL statement intersection request requires own data properties", + ); + } + if ( + typeof from !== "number" || + typeof to !== "number" || + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + from < 0 || + to < from + ) { + return invalidStatementBoundaryRequest( + "SQL statement intersection range must be ordered safe integers", + ); + } + return Object.freeze({ from, to }); +} + +function statementRange( + source: SqlSourceSnapshot, + range: SqlTextRange, +): SqlTextRange { + return mapAnalysisRangeToOriginal(source, range); +} + +function statementBoundary( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, +): SqlStatementBoundary { + const extent = statementRange(source, slot.extent); + if (slot.boundaryQuality === "opaque") { + return Object.freeze({ + boundaryQuality: "opaque", + extent, + reason: slot.endState.reason, + }); + } + const endState: SqlStatementLexicalEnd = + slot.endState.kind === "normal" + ? Object.freeze({ kind: "normal" }) + : Object.freeze({ + construct: slot.endState.construct, + from: statementRange(source, { + from: slot.endState.from, + to: slot.endState.from, + }).from, + kind: "unterminated", + }); + const base = { + boundaryQuality: "exact", + endState, + extent, + source: statementRange(source, slot.source), + terminator: slot.terminator === null + ? null + : statementRange(source, slot.terminator), + } as const; + return slot.code === null + ? Object.freeze({ + ...base, + code: null, + hasCode: false, + }) + : Object.freeze({ + ...base, + code: statementRange(source, slot.code), + hasCode: true, + }); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -838,7 +998,7 @@ export class DefaultSqlDocumentSession return this.#statementIndexCache?.index ?? null; } - getStatementIndexForTesting(): SqlStatementIndex { + #getStatementIndex(): SqlStatementIndex { if (this.#disposed) { throw new SqlSessionError( "session-disposed", @@ -865,6 +1025,65 @@ export class DefaultSqlDocumentSession return index; } + getStatementIndexForTesting(): SqlStatementIndex { + return this.#getStatementIndex(); + } + + readonly statementBoundaryAt = ( + input: SqlStatementBoundaryAtRequest, + ): SqlStatementBoundaryAtResult => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const request = statementBoundaryRequest(input); + const snapshot = this.#snapshot; + if (request.position > snapshot.source.originalText.length) { + return invalidStatementBoundaryRequest( + "SQL statement position is outside the document", + ); + } + const slot = findSqlStatementSlot( + this.#getStatementIndex(), + request.position, + request.affinity, + ); + return Object.freeze({ + boundary: statementBoundary(snapshot.source, slot), + revision: snapshot.revision, + }); + }; + + readonly statementBoundariesIntersecting = ( + input: SqlStatementBoundariesIntersectingRequest, + ): SqlStatementBoundariesIntersectingResult => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const request = statementIntersectionRequest(input); + const snapshot = this.#snapshot; + if (request.to > snapshot.source.originalText.length) { + return invalidStatementBoundaryRequest( + "SQL statement intersection range is outside the document", + ); + } + const slots = findSqlStatementSlotsIntersecting( + this.#getStatementIndex(), + request, + ); + return Object.freeze({ + boundaries: Object.freeze( + slots.map((slot) => statementBoundary(snapshot.source, slot)), + ), + revision: snapshot.revision, + }); + }; + #clearTerminalIntent(): void { const intent = this.#terminalRefreshIntent; if (!intent) return; @@ -1281,7 +1500,7 @@ export class DefaultSqlDocumentSession ); } } - const index = this.getStatementIndexForTesting(); + const index = this.#getStatementIndex(); const slot = findSqlStatementSlot(index, position, "left"); const cachedLocal = this.#localRelationStatementCache; const prepared = diff --git a/src/vnext/statement-boundary-types.ts b/src/vnext/statement-boundary-types.ts new file mode 100644 index 0000000..f415bfa --- /dev/null +++ b/src/vnext/statement-boundary-types.ts @@ -0,0 +1,78 @@ +import type { + SqlRevision, + SqlTextRange, +} from "./types.js"; + +export type SqlStatementAffinity = "left" | "right"; + +export type SqlStatementUnterminatedConstruct = + | "backtick-quoted-identifier" + | "block-comment" + | "dollar-quoted-string" + | "double-quoted-identifier" + | "double-quoted-string" + | "single-quoted-string" + | "triple-double-quoted-string" + | "triple-single-quoted-string"; + +export type SqlStatementOpaqueReason = + | "custom-delimiter" + | "procedural-block" + | "resource-limit"; + +export type SqlStatementLexicalEnd = + | { + readonly kind: "normal"; + } + | { + readonly construct: SqlStatementUnterminatedConstruct; + readonly from: number; + readonly kind: "unterminated"; + }; + +interface SqlExactStatementBoundaryBase { + readonly boundaryQuality: "exact"; + readonly endState: SqlStatementLexicalEnd; + readonly extent: SqlTextRange; + readonly source: SqlTextRange; + readonly terminator: SqlTextRange | null; +} + +export type SqlExactStatementBoundary = + SqlExactStatementBoundaryBase & ( + | { + readonly code: SqlTextRange; + readonly hasCode: true; + } + | { + readonly code: null; + readonly hasCode: false; + } + ); + +export interface SqlOpaqueStatementBoundary { + readonly boundaryQuality: "opaque"; + readonly extent: SqlTextRange; + readonly reason: SqlStatementOpaqueReason; +} + +export type SqlStatementBoundary = + | SqlExactStatementBoundary + | SqlOpaqueStatementBoundary; + +export interface SqlStatementBoundaryAtRequest { + readonly affinity: SqlStatementAffinity; + readonly position: number; +} + +export interface SqlStatementBoundaryAtResult { + readonly boundary: SqlStatementBoundary; + readonly revision: SqlRevision; +} + +export type SqlStatementBoundariesIntersectingRequest = SqlTextRange; + +export interface SqlStatementBoundariesIntersectingResult { + readonly boundaries: readonly SqlStatementBoundary[]; + readonly revision: SqlRevision; +} diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index bd5c0c0..edf4081 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -70,6 +70,7 @@ export type SqlLexicalEndState = export interface ExactSqlStatementSlot { readonly boundaryQuality: "exact"; + readonly code: SqlAnalysisRange | null; readonly endState: Exclude; readonly extent: SqlAnalysisRange; readonly hasCode: boolean; @@ -218,14 +219,18 @@ function createExactSlot( from: number, sourceTo: number, extentTo: number, - hasCode: boolean, + codeFrom: number | null, + codeTo: number, endState: ExactSqlStatementSlot["endState"], ): ExactSqlStatementSlot { const slot = Object.freeze({ boundaryQuality: "exact", + code: codeFrom === null + ? null + : createAnalysisRange(codeFrom, codeTo), endState, extent: createAnalysisRange(from, extentTo), - hasCode, + hasCode: codeFrom !== null, source: createAnalysisRange(from, sourceTo), terminator: sourceTo === extentTo ? null : createAnalysisRange(sourceTo, extentTo), @@ -495,9 +500,14 @@ function scanSqlStatementIndex( const slots: SqlStatementSlot[] = [...options.prefixSlots]; const prefixGuard = new SqlPrefixGuard(profile.proceduralGuards); let slotFrom = options.from; - let hasCode = false; + let codeFrom: number | null = null; + let codeTo = options.from; let cursor = options.from; let finalEndState: ExactSqlStatementSlot["endState"] = NORMAL_END_STATE; + const recordCode = (from: number, to: number): void => { + if (codeFrom === null) codeFrom = from; + codeTo = to; + }; const finishOpaque = ( reason: SqlOpaqueBoundaryReason, @@ -567,7 +577,7 @@ function scanSqlStatementIndex( analysisText.length, ); if (dollarQuote) { - hasCode = true; + const codeFrom_ = cursor; prefixGuard.recordNonWord(code); if (dollarQuote.delimiterTooLong) { return finishOpaque("resource-limit", cursor); @@ -579,6 +589,7 @@ function scanSqlStatementIndex( ); } cursor = dollarQuote.to; + recordCode(codeFrom_, cursor); continue; } } @@ -588,7 +599,7 @@ function scanSqlStatementIndex( code === 34 || (profile.backtickQuotedIdentifiers && code === 96) ) { - hasCode = true; + const codeFrom_ = cursor; if (code === 96) { prefixGuard.recordQuotedIdentifier(); prefixGuard.recordNonWord(code); @@ -609,6 +620,7 @@ function scanSqlStatementIndex( ); } cursor = quote.to; + recordCode(codeFrom_, cursor); continue; } @@ -648,6 +660,7 @@ function scanSqlStatementIndex( ); } cursor = quote.to; + recordCode(codeFrom_, cursor); continue; } @@ -679,7 +692,7 @@ function scanSqlStatementIndex( cursor += continueLength; } prefixGuard.recordWord(analysisText, wordFrom, cursor); - hasCode = true; + recordCode(wordFrom, cursor); if (prefixGuard.reason !== null) { return finishOpaque(prefixGuard.reason, prefixGuard.reasonAt); } @@ -699,12 +712,14 @@ function scanSqlStatementIndex( slotFrom, cursor, cursor + 1, - hasCode, + codeFrom, + codeTo, NORMAL_END_STATE, ), ); slotFrom = cursor + 1; - hasCode = false; + codeFrom = null; + codeTo = slotFrom; finalEndState = NORMAL_END_STATE; prefixGuard.reset(); cursor += 1; @@ -715,7 +730,7 @@ function scanSqlStatementIndex( continue; } - hasCode = true; + recordCode(cursor, cursor + 1); cursor += 1; } @@ -724,7 +739,8 @@ function scanSqlStatementIndex( slotFrom, analysisText.length, analysisText.length, - hasCode, + codeFrom, + codeTo, finalEndState, ), ); @@ -831,6 +847,12 @@ function shiftStatementSlot( } const shifted = Object.freeze({ boundaryQuality: "exact", + code: slot.code + ? createAnalysisRange( + slot.code.from + delta, + slot.code.to + delta, + ) + : null, endState: shiftEndState(slot.endState, delta), extent: createAnalysisRange( slot.extent.from + delta, @@ -1047,6 +1069,40 @@ export function findSqlStatementSlot( ); } +export function findSqlStatementSlotsIntersecting( + index: SqlStatementIndex, + range: { readonly from: number; readonly to: number }, +): readonly SqlStatementSlot[] { + const slots = index.slots; + const documentLength = getStatementSlot( + slots, + slots.length - 1, + ).extent.to; + if ( + !Number.isSafeInteger(range.from) || + !Number.isSafeInteger(range.to) || + range.from < 0 || + range.to < range.from || + range.to > documentLength + ) { + throw new RangeError( + "SQL statement intersection range is outside the document", + ); + } + if (range.from === range.to) return Object.freeze([]); + const matches: SqlStatementSlot[] = []; + for ( + let index_ = statementSlotIndexAt(slots, range.from, "right"); + index_ < slots.length; + index_ += 1 + ) { + const slot = getStatementSlot(slots, index_); + if (slot.extent.from >= range.to) break; + if (slot.extent.to > range.from) matches.push(slot); + } + return Object.freeze(matches); +} + function getStatementSlot( slots: readonly SqlStatementSlot[], index: number, diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 1931e7e..075c403 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -135,6 +135,12 @@ export interface OpenSqlDocument { /** Owns all mutable state for one open SQL document. */ export interface SqlDocumentSession { readonly revision: SqlRevision; + readonly statementBoundaryAt: ( + request: SqlStatementBoundaryAtRequest, + ) => SqlStatementBoundaryAtResult; + readonly statementBoundariesIntersecting: ( + request: SqlStatementBoundariesIntersectingRequest, + ) => SqlStatementBoundariesIntersectingResult; readonly update: (update: SqlDocumentUpdate) => SqlRevision; readonly complete: ( request: SqlCompletionRequest, @@ -170,6 +176,7 @@ export type SqlSessionErrorCode = | "invalid-dialect" | "invalid-document" | "invalid-service-options" + | "invalid-statement-boundary-request" | "invalid-update" | "reentrant-update" | "service-disposed" @@ -192,3 +199,9 @@ import type { SqlRelationCatalogProvider, SqlSessionChangeEvent, } from "./relation-completion-types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, +} from "./statement-boundary-types.js"; diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 520056c..4c9b8bd 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -9,6 +9,9 @@ import { type SqlLanguageService, type SqlRelationCatalogProvider, type SqlRevision, + type SqlStatementBoundariesIntersectingResult, + type SqlStatementBoundaryAtResult, + type SqlStatementBoundary, type SqlTextChange, type SqlTextRange, } from "../../src/vnext/index.js"; @@ -196,6 +199,33 @@ const embeddedRegion: SqlEmbeddedRegion = { embeddedRegion.language = "jinja"; // @ts-expect-error session revision is readonly session.revision = revision; +const statement = session.statementBoundaryAt({ + affinity: "left", + position: 0, +}); +const statementResult: SqlStatementBoundaryAtResult = statement; +const statementBoundary: SqlStatementBoundary = statement.boundary; +if (statementBoundary.boundaryQuality === "exact") { + if (statementBoundary.hasCode) { + const codeRange: SqlTextRange = statementBoundary.code; + void codeRange; + } else { + const noCode: null = statementBoundary.code; + void noCode; + } +} +const statements: SqlStatementBoundariesIntersectingResult = + session.statementBoundariesIntersecting({ from: 0, to: 1 }); +// @ts-expect-error statement boundary results are readonly +statement.boundary.extent.from = 1; +// @ts-expect-error affinity is explicit and bounded +session.statementBoundaryAt({ affinity: "nearest", position: 0 }); +// @ts-expect-error statement positions are numeric UTF-16 offsets +session.statementBoundaryAt({ affinity: "right", position: "0" }); +// @ts-expect-error intersection ranges use numeric UTF-16 offsets +session.statementBoundariesIntersecting({ from: 0, to: "1" }); +// @ts-expect-error intersecting boundary arrays are readonly +statements.boundaries.push(statementBoundary); // @ts-expect-error statement indexes remain an internal session detail session.getStatementIndexForTesting(); // @ts-expect-error dialect IDs are readonly @@ -206,6 +236,10 @@ createSqlLanguageService({ dialects: [{ id: "duckdb", displayName: "DuckDB" }] } void objectRevision; void numberRevision; void range; +void statement; +void statementBoundary; +void statementResult; +void statements; void embeddedRegion; void identitySession; void typedDialect; From e95be82600b0ed5917ea31522a30a9d57a830e3e Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 14:22:37 +0800 Subject: [PATCH 33/42] feat(vnext): complete SQL editor overhaul (#202) ## Summary Completes the final batched vNext SQL language-service overhaul milestone. - adds authenticated parser-neutral query binding models and bounded `node-sql-parser` normalization in the isolated worker protocol - adds public batched column and namespace catalog providers with stable provenance, epochs, bounded caches, cancellation, partial/loading/failure states, and hostile-response validation - integrates relation, namespace, and physical-column completion into document sessions and the CodeMirror adapter - adds exact statement gutter behavior and insertion-point completion gating for embedded marimo expressions while preserving external sources - adds correlated outer-scope column completion with conservative derived-table isolation - expands the marimo compile-time migration fixture and architecture/authority documentation ## Review hardening All 27 Cubic comments were reproduced and addressed. Two final adversarial reviews found and closed seven additional issues: - revoked proxies, decoder exceptions, exact response variants, structural identity comparisons, and blank namespace path components - generation-linearized cancellation and disposal in both catalog coordinators, including reentrant abort listeners - source-authenticated identifiers, aliases, CTE scope, derived ranges, compound blocks, and `ON`/`USING` visibility - exact statement bounds, one end-to-end response budget, the 64-relation deterministic partial cap, incomplete aliases, and line-comment boundaries - stale completion-gate callbacks, external-source preservation, and failure-safe browser cleanup CTE and derived-table output-column inference remains an explicit next-major cutover gap; vNext does not incorrectly route those logical relations to the physical catalog provider. ## Correctness and performance - original-document UTF-16 ranges and exact replacement edits - explicit partial/unsupported results instead of guessed semantics - revision, cancellation, owner disposal, scope, dialect, and epoch checks - no raw parser AST crosses or persists beyond the adapter/worker boundary - complete-only bounded catalog caches - deterministic ordering and deduplication - maximum-shape query-model validation reduced from about 888 ms to about 6.4 ms - framework-independent packed core: 187,989 raw bytes and 50,590 gzip bytes, within the unchanged 184 KiB raw and 50 KiB gzip limits ## Verification - 2,286 unit tests passed; 1 intentional expected failure - repository coverage: - 96.58% statements - 96.71% lines - 98.01% functions - 94.43% branches - changed vNext coverage: - 97.79% statements - 98.08% lines - 99.46% functions - 96.62% branches - every changed runtime file passes the 95% per-file statements/branches/functions/lines gate - strict TypeScript configurations, oxlint, and test-integrity checks pass - 14 browser tests pass in Chromium - build, demo build, packed-consumer smoke, parser-isolation/CSP checks, and isolated-worker placement audit pass Advances the next-major implementation milestone tracked in #169. --- docs/adr/0003-node-sql-parser-adapter.md | 12 +- docs/adr/0004-isolated-parser-execution.md | 20 +- docs/adr/0006-query-binding-model.md | 103 ++ docs/vnext/capability-charter.md | 3 +- docs/vnext/codemirror-adapter.md | 45 + docs/vnext/column-catalog-authority.md | 65 + docs/vnext/marimo-sql-migration.md | 202 +++ docs/vnext/namespace-catalog-authority.md | 45 + scripts/worker-placement.mjs | 4 +- .../column-catalog-batch-coordinator.bench.ts | 101 ++ .../column-catalog-batch-coordinator.test.ts | 593 +++++++ .../__tests__/column-catalog-boundary.test.ts | 884 +++++++++++ src/vnext/__tests__/column-completion.test.ts | 559 +++++++ src/vnext/__tests__/column-query-site.test.ts | 486 ++++++ src/vnext/__tests__/hostile-data.test.ts | 15 + .../__tests__/local-relation-site.test.ts | 47 + .../namespace-catalog-boundary.test.ts | 417 +++++ .../namespace-catalog-coordinator.bench.ts | 71 + .../namespace-catalog-coordinator.test.ts | 631 ++++++++ .../__tests__/node-sql-parser-adapter.test.ts | 45 +- .../node-sql-parser-browser-executor.test.ts | 24 +- ...sql-parser-browser-worker-endpoint.test.ts | 44 +- .../node-sql-parser-query-bindings.bench.ts | 31 + .../node-sql-parser-query-bindings.test.ts | 1291 ++++++++++++++++ .../__tests__/node-sql-parser-wire.test.ts | 42 +- .../__tests__/query-binding-model.bench.ts | 157 ++ .../__tests__/query-binding-model.test.ts | 1176 ++++++++++++++ .../relation-catalog-search-work.test.ts | 4 +- .../__tests__/relation-completion.test.ts | 15 +- src/vnext/__tests__/session.test.ts | 1372 +++++++++++++++-- .../fixtures/node-sql-parser-crash-worker.js | 2 +- .../fixtures/node-sql-parser-silent-worker.js | 2 +- .../node-sql-parser-browser-executor.test.ts | 5 + .../node-sql-parser-browser-worker.test.ts | 3 + .../codemirror/__tests__/sql-editor.test.ts | 739 ++++++++- .../sql-editor-completion-gate.test.ts | 345 +++++ .../browser_tests/statement-gutter.test.ts | 120 ++ src/vnext/codemirror/index.ts | 3 + src/vnext/codemirror/sql-editor.ts | 198 ++- src/vnext/codemirror/statement-gutter.ts | 238 +++ src/vnext/column-catalog-batch-coordinator.ts | 566 +++++++ src/vnext/column-catalog-boundary.ts | 694 +++++++++ src/vnext/column-catalog-types.ts | 100 ++ src/vnext/column-completion.ts | 350 +++++ src/vnext/column-query-site.ts | 681 ++++++++ src/vnext/index.ts | 32 + src/vnext/local-relation-site.ts | 62 +- src/vnext/namespace-catalog-boundary.ts | 575 +++++++ src/vnext/namespace-catalog-coordinator.ts | 501 ++++++ src/vnext/namespace-catalog-types.ts | 108 ++ src/vnext/namespace-completion.ts | 270 ++++ src/vnext/node-sql-parser-adapter.ts | 38 +- src/vnext/node-sql-parser-browser-executor.ts | 3 + ...node-sql-parser-browser-worker-endpoint.ts | 24 + src/vnext/node-sql-parser-query-bindings.ts | 1251 +++++++++++++++ src/vnext/node-sql-parser-wire.ts | 97 +- src/vnext/query-binding-model.ts | 1171 ++++++++++++++ src/vnext/relation-completion-types.ts | 119 +- src/vnext/relation-completion.ts | 2 +- src/vnext/session.ts | 748 ++++++++- src/vnext/types.ts | 20 + ...o-relation-completion-codemirror.test-d.ts | 11 + .../marimo-sql-migration.test-d.ts | 591 +++++++ 63 files changed, 17840 insertions(+), 333 deletions(-) create mode 100644 docs/adr/0006-query-binding-model.md create mode 100644 docs/vnext/column-catalog-authority.md create mode 100644 docs/vnext/marimo-sql-migration.md create mode 100644 docs/vnext/namespace-catalog-authority.md create mode 100644 src/vnext/__tests__/column-catalog-batch-coordinator.bench.ts create mode 100644 src/vnext/__tests__/column-catalog-batch-coordinator.test.ts create mode 100644 src/vnext/__tests__/column-catalog-boundary.test.ts create mode 100644 src/vnext/__tests__/column-completion.test.ts create mode 100644 src/vnext/__tests__/column-query-site.test.ts create mode 100644 src/vnext/__tests__/hostile-data.test.ts create mode 100644 src/vnext/__tests__/namespace-catalog-boundary.test.ts create mode 100644 src/vnext/__tests__/namespace-catalog-coordinator.bench.ts create mode 100644 src/vnext/__tests__/namespace-catalog-coordinator.test.ts create mode 100644 src/vnext/__tests__/node-sql-parser-query-bindings.bench.ts create mode 100644 src/vnext/__tests__/node-sql-parser-query-bindings.test.ts create mode 100644 src/vnext/__tests__/query-binding-model.bench.ts create mode 100644 src/vnext/__tests__/query-binding-model.test.ts create mode 100644 src/vnext/codemirror/browser_tests/sql-editor-completion-gate.test.ts create mode 100644 src/vnext/codemirror/browser_tests/statement-gutter.test.ts create mode 100644 src/vnext/codemirror/statement-gutter.ts create mode 100644 src/vnext/column-catalog-batch-coordinator.ts create mode 100644 src/vnext/column-catalog-boundary.ts create mode 100644 src/vnext/column-catalog-types.ts create mode 100644 src/vnext/column-completion.ts create mode 100644 src/vnext/column-query-site.ts create mode 100644 src/vnext/namespace-catalog-boundary.ts create mode 100644 src/vnext/namespace-catalog-coordinator.ts create mode 100644 src/vnext/namespace-catalog-types.ts create mode 100644 src/vnext/namespace-completion.ts create mode 100644 src/vnext/node-sql-parser-query-bindings.ts create mode 100644 src/vnext/query-binding-model.ts create mode 100644 test/vnext-types/marimo-sql-migration.test-d.ts diff --git a/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md index 3e60f1f..15f21c5 100644 --- a/docs/adr/0003-node-sql-parser-adapter.md +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -145,11 +145,11 @@ A direct object or one-element array is accepted. Zero or multiple roots are `failed/malformed-output`. The public normalized artifact exposes only its closed statement kind and full -statement-relative range. The backend AST is retained in a module-private -`WeakMap` keyed by the authenticated artifact. It is neither enumerable nor -returned through the syntax contract. Future relation extraction must decode -and validate backend nodes inside the adapter boundary rather than exposing -the raw AST to core features. +statement-relative range. The adapter consumes the backend AST immediately +through the bounded query-binding decoder and retains only an immutable, +backend-neutral binding model in a module-private `WeakMap` keyed by the +authenticated artifact. The raw AST is not retained, enumerable, or returned +through the syntax contract. ### Cancellation and execution placement @@ -201,7 +201,7 @@ This decision does not define: - Stable parser or provider APIs - Session cache keys or eviction - Document-level syntax diagnostics -- Semantic relation, scope, column, or type models +- Public semantic relation, scope, column, or type models - A worker protocol - Native DuckDB or remote validation providers - Dremio parsing diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index c5556cb..4312273 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -141,9 +141,10 @@ The initial request contains only: DuckDB uses the PostgreSQL grammar. Target-dialect policy stays in the main realm. -The initial response contains only one closed outcome: +Protocol v2 contains only one closed outcome: -- Parsed normalized statement kind +- Parsed normalized statement kind and a bounded query-binding DTO, or `null` + when the statement has no supported query-binding evidence - Syntax rejection - Bounded unsupported reason - Bounded failure code; retryability is derived from that code @@ -183,16 +184,17 @@ settles the active operation exactly once without exposing raw event data. Raw backend ASTs will not cross the worker boundary and the first protocol will not introduce remote AST handles or worker-local AST leases. -The first relation-completion slice is parser-independent under +The first relation-completion slice remains parser-independent under [ADR 0005](0005-parser-independent-relation-completion.md). Protocol v1 remains -syntax-only. Incomplete relation completion must not wait for worker startup, +historical and protocol v2 adds the internal query-binding DTO. Incomplete +relation completion must not wait for worker startup, parser acceptance, timeout, or recovery. -A future scope-dependent semantic slice will parse once and run adapter-owned -semantic decoders in the worker realm. It will move the closed protocol -atomically to v2 and return a bounded scoped IR, with normalized syntax and -semantic availability represented independently. It will not return flat -relation names, raw ASTs, source text, or absolute document ranges. +The scope decoder runs once in the worker realm immediately after parsing. +It returns query blocks, relation/alias bindings, persistent scopes, typed +visibility regions, independent coverage, and closed issues. It does not +return flat relation names, raw ASTs, source text, or absolute document ranges. +The host revalidates the complete DTO before accepting the response. Worker-local AST caching is deferred until profiling demonstrates that reparsing is material enough to justify leases, byte accounting, generation diff --git a/docs/adr/0006-query-binding-model.md b/docs/adr/0006-query-binding-model.md new file mode 100644 index 0000000..27eb783 --- /dev/null +++ b/docs/adr/0006-query-binding-model.md @@ -0,0 +1,103 @@ +# ADR 0006: Internal query binding model + +## Status + +Accepted for the vNext implementation. The model remains package-internal until a +column-completion vertical slice has validated it across the supported dialect +corpora. + +## Context + +Column completion needs more than a parser's flat table list. It must distinguish +query blocks, relation declarations, aliases, correlation, and clause-specific +visibility. The parser adapter also has strict trust boundaries: raw parser ASTs +are private, parser results can be partial, and cached analysis must belong to +the exact statement and parser authority that produced it. + +The model must remain cheap enough for interactive use and must not make the +existing parser-independent relation completion path wait for parsing. + +## Decision + +Add an internal, backend-neutral query binding model with: + +- statement-relative UTF-16 ranges; +- query blocks and relation bindings identified by model-local array indexes; +- a persistent scope chain in which each node adds at most one binding; +- ordered, globally non-overlapping visibility regions that point to a scope; +- independent query-block, relation-binding, and visibility coverage; +- closed issue and unknown-source reason sets; +- private authentication and exact statement/parser-authority ownership. + +Globally non-overlapping regions deliberately exclude nested query text from +their parent region. A decoder splits a parent region around nested queries. +This permits binary-search lookup without duplicating the visible binding set. +Persistent scopes then reconstruct bindings in declaration order using linear +space. In particular: + +- a `SELECT` list can point to the completed `FROM` scope even though it appears + earlier in the source; +- a join condition points to the scope containing prior relations and its + current right-hand relation; +- a correlated child block can enter through a parent scope; +- an ordinary derived table enters through a scope that excludes same-level + siblings; a future proven `LATERAL` decoder may opt into that scope. + +Aliases are the visible qualifier when present and hide a named relation's base +name. Identifier equality is supplied by the dialect runtime; the model does +not lowercase or otherwise reinterpret identifiers. + +Construction accepts untrusted plain data and validates it before issuing an +immutable authenticated model. It rejects accessors, sparse or oversized +arrays, malformed UTF-16 identifiers, invalid or unrelated ranges, forward +parent edges, repeated bindings in a scope chain, overlapping regions, and +unknown bindings paired with complete relation coverage. + +Resolution returns: + +- a visible binding list with complete or partial coverage; +- one resolved binding, known ambiguity, or authoritative no-match; +- unavailable instead of no-match when coverage is partial. + +## Ownership and invalidation + +The future parser service owns bounded parse/model caching and in-flight +deduplication. A document session owns the mapping from its current statement +slots to immutable models. The cache key includes exact statement text, +dialect, parser authority/conformance, and implementation version. +Statement-relative coordinates allow an unchanged statement to be reused after +an edit shifts its absolute document position. Source, dialect, or parser +authority changes invalidate the model; catalog epoch and completion context +changes do not. + +The interactive statement limit is initially 16 KiB. The model also caps query +blocks at 256, relation bindings at 1,024, scopes and regions at 2,048 each, +nesting by the block limit, issues at 256, path depth at 8, and identifier +components at 256 UTF-16 code units. + +## Degradation + +A decoder may claim complete evidence only for directly conforming parser output +and constructs it understands. Compatibility parses can supply useful partial +evidence but cannot prove absence. Unknown AST subtrees become an unknown source +or a closed issue and downgrade the relevant coverage; they are never silently +dropped under complete coverage. + +Invalid, failed, opaque, incomplete, or resource-limited analysis becomes +partial or unavailable. Parser-independent relation completion remains +available. Dremio remains partial or unavailable until it has an owned semantic +corpus. + +## Consequences + +The model can support lazy batched column loading without exposing a parser AST +or CodeMirror's eager `SQLNamespace`. Its indexes are not stable identities +across revisions. Projection inference, output columns, star expansion, types, +expression binding, semantic diagnostics, navigation, rename, formatting, and +vendor constructs such as `PIVOT`, `UNNEST`, table functions, and unproven +`LATERAL` semantics are explicitly outside this slice. + +The node-sql-parser integration now supplies a strict AST-to-plain-data decoder, +normalizes inside the worker, and checks direct-versus-worker parity. Public +semantic APIs remain deferred until a column-completion vertical slice proves +the model against consumer and dialect corpora. diff --git a/docs/vnext/capability-charter.md b/docs/vnext/capability-charter.md index d04c8dd..68e5fe5 100644 --- a/docs/vnext/capability-charter.md +++ b/docs/vnext/capability-charter.md @@ -239,7 +239,8 @@ count and estimated retained bytes. Provisional compressed bundle budgets: -- Framework-independent core with relation completion: 48 KiB +- Framework-independent core with relation, namespace, and column completion: + 50 KiB - Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB - Each ordinary dialect module: 25 KiB - Optional `node-sql-parser` chunk: no regression from its recorded baseline diff --git a/docs/vnext/codemirror-adapter.md b/docs/vnext/codemirror-adapter.md index 572d52a..d721701 100644 --- a/docs/vnext/codemirror-adapter.md +++ b/docs/vnext/codemirror-adapter.md @@ -70,6 +70,51 @@ const support = sqlEditor({ }); ``` +## Statement boundaries and gutter + +The support object exposes synchronous structural queries without exposing the +adapter-owned session: + +```ts +const current = support.statementBoundaryAt(view, { + affinity: "left", + position: view.state.selection.main.head, +}); +const visible = support.statementBoundariesIntersecting(view, { + from: view.viewport.from, + to: view.viewport.to, +}); +``` + +Both methods return `null` for foreign, destroyed, or unsynchronized views. +Catalog-backed hosts can invalidate the adapter-owned session without exposing +it: + +```ts +const revision = support.invalidateCatalog(view); +``` + +This returns `null` for foreign or destroyed views. A live view advances its +revision, cancels stale catalog work, and forces relation, column, and namespace +providers to be consulted again. + +An opt-in structural gutter marks only lines intersecting scanner-owned SQL +`code` spans. It never parses or copies statement text: + +```ts +const support = sqlEditor({ + initialContext, + service, + statementGutter: { + hideWhenNotFocused: true, + showInactive: true, + }, +}); +``` + +The gutter uses `--cm-sql-statement-color` and +`--cm-sql-statement-inactive-opacity` CSS variables. It is disabled by default. + ## Atomic inputs Context and document changes can share one CodeMirror transaction: diff --git a/docs/vnext/column-catalog-authority.md b/docs/vnext/column-catalog-authority.md new file mode 100644 index 0000000..aff30c0 --- /dev/null +++ b/docs/vnext/column-catalog-authority.md @@ -0,0 +1,65 @@ +# vNext Column Catalog Authority + +Status: internal vertical-slice contract + +Column discovery is lazy, provider-owned, and batched. A completion request +sends at most 64 unresolved relation references in one provider invocation. +The deterministic first batch is useful but explicitly partial when more +visible relations match; completion reports `query-binding-partial` and never +claims the omitted relations were searched. Each reference carries a +caller-local `requestKey`, a decoded identifier path, and no unauthenticated +entity identity. The provider resolves paths against the supplied catalog +scope, search paths, and dialect. + +The provider returns stable relation and column entity IDs. Every accepted +column contains: + +- a canonical `SqlIdentifierComponent`; +- bounded provider-rendered `insertText`; +- a stable column entity ID and ordinal; and +- immutable provenance containing provider, scope, epoch, relation, and column + identities. + +Responses describe each requested relation independently as: + +- `ready` with complete or partial column coverage; +- `loading`; or +- `failed` with a normalized code and retry policy. + +Missing, extra, conflicting, oversized, accessor-backed, or malformed data is +rejected at the provider boundary. Relations and columns have deterministic +code-unit order. Duplicate request keys and conflicting stable IDs fail closed; +identical duplicate columns are deduplicated. + +## Epoch and cache behavior + +Cold requests use `expectedEpoch: null`. A response always supplies an epoch. +An owner remembers that observation, and later null-epoch requests reuse only +cache entries from the observed epoch. Explicit expected epochs never reuse +entries from another epoch. The cache is LRU-bounded by relation entries. +Only complete ready results are cached. Partial, loading, and failed results +remain visible to the caller but are eligible for another provider request. + +Relation-catalog subscription events invalidate the session's relation, column, +and namespace observations together. Hosts without a subscribed relation +provider call `session.invalidateCatalog()` when schema authority changes. The +method advances the session revision, cancels retained completion work, rebuilds +all catalog owners, and emits one `catalog` change event. + +## Lifecycle + +One owner represents one document/session authority for a scope and dialect. +Starting a newer request supersedes and aborts that owner's prior request. +Explicit cancellation settles as cancelled. Owner or coordinator disposal +aborts outstanding work and settles it as unavailable/disposed. Provider +rejections, throws, and malformed responses are contained; late settlements +cannot publish or populate the cache. + +The coordinator batches a request once, never once per relation. Cache hits and +misses are composed deterministically while only misses are sent to the +provider. + +Provider-declared loading receives at most one automatic retry for the same +document, context, and completion position. A persistent loading response is +then returned without a refresh token; hosts resume it through catalog +invalidation instead of an unbounded polling loop. diff --git a/docs/vnext/marimo-sql-migration.md b/docs/vnext/marimo-sql-migration.md new file mode 100644 index 0000000..77d17cc --- /dev/null +++ b/docs/vnext/marimo-sql-migration.md @@ -0,0 +1,202 @@ +# Marimo SQL Completion Migration + +Status: implementation fixture; relation, column, and namespace completion + +The compile-only fixture +[`marimo-sql-migration.test-d.ts`](../../test/vnext-types/marimo-sql-migration.test-d.ts) +models the intended marimo integration exclusively through public vNext +exports. It is not application code and has no runtime dependency on marimo. + +## Ownership and provider seam + +Marimo creates one caller-owned `SqlLanguageService` for an editor fleet. +Every `sqlEditor` support/view opens its own session against that service. +Destroying a view never disposes the service; the application disposes the +service after its views have been retired. + +One relation provider projects `dataConnectionsMapAtom` and +`datasetTablesAtom` into canonical relation records. It must preserve today's +collision rule: +connection relations win over same-named local relations. Provider records +contain plain immutable data and stable entity IDs, never Jotai atoms, React +roots, or backend objects. + +Catalog `scope` identifies a live connection incarnation: + +```text +: +``` + +Replacing or reconnecting a same-named connection creates a new incarnation +and scope. Schema/table mutations within an incarnation advance its provider +epoch and notify the one scoped subscription. The context `searchPath` values +reproduce +`default_database`, `default_schema`, nested-schema, and schemaless behavior. + +## Batched DataTable columns + +The same shared service configures one `SqlColumnCatalogProvider`. Each +`loadColumns` call forwards the complete relation request array to one +DataTable metadata batch operation. It does not fetch once per relation. +The column authority resolves each canonical relation path within the same +scope and search paths as relation completion. Column IDs and returned relation +IDs are stable within that connection incarnation. + +The library sends at most 64 matching physical relations in one request. +Larger visible sets are deterministically truncated and reported as partial; +the provider must not interpret one batch as exhaustive when completion carries +`query-binding-partial`. + +Each column supplies a canonical identifier and provider-rendered +`insertText`; these are intentionally separate so quoted or dialect-sensitive +names insert correctly. The provider also supplies ordinal, data type, and +detail when known. A cold request carries `expectedEpoch: null`. The returned +epoch becomes the authority for later work, while connection replacement gets +a new scope. + +Every requested relation independently reports complete or partial ready +columns, loading, or normalized failure. Partial, loading, and failed results +remain retryable according to the library contract and are not disguised as +complete empty schemas. The public boundary validates the provider's unknown +payload before it reaches completion composition. + +## One atomic editor input + +Marimo should have one transaction builder for SQL interpretation changes. A +document or engine change emits all of these in the same CodeMirror +transaction: + +1. marimo language metadata; +2. `support.contextEffect` with engine, registered vNext dialect, connection + scope, and search paths; +3. the CodeMirror SQL dialect compartment reconfiguration; and +4. `support.embeddedRegionsEffect` containing the complete region set in the + resulting document. + +No update listener should dispatch a follow-up context or dialect transaction. +That creates an observable grammar/catalog mismatch and makes rapid +connection switches race. + +## Templates and external sources + +`sqlEditor` owns the single `autocompletion()` configuration. Marimo passes its +Python variable and keyword sources through `autocomplete.externalSources`. +The region scanner recognizes single `{python}` expressions, ignores escaped +`{{`, includes both delimiters in each region, and supplies a complete sorted +set after every document change. SQL completion is unavailable inside those +regions while the external variable source remains active. + +Embedded regions are non-empty half-open ranges, so an unmatched `{` ending at +the document boundary cannot contain the insertion point at `doc.length`. +Marimo closes that boundary with +`autocomplete.isCompletionPositionAllowed`. Its synchronous scanner returns +false while the insertion point is in an unmatched Python expression. The +adapter then skips the SQL session source, while still running the installed +variable and keyword sources. A false result or thrown gate is fail-closed, +and a gate transition to false cancels owned SQL completion work and disposes +owned rich-info resources. + +The gate receives only the immutable CodeMirror `EditorState` and numeric +position. It must remain synchronous and side-effect free. Do not encode a +range beyond the document or an empty embedded region. + +The rich-info resolver uses relation `catalog` provenance or +`column-catalog` scope, relation ID, and column ID to look up current marimo +metadata. It mounts React into a new element and returns +`{ dom, destroy: root.unmount }`. The adapter owns cancellation and disposal. + +## Dialect cutover + +The first cutover is limited to the registered vNext dialects: + +- DuckDB +- PostgreSQL +- BigQuery +- Dremio + +MySQL, MariaDB, SQLite, MSSQL, Oracle, and standard/fallback engines continue +to use the legacy schema completion source. The router must select exactly one +table/schema source; installing legacy schema completion as an external source +for a vNext-enabled dialect would create duplicate and conflicting relation +edits. + +Full removal of the legacy source requires equivalent dialect handles or an +explicit, tested generic-dialect policy. + +## Remaining library gaps + +Marimo's current `tablesCompletionSource()` is broader than its name. The +CodeMirror SQL schema source provides relations, namespace navigation, and +columns. vNext now provides all three through separate bounded providers, +including qualified and unqualified query-site completion, ambiguity handling, +stable provenance, cancellation, and batched column work. + +The remaining feature gaps are: + +- the dialect coverage described above; and +- output-column inference for CTEs and derived relations. vNext completes CTE + relation names but deliberately does not send a visible CTE name to the + physical column provider. Keep the legacy column source for those sites, or + defer full source replacement, until projection/output-column inference + lands. + +The fixture feeds marimo's immutable namespace projection—stable entity ID, +scope, canonical identifier path, and namespace kind—through one public, +scoped namespace provider on the shared service. + +## Migration sequence + +1. Capture golden legacy results for labels, kinds, edit ranges, qualification, + and details across representative connection shapes. +2. Land the shared relation and batched column providers, incarnation scope, + atomic transaction builder, region scanner, insertion-point completion + gate, and completion router behind a feature flag. +3. Run vNext relation completion in shadow mode while the legacy source remains + visible. +4. Compare relation and column results with the golden corpus, including + quoted insert text, aliases, ambiguity, partial/loading/failure states, and + cold epoch behavior. +5. Cut over supported physical-relation sites while preserving variable, + keyword, and legacy CTE/derived-output column sources. +6. Add dialect coverage, expand the router, and remove completion-only legacy + schema code. Keep legacy schema data while hover or diagnostics still use + it. + +## Acceptance tests + +The compile-only marimo fixture proves: + +- one shared service configured with one relation provider, one batched + column provider, and one namespace provider; +- a two-relation cold column request with `expectedEpoch: null`; +- stable relation and column IDs, canonical identifiers, and distinct + provider-rendered insert text; +- partial, loading, and failure provider states; +- relation and column provenance lookup by current scope with a React + disposer; +- atomic editor interpretation updates, complete template regions, and the + unmatched-expression insertion-point gate; +- preservation of variable and keyword sources; and +- supported-dialect vNext routing with exclusive legacy fallback. + +Library tests cover relation and physical-column completion for `FROM`, `JOIN`, +`alias.`, unqualified projections and predicates, `USING`, correlated nested +queries, quoted identifiers, ambiguous columns, provider +loading/invalidations, bounded batching, and template barriers. CTE tests prove +declaration-order relation visibility and that CTE names are not incorrectly +resolved through the physical column provider; they do not prove CTE +output-column inference. + +Marimo integration tests cover: + +- default, nested, and schemaless database/schema layouts; +- local-table collisions where the connection relation wins; +- atomic engine/dialect/scope changes and rapid A-B-A switching; +- reconnecting under the same display name without stale cache reuse; +- open-menu schema invalidation; +- closed, escaped, and unmatched Python expressions; +- completion edits mapping regions in the same transaction; +- variable and keyword source preservation; +- React table/column info unmount on navigation, edit, and view destruction; +- one shared service across 1, 10, and 50 views; and +- exclusive legacy routing for unsupported dialects. diff --git a/docs/vnext/namespace-catalog-authority.md b/docs/vnext/namespace-catalog-authority.md new file mode 100644 index 0000000..500f81e --- /dev/null +++ b/docs/vnext/namespace-catalog-authority.md @@ -0,0 +1,45 @@ +# vNext Namespace Catalog Authority + +Status: public provider and session integration + +Namespace completion searches catalog, schema, project, and dataset containers. +One query site produces one bounded provider request containing its qualifier, +prefix, search paths, result limit, dialect, scope, and expected catalog epoch. +The API does not recursively walk a provider tree or issue one request per +container. + +Each result has a stable container entity ID, a canonical role-tagged identifier +path, provider-rendered insertion text, match quality, and immutable provenance. +The hostile boundary rejects accessors, sparse arrays, extra properties, +oversized data, invalid epochs, and conflicting duplicate identities. Identical +duplicate identities are collapsed and results receive deterministic code-unit +ordering. + +The composer applies the dialect's prefix matcher, deduplicates by authority +identity, and produces deterministic completion edits. Unknown or throwing +prefix comparison is reported as incomplete instead of guessing. + +## Epoch, cache, and lifecycle + +A cold request uses `expectedEpoch: null`; the returned epoch becomes the +owner's observed epoch. Complete ready searches are held in a bounded LRU keyed +by provider instance, scope, dialect, epoch, query site, search paths, and limit. +Partial, loading, and failed responses remain visible but are never cached. + +A newer request supersedes and aborts prior work for the same owner. Explicit +cancellation and owner/coordinator disposal abort pending work. Provider throws, +rejections, malformed data, and late settlements are contained. + +Session integration prepares one owner for each live scope/dialect and +disposes it when catalog authority changes. Query-site integration calls +`prepareSqlNamespaceCatalogSearch`, submits the result through the owner, and +passes the outcome plus the site's replacement range and dialect prefix matcher +to `composeSqlNamespaceCompletion`. Namespace items are merged with local and +relation-catalog items under the same bounded completion response budget. +Relation-catalog subscription events invalidate namespace observations. +Namespace-only hosts, and hosts whose relation provider has no subscription, +call `session.invalidateCatalog()` after a schema-authority change. + +Provider-declared loading receives at most one automatic retry for a stable +document, context, and completion position. Further loading responses do not +schedule polling; a host-provided catalog invalidation resumes discovery. diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index fb6e692..e453071 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,8 +35,8 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_GZIP_LIMIT = 48 * 1024; -const CORE_TOTAL_RAW_LIMIT = 180 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 50 * 1024; +const CORE_TOTAL_RAW_LIMIT = 184 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; const WORKER_TOTAL_GZIP_LIMIT = 160 * 1024; const WORKER_TOTAL_RAW_LIMIT = 700 * 1024; diff --git a/src/vnext/__tests__/column-catalog-batch-coordinator.bench.ts b/src/vnext/__tests__/column-catalog-batch-coordinator.bench.ts new file mode 100644 index 0000000..e585907 --- /dev/null +++ b/src/vnext/__tests__/column-catalog-batch-coordinator.bench.ts @@ -0,0 +1,101 @@ +import { bench, describe } from "vitest"; +import { + createSqlColumnCatalogBatchCoordinator, +} from "../column-catalog-batch-coordinator.js"; +import type { + SqlColumnCatalogBatchRequest, +} from "../column-catalog-types.js"; + +const epoch = Object.freeze({ generation: 1, token: "bench" }); +const relations = Object.freeze( + Array.from({ length: 64 }, (_, index) => + Object.freeze({ + path: Object.freeze([ + Object.freeze({ + quoted: false, + value: `relation_${index}`, + }), + ]), + requestKey: `relation_${index}`, + }) + ), +); +const searchPaths = Object.freeze([ + Object.freeze([ + Object.freeze({ quoted: false, value: "main" }), + ]), +]); + +function response(request: SqlColumnCatalogBatchRequest) { + return Object.freeze({ + epoch, + relations: Object.freeze(request.relations.map((relation) => + Object.freeze({ + columns: Object.freeze( + Array.from({ length: 64 }, (_, ordinal) => + Object.freeze({ + columnEntityId: + `${relation.requestKey}:column:${ordinal}`, + identifier: Object.freeze({ + quoted: false, + value: `column_${ordinal}`, + }), + insertText: `column_${ordinal}`, + ordinal, + }) + ), + ), + coverage: "complete", + relationEntityId: `entity:${relation.requestKey}`, + requestKey: relation.requestKey, + status: "ready", + }) + )), + }); +} + +function owner() { + const created = createSqlColumnCatalogBatchCoordinator({ + maxCacheEntries: 128, + provider: { + id: "benchmark", + loadColumns: (request: SqlColumnCatalogBatchRequest) => + Promise.resolve(response(request)), + }, + }); + if (created.status !== "created") throw new Error("coordinator"); + const prepared = created.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "benchmark", + }); + if (prepared.status !== "prepared") throw new Error("owner"); + return prepared.owner; +} + +describe("column catalog batch coordinator", () => { + bench("cold 64 relation x 64 column batch", async () => { + const current = owner(); + await current.request({ + expectedEpoch: null, + relations, + searchPaths, + }).result; + current.dispose(); + }); + + const warmOwner = owner(); + const warmReady = warmOwner.request({ + expectedEpoch: epoch, + relations, + searchPaths, + }).result; + + bench("warm 64 relation cache projection", async () => { + await warmReady; + await warmOwner.request({ + expectedEpoch: epoch, + relations, + searchPaths, + }).result; + }); +}); diff --git a/src/vnext/__tests__/column-catalog-batch-coordinator.test.ts b/src/vnext/__tests__/column-catalog-batch-coordinator.test.ts new file mode 100644 index 0000000..97fb1c4 --- /dev/null +++ b/src/vnext/__tests__/column-catalog-batch-coordinator.test.ts @@ -0,0 +1,593 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSqlColumnCatalogBatchCoordinator, +} from "../column-catalog-batch-coordinator.js"; +import type { + SqlColumnCatalogBatchRequest, +} from "../column-catalog-types.js"; +import type { SqlCatalogEpoch } from "../relation-completion-types.js"; + +const epoch: SqlCatalogEpoch = + Object.freeze({ generation: 1, token: "one" }); +const nextEpoch: SqlCatalogEpoch = + Object.freeze({ generation: 2, token: "two" }); + +function reference(requestKey: string, name = requestKey) { + return Object.freeze({ + path: Object.freeze([ + Object.freeze({ quoted: false, value: name }), + ]), + requestKey, + }); +} + +function ready( + request: SqlColumnCatalogBatchRequest, + suffix = "", +) { + return { + epoch: request.expectedEpoch ?? epoch, + relations: request.relations.map((relation) => ({ + columns: [{ + columnEntityId: `column-${relation.requestKey}${suffix}`, + identifier: { + quoted: false, + value: `value_${relation.requestKey}${suffix}`, + }, + insertText: `value_${relation.requestKey}${suffix}`, + ordinal: 0, + }], + coverage: "complete", + relationEntityId: `relation-${relation.requestKey}`, + requestKey: relation.requestKey, + status: "ready", + })), + }; +} + +function setup( + loadColumns: ( + request: SqlColumnCatalogBatchRequest, + signal: AbortSignal, + ) => unknown, + maxCacheEntries = 32, +) { + const result = createSqlColumnCatalogBatchCoordinator({ + maxCacheEntries, + provider: { id: "catalog", loadColumns }, + }); + if (result.status !== "created") { + throw new Error("Expected coordinator"); + } + const prepared = result.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + }); + if (prepared.status !== "prepared") { + throw new Error("Expected owner"); + } + return { + coordinator: result.coordinator, + owner: prepared.owner, + }; +} + +function input( + relations = [reference("users"), reference("events")], + epoch_: SqlCatalogEpoch | null = epoch, +) { + return { + expectedEpoch: epoch_, + relations, + searchPaths: [[{ quoted: false, value: "main" }]], + }; +} + +function deferred() { + let resolve: (value: Value) => void = (): void => {}; + let reject: (reason?: unknown) => void = (): void => {}; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + return { promise, reject, resolve }; +} + +describe("column catalog batch coordinator", () => { + it("loads every missing relation in one deterministic batch", async () => { + const requests: SqlColumnCatalogBatchRequest[] = []; + const { coordinator, owner } = setup((request) => { + requests.push(request); + return ready(request); + }); + const outcome = await owner.request(input([ + reference("users"), + reference("events"), + reference("accounts"), + ])).result; + + expect(requests).toHaveLength(1); + expect(requests[0]?.relations.map((relation) => + relation.requestKey + )).toEqual(["accounts", "events", "users"]); + expect(outcome).toMatchObject({ + providerId: "catalog", + relations: [ + { requestKey: "accounts", status: "ready" }, + { requestKey: "events", status: "ready" }, + { requestKey: "users", status: "ready" }, + ], + status: "usable", + }); + expect(Object.isFrozen(outcome)).toBe(true); + if (outcome.status === "usable") { + expect(Object.isFrozen(outcome.relations)).toBe(true); + } + coordinator.dispose(); + }); + + it("caches ready relations by authority identity and remaps request keys", async () => { + const requests: SqlColumnCatalogBatchRequest[] = []; + const { owner } = setup((request) => { + requests.push(request); + return ready(request); + }); + await owner.request(input([reference("first", "users")])).result; + const cached = await owner.request(input([ + reference("renamed", "users"), + ])).result; + await owner.request(input( + [reference("new-epoch", "users")], + nextEpoch, + )).result; + + expect(requests).toHaveLength(2); + expect(cached).toMatchObject({ + relations: [{ + relationEntityId: "relation-first", + requestKey: "renamed", + }], + status: "usable", + }); + }); + + it("separates quoted relation and search-path cache identities", async () => { + let calls = 0; + const { owner } = setup((request) => { + calls += 1; + return ready(request); + }); + const quoted = { + expectedEpoch: epoch, + relations: [{ + path: [{ quoted: true, value: "users" }], + requestKey: "quoted", + }], + searchPaths: [[{ quoted: true, value: "main" }]], + }; + + await expect(owner.request(quoted).result).resolves.toMatchObject({ + status: "usable", + }); + await expect(owner.request(quoted).result).resolves.toMatchObject({ + status: "usable", + }); + expect(calls).toBe(1); + }); + + it("supports cold null epochs and reuses the observed response epoch", async () => { + const expected: Array = []; + const { owner } = setup((request) => { + expected.push(request.expectedEpoch); + return { + ...ready(request), + epoch, + }; + }); + await owner.request(input([reference("users")], null)).result; + const cached = await owner.request( + input([reference("renamed", "users")], null), + ).result; + + expect(expected).toEqual([null]); + expect(cached).toMatchObject({ + relations: [{ requestKey: "renamed" }], + status: "usable", + }); + }); + + it("combines cached and loaded relations without changing order", async () => { + const requests: SqlColumnCatalogBatchRequest[] = []; + const { owner } = setup((request) => { + requests.push(request); + return ready(request); + }); + await owner.request(input([reference("users")])).result; + const mixed = await owner.request(input([ + reference("users"), + reference("events"), + ])).result; + + expect(requests).toHaveLength(2); + expect(requests[1]?.relations.map((relation) => + relation.requestKey + )).toEqual(["events"]); + expect(mixed).toMatchObject({ + relations: [ + { requestKey: "events" }, + { requestKey: "users" }, + ], + status: "usable", + }); + }); + + it("caches only complete ready coverage", async () => { + let calls = 0; + const { owner } = setup((request) => { + calls += 1; + return { + epoch: request.expectedEpoch ?? epoch, + relations: request.relations.map((relation, index) => + index === 0 + ? { requestKey: relation.requestKey, status: "loading" } + : index === 1 + ? { + columns: [], + coverage: "partial", + relationEntityId: `relation-${relation.requestKey}`, + requestKey: relation.requestKey, + status: "ready", + } + : { + code: "unavailable", + requestKey: relation.requestKey, + retry: "next-request", + status: "failed", + } + ), + }; + }); + const uncached = [ + reference("loading"), + reference("partial"), + reference("failed"), + ]; + const first = await owner.request(input(uncached)).result; + const second = await owner.request(input(uncached)).result; + + expect(first).toMatchObject({ + relations: [ + { requestKey: "failed", status: "loading" }, + { requestKey: "loading", status: "ready" }, + { requestKey: "partial", status: "failed" }, + ], + status: "usable", + }); + expect(second.status).toBe("usable"); + expect(calls).toBe(2); + }); + + it("aborts and settles cancelled work exactly once", async () => { + const work = deferred(); + const signals: AbortSignal[] = []; + const { owner } = setup((_request, signal_) => { + signals.push(signal_); + return work.promise; + }); + const ticket = owner.request(input()); + ticket.cancel(); + ticket.cancel(); + + await expect(ticket.result).resolves.toEqual({ status: "cancelled" }); + expect(signals[0]?.aborted).toBe(true); + work.resolve({}); + await Promise.resolve(); + await expect(ticket.result).resolves.toEqual({ status: "cancelled" }); + }); + + it("ignores a provider rejection after its consumer is cancelled", async () => { + const work = deferred(); + const { owner } = setup(() => work.promise); + const ticket = owner.request(input()); + + ticket.cancel(); + work.reject(new Error("late rejection")); + await expect(ticket.result).resolves.toEqual({ status: "cancelled" }); + await Promise.resolve(); + }); + + it("supersedes prior owner work but isolates other owners", async () => { + const pending: Array>> = []; + const { coordinator, owner } = setup(() => { + const work = deferred(); + pending.push(work); + return work.promise; + }); + const otherResult = coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + }); + if (otherResult.status !== "prepared") { + throw new Error("Expected second owner"); + } + const first = owner.request(input()); + const other = otherResult.owner.request(input()); + const second = owner.request(input([reference("next")])); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + let otherSettled = false; + void other.result.then(() => { + otherSettled = true; + }); + await Promise.resolve(); + expect(otherSettled).toBe(false); + second.cancel(); + other.cancel(); + }); + + it("preserves the newest request across reentrant abort handlers", async () => { + const signals: AbortSignal[] = []; + let owner: ReturnType["owner"] | null = null; + const reentrant: { + ticket: + | ReturnType["owner"]["request"]> + | null; + } = { ticket: null }; + const configured = setup((_request, signal) => { + signals.push(signal); + if (signals.length === 1) { + signal.addEventListener("abort", () => { + reentrant.ticket = + owner?.request(input([reference("reentrant")])) ?? null; + }, { once: true }); + } + return new Promise(() => undefined); + }); + owner = configured.owner; + const first = owner.request(input([reference("first")])); + const interrupted = owner.request(input([reference("interrupted")])); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + await expect(interrupted.result).resolves.toEqual({ + status: "superseded", + }); + expect(signals).toHaveLength(2); + expect(signals.map((signal) => signal.aborted)).toEqual([ + true, + false, + ]); + + const newest = owner.request(input([reference("newest")])); + expect(signals.map((signal) => signal.aborted)).toEqual([ + true, + true, + false, + ]); + await expect(reentrant.ticket?.result).resolves.toEqual({ + status: "superseded", + }); + newest.cancel(); + }); + + it.each(["owner", "coordinator"] as const)( + "does not resurrect work after reentrant %s disposal", + async (target) => { + const signals: AbortSignal[] = []; + let dispose = (): void => {}; + const configured = setup((_request, signal) => { + signals.push(signal); + if (signals.length === 1) { + signal.addEventListener("abort", () => dispose(), { + once: true, + }); + } + return new Promise(() => undefined); + }); + dispose = target === "owner" + ? configured.owner.dispose + : configured.coordinator.dispose; + const first = configured.owner.request( + input([reference("first")]), + ); + const interrupted = configured.owner.request( + input([reference("interrupted")]), + ); + + await expect(first.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await expect(interrupted.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(signals).toHaveLength(1); + expect(signals[0]?.aborted).toBe(true); + }, + ); + + it("disposes owners and coordinator with prompt aborts", async () => { + const signals: AbortSignal[] = []; + const { coordinator, owner } = setup((_request, signal) => { + signals.push(signal); + return new Promise(() => undefined); + }); + const ownerTicket = owner.request(input()); + owner.dispose(); + owner.dispose(); + await expect(ownerTicket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(signals[0]?.aborted).toBe(true); + await expect(owner.request(input()).result).resolves.toMatchObject({ + reason: "disposed", + }); + + const next = coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + }); + if (next.status !== "prepared") throw new Error("Expected owner"); + const coordinatorTicket = next.owner.request(input()); + coordinator.dispose(); + coordinator.dispose(); + await expect(coordinatorTicket.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + })).toEqual({ reason: "disposed", status: "unavailable" }); + }); + + it.each([ + { + expected: "provider-failed", + load: () => { + throw new Error("sync"); + }, + }, + { + expected: "provider-failed", + load: () => Promise.reject(new Error("async")), + }, + { + expected: "malformed-response", + load: () => ({ epoch, relations: [] }), + }, + ])("contains provider failure as $expected", async ({ expected, load }) => { + const { owner } = setup(load); + await expect(owner.request(input()).result).resolves.toEqual({ + reason: expected, + status: "unavailable", + }); + }); + + it("settles a revoked-array provider response as malformed", async () => { + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + const { owner } = setup(() => ({ + epoch, + relations: revoked.proxy, + })); + await expect(owner.request(input()).result).resolves.toEqual({ + reason: "malformed-response", + status: "unavailable", + }); + }); + + it("contains an unexpected decoder exception", async () => { + const integerCheck = vi.spyOn(Number, "isSafeInteger"); + const { owner } = setup((request) => { + integerCheck.mockImplementationOnce(() => { + throw new Error("decoder"); + }); + return ready(request); + }); + await expect(owner.request(input()).result).resolves.toEqual({ + reason: "malformed-response", + status: "unavailable", + }); + integerCheck.mockRestore(); + }); + + it("bounds cache entries with deterministic LRU eviction", async () => { + const calls = new Map(); + const { owner } = setup((request) => { + const key = request.relations[0]?.path[0]?.value ?? ""; + calls.set(key, (calls.get(key) ?? 0) + 1); + return ready(request); + }, 2); + await owner.request(input([reference("a")])).result; + await owner.request(input([reference("b")])).result; + await owner.request(input([reference("a")])).result; + await owner.request(input([reference("c")])).result; + await owner.request(input([reference("b")])).result; + + expect(calls).toEqual(new Map([ + ["a", 1], + ["b", 2], + ["c", 1], + ])); + }); + + it("rejects invalid providers, options, owners, and requests", async () => { + expect(createSqlColumnCatalogBatchCoordinator(null)).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + expect(createSqlColumnCatalogBatchCoordinator({ + provider: {}, + })).toEqual({ + reason: "invalid-provider", + status: "unavailable", + }); + expect(createSqlColumnCatalogBatchCoordinator({ + maxCacheEntries: 0, + provider: { id: "catalog", loadColumns: vi.fn() }, + })).toEqual({ + reason: "invalid-options", + status: "unavailable", + }); + const created = createSqlColumnCatalogBatchCoordinator({ + provider: { id: "catalog", loadColumns: vi.fn() }, + }); + if (created.status !== "created") throw new Error("Expected coordinator"); + expect(created.coordinator.prepareOwner({ + dialectId: "", + scope: "scope", + })).toMatchObject({ status: "unavailable" }); + const prepared = created.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + }); + if (prepared.status !== "prepared") throw new Error("Expected owner"); + const invalidTicket = prepared.owner.request({ + expectedEpoch: epoch, + relations: [], + searchPaths: [], + }); + invalidTicket.cancel(); + await expect(invalidTicket.result).resolves.toEqual({ + reason: "invalid-request", + status: "unavailable", + }); + }); + + it("contains hostile and missing request properties", async () => { + const { owner } = setup(vi.fn()); + const hostile = new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error("hostile descriptor"); + }, + }); + + const hostileTicket = Reflect.apply( + owner.request, + undefined, + [hostile], + ); + await expect(hostileTicket.result).resolves.toEqual({ + reason: "invalid-request", + status: "unavailable", + }); + const missingTicket = Reflect.apply( + owner.request, + undefined, + [{ + expectedEpoch: epoch, + relations: [reference("users")], + }], + ); + await expect(missingTicket.result).resolves.toEqual({ + reason: "invalid-request", + status: "unavailable", + }); + }); +}); diff --git a/src/vnext/__tests__/column-catalog-boundary.test.ts b/src/vnext/__tests__/column-catalog-boundary.test.ts new file mode 100644 index 0000000..2ab1f9e --- /dev/null +++ b/src/vnext/__tests__/column-catalog-boundary.test.ts @@ -0,0 +1,884 @@ +import { describe, expect, it, vi } from "vitest"; +import { + captureSqlColumnCatalogProvider, + createSqlColumnCatalogBatchRequest, + decodeSqlColumnCatalogBatchResponse, + MAX_COLUMN_BATCH_RELATIONS, + MAX_COLUMN_ENTITY_ID_LENGTH, + MAX_COLUMNS_PER_RELATION, + resolveSqlColumnCatalogProvider, +} from "../column-catalog-boundary.js"; + +const epoch = Object.freeze({ generation: 7, token: "epoch-7" }); +const path = Object.freeze([ + Object.freeze({ quoted: false, value: "users" }), +]); + +function request() { + const result = createSqlColumnCatalogBatchRequest({ + dialectId: "duckdb", + expectedEpoch: epoch, + relations: [ + { path, requestKey: "users" }, + { + path: [{ quoted: false, value: "events" }], + requestKey: "events", + }, + ], + scope: "connection-1", + searchPaths: [[{ quoted: false, value: "main" }]], + }); + if (result.status !== "accepted") { + throw new Error("Expected valid request"); + } + return result.value; +} + +function provider(loadColumns: (...arguments_: unknown[]) => unknown) { + const captured = captureSqlColumnCatalogProvider({ + id: "catalog", + loadColumns, + }); + if (captured.status !== "accepted") { + throw new Error("Expected valid provider"); + } + return captured.value; +} + +function readyResponse() { + return { + epoch, + relations: [ + { + columns: [ + { + columnEntityId: "column-name", + dataType: "VARCHAR", + identifier: { quoted: false, value: "name" }, + insertText: "name", + ordinal: 1, + }, + { + columnEntityId: "column-id", + detail: "primary key", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }, + ], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + { + code: "unavailable", + requestKey: "events", + retry: "next-request", + status: "failed", + }, + ], + }; +} + +describe("column catalog provider boundary", () => { + it("captures methods without retaining provider receivers", () => { + const calls: unknown[][] = []; + const captured = provider(function ( + this: unknown, + ...arguments_: unknown[] + ) { + calls.push([this, ...arguments_]); + return null; + }); + const resolved = resolveSqlColumnCatalogProvider(captured); + const signal = new AbortController().signal; + resolved?.loadColumns(request(), signal); + + expect(resolved?.id).toBe("catalog"); + expect(calls).toEqual([[undefined, request(), signal]]); + expect(Object.isFrozen(resolved)).toBe(true); + expect(resolveSqlColumnCatalogProvider({})).toBeNull(); + }); + + it("rejects inherited, accessor, and oversized provider data", () => { + let calls = 0; + const accessor = Object.defineProperty( + { id: "catalog" }, + "loadColumns", + { + get: () => { + calls += 1; + return () => undefined; + }, + }, + ); + expect(captureSqlColumnCatalogProvider(accessor)).toMatchObject({ + reason: "invalid-shape", + status: "malformed", + }); + expect(calls).toBe(0); + expect(captureSqlColumnCatalogProvider( + Object.create({ id: "catalog", loadColumns: () => undefined }), + ).status).toBe("malformed"); + expect(captureSqlColumnCatalogProvider({ + id: "x".repeat(257), + loadColumns: () => undefined, + }).status).toBe("malformed"); + expect(captureSqlColumnCatalogProvider(null).status).toBe("malformed"); + expect(resolveSqlColumnCatalogProvider(null)).toBeNull(); + expect(resolveSqlColumnCatalogProvider(1)).toBeNull(); + }); + + it("contains hostile own-key and descriptor traps", () => { + const ownKeys = new Proxy( + { id: "catalog", loadColumns: () => undefined }, + { + ownKeys() { + throw new Error("private ownKeys trap"); + }, + }, + ); + const descriptor = new Proxy( + { id: "catalog", loadColumns: () => undefined }, + { + getOwnPropertyDescriptor() { + throw new Error("private descriptor trap"); + }, + }, + ); + expect(captureSqlColumnCatalogProvider(ownKeys).status).toBe("malformed"); + expect(captureSqlColumnCatalogProvider(descriptor).status).toBe( + "malformed", + ); + }); +}); + +describe("column catalog batch request boundary", () => { + it("normalizes paths, search paths, and deterministic request order", () => { + const result = createSqlColumnCatalogBatchRequest({ + dialectId: "duckdb", + expectedEpoch: epoch, + relations: [ + { path, requestKey: "z" }, + { path, requestKey: "a" }, + ], + scope: "scope", + searchPaths: [[{ quoted: true, value: "Main" }]], + }); + expect(result).toMatchObject({ + status: "accepted", + value: { + relations: [ + { requestKey: "a" }, + { requestKey: "z" }, + ], + }, + }); + if (result.status === "accepted") { + expect(Object.isFrozen(result.value)).toBe(true); + expect(Object.isFrozen(result.value.relations)).toBe(true); + expect(Object.isFrozen(result.value.relations[0]?.path)).toBe(true); + expect(Object.isFrozen(result.value.searchPaths)).toBe(true); + } + }); + + it("rejects malformed, duplicate, sparse, and resource-heavy requests", () => { + const base = { + dialectId: "duckdb", + expectedEpoch: epoch, + scope: "scope", + searchPaths: [], + }; + for (const relations of [ + [], + [{ path: [], requestKey: "empty-path" }], + [{ path, requestKey: "" }], + [ + { path, requestKey: "same" }, + { path, requestKey: "same" }, + ], + Array.from( + { length: MAX_COLUMN_BATCH_RELATIONS + 1 }, + (_, index) => ({ path, requestKey: `r${index}` }), + ), + ]) { + expect(createSqlColumnCatalogBatchRequest({ + ...base, + relations, + }).status).toBe("malformed"); + } + const sparse: unknown[] = []; + sparse.length = 1; + expect(createSqlColumnCatalogBatchRequest({ + ...base, + relations: sparse, + }).status).toBe("malformed"); + expect(createSqlColumnCatalogBatchRequest({ + ...base, + relations: [{ + path, + relationEntityId: "x".repeat(MAX_COLUMN_ENTITY_ID_LENGTH + 1), + requestKey: "long", + }], + }).status).toBe("malformed"); + expect(createSqlColumnCatalogBatchRequest( + Object.create(base), + ).status).toBe("malformed"); + }); + + it("does not invoke request accessors or hostile array iterators", () => { + let getterCalls = 0; + const input = Object.defineProperty( + { + dialectId: "duckdb", + epoch, + relations: [{ path, requestKey: "users" }], + searchPaths: [], + }, + "scope", + { + get: () => { + getterCalls += 1; + return "scope"; + }, + }, + ); + expect(createSqlColumnCatalogBatchRequest(input).status).toBe( + "malformed", + ); + expect(getterCalls).toBe(0); + + const relations = [{ path, requestKey: "users" }]; + Object.defineProperty(relations, Symbol.iterator, { + value: () => { + throw new Error("iterator must not run"); + }, + }); + expect(createSqlColumnCatalogBatchRequest({ + dialectId: "duckdb", + expectedEpoch: epoch, + relations, + scope: "scope", + searchPaths: [], + }).status).toBe("accepted"); + }); + + it("rejects malformed epochs, path components, search paths, and array traps", () => { + const base = { + dialectId: "duckdb", + expectedEpoch: epoch, + relations: [{ path, requestKey: "users" }], + scope: "scope", + searchPaths: [], + }; + const badEpochs = [ + { generation: -1, token: "x" }, + { generation: 1.5, token: "x" }, + { generation: 1, token: "" }, + { generation: "1", token: "x" }, + ]; + for (const expectedEpoch of badEpochs) { + expect( + createSqlColumnCatalogBatchRequest({ ...base, expectedEpoch }).status, + ).toBe("malformed"); + } + + const sparsePath: unknown[] = []; + sparsePath.length = 1; + const sparseSearchPaths: unknown[] = []; + sparseSearchPaths.length = 1; + const malformedPaths = [ + sparsePath, + [null], + [{ quoted: "false", value: "users" }], + [{ quoted: false, value: "" }], + ]; + for (const malformedPath of malformedPaths) { + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + relations: [{ path: malformedPath, requestKey: "users" }], + }).status, + ).toBe("malformed"); + } + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + searchPaths: sparseSearchPaths, + }).status, + ).toBe("malformed"); + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + searchPaths: [["not-a-component"]], + }).status, + ).toBe("malformed"); + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + relations: [null], + }).status, + ).toBe("malformed"); + + const trappedLength = new Proxy( + [{ path, requestKey: "users" }], + { + getOwnPropertyDescriptor(target, key) { + if (key === "length") { + throw new Error("private length trap"); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + const trappedElement = new Proxy( + [{ path, requestKey: "users" }], + { + getOwnPropertyDescriptor(target, key) { + if (key === "0") { + throw new Error("private element trap"); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + relations: trappedLength, + }).status, + ).toBe("malformed"); + expect( + createSqlColumnCatalogBatchRequest({ + ...base, + relations: trappedElement, + }).status, + ).toBe("malformed"); + }); +}); + +describe("column catalog response boundary", () => { + it("decodes all coverage states with stable frozen provenance", () => { + const captured = provider(() => undefined); + const result = decodeSqlColumnCatalogBatchResponse( + captured, + request(), + readyResponse(), + ); + expect(result).toMatchObject({ + status: "accepted", + value: { + relations: [ + { + code: "unavailable", + requestKey: "events", + status: "failed", + }, + { + columns: [ + { columnEntityId: "column-id", ordinal: 0 }, + { columnEntityId: "column-name", ordinal: 1 }, + ], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + ], + }, + }); + if (result.status === "accepted") { + const users = result.value.relations[1]; + expect(Object.isFrozen(result.value)).toBe(true); + expect(Object.isFrozen(result.value.relations)).toBe(true); + if (users?.status === "ready") { + expect(users.columns[0]?.provenance).toEqual({ + columnEntityId: "column-id", + epoch, + providerId: "catalog", + relationEntityId: "relation-users", + scope: "connection-1", + }); + expect(Object.isFrozen(users.columns[0]?.provenance)).toBe(true); + } + } + }); + + it("accepts loading, partial, and identical duplicate columns", () => { + const captured = provider(() => undefined); + const request_ = request(); + const column = { + columnEntityId: "same", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }; + const result = decodeSqlColumnCatalogBatchResponse( + captured, + request_, + { + epoch, + relations: [ + { + columns: [column, { ...column }], + coverage: "partial", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + { requestKey: "events", status: "loading" }, + ], + }, + ); + expect(result).toMatchObject({ + status: "accepted", + value: { + relations: [ + { requestKey: "events", status: "loading" }, + { + columns: [{ columnEntityId: "same" }], + coverage: "partial", + requestKey: "users", + }, + ], + }, + }); + }); + + it.each([ + { + name: "wrong epoch", + value: { ...readyResponse(), epoch: { generation: 8, token: "x" } }, + }, + { + name: "missing relation", + value: { epoch, relations: [readyResponse().relations[0]] }, + }, + { + name: "unexpected relation", + value: { + epoch, + relations: [ + ...readyResponse().relations, + { requestKey: "other", status: "loading" }, + ], + }, + }, + { + name: "duplicate request key", + value: { + epoch, + relations: [ + readyResponse().relations[0], + readyResponse().relations[0], + ], + }, + }, + { + name: "conflicting duplicate column", + value: { + epoch, + relations: [ + { + ...readyResponse().relations[0], + columns: [ + { + columnEntityId: "x", + identifier: { quoted: false, value: "a" }, + insertText: "a", + ordinal: 0, + }, + { + columnEntityId: "x", + identifier: { quoted: false, value: "b" }, + insertText: "b", + ordinal: 0, + }, + ], + }, + readyResponse().relations[1], + ], + }, + }, + { + name: "too many columns", + value: { + epoch, + relations: [ + { + ...readyResponse().relations[0], + columns: Array.from( + { length: MAX_COLUMNS_PER_RELATION + 1 }, + (_, ordinal) => ({ + columnEntityId: `c${ordinal}`, + identifier: { + quoted: false, + value: `c${ordinal}`, + }, + insertText: `c${ordinal}`, + ordinal, + }), + ), + }, + readyResponse().relations[1], + ], + }, + }, + ])("rejects $name", ({ value }) => { + expect(decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + value, + ).status).toBe("malformed"); + }); + + it("contains response accessors, iterators, and invalid provider handles", () => { + let getterCalls = 0; + const response = Object.defineProperty( + { epoch }, + "relations", + { + get: () => { + getterCalls += 1; + return []; + }, + }, + ); + expect(decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + response, + ).status).toBe("malformed"); + expect(getterCalls).toBe(0); + + const value = readyResponse(); + Object.defineProperty(value.relations, Symbol.iterator, { + value: vi.fn(() => { + throw new Error("iterator must not run"); + }), + }); + expect(decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + value, + ).status).toBe("accepted"); + const invalid = Reflect.apply( + decodeSqlColumnCatalogBatchResponse, + undefined, + [{}, request(), value], + ); + expect(invalid.status).toBe("malformed"); + }); + + it("rejects revoked response arrays without throwing", () => { + const captured = provider(() => undefined); + for (const response of [ + (() => { + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + return { epoch, relations: revoked.proxy }; + })(), + (() => { + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + return { + epoch, + relations: [ + { + ...readyResponse().relations[0], + columns: revoked.proxy, + }, + readyResponse().relations[1], + ], + }; + })(), + ]) { + expect(() => + decodeSqlColumnCatalogBatchResponse( + captured, + request(), + response, + ) + ).not.toThrow(); + expect(decodeSqlColumnCatalogBatchResponse( + captured, + request(), + response, + )).toEqual({ + reason: "invalid-shape", + status: "malformed", + }); + } + }); + + it("rejects cross-variant relation fields", () => { + const captured = provider(() => undefined); + const invalid = [ + { + code: "unknown", + requestKey: "users", + retry: "never", + status: "loading", + }, + { + code: "unknown", + ...readyResponse().relations[0], + retry: "never", + }, + { + code: "unknown", + columns: [], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + retry: "never", + status: "failed", + }, + ]; + for (const relation of invalid) { + expect(decodeSqlColumnCatalogBatchResponse( + captured, + request(), + { + epoch, + relations: [relation, readyResponse().relations[1]], + }, + ).status).toBe("malformed"); + } + }); + + it("rejects NUL-delimited conflicting column identities", () => { + const result = decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + { + epoch, + relations: [ + { + columns: [ + { + columnEntityId: "same", + identifier: { quoted: false, value: "x\u0000y" }, + insertText: "z", + ordinal: 0, + }, + { + columnEntityId: "same", + identifier: { quoted: false, value: "x" }, + insertText: "y\u0000z", + ordinal: 0, + }, + ], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + readyResponse().relations[1], + ], + }, + ); + expect(result).toEqual({ + reason: "duplicate-column-entity-id", + status: "malformed", + }); + }); + + it("rejects malformed relation and column state combinations", () => { + const captured = provider(() => undefined); + const request_ = request(); + const validUsers = readyResponse().relations[0]; + const validEvents = readyResponse().relations[1]; + const invalidUsers = [ + null, + { requestKey: "", status: "loading" }, + { + columns: [], + coverage: "complete", + relationEntityId: "", + requestKey: "users", + status: "ready", + }, + { code: "mystery", requestKey: "users", retry: "never", status: "failed" }, + { + code: "unavailable", + requestKey: "users", + retry: "mystery", + status: "failed", + }, + { requestKey: "users", status: "mystery" }, + { + columns: [null], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + { + columns: [{ + columnEntityId: "", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + { + columns: [{ + columnEntityId: "id", + identifier: null, + insertText: "id", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + { + columns: [{ + columnEntityId: "id", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: -1, + }], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + ]; + for (const users of invalidUsers) { + expect( + decodeSqlColumnCatalogBatchResponse(captured, request_, { + epoch, + relations: [users, validEvents], + }).status, + ).toBe("malformed"); + } + + const sparseColumns: unknown[] = []; + sparseColumns.length = 1; + const sparseRelations: unknown[] = []; + sparseRelations.length = 2; + expect( + decodeSqlColumnCatalogBatchResponse(captured, request_, { + epoch, + relations: [ + { ...validUsers, columns: sparseColumns }, + validEvents, + ], + }).status, + ).toBe("malformed"); + expect( + decodeSqlColumnCatalogBatchResponse(captured, request_, { + epoch, + relations: sparseRelations, + }).status, + ).toBe("malformed"); + }); + + it("rejects an unexpected relation without conflating it with response size", () => { + const result = decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + { + epoch, + relations: [ + readyResponse().relations[0], + { requestKey: "other", status: "loading" }, + ], + }, + ); + expect(result).toEqual({ + reason: "unexpected-relation", + status: "malformed", + }); + }); + + it("sorts stable ties and enforces the aggregate batch column cap", () => { + const tied = { + epoch, + relations: [ + { + columns: [ + { + columnEntityId: "b", + identifier: { quoted: true, value: "same" }, + insertText: "same", + ordinal: 0, + }, + { + columnEntityId: "a", + identifier: { quoted: true, value: "same" }, + insertText: "same", + ordinal: 0, + }, + ], + coverage: "complete", + relationEntityId: "relation-users", + requestKey: "users", + status: "ready", + }, + readyResponse().relations[1], + ], + }; + const sorted = decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + request(), + tied, + ); + expect(sorted).toMatchObject({ + status: "accepted", + value: { + relations: [ + { requestKey: "events" }, + { + columns: [ + { columnEntityId: "a" }, + { columnEntityId: "b" }, + ], + }, + ], + }, + }); + + const references = Array.from({ length: 17 }, (_, index) => ({ + path, + requestKey: `r${index}`, + })); + const created = createSqlColumnCatalogBatchRequest({ + dialectId: "duckdb", + expectedEpoch: epoch, + relations: references, + scope: "scope", + searchPaths: [], + }); + if (created.status !== "accepted") { + throw new Error("Expected aggregate-limit request"); + } + const relations = references.map((reference, relationIndex) => ({ + columns: Array.from({ length: MAX_COLUMNS_PER_RELATION }, (_, ordinal) => ({ + columnEntityId: `c${relationIndex}-${ordinal}`, + identifier: { quoted: false, value: `c${ordinal}` }, + insertText: `c${ordinal}`, + ordinal, + })), + coverage: "complete", + relationEntityId: `relation-${relationIndex}`, + requestKey: reference.requestKey, + status: "ready", + })); + expect( + decodeSqlColumnCatalogBatchResponse( + provider(() => undefined), + created.value, + { epoch, relations }, + ), + ).toEqual({ reason: "resource-limit", status: "malformed" }); + }); +}); diff --git a/src/vnext/__tests__/column-completion.test.ts b/src/vnext/__tests__/column-completion.test.ts new file mode 100644 index 0000000..f86655c --- /dev/null +++ b/src/vnext/__tests__/column-completion.test.ts @@ -0,0 +1,559 @@ +import { describe, expect, it } from "vitest"; +import { + composeSqlColumnCompletion, + prepareSqlColumnCatalogRelations, +} from "../column-completion.js"; +import { + MAX_COLUMN_BATCH_RELATIONS, +} from "../column-catalog-boundary.js"; +import type { + SqlColumnCatalogBatchOutcome, +} from "../column-catalog-batch-coordinator.js"; +import type { + SqlColumnCatalogResolvedColumn, +} from "../column-catalog-types.js"; +import type { + SqlColumnQuerySiteResult, +} from "../column-query-site.js"; +import { + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; + +type ReadySite = Extract< + SqlColumnQuerySiteResult, + { status: "ready" } +>; + +const epoch = Object.freeze({ generation: 1, token: "epoch-1" }); + +function site( + qualifier: ReadySite["qualifier"] = [], + prefix = "", +): ReadySite { + return Object.freeze({ + coverage: "complete", + issues: Object.freeze([]), + prefix: Object.freeze({ quoted: false, value: prefix }), + qualifier: Object.freeze(qualifier), + relations: Object.freeze([ + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "u" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "app" }), + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 20, to: 29 }), + }), + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "o" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "orders" }), + ]), + range: Object.freeze({ from: 35, to: 41 }), + }), + ]), + replacementRange: Object.freeze({ from: 9, to: 9 + prefix.length }), + status: "ready", + }); +} + +function column( + name: string, + relationEntityId: string, + ordinal: number, +): SqlColumnCatalogResolvedColumn { + return Object.freeze({ + columnEntityId: `${relationEntityId}:${name}`, + dataType: "VARCHAR", + identifier: Object.freeze({ quoted: false, value: name }), + insertText: name, + ordinal, + provenance: Object.freeze({ + columnEntityId: `${relationEntityId}:${name}`, + epoch, + providerId: "columns", + relationEntityId, + scope: "engine:1", + }), + }); +} + +function usable( + relations: Extract< + SqlColumnCatalogBatchOutcome, + { status: "usable" } + >["relations"], +): Extract { + return Object.freeze({ + epoch, + providerId: "columns", + relations: Object.freeze(relations), + scope: "engine:1", + status: "usable", + }); +} + +describe("column completion", () => { + it("bounds relation batches and reports the omitted bindings", () => { + const relations = Array.from( + { length: MAX_COLUMN_BATCH_RELATIONS + 1 }, + (_, index) => Object.freeze({ + alias: Object.freeze({ + quoted: false, + value: `t_${index}`, + }), + path: Object.freeze([ + Object.freeze({ + quoted: false, + value: `table_${index}`, + }), + ]), + range: Object.freeze({ from: index, to: index + 1 }), + }), + ); + const current = Object.freeze({ + ...site(), + relations: Object.freeze(relations), + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + + expect(prepared).toMatchObject({ + coverage: "partial", + references: { length: MAX_COLUMN_BATCH_RELATIONS }, + }); + expect(composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([]), + prepared, + providerId: "columns", + site: current, + })).toMatchObject({ + sources: [{ coverage: "partial", outcome: "ready" }], + value: { + isIncomplete: true, + issues: [{ reason: "query-binding-partial" }], + }, + }); + }); + + it("batches only the relation selected by an alias qualifier", () => { + const current = site([{ quoted: false, value: "u" }], "na"); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + + expect(prepared.references).toEqual([{ + path: [ + { quoted: false, value: "app" }, + { quoted: false, value: "users" }, + ], + requestKey: "binding:0", + }]); + }); + + it("matches an unaliased multipart path suffix", () => { + const current = Object.freeze({ + ...site([{ quoted: false, value: "app" }, { + quoted: false, + value: "users", + }]), + relations: Object.freeze([ + Object.freeze({ + alias: null, + path: Object.freeze([ + Object.freeze({ quoted: false, value: "app" }), + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 20, to: 29 }), + }), + ]), + }); + expect( + prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ).references, + ).toHaveLength(1); + }); + + it("composes deterministic exact edits and provenance", () => { + const current = site([], "na"); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [ + column("nickname", "users", 2), + column("name", "users", 1), + ], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { + columns: [column("name", "orders", 1)], + coverage: "complete", + relationEntityId: "orders", + requestKey: "binding:1", + status: "ready", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value).toMatchObject({ + isIncomplete: false, + items: [ + { + detail: "VARCHAR — o", + edit: { from: 9, insert: "name", to: 11 }, + kind: "column", + label: "name", + provenance: { + columnEntityId: "orders:name", + kind: "column-catalog", + relationEntityId: "orders", + }, + }, + { + detail: "VARCHAR — u", + label: "name", + }, + ], + }); + }); + + it("reports partial, loading, and failed relation evidence", () => { + const current = Object.freeze({ + ...site(), + coverage: "partial" as const, + issues: Object.freeze(["derived-relation" as const]), + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [ + column("id", "users", 0), + Object.freeze({ + ...column("id", "users", 1), + columnEntityId: "users:id-alternate", + insertText: "id_alternate", + provenance: Object.freeze({ + ...column("id", "users", 1).provenance, + columnEntityId: "users:id-alternate", + }), + }), + ], + coverage: "partial", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { requestKey: "binding:1", status: "loading" }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value).toMatchObject({ + isIncomplete: true, + issues: [ + { reason: "query-binding-partial" }, + { reason: "column-catalog-partial" }, + { reason: "column-catalog-loading" }, + ], + }); + expect(result?.sources[0]).toMatchObject({ + feature: "column-catalog", + outcome: "loading", + }); + }); + + it("maps provider unavailability and suppresses cancellations", () => { + const current = site(); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: { + reason: "malformed-response", + status: "unavailable", + }, + prepared, + providerId: "columns", + site: current, + }), + ).toMatchObject({ + value: { + issues: [{ reason: "column-catalog-malformed" }], + }, + }); + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: { status: "cancelled" }, + prepared, + providerId: "columns", + site: current, + }), + ).toBeNull(); + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: { status: "superseded" }, + prepared, + providerId: "columns", + site: current, + }), + ).toBeNull(); + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: { + reason: "provider-failed", + status: "unavailable", + }, + prepared, + providerId: "columns", + site: current, + }), + ).toMatchObject({ + value: { + issues: [{ reason: "column-catalog-failed" }], + }, + }); + }); + + it("fails qualifier matching closed and labels unaliased relations", () => { + const current = Object.freeze({ + ...site([ + { quoted: false, value: "too" }, + { quoted: false, value: "deep" }, + { quoted: false, value: "path" }, + ]), + relations: Object.freeze([ + Object.freeze({ + alias: null, + path: Object.freeze([ + Object.freeze({ quoted: false, value: "app" }), + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 20, to: 29 }), + }), + ]), + }); + expect( + prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ).references, + ).toEqual([]); + + const unqualified = Object.freeze({ + ...current, + qualifier: Object.freeze([]), + }); + const prepared = prepareSqlColumnCatalogRelations( + unqualified, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const noMetadata = Object.freeze({ + columnEntityId: "users:x", + identifier: Object.freeze({ quoted: false, value: "x" }), + insertText: "x", + ordinal: 0, + provenance: Object.freeze({ + columnEntityId: "users:x", + epoch, + providerId: "columns", + relationEntityId: "users", + scope: "engine:1", + }), + }); + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([{ + columns: [noMetadata], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }]), + prepared, + providerId: "columns", + site: unqualified, + })?.value.items[0], + ).toMatchObject({ + detail: "app.users", + label: "x", + }); + }); + + it("contains malformed mappings, duplicates, prefix misses, and failures", () => { + const current = site([], "i"); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const repeated = column("id", "users", 0); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [repeated, repeated, column("name", "users", 1)], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { + columns: [column("id", "missing", 0)], + coverage: "complete", + relationEntityId: "missing", + requestKey: "unknown", + status: "ready", + }, + { + code: "unavailable", + requestKey: "binding:1", + retry: "next-request", + status: "failed", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result).toMatchObject({ + sources: [{ + coverage: "partial", + failures: [{ + code: "unavailable", + requestKey: "binding:1", + retry: "next-request", + }], + outcome: "ready", + }], + value: { + isIncomplete: true, + issues: [ + { reason: "column-catalog-malformed" }, + { reason: "column-catalog-failed" }, + ], + items: [{ label: "id" }], + }, + }); + + expect( + composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([{ + code: "unavailable", + requestKey: "binding:0", + retry: "next-request", + status: "failed", + }]), + prepared, + providerId: "columns", + site: current, + })?.sources[0], + ).toMatchObject({ + failures: [{ + code: "unavailable", + requestKey: "binding:0", + retry: "next-request", + }], + outcome: "failed", + }); + }); + + it("uses deterministic code-unit ordering", () => { + const current = site(); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([{ + columns: [ + column("ä_value", "users", 0), + column("z_value", "users", 1), + ], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items.map((item) => item.label)).toEqual([ + "z_value", + "ä_value", + ]); + }); + + it("uses relation detail to order columns with equal labels", () => { + const current = site(); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [column("id", "users", 0)], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { + columns: [column("id", "orders", 0)], + coverage: "complete", + relationEntityId: "orders", + requestKey: "binding:1", + status: "ready", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items.map((item) => item.detail)).toEqual([ + "VARCHAR — o", + "VARCHAR — u", + ]); + expect(result?.value.items.map((item) => item.edit.insert)).toEqual([ + "id", + "id", + ]); + }); +}); diff --git a/src/vnext/__tests__/column-query-site.test.ts b/src/vnext/__tests__/column-query-site.test.ts new file mode 100644 index 0000000..abddfc7 --- /dev/null +++ b/src/vnext/__tests__/column-query-site.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it } from "vitest"; +import { MAX_BOUNDED_SQL_LEXEMES } from "../bounded-sql-lexer.js"; +import { + MAX_COLUMN_QUERY_RELATIONS, + recognizeSqlColumnQuerySite, + type SqlColumnQuerySiteResult, +} from "../column-query-site.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, +} from "../statement-index.js"; + +function analyze( + marked: string, + options: { + readonly dialect?: SqlRelationDialectRuntime; + readonly regions?: readonly { + readonly from: number; + readonly language: string; + readonly to: number; + }[]; + } = {}, +): SqlColumnQuerySiteResult { + const position = marked.indexOf("|"); + if (position < 0 || marked.indexOf("|", position + 1) >= 0) { + throw new Error("Fixture requires exactly one cursor marker"); + } + const text = marked.slice(0, position) + marked.slice(position + 1); + const dialect = options.dialect ?? POSTGRESQL_SQL_RELATION_DIALECT; + const source = options.regions + ? createMaskedSqlSource(text, options.regions) + : createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + return recognizeSqlColumnQuerySite( + source, + slot, + position, + dialect, + ); +} + +function ready( + result: SqlColumnQuerySiteResult, +): Extract { + if (result.status !== "ready") { + throw new Error(`Expected ready, received ${JSON.stringify(result)}`); + } + expect(result.status).toBe("ready"); + return result; +} + +describe("recognizeSqlColumnQuerySite", () => { + it("finds a qualified SELECT-list site and its aliased relation", () => { + const result = ready( + analyze("SELECT u.na| FROM app.users AS u"), + ); + + expect(result).toMatchObject({ + coverage: "complete", + prefix: { quoted: false, value: "na" }, + qualifier: [{ quoted: false, value: "u" }], + replacementRange: { from: 9, to: 11 }, + }); + expect(result.relations).toEqual([{ + alias: { quoted: false, value: "u" }, + path: [ + { quoted: false, value: "app" }, + { quoted: false, value: "users" }, + ], + range: { from: 17, to: 26 }, + }]); + }); + + it("collects comma and join relations for expression clauses", () => { + const result = ready( + analyze( + "SELECT * FROM users u, ignored i JOIN orders AS o ON o.id = u.id WHERE cr|", + ), + ); + + expect(result.prefix.value).toBe("cr"); + expect(result.qualifier).toEqual([]); + expect(result.relations.map((relation) => relation.alias?.value)) + .toEqual(["u", "i", "o"]); + }); + + it("includes correlated outer-query relations", () => { + const result = ready( + analyze( + "SELECT * FROM outer_table o WHERE EXISTS (SELECT i.na| FROM inner_table i)", + ), + ); + + expect(result.qualifier[0]?.value).toBe("i"); + expect(result.coverage).toBe("partial"); + expect(result.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(["inner_table", "outer_table"]); + }); + + it("does not correlate an ordinary derived table", () => { + const result = ready( + analyze( + "SELECT * FROM (SELECT i.na| FROM inner_table i) d JOIN outer_table o ON true", + ), + ); + + expect(result.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(["inner_table"]); + }); + + it("correlates LATERAL tables only to preceding relations", () => { + for ( + const dialect of [ + POSTGRESQL_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + ] + ) { + for (const expression of ["u.na|", "na|"]) { + const result = ready( + analyze( + `SELECT * FROM users u, LATERAL (SELECT ${expression} FROM orders o) x, secrets s`, + { dialect }, + ), + ); + + expect(result.relations.map((relation) => + relation.alias?.value + )).toEqual(["o", "u"]); + } + } + }); + + it("limits correlation in a JOIN condition to prior relations", () => { + const result = ready( + analyze( + "SELECT * FROM users u JOIN orders o ON EXISTS (SELECT u.|) JOIN secrets s ON true", + ), + ); + + expect(result.relations.map((relation) => + relation.alias?.value + )).toEqual(["u", "o"]); + }); + + it("walks nested correlation without adopting a closed sibling", () => { + const nested = ready( + analyze( + "SELECT * FROM root_table r WHERE EXISTS (SELECT * FROM mid_table m WHERE EXISTS (SELECT r.|))", + ), + ); + expect(nested.relations.map((relation) => + relation.alias?.value + )).toEqual(["m", "r"]); + + const sibling = ready( + analyze( + "SELECT * FROM root_table r WHERE EXISTS (SELECT 1) AND EXISTS (SELECT r.|)", + ), + ); + expect(sibling.relations.map((relation) => + relation.alias?.value + )).toEqual(["r"]); + }); + + it("allows a parenthesized top-level query without an outer scope", () => { + const result = ready(analyze("(SELECT | FROM inner_table)")); + expect(result.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(["inner_table"]); + }); + + it("keeps set-operation arms and JOIN visibility isolated", () => { + const firstArm = ready( + analyze("SELECT | FROM users UNION SELECT x FROM secrets"), + ); + expect(firstArm.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(["users"]); + + const secondArm = ready( + analyze("SELECT x FROM users UNION SELECT | FROM secrets"), + ); + expect(secondArm.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(["secrets"]); + + const joinCondition = ready( + analyze( + "SELECT * FROM users u JOIN orders o ON o.user_id = u.| JOIN payments p ON true", + ), + ); + expect(joinCondition.relations.map((relation) => + relation.alias?.value + )).toEqual(["u", "o"]); + }); + + it("supports BigQuery quoted multipart bindings", () => { + const result = ready( + analyze("SELECT t.| FROM `project.dataset.table` AS t", { + dialect: BIGQUERY_SQL_RELATION_DIALECT, + }), + ); + + expect(result.relations[0]).toMatchObject({ + alias: { quoted: false, value: "t" }, + path: [ + { quoted: true, value: "project" }, + { quoted: true, value: "dataset" }, + { quoted: true, value: "table" }, + ], + }); + }); + + it.each([ + ["SELECT * FROM us|", "not-column-position"], + ["INSERT INTO users VALUES (|)", "not-select-query"], + ["SELECT 'abc|' FROM users", "cursor-in-string"], + ["SELECT /* abc| */ 1 FROM users", "cursor-in-comment"], + ] as const)("fails closed for %s", (marked, reason) => { + expect(analyze(marked)).toMatchObject({ reason, status: "inactive" }); + }); + + it("fails closed inside an embedded region", () => { + const marked = "SELECT {value|} FROM users"; + const position = marked.indexOf("|"); + expect( + analyze(marked, { + regions: [{ + from: marked.indexOf("{"), + language: "python", + to: marked.indexOf("}") + 1, + }], + }), + ).toMatchObject({ + reason: "cursor-in-embedded-region", + status: "inactive", + }); + expect(position).toBeGreaterThan(0); + }); + + it("marks derived and table-function evidence partial", () => { + expect( + ready( + analyze( + "SELECT x.| FROM (SELECT 1) x JOIN UNNEST(items) AS item ON true", + ), + ), + ).toMatchObject({ + coverage: "partial", + issues: [ + "derived-relation", + "nested-query", + "table-function", + ], + }); + }); + + it.each([ + "SELECT * FROM users WHERE na|", + "SELECT * FROM users GROUP BY na|", + "SELECT * FROM users HAVING na|", + "SELECT * FROM users QUALIFY na|", + "SELECT * FROM users ORDER BY na|", + "SELECT * FROM users u JOIN orders o ON o.id = u.|", + "SELECT * FROM users u JOIN orders o USING (i|)", + ])("recognizes expression clause sites in %s", (marked) => { + expect(ready(analyze(marked)).relations.length).toBeGreaterThan(0); + }); + + it.each([ + "SELECT * FROM users LIMIT |", + "SELECT * FROM users FETCH |", + "SELECT * FROM users OFFSET |", + ])("rejects row-limit clause sites in %s", (marked) => { + expect(analyze(marked)).toMatchObject({ + reason: "not-column-position", + status: "inactive", + }); + }); + + it("supports implicit quoted aliases and incomplete relations", () => { + expect( + ready(analyze('SELECT "u".| FROM app.users "u"')).relations[0], + ).toMatchObject({ + alias: { quoted: true, value: "u" }, + }); + expect(ready(analyze("SELECT | FROM"))).toMatchObject({ + coverage: "partial", + issues: ["incomplete-relation"], + relations: [], + }); + expect(ready(analyze('SELECT | FROM ""'))).toMatchObject({ + coverage: "partial", + issues: ["incomplete-relation"], + relations: [], + }); + expect(ready(analyze("SELECT | FROM users AS"))).toMatchObject({ + coverage: "partial", + issues: ["incomplete-relation"], + relations: [{ + alias: null, + path: [{ value: "users" }], + }], + }); + expect(ready(analyze("SELECT | FROM users AS WHERE true"))) + .toMatchObject({ + coverage: "partial", + issues: ["incomplete-relation"], + }); + expect(ready(analyze('SELECT | FROM users AS "u"')).relations[0]) + .toMatchObject({ + alias: { quoted: true, value: "u" }, + }); + }); + + it("fails closed for malformed typed paths and line comments", () => { + expect(analyze("SELECT u..| FROM users u")).toMatchObject({ + reason: "not-column-position", + status: "inactive", + }); + expect(analyze("SELECT 1 -- co|mment\nFROM users")).toMatchObject({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect(analyze("SELECT 1 -- comment|")).toMatchObject({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect(analyze("SELECT 1 -- comment|\nFROM users")).toMatchObject({ + reason: "cursor-in-comment", + status: "inactive", + }); + expect( + analyze("SELECT 1 # comment|", { + dialect: BIGQUERY_SQL_RELATION_DIALECT, + }), + ).toMatchObject({ + reason: "cursor-in-comment", + status: "inactive", + }); + }); + + it("bounds lexical work before relation analysis", () => { + const expression = Array.from( + { length: MAX_BOUNDED_SQL_LEXEMES + 1 }, + () => "x", + ).join("+"); + expect(analyze(`SELECT ${expression}| FROM users`)).toMatchObject({ + reason: "resource-limit", + status: "unavailable", + }); + }); + + it("bounds relation materialization", () => { + const joins = Array.from( + { length: MAX_COLUMN_QUERY_RELATIONS + 1 }, + (_, index) => ` JOIN table_${index} t_${index} ON true`, + ).join(""); + expect(analyze(`SELECT | FROM base b${joins}`)).toMatchObject({ + reason: "resource-limit", + status: "unavailable", + }); + expect(analyze(`SELECT (SELECT |) FROM base b${joins}`)) + .toMatchObject({ + reason: "resource-limit", + status: "unavailable", + }); + }); + + it("rejects foreign contract values without inspecting them", () => { + let inspected = false; + const hostile = new Proxy({}, { + get() { + inspected = true; + throw new Error("hostile"); + }, + }); + expect( + Reflect.apply(recognizeSqlColumnQuerySite, undefined, [ + hostile, + hostile, + 0, + hostile, + ]), + ).toMatchObject({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect(inspected).toBe(false); + }); + + it("rejects each unauthenticated argument and invalid position", () => { + const source = createIdentitySqlSource("SELECT |"); + const position = source.analysisText.indexOf("|"); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + for (const input of [ + [{}, slot, position, POSTGRESQL_SQL_RELATION_DIALECT], + [source, {}, position, POSTGRESQL_SQL_RELATION_DIALECT], + [source, slot, position, {}], + [source, slot, Number.NaN, POSTGRESQL_SQL_RELATION_DIALECT], + [source, slot, -1, POSTGRESQL_SQL_RELATION_DIALECT], + [ + source, + slot, + source.analysisText.length + 1, + POSTGRESQL_SQL_RELATION_DIALECT, + ], + ]) { + expect( + Reflect.apply(recognizeSqlColumnQuerySite, undefined, input), + ).toMatchObject({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + } + }); + + it("rejects a cursor outside its authenticated exact slot", () => { + const text = + "SELECT x FROM first_table WHERE ; SELECT y FROM second_table"; + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + text, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const first = findSqlStatementSlot(index, 0, "right"); + const secondPosition = text.indexOf("y FROM"); + + expect( + recognizeSqlColumnQuerySite( + source, + first, + secondPosition, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "not-column-position", + status: "inactive", + }); + }); + + it("preserves opaque statement failures", () => { + const source = createIdentitySqlSource("DELIMITER $$"); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + expect(slot.boundaryQuality).toBe("opaque"); + expect( + recognizeSqlColumnQuerySite( + source, + slot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ + reason: "opaque-statement", + status: "unavailable", + }); + }); +}); diff --git a/src/vnext/__tests__/hostile-data.test.ts b/src/vnext/__tests__/hostile-data.test.ts new file mode 100644 index 0000000..f5194d2 --- /dev/null +++ b/src/vnext/__tests__/hostile-data.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isDataArray } from "../types.js"; + +describe("hostile data arrays", () => { + it("recognizes arrays without invoking elements", () => { + expect(isDataArray([])).toBe(true); + expect(isDataArray({})).toBe(false); + }); + + it("contains revoked and hostile proxy traps", () => { + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + expect(isDataArray(revoked.proxy)).toBe(false); + }); +}); diff --git a/src/vnext/__tests__/local-relation-site.test.ts b/src/vnext/__tests__/local-relation-site.test.ts index 899e6c1..1a1c56f 100644 --- a/src/vnext/__tests__/local-relation-site.test.ts +++ b/src/vnext/__tests__/local-relation-site.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + analyzeSqlLocalColumnSite, analyzeSqlLocalRelationSite, prepareSqlLocalRelationStatement, type SqlLocalRelationSiteResult, @@ -411,6 +412,34 @@ describe("local relation-site evidence", () => { ); }); + it("fails column analysis closed outside the prepared statement", () => { + const text = + "SELECT x FROM first_table WHERE ; SELECT y FROM second_table"; + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + text, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const first = findSqlStatementSlot(index, 0, "right"); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + first, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (preparation.status !== "ready") { + throw new Error("Expected exact first-statement preparation"); + } + + expect(analyzeSqlLocalColumnSite( + preparation.statement, + text.indexOf("y FROM"), + )).toEqual({ + reason: "not-column-position", + status: "inactive", + }); + }); + it("separates qualified sites from irrelevant CTE uncertainty", () => { const ready = expectReady( analyzeMarked( @@ -495,6 +524,24 @@ describe("local relation-site evidence", () => { reason: "ambiguous-query-site", status: "unavailable", }); + expect( + Reflect.apply(analyzeSqlLocalColumnSite, undefined, [ + null, + fixture.position, + ]), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect( + analyzeSqlLocalColumnSite( + { ...preparation.statement }, + fixture.position, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); }); it("is deterministic and freezes every exposed wrapper", () => { diff --git a/src/vnext/__tests__/namespace-catalog-boundary.test.ts b/src/vnext/__tests__/namespace-catalog-boundary.test.ts new file mode 100644 index 0000000..bf2cae6 --- /dev/null +++ b/src/vnext/__tests__/namespace-catalog-boundary.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from "vitest"; +import { + captureSqlNamespaceCatalogProvider, + createSqlNamespaceCatalogSearchRequest, + decodeSqlNamespaceCatalogSearchResponse, + MAX_NAMESPACE_RESULTS, + resolveSqlNamespaceCatalogProvider, +} from "../namespace-catalog-boundary.js"; + +const epoch = Object.freeze({ generation: 1, token: "one" }); + +function request(expectedEpoch = epoch) { + const result = createSqlNamespaceCatalogSearchRequest({ + dialectId: "duckdb", + expectedEpoch, + limit: 10, + prefix: { quoted: false, value: "ma" }, + qualifier: [{ quoted: false, value: "memory" }], + scope: "connection", + searchPaths: [[{ quoted: false, value: "main" }]], + }); + if (result.status !== "accepted") throw new Error("request"); + return result.value; +} + +function captured(search: unknown = vi.fn()) { + const result = captureSqlNamespaceCatalogProvider({ + id: "namespaces", + search, + }); + if (result.status !== "accepted") throw new Error("provider"); + return result.value; +} + +function container( + id: string, + role: "catalog" | "dataset" | "project" | "schema", + value: string, +) { + return { + canonicalPath: [{ + quoted: false, + role, + value, + }], + containerEntityId: id, + detail: `${role} detail`, + insertText: value, + matchQuality: "exact", + }; +} + +describe("namespace catalog boundary", () => { + it("captures provider methods without exposing a receiver", () => { + const receivers: unknown[] = []; + const search = function (this: unknown) { + receivers.push(this); + }; + const handle = captured(search); + const context = resolveSqlNamespaceCatalogProvider(handle); + expect(context?.id).toBe("namespaces"); + context?.search(request(), new AbortController().signal); + expect(receivers).toEqual([undefined]); + expect(Reflect.apply( + resolveSqlNamespaceCatalogProvider, + undefined, + [{}], + )).toBeNull(); + expect(Object.isFrozen(handle)).toBe(true); + }); + + it("rejects hostile or invalid providers", () => { + const accessor = {}; + Object.defineProperty(accessor, "id", { get: vi.fn() }); + expect(captureSqlNamespaceCatalogProvider(accessor).status) + .toBe("malformed"); + expect(captureSqlNamespaceCatalogProvider(null).status) + .toBe("malformed"); + expect(captureSqlNamespaceCatalogProvider({ + id: "", + search() {}, + }).status).toBe("malformed"); + expect(captureSqlNamespaceCatalogProvider({ + id: "x", + search: 1, + }).status).toBe("malformed"); + expect(captureSqlNamespaceCatalogProvider({ + extra: true, + id: "x", + search() {}, + }).status).toBe("malformed"); + expect(captureSqlNamespaceCatalogProvider( + new Proxy({}, { ownKeys: () => { throw new Error("trap"); } }), + ).status).toBe("malformed"); + }); + + it("normalizes and freezes one bounded query-site search", () => { + const value = request(); + expect(value).toEqual({ + dialectId: "duckdb", + expectedEpoch: epoch, + limit: 10, + prefix: { quoted: false, value: "ma" }, + qualifier: [{ quoted: false, value: "memory" }], + scope: "connection", + searchPaths: [[{ quoted: false, value: "main" }]], + }); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.prefix)).toBe(true); + expect(Object.isFrozen(value.qualifier)).toBe(true); + expect(Object.isFrozen(value.searchPaths[0])).toBe(true); + expect(createSqlNamespaceCatalogSearchRequest({ + ...value, + expectedEpoch: null, + }).status).toBe("accepted"); + }); + + it("rejects malformed, sparse, accessor, and oversized requests", () => { + const valid = request(); + const sparse: unknown[] = []; + sparse.length = 1; + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + const cases: unknown[] = [ + null, + { ...valid, extra: true }, + { ...valid, dialectId: "" }, + { ...valid, scope: "" }, + { ...valid, expectedEpoch: undefined }, + { ...valid, expectedEpoch: { generation: -1, token: "x" } }, + { ...valid, expectedEpoch: { generation: 1.5, token: "x" } }, + { ...valid, expectedEpoch: { generation: 1, token: "" } }, + { ...valid, limit: 0 }, + { ...valid, limit: MAX_NAMESPACE_RESULTS + 1 }, + { ...valid, limit: 1.5 }, + { ...valid, prefix: { quoted: "no", value: "x" } }, + { ...valid, prefix: { quoted: false, value: "x", extra: 1 } }, + { ...valid, qualifier: [{ quoted: false, value: "" }] }, + { ...valid, searchPaths: [[{ quoted: false, value: "" }]] }, + { ...valid, qualifier: sparse }, + { ...valid, searchPaths: sparse }, + { ...valid, qualifier: revoked.proxy }, + { ...valid, qualifier: [{ quoted: false, value: "x".repeat(257) }] }, + { ...valid, searchPaths: Array.from({ length: 33 }, () => []) }, + ]; + const accessor = { ...valid }; + Object.defineProperty(accessor, "prefix", { get: vi.fn() }); + cases.push(accessor); + cases.push({ + ...valid, + qualifier: new Proxy([], { + getOwnPropertyDescriptor: (_target, key) => { + if (key === "length") throw new Error("length trap"); + return undefined; + }, + }), + }); + cases.push({ + ...valid, + qualifier: new Proxy([{ quoted: false, value: "x" }], { + getOwnPropertyDescriptor: (target, key) => { + if (key === "0") throw new Error("element trap"); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }), + }); + for (const value of cases) { + expect(createSqlNamespaceCatalogSearchRequest(value).status) + .toBe("malformed"); + } + expect(createSqlNamespaceCatalogSearchRequest({ + ...valid, + prefix: { quoted: false, value: "" }, + qualifier: [], + }).status).toBe("accepted"); + }); + + it("decodes, deduplicates, orders, and freezes all container roles", () => { + const provider = captured(); + const decoded = decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + { + containers: [ + container("schema", "schema", "main"), + { ...container("schema", "schema", "main") }, + container("project", "project", "alpha"), + container("dataset", "dataset", "events"), + container("catalog", "catalog", "memory"), + ], + coverage: "partial", + epoch, + status: "ready", + }, + ); + expect(decoded.status).toBe("accepted"); + if (decoded.status !== "accepted" || + decoded.value.status !== "ready") { + throw new Error("Expected a ready namespace response"); + } + expect(decoded.value.containers.map((value) => + value.canonicalPath[0]?.role + )).toEqual(["catalog", "dataset", "project", "schema"]); + expect(decoded.value.containers[0]?.provenance).toEqual({ + containerEntityId: "catalog", + epoch, + providerId: "namespaces", + scope: "connection", + }); + expect(Object.isFrozen(decoded.value)).toBe(true); + expect(Object.isFrozen(decoded.value.containers)).toBe(true); + expect(Object.isFrozen( + decoded.value.containers[0]?.canonicalPath, + )).toBe(true); + }); + + it("decodes loading and normalized failures", () => { + const provider = captured(); + expect(decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + { epoch, status: "loading" }, + )).toMatchObject({ + status: "accepted", + value: { status: "loading" }, + }); + expect(decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + { + code: "rate-limited", + epoch, + retry: "next-request", + status: "failed", + }, + )).toMatchObject({ + status: "accepted", + value: { + code: "rate-limited", + retry: "next-request", + status: "failed", + }, + }); + }); + + it("orders tied identities and accepts quoted paths without detail", () => { + const provider = captured(); + const base = { + canonicalPath: [{ + quoted: true, + role: "schema", + value: "Main", + }], + insertText: "\"Main\"", + matchQuality: "exact", + }; + const decoded = decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + { + containers: [ + { ...base, containerEntityId: "z" }, + { ...base, containerEntityId: "a" }, + ], + coverage: "complete", + epoch, + status: "ready", + }, + ); + expect(decoded).toMatchObject({ + status: "accepted", + value: { + containers: [ + { containerEntityId: "a" }, + { containerEntityId: "z" }, + ], + }, + }); + }); + + it("orders raw identifier values by UTF-16 code units", () => { + const decoded = decodeSqlNamespaceCatalogSearchResponse( + captured(), + request(), + { + containers: [ + container("hash", "schema", "#"), + container("quote", "schema", "\""), + container("slash", "schema", "\\"), + container("control", "schema", "\u0000"), + ], + coverage: "complete", + epoch, + status: "ready", + }, + ); + expect(decoded).toMatchObject({ + status: "accepted", + value: { + containers: [ + { containerEntityId: "control" }, + { containerEntityId: "quote" }, + { containerEntityId: "hash" }, + { containerEntityId: "slash" }, + ], + }, + }); + }); + + it("compares canonical paths structurally when they contain NUL", () => { + const provider = captured(); + const base = { + containerEntityId: "same", + insertText: "value", + matchQuality: "exact", + }; + const decoded = decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + { + containers: [ + { + ...base, + canonicalPath: [{ + quoted: false, + role: "schema", + value: "a\u0000catalog:u:b", + }], + }, + { + ...base, + canonicalPath: [ + { quoted: false, role: "schema", value: "a" }, + { quoted: false, role: "catalog", value: "b" }, + ], + }, + ], + coverage: "complete", + epoch, + status: "ready", + }, + ); + expect(decoded).toEqual({ + reason: "duplicate-entity-id", + status: "malformed", + }); + }); + + it("rejects malformed, conflicting, stale, and oversized responses", () => { + const provider = captured(); + const valid = { + containers: [container("one", "schema", "main")], + coverage: "complete", + epoch, + status: "ready", + }; + const conflicting = { + ...valid, + containers: [ + container("same", "schema", "main"), + container("same", "schema", "other"), + ], + }; + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + const malformedContainers = [ + { ...container("x", "schema", "x"), canonicalPath: [] }, + { ...container("x", "schema", "x"), canonicalPath: [{ + quoted: false, + role: "schema", + value: "", + }] }, + { ...container("x", "schema", "x"), canonicalPath: [{ + quoted: false, + role: "table", + value: "x", + }] }, + { ...container("x", "schema", "x"), containerEntityId: "" }, + { ...container("x", "schema", "x"), insertText: "" }, + { ...container("x", "schema", "x"), matchQuality: "bad" }, + { ...container("x", "schema", "x"), detail: 1 }, + ]; + const cases: unknown[] = [ + null, + { ...valid, extra: true }, + { ...valid, epoch: { generation: 2, token: "two" } }, + { ...valid, coverage: "unknown" }, + { ...valid, containers: Array.from( + { length: 11 }, + (_, index) => container(String(index), "schema", String(index)), + ) }, + conflicting, + { ...valid, containers: [null] }, + { ...valid, containers: revoked.proxy }, + { epoch, extra: true, status: "loading" }, + { code: "bad", epoch, retry: "never", status: "failed" }, + { code: "unknown", epoch, retry: "bad", status: "failed" }, + { code: "unknown", epoch, retry: "never", status: "failed", x: 1 }, + ...malformedContainers.map((value) => ({ + ...valid, + containers: [value], + })), + ]; + for (const value of cases) { + expect(decodeSqlNamespaceCatalogSearchResponse( + provider, + request(), + value, + ).status).toBe("malformed"); + } + expect(Reflect.apply( + decodeSqlNamespaceCatalogSearchResponse, + undefined, + [{}, request(), valid], + )).toMatchObject({ status: "malformed" }); + }); +}); diff --git a/src/vnext/__tests__/namespace-catalog-coordinator.bench.ts b/src/vnext/__tests__/namespace-catalog-coordinator.bench.ts new file mode 100644 index 0000000..47489ee --- /dev/null +++ b/src/vnext/__tests__/namespace-catalog-coordinator.bench.ts @@ -0,0 +1,71 @@ +import { beforeAll, bench, describe } from "vitest"; +import { + createSqlNamespaceCatalogCoordinator, +} from "../namespace-catalog-coordinator.js"; +import type { + SqlNamespaceCatalogSearchRequest, +} from "../namespace-catalog-types.js"; + +const epoch = Object.freeze({ generation: 1, token: "bench" }); + +function response(request: SqlNamespaceCatalogSearchRequest) { + return { + containers: Array.from({ length: 100 }, (_, index) => ({ + canonicalPath: [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: `schema_${index}` }, + ], + containerEntityId: `schema:${index}`, + insertText: `schema_${index}`, + matchQuality: "exact", + })), + coverage: "complete", + epoch: request.expectedEpoch ?? epoch, + status: "ready", + }; +} + +function owner() { + const created = createSqlNamespaceCatalogCoordinator({ + provider: { + id: "benchmark", + search: (request: SqlNamespaceCatalogSearchRequest) => + Promise.resolve(response(request)), + }, + }); + if (created.status !== "created") throw new Error("coordinator"); + const prepared = created.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "benchmark", + }); + if (prepared.status !== "prepared") throw new Error("owner"); + return prepared.owner; +} + +const search = Object.freeze({ + expectedEpoch: epoch, + limit: 100, + prefix: Object.freeze({ quoted: false, value: "schema" }), + qualifier: Object.freeze([ + Object.freeze({ quoted: false, value: "memory" }), + ]), + searchPaths: Object.freeze([]), +}); + +describe("namespace catalog coordinator", () => { + bench("cold 100-container search", async () => { + const current = owner(); + await current.request(search).result; + current.dispose(); + }); + + const warm = owner(); + const primed = warm.request(search).result; + beforeAll(async () => { + await primed; + }); + + bench("warm 100-container cache lookup", async () => { + await warm.request(search).result; + }); +}); diff --git a/src/vnext/__tests__/namespace-catalog-coordinator.test.ts b/src/vnext/__tests__/namespace-catalog-coordinator.test.ts new file mode 100644 index 0000000..3760d80 --- /dev/null +++ b/src/vnext/__tests__/namespace-catalog-coordinator.test.ts @@ -0,0 +1,631 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSqlNamespaceCatalogCoordinator, + type SqlNamespaceCatalogSearchOutcome, +} from "../namespace-catalog-coordinator.js"; +import { + composeSqlNamespaceCompletion, + prepareSqlNamespaceCatalogSearch, +} from "../namespace-completion.js"; +import type { + SqlNamespaceCatalogResolvedContainer, + SqlNamespaceCatalogSearchRequest, +} from "../namespace-catalog-types.js"; +import type { SqlCatalogEpoch } from "../relation-completion-types.js"; + +const epoch: SqlCatalogEpoch = + Object.freeze({ generation: 1, token: "one" }); +const nextEpoch: SqlCatalogEpoch = + Object.freeze({ generation: 2, token: "two" }); + +function input( + prefix = "ma", + expectedEpoch: SqlCatalogEpoch | null = epoch, +) { + return { + expectedEpoch, + limit: 10, + prefix: { quoted: false, value: prefix }, + qualifier: [{ quoted: false, value: "memory" }], + searchPaths: [[{ quoted: false, value: "main" }]], + }; +} + +function response( + request: SqlNamespaceCatalogSearchRequest, + coverage: "complete" | "partial" = "complete", +) { + return { + containers: [{ + canonicalPath: [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + ], + containerEntityId: `schema:${request.prefix.value}`, + detail: "Schema", + insertText: "main", + matchQuality: "exact", + }], + coverage, + epoch: request.expectedEpoch ?? epoch, + status: "ready", + }; +} + +function setup( + search: ( + request: SqlNamespaceCatalogSearchRequest, + signal: AbortSignal, + ) => unknown, + maxCacheEntries = 16, +) { + const created = createSqlNamespaceCatalogCoordinator({ + maxCacheEntries, + provider: { id: "namespaces", search }, + }); + if (created.status !== "created") throw new Error("coordinator"); + const prepared = created.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "connection", + }); + if (prepared.status !== "prepared") throw new Error("owner"); + return { + coordinator: created.coordinator, + owner: prepared.owner, + }; +} + +function deferred() { + let resolve: (value: Value) => void = (): void => {}; + let reject: (reason?: unknown) => void = (): void => {}; + const promise = new Promise((resolve_, reject_) => { + resolve = resolve_; + reject = reject_; + }); + return { promise, reject, resolve }; +} + +describe("namespace catalog coordinator", () => { + it("performs exactly one bounded search for one query site", async () => { + const searches: SqlNamespaceCatalogSearchRequest[] = []; + const { coordinator, owner } = setup((request) => { + searches.push(request); + return response(request); + }); + const outcome = await owner.request({ + ...input(), + prefix: { quoted: true, value: "ma" }, + }).result; + expect(searches).toHaveLength(1); + expect(searches[0]).toMatchObject({ + dialectId: "duckdb", + limit: 10, + prefix: { quoted: true, value: "ma" }, + qualifier: [{ value: "memory" }], + scope: "connection", + }); + expect(outcome).toMatchObject({ + providerId: "namespaces", + response: { status: "ready" }, + scope: "connection", + status: "usable", + }); + expect(Object.isFrozen(outcome)).toBe(true); + coordinator.dispose(); + }); + + it("observes cold epochs and caches complete searches", async () => { + const expected: Array = []; + const { owner } = setup((request) => { + expected.push(request.expectedEpoch); + return { ...response(request), epoch }; + }); + await owner.request(input("ma", null)).result; + const cached = await owner.request(input("ma", null)).result; + await owner.request(input("ma", nextEpoch)).result; + expect(expected).toEqual([null, nextEpoch]); + expect(cached.status).toBe("usable"); + }); + + it("does not cache partial, loading, or failed results", async () => { + let calls = 0; + const { owner } = setup((request) => { + calls += 1; + if (calls <= 2) return response(request, "partial"); + if (calls <= 4) { + return { epoch, status: "loading" }; + } + return { + code: "unavailable", + epoch, + retry: "next-request", + status: "failed", + }; + }); + await owner.request(input("partial")).result; + await owner.request(input("partial")).result; + await owner.request(input("loading")).result; + await owner.request(input("loading")).result; + await owner.request(input("failed")).result; + await owner.request(input("failed")).result; + expect(calls).toBe(6); + }); + + it("cancels and supersedes pending owner searches", async () => { + const pending: Array>> = []; + const signals: AbortSignal[] = []; + const { owner } = setup((_request, signal) => { + const work = deferred(); + pending.push(work); + signals.push(signal); + return work.promise; + }); + const first = owner.request(input("first")); + const second = owner.request(input("second")); + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + expect(signals[0]?.aborted).toBe(true); + second.cancel(); + second.cancel(); + await expect(second.result).resolves.toEqual({ + status: "cancelled", + }); + expect(signals[1]?.aborted).toBe(true); + pending[0]?.resolve({}); + pending[1]?.reject(new Error("late")); + await Promise.resolve(); + }); + + it("preserves the newest request across reentrant abort handlers", async () => { + const signals: AbortSignal[] = []; + let owner: ReturnType["owner"] | null = null; + const reentrant: { + ticket: + | ReturnType["owner"]["request"]> + | null; + } = { ticket: null }; + const configured = setup((_request, signal) => { + signals.push(signal); + if (signals.length === 1) { + signal.addEventListener("abort", () => { + reentrant.ticket = owner?.request(input("reentrant")) ?? null; + }, { once: true }); + } + return new Promise(() => undefined); + }); + owner = configured.owner; + const first = owner.request(input("first")); + const interrupted = owner.request(input("interrupted")); + + await expect(first.result).resolves.toEqual({ + status: "superseded", + }); + await expect(interrupted.result).resolves.toEqual({ + status: "superseded", + }); + expect(signals.map((signal) => signal.aborted)).toEqual([ + true, + false, + ]); + + const newest = owner.request(input("newest")); + expect(signals.map((signal) => signal.aborted)).toEqual([ + true, + true, + false, + ]); + await expect(reentrant.ticket?.result).resolves.toEqual({ + status: "superseded", + }); + newest.cancel(); + }); + + it.each(["owner", "coordinator"] as const)( + "does not resurrect work after reentrant %s disposal", + async (target) => { + const signals: AbortSignal[] = []; + let dispose = (): void => {}; + const configured = setup((_request, signal) => { + signals.push(signal); + if (signals.length === 1) { + signal.addEventListener("abort", () => dispose(), { + once: true, + }); + } + return new Promise(() => undefined); + }); + dispose = target === "owner" + ? configured.owner.dispose + : configured.coordinator.dispose; + const first = configured.owner.request(input("first")); + const interrupted = configured.owner.request(input("interrupted")); + + await expect(first.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + await expect(interrupted.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(signals).toHaveLength(1); + expect(signals[0]?.aborted).toBe(true); + }, + ); + + it("isolates owners and settles disposal", async () => { + const pending: Array>> = []; + const { coordinator, owner } = setup(() => { + const work = deferred(); + pending.push(work); + return work.promise; + }); + const other = coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "connection", + }); + if (other.status !== "prepared") throw new Error("second owner"); + const first = owner.request(input("first")); + const second = other.owner.request(input("second")); + owner.dispose(); + owner.dispose(); + await expect(first.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + let secondSettled = false; + void second.result.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); + coordinator.dispose(); + coordinator.dispose(); + await expect(second.result).resolves.toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "connection", + })).toMatchObject({ + reason: "disposed", + status: "unavailable", + }); + expect(owner.request(input())).toBeDefined(); + }); + + it("contains provider failures and malformed settlements", async () => { + const thrown = setup(() => { + throw new Error("sync"); + }); + await expect(thrown.owner.request(input()).result).resolves + .toMatchObject({ + reason: "provider-failed", + status: "unavailable", + }); + const rejected = setup(() => Promise.reject(new Error("async"))); + await expect(rejected.owner.request(input()).result).resolves + .toMatchObject({ + reason: "provider-failed", + status: "unavailable", + }); + const malformed = setup(() => ({})); + await expect(malformed.owner.request(input()).result).resolves + .toMatchObject({ + reason: "malformed-response", + status: "unavailable", + }); + }); + + it("bounds the LRU and re-fetches evicted searches", async () => { + let calls = 0; + const { owner } = setup((request) => { + calls += 1; + return response(request); + }, 1); + await owner.request(input("one")).result; + await owner.request(input("two")).result; + await owner.request(input("one")).result; + expect(calls).toBe(3); + }); + + it("fails closed for hostile or invalid configuration and inputs", async () => { + expect(createSqlNamespaceCatalogCoordinator(null)).toMatchObject({ + reason: "invalid-provider", + status: "unavailable", + }); + expect(createSqlNamespaceCatalogCoordinator({ + maxCacheEntries: 0, + provider: { id: "x", search() {} }, + })).toMatchObject({ + reason: "invalid-options", + status: "unavailable", + }); + const created = createSqlNamespaceCatalogCoordinator({ + provider: { id: "x", search() {} }, + }); + if (created.status !== "created") throw new Error("created"); + expect(created.coordinator.prepareOwner({ + dialectId: "", + scope: "", + })).toMatchObject({ + reason: "invalid-options", + status: "unavailable", + }); + const prepared = created.coordinator.prepareOwner({ + dialectId: "duckdb", + scope: "scope", + }); + if (prepared.status !== "prepared") throw new Error("prepared"); + const invalidTicket = prepared.owner.request({ + ...input(), + limit: 0, + }); + invalidTicket.cancel(); + expect(await invalidTicket.result).toMatchObject({ + reason: "invalid-request", + status: "unavailable", + }); + const hostile = {}; + Object.defineProperty(hostile, "expectedEpoch", { get: vi.fn() }); + const hostileTicket = Reflect.apply( + prepared.owner.request, + undefined, + [hostile], + ); + expect(await hostileTicket.result).toMatchObject({ + reason: "invalid-request", + status: "unavailable", + }); + }); +}); + +describe("namespace completion composer", () => { + const readyOutcome: Extract< + SqlNamespaceCatalogSearchOutcome, + { readonly status: "usable" } + > = { + providerId: "namespaces", + response: { + containers: [ + { + canonicalPath: [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + ], + containerEntityId: "main", + detail: "Schema", + insertText: "main", + matchQuality: "exact", + provenance: { + containerEntityId: "main", + epoch, + providerId: "namespaces", + scope: "connection", + }, + }, + { + canonicalPath: [ + { quoted: false, role: "project", value: "alpha" }, + { quoted: false, role: "dataset", value: "metrics" }, + ], + containerEntityId: "metrics", + insertText: "metrics", + matchQuality: "equivalent", + provenance: { + containerEntityId: "metrics", + epoch, + providerId: "namespaces", + scope: "connection", + }, + }, + ], + coverage: "complete", + epoch, + status: "ready", + }, + scope: "connection", + status: "usable", + }; + + function catalogResponse() { + if (readyOutcome.response.status !== "ready") { + throw new Error("ready response"); + } + return readyOutcome.response; + } + + it("prepares one immutable search from a query site", () => { + const prepared = prepareSqlNamespaceCatalogSearch({ + prefix: { quoted: false, value: "ma" }, + qualifier: [{ quoted: false, value: "memory" }], + replacementRange: { from: 7, to: 9 }, + }, null, [], 20); + expect(prepared).toEqual({ + expectedEpoch: null, + limit: 20, + prefix: { quoted: false, value: "ma" }, + qualifier: [{ quoted: false, value: "memory" }], + searchPaths: [], + }); + expect(Object.isFrozen(prepared)).toBe(true); + }); + + it("filters prefixes, deduplicates, orders, and uses provider edits", () => { + const catalog = catalogResponse(); + const main = catalog.containers[0]; + if (!main) throw new Error("main"); + const parent = main.canonicalPath[0]; + const child = main.canonicalPath[1]; + if (!child) throw new Error("child"); + const copied: SqlNamespaceCatalogResolvedContainer = { + ...main, + canonicalPath: [ + parent, + { ...child, quoted: true }, + ], + containerEntityId: "main-copy", + provenance: { + ...main.provenance, + containerEntityId: "main-copy", + }, + }; + const tied: SqlNamespaceCatalogResolvedContainer = { + ...main, + containerEntityId: "aaa", + provenance: { + ...main.provenance, + containerEntityId: "aaa", + }, + }; + const duplicated = { + ...readyOutcome, + response: { + ...catalog, + containers: [ + ...catalog.containers, + main, + copied, + tied, + ], + }, + }; + const all = composeSqlNamespaceCompletion({ + matchPrefix: (): "match" => "match", + outcome: duplicated, + prefix: { quoted: false, value: "" }, + providerId: "namespaces", + replacementRange: { from: 10, to: 12 }, + }); + expect(all?.value.items.map((value) => value.label)) + .toEqual(["main", "main", "main", "metrics"]); + const composition = composeSqlNamespaceCompletion({ + matchPrefix: (candidate, prefix) => + candidate.value.startsWith(prefix.value) + ? "match" + : "no-match", + outcome: duplicated, + prefix: { quoted: false, value: "ma" }, + providerId: "namespaces", + replacementRange: { from: 10, to: 12 }, + }); + expect(composition).toMatchObject({ + source: { coverage: "complete", outcome: "ready" }, + value: { + isIncomplete: false, + items: [{ + edit: { from: 10, insert: "main", to: 12 }, + label: "main", + role: "schema", + }, { + edit: { from: 10, insert: "main", to: 12 }, + label: "main", + role: "schema", + }, { + edit: { from: 10, insert: "main", to: 12 }, + label: "main", + role: "schema", + }], + }, + }); + expect(Object.isFrozen(composition?.value.items)).toBe(true); + }); + + it("surfaces partial and uncertain prefix coverage", () => { + const catalog = catalogResponse(); + const composition = composeSqlNamespaceCompletion({ + matchPrefix: () => { + throw new Error("dialect"); + }, + outcome: { + ...readyOutcome, + response: { ...catalog, coverage: "partial" }, + }, + prefix: { quoted: false, value: "m" }, + providerId: "namespaces", + replacementRange: { from: 0, to: 1 }, + }); + expect(composition?.value).toMatchObject({ + isIncomplete: true, + issues: [ + "namespace-catalog-partial", + "namespace-prefix-uncertain", + ], + items: [], + }); + }); + + it("maps loading, failed, unavailable, and cancellation", () => { + const base = { + matchPrefix: (): "match" => "match", + prefix: { quoted: false, value: "" }, + providerId: "namespaces", + replacementRange: { from: 0, to: 0 }, + }; + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { + providerId: "namespaces", + response: { epoch, status: "loading" }, + scope: "connection", + status: "usable", + }, + })).toMatchObject({ + source: { outcome: "loading" }, + value: { issues: ["namespace-catalog-loading"] }, + }); + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { + providerId: "namespaces", + response: { + code: "unknown", + epoch, + retry: "never", + status: "failed", + }, + scope: "connection", + status: "usable", + }, + })).toMatchObject({ + source: { + code: "unknown", + outcome: "failed", + retry: "never", + }, + value: { issues: ["namespace-catalog-failed"] }, + }); + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { + reason: "malformed-response", + status: "unavailable", + }, + })).toMatchObject({ + source: { + outcome: "unavailable", + reason: "malformed-response", + }, + value: { issues: ["namespace-catalog-malformed"] }, + }); + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { + reason: "provider-failed", + status: "unavailable", + }, + })).toMatchObject({ + value: { issues: ["namespace-catalog-failed"] }, + }); + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { status: "cancelled" }, + })).toBeNull(); + expect(composeSqlNamespaceCompletion({ + ...base, + outcome: { status: "superseded" }, + })).toBeNull(); + }); +}); diff --git a/src/vnext/__tests__/node-sql-parser-adapter.test.ts b/src/vnext/__tests__/node-sql-parser-adapter.test.ts index b9d61fe..e951f1f 100644 --- a/src/vnext/__tests__/node-sql-parser-adapter.test.ts +++ b/src/vnext/__tests__/node-sql-parser-adapter.test.ts @@ -5,6 +5,7 @@ import { exportedForTesting, getBigQueryNodeSqlStatementParser, getDuckDbCompatibilityNodeSqlStatementParser, + getNodeSqlParserQueryBindingModel, getPostgresqlNodeSqlStatementParser, MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, } from "../node-sql-parser-adapter.js"; @@ -885,6 +886,20 @@ describe("real node-sql-parser builds", () => { mode: "compatibility", status: "parsed", }); + if ( + expectedKind === "query" && + state.analysis.status === "parsed" + ) { + expect( + getNodeSqlParserQueryBindingModel(state.analysis.artifact), + ).toMatchObject({ + coverage: { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }, + }); + } }, ); @@ -935,6 +950,18 @@ describe("real node-sql-parser builds", () => { mode: "compatibility", status: "parsed", }); + if (state.analysis.status === "parsed") { + expect( + getNodeSqlParserQueryBindingModel(state.analysis.artifact), + ).toMatchObject({ + coverage: { + queryBlocks: "partial", + relationBindings: "partial", + visibility: "partial", + }, + issues: [{ code: "parser-compatibility" }], + }); + } }); it("never turns a DuckDB compatibility rejection into invalid SQL", async () => { @@ -953,7 +980,7 @@ describe("real node-sql-parser builds", () => { }); describe("backend artifact privacy", () => { - it("retains backend data only behind the authentic artifact", async () => { + it("retains only normalized bindings behind the authentic artifact", async () => { const backendRoot = { privateBackendField: "must-not-leak", type: "select", @@ -966,7 +993,15 @@ describe("backend artifact privacy", () => { } const { artifact } = state.analysis; - expect(exportedForTesting.hasBackendPayload(artifact)).toBe(true); + expect(exportedForTesting.hasBackendPayload(artifact)).toBe(false); + expect(getNodeSqlParserQueryBindingModel(artifact)).toMatchObject({ + coverage: { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }, + statementRange: { from: 0, to: 8 }, + }); expect(Object.keys(artifact)).toEqual(["kind", "range"]); expect(JSON.stringify(state)).not.toContain("must-not-leak"); expect(Reflect.ownKeys(artifact)).not.toContain("privateBackendField"); @@ -981,6 +1016,12 @@ describe("backend artifact privacy", () => { structuralCopy, ), ).toBe(false); + expect( + invokeWithUnknown( + getNodeSqlParserQueryBindingModel, + structuralCopy, + ), + ).toBeNull(); }); it("does not retain malformed or unsupported backend values", async () => { diff --git a/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts index 9e86078..20fc812 100644 --- a/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts +++ b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts @@ -294,7 +294,11 @@ function ready(worker: FakeWorker): void { function respond( worker: FakeWorker, outcome: - | { readonly kind: "parsed"; readonly statementKind: "query" } + | { + readonly kind: "parsed"; + readonly queryBindings: null; + readonly statementKind: "query"; + } | { readonly kind: "syntax-rejected" } | { readonly kind: "unsupported"; @@ -309,8 +313,9 @@ function respond( const backendOutcome = outcome.kind === "parsed" ? { - ...outcome, + kind: outcome.kind, root: Object.freeze({}), + statementKind: outcome.statementKind, } : outcome.kind === "failed" ? { @@ -322,6 +327,7 @@ function respond( encodeNodeSqlParserWireBackendOutcome( postedRequest(worker, index).requestId, backendOutcome, + outcome.kind === "parsed" ? outcome.queryBindings : null, ), ); } @@ -366,7 +372,7 @@ describe("node-sql-parser browser executor admission", () => { expect(request).toStrictEqual({ grammar: "postgresql", kind: "parse", - protocolVersion: 1, + protocolVersion: 2, requestId: 1, text: "SELECT 1", }); @@ -374,11 +380,13 @@ describe("node-sql-parser browser executor admission", () => { respond(worker, { kind: "parsed", + queryBindings: null, statementKind: "query", }); const result = await outcome(submission); expect(result).toStrictEqual({ kind: "parsed", + queryBindings: null, statementKind: "query", }); expect(Object.isFrozen(result)).toBe(true); @@ -429,10 +437,12 @@ describe("node-sql-parser browser executor admission", () => { respond(worker, { kind: "parsed", + queryBindings: null, statementKind: "query", }, 2); expect(await outcome(third)).toStrictEqual({ kind: "parsed", + queryBindings: null, statementKind: "query", }); }); @@ -1210,12 +1220,12 @@ describe("node-sql-parser browser executor hostile worker handling", () => { null, {}, { data: null }, - { data: { kind: "ready", protocolVersion: 2 } }, + { data: { kind: "ready", protocolVersion: 1 } }, { data: { extra: true, kind: "ready", - protocolVersion: 1, + protocolVersion: 2, }, }, ])("fails closed for malformed message event %#", async (event) => { @@ -1285,7 +1295,7 @@ describe("node-sql-parser browser executor hostile worker handling", () => { worker.emit({ code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, } satisfies NodeSqlParserWireMessage); expect(await outcome(submission)).toStrictEqual({ code: "protocol-error", @@ -1311,7 +1321,7 @@ describe("node-sql-parser browser executor hostile worker handling", () => { firstWorker.emit({ kind: "syntax-rejected", - protocolVersion: 1, + protocolVersion: 2, requestId: activeId + 1, } satisfies NodeSqlParserWireMessage); expect(await outcome(active)).toStrictEqual({ 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 index 749c469..f4a516e 100644 --- a/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts +++ b/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts @@ -161,7 +161,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.listenerCount()).toBe(1); expect(scope.messages).toStrictEqual([ - { kind: "ready", protocolVersion: 1 }, + { kind: "ready", protocolVersion: 2 }, ]); expect(evaluations).toStrictEqual({ bigquery: 0, @@ -172,7 +172,8 @@ describe("node-sql-parser browser worker endpoint", () => { await waitForMessageCount(scope, 2); expect(scope.messages[1]).toStrictEqual({ kind: "parsed", - protocolVersion: 1, + queryBindings: expect.any(Object), + protocolVersion: 2, requestId: 1, statementKind: "query", }); @@ -185,7 +186,8 @@ describe("node-sql-parser browser worker endpoint", () => { await waitForMessageCount(scope, 3); expect(scope.messages[2]).toStrictEqual({ kind: "parsed", - protocolVersion: 1, + queryBindings: expect.any(Object), + protocolVersion: 2, requestId: 2, statementKind: "insert", }); @@ -330,7 +332,8 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ kind: "parsed", - protocolVersion: 1, + queryBindings: expect.any(Object), + protocolVersion: 2, requestId: 19, statementKind: "query", }); @@ -374,7 +377,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "module-load", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 17, }); expect(JSON.stringify(scope.messages[1])).not.toContain( @@ -420,7 +423,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 1, }); expect(scope.closeCalls()).toBe(1); @@ -457,7 +460,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 8, }); expect(scope.closeCalls()).toBe(1); @@ -508,7 +511,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 10, }); expect(scope.closeCalls()).toBe(1); @@ -547,7 +550,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 11, }); expect(JSON.stringify(scope.messages[1])).not.toContain( @@ -589,7 +592,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 9, }); expect(evaluations).toBe(0); @@ -623,7 +626,7 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, }); expect(scope.closeCalls()).toBe(1); expect(scope.listenerCount()).toBe(0); @@ -645,11 +648,11 @@ describe("node-sql-parser browser worker endpoint", () => { scope.dispatch({ ...valid, extra: true }); expect(scope.messages).toStrictEqual([ - { kind: "ready", protocolVersion: 1 }, + { kind: "ready", protocolVersion: 2 }, { code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, }, ]); expect(scope.closeCalls()).toBe(1); @@ -678,7 +681,8 @@ describe("node-sql-parser browser worker endpoint", () => { expect(scope.messages[1]).toStrictEqual({ kind: "parsed", - protocolVersion: 1, + queryBindings: expect.any(Object), + protocolVersion: 2, requestId: 23, statementKind: "query", }); @@ -694,7 +698,7 @@ describe("node-sql-parser browser worker endpoint", () => { code: undefined, expected: { kind: "syntax-rejected", - protocolVersion: 1, + protocolVersion: 2, requestId: 4, }, moduleValue: parserModule(() => { @@ -712,7 +716,7 @@ describe("node-sql-parser browser worker endpoint", () => { code: undefined, expected: { kind: "unsupported", - protocolVersion: 1, + protocolVersion: 2, reason: "multiple-statements", requestId: 4, }, @@ -726,7 +730,7 @@ describe("node-sql-parser browser worker endpoint", () => { expected: { code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 4, }, moduleValue: parserModule(() => { @@ -738,7 +742,7 @@ describe("node-sql-parser browser worker endpoint", () => { expected: { code: "malformed-output", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 4, }, moduleValue: {}, @@ -863,11 +867,11 @@ describe("node-sql-parser browser worker endpoint", () => { scope.dispatch(request(1)); expect(scope.messages).toStrictEqual([ - { kind: "ready", protocolVersion: 1 }, + { kind: "ready", protocolVersion: 2 }, { code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, }, ]); expect(scope.closeCalls()).toBe(1); diff --git a/src/vnext/__tests__/node-sql-parser-query-bindings.bench.ts b/src/vnext/__tests__/node-sql-parser-query-bindings.bench.ts new file mode 100644 index 0000000..ca3c991 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-query-bindings.bench.ts @@ -0,0 +1,31 @@ +import { bench, describe } from "vitest"; +import { + normalizeNodeSqlParserQueryBindings, +} from "../node-sql-parser-query-bindings.js"; + +const RELATION_COUNT = 1_000; +const relations = Array.from( + { length: RELATION_COUNT }, + (_, index) => `t${index} a${index}`, +); +const text = `SELECT a999.id FROM ${relations.join(", ")}`; +const root = { + from: Array.from({ length: RELATION_COUNT }, (_, index) => ({ + as: `a${index}`, + db: null, + table: `t${index}`, + })), + type: "select", +}; +const authority = {}; + +describe("node-sql-parser query binding normalization", () => { + bench("normalizes 1,000 relations", () => { + normalizeNodeSqlParserQueryBindings( + root, + text, + authority, + { compatibility: false, grammar: "postgresql" }, + ); + }); +}); diff --git a/src/vnext/__tests__/node-sql-parser-query-bindings.test.ts b/src/vnext/__tests__/node-sql-parser-query-bindings.test.ts new file mode 100644 index 0000000..24b2c7f --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-query-bindings.test.ts @@ -0,0 +1,1291 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + normalizeNodeSqlParserQueryBindings, + type NodeSqlParserQueryBindingOptions, +} from "../node-sql-parser-query-bindings.js"; +import { + resolveSqlRelationQualifier, + visibleSqlRelationBindingsAt, +} from "../query-binding-model.js"; +import { + decodeNodeSqlParserWireMessage, + encodeNodeSqlParserWireBackendOutcome, +} from "../node-sql-parser-wire.js"; +import type { SqlIdentifierComponent } from "../types.js"; + +const PG: NodeSqlParserQueryBindingOptions = { + compatibility: false, + grammar: "postgresql", +}; +const BQ: NodeSqlParserQueryBindingOptions = { + compatibility: false, + grammar: "bigquery", +}; + +function normalize( + root: unknown, + text: string, + options: NodeSqlParserQueryBindingOptions = PG, +) { + return normalizeNodeSqlParserQueryBindings(root, text, {}, options); +} + +function relation(table: string, alias: string | null = null) { + return { as: alias, db: null, table }; +} + +function equals( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + return left.quoted === right.quoted && + (left.quoted + ? left.value === right.value + : left.value.toLowerCase() === right.value.toLowerCase()); +} + +describe("node-sql-parser query binding normalization", () => { + it("normalizes named relations, aliases, scopes, and clause visibility", () => { + const text = + "SELECT u.id FROM users AS u JOIN teams t ON u.team_id=t.id " + + "WHERE u.active GROUP BY u.id HAVING count(*)>1 " + + "ORDER BY u.id LIMIT 5"; + const result = normalize( + { + from: [ + relation("users", "u"), + { ...relation("teams", "t"), join: "JOIN", on: {} }, + ], + type: "select", + where: {}, + }, + text, + ); + + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + return; + } + expect(result.model.coverage).toEqual({ + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }); + expect(result.model.bindings).toMatchObject([ + { + alias: { explicit: true, name: { value: "u" } }, + source: { kind: "named", path: [{ value: "users" }] }, + }, + { + alias: { explicit: false, name: { value: "t" } }, + source: { kind: "named", path: [{ value: "teams" }] }, + }, + ]); + expect(result.model.regions.map((region) => region.kind)).toEqual( + expect.arrayContaining([ + "select-list", + "from-source", + "join-condition", + "where", + "group-by", + "having", + "order-by", + "limit", + ]), + ); + const selected = visibleSqlRelationBindingsAt( + result.model, + text.indexOf("u.id"), + ); + expect(selected.status === "ready" && selected.bindings).toHaveLength(2); + expect( + resolveSqlRelationQualifier( + result.model, + text.indexOf("u.id"), + { quoted: false, value: "U" }, + equals, + ).status, + ).toBe("resolved"); + }); + + it("normalizes derived query blocks without leaking sibling scope", () => { + const text = + "SELECT d.id FROM (SELECT id FROM teams) AS d " + + "JOIN users u ON d.id=u.id"; + const derived = { + from: [relation("teams")], + type: "select", + }; + const result = normalize( + { + from: [ + { + as: "d", + expr: { ast: derived, parentheses: true }, + }, + { ...relation("users", "u"), join: "JOIN", on: {} }, + ], + type: "select", + }, + text, + ); + + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + return; + } + expect(result.model.blocks).toHaveLength(2); + expect(result.model.blocks[1]?.parentBlock).toBe(0); + expect(result.model.bindings).toMatchObject([ + { alias: { name: { value: "d" } }, source: { block: 1, kind: "derived" } }, + { alias: { name: { value: "u" } }, source: { kind: "named" } }, + { owner: 1, source: { kind: "named", path: [{ value: "teams" }] } }, + ]); + expect(result.model.blocks[1]?.baseScope).toBe(0); + }); + + it("resolves CTE references to declaration evidence", () => { + const text = + "WITH recent AS (SELECT * FROM events) " + + "SELECT * FROM recent r"; + const cteStatement = { + from: [relation("events")], + type: "select", + }; + const result = normalize( + { + from: [relation("recent", "r")], + type: "select", + with: [ + { + name: { type: "default", value: "recent" }, + stmt: cteStatement, + }, + ], + }, + text, + ); + + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.model.blocks).toHaveLength(2); + expect(result.model.bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: expect.objectContaining({ + kind: "cte", + name: { quoted: false, value: "recent" }, + }), + }), + ]), + ); + } + }); + + it("preserves quoted CTE identity and case semantics", () => { + const cteStatement = { + from: [relation("events")], + type: "select", + }; + const quoted = normalize( + { + from: [relation("Recent")], + type: "select", + with: [ + { + name: { type: "default", value: "Recent" }, + stmt: cteStatement, + }, + ], + }, + 'WITH "Recent" AS (SELECT * FROM events) SELECT * FROM "Recent"', + ); + expect(quoted).toMatchObject({ + model: { + bindings: [ + { source: { kind: "cte", name: { quoted: true, value: "Recent" } } }, + { source: { kind: "named" } }, + ], + }, + status: "ready", + }); + + const differentlyQuoted = normalize( + { + from: [relation("recent")], + type: "select", + with: [ + { + name: { type: "default", value: "Recent" }, + stmt: cteStatement, + }, + ], + }, + 'WITH "Recent" AS (SELECT * FROM events) SELECT * FROM recent', + ); + expect(differentlyQuoted).toMatchObject({ + model: { + bindings: [ + { source: { kind: "named", path: [{ value: "recent" }] } }, + { source: { kind: "named" } }, + ], + }, + status: "ready", + }); + }); + + it("applies parent and preceding-sibling CTE visibility per block", () => { + const first = { + from: [relation("later")], + type: "select", + }; + const later = { + from: [relation("events")], + type: "select", + }; + const result = normalize( + { + from: [relation("first")], + type: "select", + with: [ + { name: { value: "first" }, stmt: first }, + { name: { value: "later" }, stmt: later }, + ], + }, + "WITH first AS (SELECT * FROM later), " + + "later AS (SELECT * FROM events) SELECT * FROM first", + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte", name: { value: "first" } } }, + { + owner: 1, + source: { kind: "named", path: [{ value: "later" }] }, + }, + { + owner: 2, + source: { kind: "named", path: [{ value: "events" }] }, + }, + ], + }, + status: "ready", + }); + }); + + it("authenticates recursive CTE self-visibility", () => { + const body = { + from: [relation("self")], + type: "select", + }; + const result = normalize( + { + from: [relation("self")], + type: "select", + with: [{ + name: { value: "self" }, + recursive: true, + stmt: body, + }], + }, + "WITH RECURSIVE self AS (SELECT * FROM self) SELECT * FROM self", + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte", name: { value: "self" } } }, + { owner: 1, source: { kind: "cte", name: { value: "self" } } }, + ], + coverage: { relationBindings: "complete" }, + }, + status: "ready", + }); + if (result.status === "ready") { + expect(result.model.issues).not.toContainEqual( + expect.objectContaining({ code: "recursive-cte-uncertainty" }), + ); + } + }); + + it("fails closed when recursive lexical and AST evidence disagree", () => { + const body = { + from: [relation("self")], + type: "select", + }; + const lexicalOnly = normalize( + { + from: [relation("self")], + type: "select", + with: [{ name: { value: "self" }, stmt: body }], + }, + "WITH RECURSIVE self AS (SELECT * FROM self) SELECT * FROM self", + ); + const astOnly = normalize( + { + from: [relation("self")], + type: "select", + with: [{ + name: { value: "self" }, + recursive: true, + stmt: body, + }], + }, + "WITH self AS (SELECT * FROM self) SELECT * FROM self", + ); + for (const result of [lexicalOnly, astOnly]) { + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte" } }, + { + owner: 1, + source: { kind: "unknown", reason: "unknown-correlation" }, + }, + ], + coverage: { relationBindings: "partial" }, + issues: [{ code: "recursive-cte-uncertainty" }], + }, + status: "ready", + }); + } + }); + + it("does not resolve an uncertain local CTE through an outer namesake", () => { + const nested = { + from: [relation("shadow")], + type: "select", + with: [{ + name: { value: "shadow" }, + stmt: { from: [relation("shadow")], type: "select" }, + }], + }; + const result = normalize( + { + from: [{ as: "d", expr: { ast: nested, parentheses: true } }], + type: "select", + with: [{ + name: { value: "shadow" }, + stmt: { from: [relation("events")], type: "select" }, + }], + }, + "WITH shadow AS (SELECT * FROM events) SELECT * FROM (" + + "WITH RECURSIVE shadow AS (SELECT * FROM shadow) " + + "SELECT * FROM shadow) d", + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { block: 2, kind: "derived" } }, + { + owner: 1, + source: { kind: "named", path: [{ value: "events" }] }, + }, + { owner: 2, source: { kind: "cte", name: { value: "shadow" } } }, + { + owner: 3, + source: { kind: "unknown", reason: "unknown-correlation" }, + }, + ], + coverage: { relationBindings: "partial" }, + issues: [{ code: "recursive-cte-uncertainty" }], + }, + status: "ready", + }); + }); + + it("keeps non-recursive self and forward names outside CTE visibility", () => { + const self = normalize( + { + from: [relation("self")], + type: "select", + with: [{ + name: { value: "self" }, + stmt: { from: [relation("self")], type: "select" }, + }], + }, + "WITH self AS (SELECT * FROM self) SELECT * FROM self", + ); + expect(self).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte" } }, + { owner: 1, source: { kind: "named", path: [{ value: "self" }] } }, + ], + coverage: { relationBindings: "complete" }, + }, + status: "ready", + }); + }); + + it("marks recursive forward and mutual references uncertain", () => { + const first = { + from: [relation("later")], + type: "select", + }; + const forward = normalize( + { + from: [relation("first")], + type: "select", + with: [ + { + name: { value: "first" }, + recursive: true, + stmt: first, + }, + { + name: { value: "later" }, + stmt: { from: [relation("events")], type: "select" }, + }, + ], + }, + "WITH RECURSIVE first AS (SELECT * FROM later), " + + "later AS (SELECT * FROM events) SELECT * FROM first", + ); + expect(forward).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte", name: { value: "first" } } }, + { + owner: 1, + source: { kind: "unknown", reason: "unknown-correlation" }, + }, + { + owner: 2, + source: { kind: "named", path: [{ value: "events" }] }, + }, + ], + coverage: { relationBindings: "partial" }, + issues: [{ code: "recursive-cte-uncertainty" }], + }, + status: "ready", + }); + + const later = { + from: [relation("first")], + type: "select", + }; + const result = normalize( + { + from: [relation("first")], + type: "select", + with: [ + { + name: { value: "first" }, + recursive: true, + stmt: first, + }, + { name: { value: "later" }, stmt: later }, + ], + }, + "WITH RECURSIVE first AS (SELECT * FROM later), " + + "later AS (SELECT * FROM first) SELECT * FROM first", + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { kind: "cte", name: { value: "first" } } }, + { + owner: 1, + source: { kind: "unknown", reason: "unknown-correlation" }, + }, + { owner: 2, source: { kind: "cte", name: { value: "first" } } }, + ], + coverage: { relationBindings: "partial" }, + issues: [{ code: "recursive-cte-uncertainty" }], + }, + status: "ready", + }); + }); + + it("resolves a nested WITH inside its derived query block", () => { + const nestedCte = { + from: [relation("events")], + type: "select", + }; + const nested = { + from: [relation("local")], + type: "select", + with: [{ name: { value: "local" }, stmt: nestedCte }], + }; + const result = normalize( + { + from: [{ as: "d", expr: { ast: nested, parentheses: true } }], + type: "select", + }, + "SELECT * FROM (WITH local AS (SELECT * FROM events) " + + "SELECT * FROM local) d", + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { owner: 0, source: { block: 1, kind: "derived" } }, + { owner: 1, source: { kind: "cte", name: { value: "local" } } }, + { + owner: 2, + source: { kind: "named", path: [{ value: "events" }] }, + }, + ], + }, + status: "ready", + }); + }); + + it("normalizes BigQuery multipart paths and QUALIFY", () => { + const text = + "SELECT a.id FROM `project.dataset.users` a " + + "QUALIFY row_number() over()=1"; + const result = normalize( + { + from: [ + { + as: "a", + db: null, + surround: { table: "`" }, + table: "project.dataset.users", + }, + ], + qualify: {}, + type: "select", + }, + text, + BQ, + ); + + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.model.bindings[0]?.source).toMatchObject({ + kind: "named", + path: [ + { value: "project" }, + { value: "dataset" }, + { value: "users" }, + ], + }); + expect( + result.model.regions.some((region) => region.kind === "qualify"), + ).toBe(true); + } + }); + + it("marks DuckDB compatibility evidence partial in every dimension", () => { + const result = normalize( + { from: [relation("events")], type: "select" }, + "SELECT * FROM events", + { compatibility: true, grammar: "postgresql" }, + ); + expect(result).toMatchObject({ + model: { + coverage: { + queryBlocks: "partial", + relationBindings: "partial", + visibility: "partial", + }, + issues: [{ code: "parser-compatibility" }], + }, + status: "ready", + }); + }); + + it("downgrades unmatched lexical or parser relations without guessing", () => { + const result = normalize( + { + from: [ + relation("users"), + { as: null, expr: { type: "function" } }, + ], + type: "select", + }, + "SELECT * FROM users", + ); + expect(result).toMatchObject({ + model: { + coverage: { + relationBindings: "partial", + visibility: "partial", + }, + issues: [{ code: "unsupported-relation-source" }], + }, + status: "ready", + }); + }); + + it("marks extra lexical query blocks and unknown relation shapes partial", () => { + const extraBlock = normalize( + { type: "select" }, + "SELECT EXISTS (SELECT 1)", + ); + expect(extraBlock).toMatchObject({ + model: { + coverage: { queryBlocks: "partial" }, + issues: [{ code: "unsupported-clause" }], + }, + status: "ready", + }); + + const unknownRelation = normalize( + { from: [{}], type: "select" }, + "SELECT * FROM users", + ); + expect(unknownRelation).toMatchObject({ + model: { + coverage: { relationBindings: "partial" }, + bindings: [ + { + source: { + kind: "unknown", + reason: "unsupported-relation-source", + }, + }, + ], + }, + status: "ready", + }); + }); + + it("downgrades alias disagreement instead of trusting either side", () => { + const lexicalOnly = normalize( + { from: [relation("users")], type: "select" }, + "SELECT * FROM users u", + ); + const astOnly = normalize( + { from: [relation("users", "u")], type: "select" }, + "SELECT * FROM users", + ); + const differentValues = normalize( + { from: [relation("users", "x")], type: "select" }, + "SELECT * FROM users u", + ); + const differentQuotes = normalize( + { from: [relation("users", "U")], type: "select" }, + 'SELECT * FROM users AS "u"', + ); + for (const result of [ + lexicalOnly, + astOnly, + differentValues, + differentQuotes, + ]) { + expect(result).toMatchObject({ + model: { + bindings: [{ alias: null }], + coverage: { relationBindings: "partial" }, + issues: [{ code: "unsupported-relation-source" }], + }, + status: "ready", + }); + } + }); + + it("never publishes AST relation names absent from the SQL text", () => { + const table = normalize( + { from: [relation("events")], type: "select" }, + "SELECT * FROM users", + ); + const schema = normalize( + { + from: [{ as: null, db: "private", table: "users" }], + type: "select", + }, + "SELECT * FROM public.users", + ); + for (const result of [table, schema]) { + expect(result).toMatchObject({ + model: { + bindings: [ + { + source: { + kind: "unknown", + reason: "unsupported-relation-source", + }, + }, + ], + coverage: { relationBindings: "partial" }, + issues: [{ code: "unsupported-relation-source" }], + }, + status: "ready", + }); + } + expect( + normalize( + { from: [relation("private.dataset.users")], type: "select" }, + "SELECT * FROM `public.dataset.users`", + BQ, + ), + ).toMatchObject({ + model: { + bindings: [{ source: { kind: "unknown" } }], + coverage: { relationBindings: "partial" }, + }, + status: "ready", + }); + }); + + it("keeps ON and USING join visibility aligned with relation sites", () => { + const onText = "SELECT a.id, b.id FROM a JOIN b ON a.id=b.id"; + const onResult = normalize( + { + from: [ + relation("a"), + { ...relation("b"), join: "JOIN", on: {} }, + ], + type: "select", + }, + onText, + ); + const usingText = "SELECT * FROM a JOIN b USING (id)"; + const usingResult = normalize( + { + from: [ + relation("a"), + { ...relation("b"), join: "JOIN" }, + ], + type: "select", + }, + usingText, + ); + for (const [result, position] of [ + [onResult, onText.indexOf("a.id", onText.indexOf("ON"))], + [usingResult, usingText.indexOf("id")], + ] as const) { + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.model.coverage.visibility).toBe("complete"); + expect(visibleSqlRelationBindingsAt(result.model, position)).toMatchObject({ + bindings: [{}, {}], + region: { kind: "join-condition" }, + status: "ready", + }); + } + } + }); + + it("does not assign a later join condition to an earlier relation", () => { + const text = "SELECT * FROM a JOIN b JOIN c ON c.id=b.id"; + const result = normalize( + { + from: [ + relation("a"), + { ...relation("b"), join: "JOIN" }, + { ...relation("c"), join: "JOIN", on: {} }, + ], + type: "select", + }, + text, + ); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + const regions = result.model.regions.filter((region) => + region.kind === "join-condition" + ); + expect(regions).toHaveLength(1); + expect(visibleSqlRelationBindingsAt( + result.model, + text.indexOf("c.id"), + )).toMatchObject({ + bindings: [{}, {}, {}], + status: "ready", + }); + } + }); + + it.each([ + ["WHERE true", { where: {} }], + ["GROUP BY a.id", { groupby: [{ expr: {} }] }], + ["HAVING true", { having: {} }], + ["QUALIFY true", { qualify: {} }], + ["ORDER BY a.id", { orderby: [{ expr: {} }] }], + ["LIMIT 1", { limit: {} }], + ])( + "stops a conditionless join before %s", + (suffix, clause) => { + const result = normalize( + { + ...clause, + from: [ + relation("a"), + { ...relation("b"), join: "CROSS JOIN" }, + ], + type: "select", + }, + `SELECT * FROM a CROSS JOIN b ${suffix}`, + ); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect( + result.model.regions.some((region) => + region.kind === "join-condition" + ), + ).toBe(false); + } + }, + ); + + it.each([ + "WHERE true", + "GROUP BY a.id", + "HAVING true", + "QUALIFY true", + "ORDER BY a.id", + "LIMIT 1", + ])("ends an ON region before %s", (suffix) => { + const text = `SELECT * FROM a JOIN b ON true ${suffix}`; + const result = normalize( + { + from: [ + relation("a"), + { ...relation("b"), join: "JOIN", on: {} }, + ], + type: "select", + }, + text, + ); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + const join = result.model.regions.find((region) => + region.kind === "join-condition" + ); + expect(join?.range.to).toBe( + text.indexOf(suffix.split(" ")[0] ?? suffix), + ); + } + }); + + it("emits compound query block kinds", () => { + for (const type of ["union", "intersect", "except"]) { + expect(normalize({ type }, "SELECT 1")).toMatchObject({ + model: { blocks: [{ kind: "compound" }] }, + status: "ready", + }); + } + }); + + it("preserves PostgreSQL quoted path components", () => { + const result = normalize( + { + from: [{ as: "U", db: "MySchema", table: "Users" }], + type: "select", + }, + 'SELECT * FROM "MySchema"."Users" AS "U"', + ); + expect(result).toMatchObject({ + model: { + bindings: [ + { + alias: { name: { quoted: true, value: "U" } }, + source: { + kind: "named", + path: [ + { quoted: true, value: "MySchema" }, + { quoted: true, value: "Users" }, + ], + }, + }, + ], + }, + status: "ready", + }); + }); + + it("round trips normalized data across wire v2 without raw AST or text", () => { + const text = "SELECT u.id FROM users u"; + const result = normalize( + { from: [relation("users", "u")], type: "select" }, + text, + ); + if (result.status !== "ready") { + throw new Error("Expected normalized query bindings"); + } + const root = { privateAstSecret: "never-cross-wire", type: "select" }; + const encoded = encodeNodeSqlParserWireBackendOutcome( + 1, + { kind: "parsed", root, statementKind: "query" }, + result.model, + ); + const decoded = decodeNodeSqlParserWireMessage(encoded); + + expect(decoded).not.toBeNull(); + expect(decoded?.kind).toBe("parsed"); + if (decoded?.kind === "parsed") { + expect(decoded.queryBindings).toStrictEqual(result.model); + expect(decoded.queryBindings).not.toBe(result.model); + } + expect(JSON.stringify(encoded)).not.toContain("privateAstSecret"); + expect(JSON.stringify(encoded)).not.toContain(text); + expect(Object.hasOwn(encoded, "root")).toBe(false); + }); +}); + +describe("node-sql-parser query binding hostile boundaries", () => { + it("does not invoke AST accessors", () => { + let calls = 0; + const root = {}; + Object.defineProperty(root, "type", { + get() { + calls += 1; + return "select"; + }, + }); + expect(normalize(root, "SELECT 1")).toEqual({ + reason: "not-query", + status: "unavailable", + }); + expect(calls).toBe(0); + }); + + it("rejects cycles and sparse or oversized AST arrays", () => { + const cyclic = { from: [] as unknown[], type: "select" }; + cyclic.from.push({ expr: { ast: cyclic } }); + expect(normalize(cyclic, "SELECT * FROM (SELECT 1) x")).toEqual({ + reason: "malformed-ast", + status: "unavailable", + }); + + const sparse: unknown[] = []; + sparse.length = 1; + expect(normalize({ from: sparse, type: "select" }, "SELECT 1")).toEqual({ + reason: "malformed-ast", + status: "unavailable", + }); + + const oversized = Array.from({ length: 1_025 }, () => relation("x")); + expect(normalize({ from: oversized, type: "select" }, "SELECT 1")).toEqual({ + reason: "malformed-ast", + status: "unavailable", + }); + + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + expect( + normalize({ from: revoked.proxy, type: "select" }, "SELECT 1"), + ).toEqual({ + reason: "malformed-ast", + status: "unavailable", + }); + }); + + it("rejects malformed known AST properties and CTE entries", () => { + const cases: readonly { + readonly root: unknown; + readonly text: string; + }[] = [ + { root: { from: {}, type: "select" }, text: "SELECT 1" }, + { root: { from: [null], type: "select" }, text: "SELECT 1" }, + { root: { type: "select", with: {} }, text: "SELECT 1" }, + { root: { type: "select", with: [null] }, text: "SELECT 1" }, + { + root: { type: "select", with: [{}] }, + text: "WITH x AS (SELECT 1) SELECT 1", + }, + { + root: { + type: "select", + with: [{ name: {}, stmt: { type: "select" } }], + }, + text: "WITH x AS (SELECT 1) SELECT 1", + }, + { + root: { + type: "select", + with: [{ name: "missing", stmt: { type: "select" } }], + }, + text: "WITH present AS (SELECT 1) SELECT 1", + }, + ]; + for (const { root, text } of cases) { + expect(normalize(root, text).status).toBe("unavailable"); + } + }); + + it("rejects CTE structures that change across safe inspections", () => { + const child = { type: "select" }; + const item = { name: "x", stmt: child }; + function changingRoot(secondWith: unknown) { + let reads = 0; + return new Proxy( + { type: "select", with: [item] }, + { + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (key !== "with" || descriptor === undefined) { + return descriptor; + } + reads += 1; + return { ...descriptor, value: reads === 1 ? [item] : secondWith }; + }, + }, + ); + } + for (const root of [changingRoot({}), changingRoot([])]) { + expect( + normalize(root, "WITH x AS (SELECT 1) SELECT 1"), + ).toEqual({ reason: "malformed-ast", status: "unavailable" }); + } + + let itemReads = 0; + const changingItems = new Proxy([item], { + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (key !== "0" || descriptor === undefined) { + return descriptor; + } + itemReads += 1; + return { ...descriptor, value: itemReads === 1 ? item : null }; + }, + }); + expect( + normalize( + { type: "select", with: changingItems }, + "WITH x AS (SELECT 1) SELECT 1", + ), + ).toEqual({ reason: "malformed-ast", status: "unavailable" }); + + function changingChild() { + let reads = 0; + return new Proxy( + { type: "select", with: [] }, + { + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (key !== "with" || descriptor === undefined) { + return descriptor; + } + reads += 1; + return { ...descriptor, value: reads === 1 ? [] : {} }; + }, + }, + ); + } + expect( + normalize( + { + type: "select", + with: [{ name: "x", stmt: changingChild() }], + }, + "WITH x AS (SELECT 1) SELECT 1", + ), + ).toEqual({ reason: "malformed-ast", status: "unavailable" }); + expect( + normalize( + { + from: [{ as: "d", expr: { ast: changingChild() } }], + type: "select", + }, + "SELECT * FROM (SELECT 1) d", + ), + ).toEqual({ reason: "malformed-ast", status: "unavailable" }); + }); + + it("handles comments, incomplete relation sites, and nesting limits", () => { + expect( + normalize( + { from: [relation("users")], type: "select" }, + "SELECT /* private */ * FROM users -- tail", + ).status, + ).toBe("ready"); + expect( + normalize({ type: "select" }, "SELECT * FROM").status, + ).toBe("ready"); + expect( + normalize( + { type: "select" }, + `${"(".repeat(257)}SELECT 1${")".repeat(257)}`, + ), + ).toEqual({ reason: "resource-limit", status: "unavailable" }); + }); + + it("normalizes descriptor traps and invalid normalization authority", () => { + const hostile = new Proxy( + { type: "select" }, + { + getOwnPropertyDescriptor() { + throw new Error("private trap"); + }, + }, + ); + expect(normalize(hostile, "SELECT 1")).toEqual({ + reason: "not-query", + status: "unavailable", + }); + expect( + normalizeNodeSqlParserQueryBindings( + { type: "select" }, + "SELECT 1", + null, + PG, + ), + ).toEqual({ reason: "resource-limit", status: "unavailable" }); + + const fromAccessor = { type: "select" }; + Object.defineProperty(fromAccessor, "from", { + get() { + throw new Error("private from getter"); + }, + }); + expect(normalize(fromAccessor, "SELECT 1")).toEqual({ + reason: "malformed-ast", + status: "unavailable", + }); + expect( + normalize({ from: null, type: "select" }, "SELECT 1").status, + ).toBe("ready"); + }); + + it("bounds recursive CTEs, unclosed derived sites, and identifier paths", () => { + const recursive = { + type: "select", + with: [] as unknown[], + }; + recursive.with.push({ name: "self", stmt: recursive }); + expect( + normalize( + recursive, + "WITH self AS (SELECT 1) SELECT 1", + ), + ).toEqual({ reason: "malformed-ast", status: "unavailable" }); + + const child = { type: "select" }; + expect( + normalize( + { + from: [{ as: "d", expr: { ast: child } }], + type: "select", + }, + "SELECT * FROM (SELECT 1", + ), + ).toMatchObject({ + model: { coverage: { relationBindings: "partial" } }, + status: "ready", + }); + + const longName = "x".repeat(257); + expect( + normalize( + { from: [relation(longName)], type: "select" }, + `SELECT * FROM ${longName}`, + ), + ).toMatchObject({ + model: { + bindings: [{ source: { kind: "unknown" } }], + coverage: { relationBindings: "partial" }, + }, + status: "ready", + }); + + const manyComponents = Array.from( + { length: 9 }, + (_, index) => `p${index}`, + ).join("."); + expect( + normalize( + { from: [relation(manyComponents)], type: "select" }, + `SELECT * FROM \`${manyComponents}\``, + BQ, + ), + ).toMatchObject({ + model: { bindings: [{ source: { kind: "unknown" } }] }, + status: "ready", + }); + }); + + it("covers alternate bounded grammar shapes without widening evidence", () => { + expect(normalize({ type: "union" }, "SELECT 1").status).toBe("ready"); + expect( + normalize( + { + from: [{ as: null, schema: "public", table: "users" }], + type: "select", + }, + "SELECT * FROM public.users", + ), + ).toMatchObject({ + model: { + bindings: [ + { + source: { + path: [{ value: "public" }, { value: "users" }], + }, + }, + ], + }, + status: "ready", + }); + expect( + normalize( + { + type: "select", + with: [{ name: "x", stmt: { type: "select" } }], + }, + "WITH x AS (SELECT 1) SELECT 1", + ).status, + ).toBe("ready"); + expect( + normalize( + { from: [relation("users")], type: "select" }, + 'SELECT * FROM ""', + ).status, + ).toBe("ready"); + expect(normalize({ type: "select" }, "SELECT 1 ON true").status).toBe( + "ready", + ); + expect( + normalize( + { type: "select" }, + `SELECT $${"a".repeat(300)}$`, + ), + ).toEqual({ reason: "resource-limit", status: "unavailable" }); + expect( + normalizeNodeSqlParserQueryBindings( + { type: "select" }, + 1, + {}, + PG, + ), + ).toEqual({ reason: "resource-limit", status: "unavailable" }); + }); + + it("rejects malformed roots, syntax shapes, and resource excess", () => { + expect(normalize(null, "SELECT 1")).toEqual({ + reason: "not-query", + status: "unavailable", + }); + expect(normalize({ type: "insert" }, "INSERT INTO x VALUES (1)")).toEqual({ + reason: "not-query", + status: "unavailable", + }); + expect(normalize({ type: "select" }, "x".repeat(16 * 1024 + 1))).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(normalize({ type: "select" }, "SELECT 1)")).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(normalize({ type: "select" }, "not a query")).toEqual({ + reason: "unsupported-shape", + status: "unavailable", + }); + }); + + it("rejects hostile wire binding payloads without invoking getters", () => { + let calls = 0; + const hostile = {}; + Object.defineProperty(hostile, "statementRange", { + enumerable: true, + get() { + calls += 1; + return { from: 0, to: 8 }; + }, + }); + const decoded = decodeNodeSqlParserWireMessage({ + kind: "parsed", + protocolVersion: 2, + queryBindings: hostile, + requestId: 1, + statementKind: "query", + }); + expect(decoded).toBeNull(); + expect(calls).toBe(0); + }); +}); diff --git a/src/vnext/__tests__/node-sql-parser-wire.test.ts b/src/vnext/__tests__/node-sql-parser-wire.test.ts index 7647ca5..11a5eb3 100644 --- a/src/vnext/__tests__/node-sql-parser-wire.test.ts +++ b/src/vnext/__tests__/node-sql-parser-wire.test.ts @@ -47,6 +47,7 @@ const validMessages: readonly NodeSqlParserWireMessage[] = [ ...statementKinds.map( (statementKind): NodeSqlParserWireMessage => ({ kind: "parsed", + queryBindings: null, protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, requestId: 1, statementKind, @@ -207,7 +208,7 @@ describe("node-sql-parser wire request codec", () => { expect(encoded).toStrictEqual({ grammar, kind: "parse", - protocolVersion: 1, + protocolVersion: 2, requestId: Number.MAX_SAFE_INTEGER, text: " SELECT 1\n", }); @@ -289,7 +290,7 @@ describe("node-sql-parser wire request codec", () => { ).toBeNull(); }); - it.each([0, 2, "1", null])( + it.each([0, 1, 3, "2", null])( "rejects protocol version %#", (protocolVersion) => { expect( @@ -376,7 +377,8 @@ describe("node-sql-parser wire message codec", () => { encodeNodeSqlParserWireBackendOutcome(7, outcome), ).toStrictEqual({ kind: "parsed", - protocolVersion: 1, + queryBindings: null, + protocolVersion: 2, requestId: 7, statementKind, }); @@ -392,7 +394,7 @@ describe("node-sql-parser wire message codec", () => { { kind: "syntax-rejected" }, { kind: "syntax-rejected", - protocolVersion: 1, + protocolVersion: 2, requestId: 3, }, ], @@ -400,7 +402,7 @@ describe("node-sql-parser wire message codec", () => { { kind: "unsupported", reason: "multiple-statements" }, { kind: "unsupported", - protocolVersion: 1, + protocolVersion: 2, reason: "multiple-statements", requestId: 3, }, @@ -409,7 +411,7 @@ describe("node-sql-parser wire message codec", () => { { kind: "unsupported", reason: "resource-limit" }, { kind: "unsupported", - protocolVersion: 1, + protocolVersion: 2, reason: "resource-limit", requestId: 3, }, @@ -419,7 +421,7 @@ describe("node-sql-parser wire message codec", () => { { code: "backend", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 3, }, ], @@ -432,7 +434,7 @@ describe("node-sql-parser wire message codec", () => { { code: "malformed-output", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 3, }, ], @@ -441,7 +443,7 @@ describe("node-sql-parser wire message codec", () => { { code: "module-load", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 3, }, ], @@ -450,7 +452,7 @@ describe("node-sql-parser wire message codec", () => { const ready = encodeNodeSqlParserWireReady(); expect(ready).toStrictEqual({ kind: "ready", - protocolVersion: 1, + protocolVersion: 2, }); expect(Object.isFrozen(ready)).toBe(true); for (const [outcome, expected] of cases) { @@ -493,6 +495,7 @@ describe("node-sql-parser wire message codec", () => { expect(Reflect.ownKeys(parsed)).toStrictEqual([ "kind", "protocolVersion", + "queryBindings", "requestId", "statementKind", ]); @@ -532,7 +535,7 @@ describe("node-sql-parser wire message codec", () => { expect(protocolError).toStrictEqual({ code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, }); expect(Reflect.ownKeys(protocolError)).not.toContain("requestId"); expect(Object.isFrozen(protocolError)).toBe(true); @@ -568,7 +571,7 @@ describe("node-sql-parser wire message codec", () => { expect( decodeNodeSqlParserWireMessage({ kind: "syntax-rejected", - protocolVersion: 1, + protocolVersion: 2, requestId, }), ).toBeNull(); @@ -584,7 +587,8 @@ describe("node-sql-parser wire message codec", () => { expect( decodeNodeSqlParserWireMessage({ kind: "parsed", - protocolVersion: 1, + queryBindings: null, + protocolVersion: 2, requestId: 1, statementKind, }), @@ -600,7 +604,7 @@ describe("node-sql-parser wire message codec", () => { expect( decodeNodeSqlParserWireMessage({ kind: "unsupported", - protocolVersion: 1, + protocolVersion: 2, reason, requestId: 1, }), @@ -617,7 +621,7 @@ describe("node-sql-parser wire message codec", () => { decodeNodeSqlParserWireMessage({ code, kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 1, }), ).toBeNull(); @@ -632,12 +636,12 @@ describe("node-sql-parser wire message codec", () => { expect( decodeNodeSqlParserWireMessage({ kind, - protocolVersion: 1, + protocolVersion: 2, }), ).toBeNull(); }); - it.each([0, 2, "1", null])( + it.each([0, 1, 3, "2", null])( "rejects response protocol version %#", (protocolVersion) => { expect( @@ -654,7 +658,7 @@ describe("node-sql-parser wire message codec", () => { decodeNodeSqlParserWireMessage({ code: "module-load", kind: "failed", - protocolVersion: 1, + protocolVersion: 2, requestId: 1, retryable: true, }), @@ -663,7 +667,7 @@ describe("node-sql-parser wire message codec", () => { decodeNodeSqlParserWireMessage({ code: "invalid-request", kind: "protocol-error", - protocolVersion: 1, + protocolVersion: 2, requestId: 1, }), ).toBeNull(); diff --git a/src/vnext/__tests__/query-binding-model.bench.ts b/src/vnext/__tests__/query-binding-model.bench.ts new file mode 100644 index 0000000..ae742fa --- /dev/null +++ b/src/vnext/__tests__/query-binding-model.bench.ts @@ -0,0 +1,157 @@ +import { bench, describe } from "vitest"; +import { + createSqlQueryBindingModel, + MAX_QUERY_BINDING_BLOCKS, + MAX_QUERY_BINDINGS, + MAX_QUERY_BINDING_SCOPES, + MAX_QUERY_VISIBILITY_REGIONS, + resolveSqlRelationQualifier, + visibleSqlRelationBindingsAt, +} from "../query-binding-model.js"; +import type { SqlIdentifierComponent } from "../types.js"; + +const BINDING_COUNT = 1_000; +const TEXT = "x".repeat(10 * 1_024); +const AUTHORITY = {}; + +function component(value: string): SqlIdentifierComponent { + return { quoted: false, value }; +} + +const bindings = Array.from({ length: BINDING_COUNT }, (_, index) => ({ + alias: null, + owner: 0, + range: { from: index, to: index + 1 }, + source: { + kind: "named", + path: [component(`relation_${index}`)], + }, +})); + +const scopes = [ + { addedBinding: null, parentScope: null }, + ...bindings.map((_binding, index) => ({ + addedBinding: index, + parentScope: index, + })), +]; + +const input = { + bindings, + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: { from: 0, to: TEXT.length }, + }, + ], + coverage: { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }, + issues: [], + regions: [ + { + block: 0, + kind: "select-list", + range: { from: 0, to: TEXT.length }, + scope: BINDING_COUNT, + }, + ], + scopes, + statementRange: { from: 0, to: TEXT.length }, +}; + +const model = createSqlQueryBindingModel(TEXT, AUTHORITY, input); + +const MAXIMUM_TEXT = "x".repeat(MAX_QUERY_VISIBILITY_REGIONS * 2); +const maximumBlocks = Array.from( + { length: MAX_QUERY_BINDING_BLOCKS }, + (_, index) => ({ + baseScope: 0, + kind: "select", + parentBlock: index === 0 ? null : index - 1, + range: { from: 0, to: MAXIMUM_TEXT.length }, + }), +); +const maximumBindings = Array.from( + { length: MAX_QUERY_BINDINGS }, + (_, index) => ({ + alias: null, + owner: index % MAX_QUERY_BINDING_BLOCKS, + range: { from: 0, to: 1 }, + source: { + kind: "named", + path: [component(`relation_${index}`)], + }, + }), +); +const maximumScopes: { + addedBinding: number | null; + parentScope: number | null; +}[] = [ + { addedBinding: null, parentScope: null }, + ...maximumBindings.map((_binding, index) => ({ + addedBinding: index, + parentScope: index, + })), +]; +while (maximumScopes.length < MAX_QUERY_BINDING_SCOPES) { + maximumScopes.push({ + addedBinding: null, + parentScope: maximumScopes.length - 1, + }); +} +const maximumInput = { + bindings: maximumBindings, + blocks: maximumBlocks, + coverage: { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }, + issues: [], + regions: Array.from( + { length: MAX_QUERY_VISIBILITY_REGIONS }, + (_, index) => ({ + block: MAX_QUERY_BINDING_BLOCKS - 1, + kind: "where", + range: { from: index * 2, to: index * 2 + 1 }, + scope: MAX_QUERY_BINDING_SCOPES - 1, + }), + ), + scopes: maximumScopes, + statementRange: { from: 0, to: MAXIMUM_TEXT.length }, +}; + +function asciiEqual( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + return left.value === right.value; +} + +describe("query binding model", () => { + bench("validate 1,000 bindings in a 10 KiB statement", () => { + createSqlQueryBindingModel(TEXT, AUTHORITY, input); + }); + + bench("validate maximum relationship dimensions", () => { + createSqlQueryBindingModel(MAXIMUM_TEXT, AUTHORITY, maximumInput); + }); + + bench("walk 1,000 visible bindings", () => { + visibleSqlRelationBindingsAt(model, 5_000); + }); + + bench("resolve the last of 1,000 qualifiers", () => { + resolveSqlRelationQualifier( + model, + 5_000, + component("relation_999"), + asciiEqual, + ); + }); +}); diff --git a/src/vnext/__tests__/query-binding-model.test.ts b/src/vnext/__tests__/query-binding-model.test.ts new file mode 100644 index 0000000..7126094 --- /dev/null +++ b/src/vnext/__tests__/query-binding-model.test.ts @@ -0,0 +1,1176 @@ +import { describe, expect, it } from "vitest"; +import { + createSqlQueryBindingModel, + isSqlQueryBindingModel, + isSqlQueryBindingModelError, + MAX_QUERY_BINDING_BLOCKS, + MAX_QUERY_BINDINGS, + MAX_QUERY_BINDING_SCOPES, + MAX_QUERY_VISIBILITY_REGIONS, + resolveSqlRelationQualifier, + sqlQueryBindingModelMatches, + visibleSqlRelationBindingsAt, + type SqlQueryBindingModel, + type SqlQueryBindingModelErrorCode, +} from "../query-binding-model.js"; +import type { SqlIdentifierComponent } from "../types.js"; + +const SQL = + "SELECT a.id FROM users AS a JOIN teams t ON a.team_id=t.id WHERE a.id>0"; + +function component(value: string, quoted = false) { + return { quoted, value }; +} + +function range(from: number, to: number) { + return { from, to }; +} + +function completeCoverage() { + return { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }; +} + +function fixture(overrides: Readonly> = {}) { + return { + bindings: [ + { + alias: { + explicit: true, + name: component("a"), + range: range(26, 27), + }, + owner: 0, + range: range(17, 27), + source: { + kind: "named", + path: [component("users")], + }, + }, + { + alias: { + explicit: false, + name: component("t"), + range: range(39, 40), + }, + owner: 0, + range: range(33, 40), + source: { + kind: "named", + path: [component("public"), component("teams")], + }, + }, + ], + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(0, SQL.length), + }, + ], + coverage: completeCoverage(), + issues: [], + regions: [ + { + block: 0, + kind: "select-list", + range: range(7, 11), + scope: 2, + }, + { + block: 0, + kind: "join-condition", + range: range(44, 59), + scope: 2, + }, + { + block: 0, + kind: "where", + range: range(66, SQL.length), + scope: 2, + }, + ], + scopes: [ + { addedBinding: null, parentScope: null }, + { addedBinding: 0, parentScope: 0 }, + { addedBinding: 1, parentScope: 1 }, + ], + statementRange: range(0, SQL.length), + ...overrides, + }; +} + +function model( + overrides: Readonly> = {}, + authority: object = {}, +): SqlQueryBindingModel { + return createSqlQueryBindingModel(SQL, authority, fixture(overrides)); +} + +function asciiEqual( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + if (left.quoted || right.quoted) { + return left.quoted === right.quoted && left.value === right.value; + } + return left.value.toLowerCase() === right.value.toLowerCase(); +} + +function expectError( + operation: () => unknown, + code: SqlQueryBindingModelErrorCode, +): void { + try { + operation(); + } catch (error) { + expect(isSqlQueryBindingModelError(error)).toBe(true); + if (isSqlQueryBindingModelError(error)) { + expect(error.code).toBe(code); + } + return; + } + throw new Error("Expected a query binding model error"); +} + +describe("query binding model authentication", () => { + it("creates a deeply immutable model bound to exact source and authority", () => { + const authority = {}; + const input = fixture(); + const result = createSqlQueryBindingModel(SQL, authority, input); + + expect(isSqlQueryBindingModel(result)).toBe(true); + expect(isSqlQueryBindingModel(input)).toBe(false); + expect(sqlQueryBindingModelMatches(result, SQL, authority)).toBe(true); + expect(sqlQueryBindingModelMatches(result, `${SQL} `, authority)).toBe(false); + expect(sqlQueryBindingModelMatches(result, SQL, {})).toBe(false); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.blocks)).toBe(true); + expect(Object.isFrozen(result.blocks[0])).toBe(true); + expect(Object.isFrozen(result.bindings[0]?.source)).toBe(true); + expect(Object.isFrozen(result.bindings[0]?.alias?.name)).toBe(true); + }); + + it("does not authenticate structural imitations", () => { + const imitation = fixture(); + expect(isSqlQueryBindingModel(null)).toBe(false); + expect(isSqlQueryBindingModel(1)).toBe(false); + expect(isSqlQueryBindingModel(imitation)).toBe(false); + expect( + sqlQueryBindingModelMatches(imitation, SQL, {}), + ).toBe(false); + expect(isSqlQueryBindingModelError(new Error("no"))).toBe(false); + expect(sqlQueryBindingModelMatches(null, SQL, {})).toBe(false); + }); +}); + +describe("query binding visibility", () => { + it("walks persistent scopes in declaration order", () => { + const result = visibleSqlRelationBindingsAt(model(), 8); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.region.kind).toBe("select-list"); + expect( + result.bindings.map((binding) => binding.alias?.name.value), + ).toEqual(["a", "t"]); + expect(result.coverage).toBe("complete"); + expect(result.issues).toEqual([]); + } + }); + + it("uses half-open regions and rejects invalid requests", () => { + const authenticated = model(); + expect(visibleSqlRelationBindingsAt(authenticated, 11)).toEqual({ + reason: "outside-visibility-region", + status: "unavailable", + }); + expect(visibleSqlRelationBindingsAt(authenticated, 44).status).toBe("ready"); + expect(visibleSqlRelationBindingsAt(authenticated, SQL.length)).toEqual({ + reason: "outside-visibility-region", + status: "unavailable", + }); + expect(visibleSqlRelationBindingsAt(authenticated, -1)).toEqual({ + reason: "invalid-model", + status: "unavailable", + }); + expect(visibleSqlRelationBindingsAt(authenticated, 1.5)).toEqual({ + reason: "invalid-model", + status: "unavailable", + }); + expect( + visibleSqlRelationBindingsAt(fixture(), 8), + ).toEqual({ reason: "invalid-model", status: "unavailable" }); + }); + + it("combines relation and visibility coverage", () => { + for (const coverage of [ + { + queryBlocks: "partial", + relationBindings: "complete", + visibility: "complete", + }, + { + queryBlocks: "complete", + relationBindings: "partial", + visibility: "complete", + }, + { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "partial", + }, + ]) { + const result = visibleSqlRelationBindingsAt(model({ coverage }), 8); + expect(result.status === "ready" && result.coverage).toBe( + coverage.queryBlocks === "partial" ? "complete" : "partial", + ); + } + }); +}); + +describe("query binding qualifier resolution", () => { + it("resolves aliases and makes an alias hide the base name", () => { + const authenticated = model(); + const alias = resolveSqlRelationQualifier( + authenticated, + 8, + component("A"), + asciiEqual, + ); + expect(alias.status).toBe("resolved"); + if (alias.status === "resolved") { + expect(alias.binding.alias?.name.value).toBe("a"); + expect(alias.coverage).toBe("complete"); + } + expect( + resolveSqlRelationQualifier( + authenticated, + 8, + component("users"), + asciiEqual, + ), + ).toEqual({ status: "no-match" }); + expect( + resolveSqlRelationQualifier( + authenticated, + 8, + component("TEAMS"), + asciiEqual, + ).status, + ).toBe("no-match"); + }); + + it("uses the last named path component and a CTE name without aliases", () => { + const bindings = [ + { + alias: null, + owner: 0, + range: range(17, 22), + source: { + kind: "named", + path: [component("public"), component("users")], + }, + }, + { + alias: null, + owner: 0, + range: range(33, 40), + source: { + declarationRange: range(0, 4), + kind: "cte", + name: component("teams"), + }, + }, + ]; + const authenticated = model({ bindings }); + expect( + resolveSqlRelationQualifier( + authenticated, + 8, + component("users"), + asciiEqual, + ).status, + ).toBe("resolved"); + expect( + resolveSqlRelationQualifier( + authenticated, + 8, + component("teams"), + asciiEqual, + ).status, + ).toBe("resolved"); + }); + + it("reports ambiguity and partial evidence explicitly", () => { + const bindings = [ + fixture().bindings[0], + { + ...fixture().bindings[1], + alias: { + explicit: false, + name: component("a"), + range: range(39, 40), + }, + }, + ]; + const ambiguous = resolveSqlRelationQualifier( + model({ bindings }), + 8, + component("a"), + asciiEqual, + ); + expect(ambiguous.status).toBe("ambiguous"); + if (ambiguous.status === "ambiguous") { + expect(ambiguous.bindings).toHaveLength(2); + expect(Object.isFrozen(ambiguous.bindings)).toBe(true); + } + + const coverage = { + queryBlocks: "complete", + relationBindings: "partial", + visibility: "complete", + }; + const partial = model({ coverage }); + const found = resolveSqlRelationQualifier( + partial, + 8, + component("a"), + asciiEqual, + ); + expect(found.status === "resolved" && found.coverage).toBe("partial"); + expect( + resolveSqlRelationQualifier( + partial, + 8, + component("missing"), + asciiEqual, + ), + ).toEqual({ reason: "partial-coverage", status: "unavailable" }); + }); + + it("propagates unavailable visibility and ignores unqualified derived sources", () => { + const blocks = [ + fixture().blocks[0], + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(17, 22), + }, + ]; + const bindings = [ + { + alias: null, + owner: 0, + range: range(17, 22), + source: { block: 1, kind: "derived" }, + }, + fixture().bindings[1], + ]; + const authenticated = model({ bindings, blocks }); + expect( + resolveSqlRelationQualifier( + authenticated, + 12, + component("t"), + asciiEqual, + ), + ).toEqual({ + reason: "outside-visibility-region", + status: "unavailable", + }); + expect( + resolveSqlRelationQualifier( + authenticated, + 8, + component("anything"), + asciiEqual, + ), + ).toEqual({ status: "no-match" }); + }); +}); + +describe("query binding model validation", () => { + it("rejects invalid source and authority inputs", () => { + expectError( + () => createSqlQueryBindingModel(1, {}, fixture()), + "invalid-source", + ); + expectError( + () => createSqlQueryBindingModel("", {}, fixture()), + "invalid-source", + ); + expectError( + () => + createSqlQueryBindingModel( + "x".repeat(16 * 1024 + 1), + {}, + fixture(), + ), + "resource-limit", + ); + expectError( + () => + createSqlQueryBindingModel( + SQL, + null, + fixture(), + ), + "invalid-authority", + ); + }); + + it.each([ + ["null input", null], + ["missing fields", {}], + ["no blocks", fixture({ blocks: [] })], + ["no scopes", fixture({ scopes: [] })], + [ + "wrong statement extent", + fixture({ statementRange: range(0, SQL.length - 1) }), + ], + [ + "empty statement extent", + fixture({ statementRange: range(0, 0) }), + ], + [ + "unsupported coverage", + fixture({ + coverage: { + ...completeCoverage(), + visibility: "unknown", + }, + }), + ], + ["non-array blocks", fixture({ blocks: {} })], + ])("rejects %s", (_name, input) => { + expectError( + () => createSqlQueryBindingModel(SQL, {}, input), + "invalid-model", + ); + }); + + it("rejects resource excess and sparse arrays", () => { + expectError( + () => + createSqlQueryBindingModel( + SQL, + {}, + fixture({ + blocks: Array.from( + { length: MAX_QUERY_BINDING_BLOCKS + 1 }, + () => fixture().blocks[0], + ), + }), + ), + "resource-limit", + ); + const sparse: unknown[] = []; + sparse.length = 1; + expectError( + () => + createSqlQueryBindingModel( + SQL, + {}, + fixture({ blocks: sparse }), + ), + "invalid-model", + ); + }); + + it("never invokes accessors", () => { + let calls = 0; + const hostile = fixture(); + Object.defineProperty(hostile, "blocks", { + enumerable: true, + get() { + calls += 1; + return []; + }, + }); + expectError( + () => createSqlQueryBindingModel(SQL, {}, hostile), + "invalid-model", + ); + expect(calls).toBe(0); + }); + + it("rejects malformed primitive fields without coercion", () => { + const invalidCases: readonly Readonly>[] = [ + { + bindings: [ + { + ...fixture().bindings[0], + alias: { + ...fixture().bindings[0]?.alias, + explicit: "yes", + }, + }, + fixture().bindings[1], + ], + }, + { + blocks: [ + { ...fixture().blocks[0], kind: 1 }, + ], + }, + { + blocks: [ + { ...fixture().blocks[0], baseScope: -1 }, + ], + }, + { + blocks: [ + { ...fixture().blocks[0], baseScope: 0.5 }, + ], + }, + { + regions: [ + { + ...fixture().regions[0], + range: range(11, 7), + }, + ], + }, + { + regions: [ + { + ...fixture().regions[0], + range: range(7, 7), + }, + ], + }, + ]; + for (const invalidCase of invalidCases) { + expectError(() => model(invalidCase), "invalid-model"); + } + }); + + it("rejects arrays whose length cannot be inspected as data", () => { + const hostileArray = new Proxy([], { + getOwnPropertyDescriptor(target, key) { + if (key === "length") { + return { + configurable: false, + enumerable: false, + value: "one", + writable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + expectError( + () => model({ blocks: hostileArray }), + "invalid-model", + ); + }); + + it("normalizes proxy and inspection failures", () => { + const hostile = new Proxy(fixture(), { + getOwnPropertyDescriptor() { + throw new Error("hostile trap"); + }, + }); + expectError( + () => createSqlQueryBindingModel(SQL, {}, hostile), + "invalid-model", + ); + }); + + it.each([ + [ + "forward block parent", + { + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: 1, + range: range(0, SQL.length), + }, + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(0, SQL.length), + }, + ], + }, + ], + [ + "child outside parent", + { + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(0, 20), + }, + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(19, 30), + }, + ], + }, + ], + [ + "forward scope parent", + { + scopes: [ + { addedBinding: null, parentScope: 1 }, + { addedBinding: null, parentScope: null }, + ], + }, + ], + [ + "repeated binding in scope chain", + { + scopes: [ + { addedBinding: 0, parentScope: null }, + { addedBinding: 0, parentScope: 0 }, + { addedBinding: 1, parentScope: 1 }, + ], + }, + ], + [ + "overlapping visibility", + { + regions: [ + { + block: 0, + kind: "select-list", + range: range(7, 20), + scope: 2, + }, + { + block: 0, + kind: "where", + range: range(19, 22), + scope: 2, + }, + ], + }, + ], + [ + "unordered visibility", + { + regions: [ + { + block: 0, + kind: "where", + range: range(66, 70), + scope: 2, + }, + { + block: 0, + kind: "select-list", + range: range(7, 11), + scope: 2, + }, + ], + }, + ], + ])("rejects %s", (_name, overrides) => { + expectError( + () => model(overrides), + "invalid-model", + ); + }); + + it("rejects invalid binding ownership and source relationships", () => { + const outside = [ + { + ...fixture().bindings[0], + range: range(0, SQL.length), + }, + fixture().bindings[1], + ]; + const narrowBlock = [ + { + ...fixture().blocks[0], + range: range(5, SQL.length), + }, + ]; + expectError(() => model({ bindings: outside, blocks: narrowBlock }), "invalid-model"); + + const blocks = [ + fixture().blocks[0], + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(17, 22), + }, + ]; + const bindings = [ + { + ...fixture().bindings[0], + source: { block: 1, kind: "derived" }, + }, + fixture().bindings[1], + ]; + expectError(() => model({ bindings, blocks }), "invalid-model"); + }); + + it("rejects a derived binding that points at a sibling outside its range", () => { + const text = "x".repeat(20); + expectError( + () => + createSqlQueryBindingModel(text, {}, { + bindings: [ + { + alias: null, + owner: 0, + range: range(1, 5), + source: { block: 2, kind: "derived" }, + }, + ], + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(0, 20), + }, + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(1, 5), + }, + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(10, 15), + }, + ], + coverage: completeCoverage(), + issues: [], + regions: [], + scopes: [ + { addedBinding: null, parentScope: null }, + { addedBinding: 0, parentScope: 0 }, + ], + statementRange: range(0, text.length), + }), + "invalid-model", + ); + }); + + it("bounds maximum relationship validation work", () => { + const text = "x".repeat(MAX_QUERY_VISIBILITY_REGIONS * 2); + const blocks = Array.from( + { length: MAX_QUERY_BINDING_BLOCKS }, + (_, index) => ({ + baseScope: 0, + kind: "select", + parentBlock: index === 0 ? null : index - 1, + range: range(0, text.length), + }), + ); + const bindings = Array.from( + { length: MAX_QUERY_BINDINGS }, + (_, index) => ({ + alias: null, + owner: index % MAX_QUERY_BINDING_BLOCKS, + range: range(0, 1), + source: { + kind: "named", + path: [component(`r${index}`)], + }, + }), + ); + const scopes: { + addedBinding: number | null; + parentScope: number | null; + }[] = [ + { addedBinding: null, parentScope: null }, + ...bindings.map((_binding, index) => ({ + addedBinding: index, + parentScope: index, + })), + ]; + while (scopes.length < MAX_QUERY_BINDING_SCOPES) { + scopes.push({ + addedBinding: null, + parentScope: scopes.length - 1, + }); + } + const regions = Array.from( + { length: MAX_QUERY_VISIBILITY_REGIONS }, + (_, index) => ({ + block: MAX_QUERY_BINDING_BLOCKS - 1, + kind: "where", + range: range(index * 2, index * 2 + 1), + scope: MAX_QUERY_BINDING_SCOPES - 1, + }), + ); + const start = performance.now(); + const result = createSqlQueryBindingModel(text, {}, { + bindings, + blocks, + coverage: completeCoverage(), + issues: [], + regions, + scopes, + statementRange: range(0, text.length), + }); + const duration = performance.now() - start; + + expect(result.bindings).toHaveLength(MAX_QUERY_BINDINGS); + expect(result.regions).toHaveLength(MAX_QUERY_VISIBILITY_REGIONS); + expect(duration).toBeLessThan(500); + }); + + it("requires partial relation coverage for unknown sources", () => { + const bindings = [ + { + ...fixture().bindings[0], + source: { + kind: "unknown", + reason: "unsupported-relation-source", + }, + }, + fixture().bindings[1], + ]; + expectError(() => model({ bindings }), "invalid-model"); + const coverage = { + ...completeCoverage(), + relationBindings: "partial", + }; + expect(model({ bindings, coverage }).bindings[0]?.source.kind).toBe( + "unknown", + ); + }); + + it("rejects malformed identifiers, paths, ranges, aliases, enums, and indexes", () => { + const cases: readonly Readonly>[] = [ + { + bindings: [ + { + ...fixture().bindings[0], + source: { kind: "named", path: [] }, + }, + fixture().bindings[1], + ], + }, + { + bindings: [ + { + ...fixture().bindings[0], + source: { + kind: "named", + path: [component("\ud800")], + }, + }, + fixture().bindings[1], + ], + }, + { + bindings: [ + { + ...fixture().bindings[0], + alias: { + explicit: true, + name: component("a"), + range: range(28, 29), + }, + }, + fixture().bindings[1], + ], + }, + { + bindings: [ + { ...fixture().bindings[0], owner: 99 }, + fixture().bindings[1], + ], + }, + { + bindings: [ + { + ...fixture().bindings[0], + source: { kind: "mystery" }, + }, + fixture().bindings[1], + ], + }, + { + blocks: [ + { ...fixture().blocks[0], kind: "values" }, + ], + }, + { + regions: [ + { ...fixture().regions[0], kind: "window" }, + ], + }, + { + issues: [{ code: "mystery", range: range(0, 1) }], + }, + { + scopes: [ + { addedBinding: 99, parentScope: null }, + ], + }, + ]; + for (let index = 0; index < cases.length; index += 1) { + const overrides = cases[index]; + if (!overrides) { + throw new Error("Malformed fixture table contains a hole"); + } + expectError(() => model(overrides), "invalid-model"); + } + }); + + it("accepts every closed issue and unknown reason", () => { + const codes = [ + "ambiguous-alias", + "duplicate-alias", + "opaque-template-context", + "parser-compatibility", + "recursive-cte-uncertainty", + "resource-limit", + "unknown-correlation", + "unsupported-clause", + "unsupported-relation-source", + ]; + const coverage = { + queryBlocks: "partial", + relationBindings: "partial", + visibility: "partial", + }; + const issues = codes.map((code, index) => ({ + code, + range: range(index, index + 1), + })); + expect(model({ coverage, issues }).issues).toHaveLength(codes.length); + + for (const reason of [ + "opaque-template-context", + "unknown-correlation", + "unsupported-relation-source", + ]) { + const bindings = [ + { + ...fixture().bindings[0], + source: { kind: "unknown", reason }, + }, + fixture().bindings[1], + ]; + expect(model({ bindings, coverage }).bindings[0]?.source).toEqual({ + kind: "unknown", + reason, + }); + } + }); + + it("accepts well-formed surrogate pairs and rejects lone trailing surrogates", () => { + const validBindings = [ + { + ...fixture().bindings[0], + source: { + kind: "named", + path: [component("relation_\ud83d\ude80")], + }, + }, + fixture().bindings[1], + ]; + expect(model({ bindings: validBindings }).bindings[0]?.source.kind).toBe( + "named", + ); + const invalidBindings = [ + { + ...fixture().bindings[0], + source: { + kind: "named", + path: [component("\udc00")], + }, + }, + fixture().bindings[1], + ]; + expectError(() => model({ bindings: invalidBindings }), "invalid-model"); + }); + + it("rejects a region assigned outside its query block", () => { + const blocks = [ + fixture().blocks[0], + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(17, 22), + }, + ]; + const regions = [ + { + block: 1, + kind: "select-list", + range: range(7, 11), + scope: 2, + }, + ]; + expectError(() => model({ blocks, regions }), "invalid-model"); + }); + + it("allows parent correlation but rejects local or sibling bindings in entry scopes", () => { + const correlatedBlocks = [ + fixture().blocks[0], + { + baseScope: 1, + kind: "select", + parentBlock: 0, + range: range(44, 59), + }, + ]; + const correlatedRegions = [ + { + block: 1, + kind: "where", + range: range(45, 50), + scope: 1, + }, + ]; + expect( + model({ + blocks: correlatedBlocks, + regions: correlatedRegions, + }).blocks[1]?.baseScope, + ).toBe(1); + + const localEntryBlocks = [ + { ...fixture().blocks[0], baseScope: 1 }, + ]; + expectError( + () => model({ blocks: localEntryBlocks }), + "invalid-model", + ); + + const siblingBlocks = [ + fixture().blocks[0], + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(17, 22), + }, + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(33, 40), + }, + ]; + const siblingBindings = [ + { + alias: null, + owner: 1, + range: range(17, 22), + source: { kind: "named", path: [component("left_child")] }, + }, + { + alias: null, + owner: 0, + range: range(33, 40), + source: { kind: "named", path: [component("root_relation")] }, + }, + ]; + const siblingScopes = [ + { addedBinding: null, parentScope: null }, + { addedBinding: 0, parentScope: 0 }, + { addedBinding: 1, parentScope: 0 }, + ]; + const siblingRegions = [ + { + block: 2, + kind: "select-list", + range: range(34, 38), + scope: 1, + }, + ]; + expectError( + () => + model({ + bindings: siblingBindings, + blocks: siblingBlocks, + regions: siblingRegions, + scopes: siblingScopes, + }), + "invalid-model", + ); + }); + + it("rejects overlapping sibling query blocks", () => { + const blocks = [ + fixture().blocks[0], + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(17, 30), + }, + { + baseScope: 0, + kind: "select", + parentBlock: 0, + range: range(20, 40), + }, + ]; + expectError(() => model({ blocks }), "invalid-model"); + }); + + it("preserves invariants across generated scope chains", () => { + for (let count = 1; count <= 32; count += 1) { + const text = "x".repeat(Math.max(count, 2)); + const bindings = Array.from({ length: count }, (_, index) => ({ + alias: null, + owner: 0, + range: range(index, index + 1), + source: { + kind: "named", + path: [component(`r${index}`)], + }, + })); + const scopes = [ + { addedBinding: null, parentScope: null }, + ...bindings.map((_binding, index) => ({ + addedBinding: index, + parentScope: index, + })), + ]; + const generated = createSqlQueryBindingModel(text, {}, { + bindings, + blocks: [ + { + baseScope: 0, + kind: "select", + parentBlock: null, + range: range(0, text.length), + }, + ], + coverage: completeCoverage(), + issues: [], + regions: [ + { + block: 0, + kind: "other", + range: range(0, text.length), + scope: count, + }, + ], + scopes, + statementRange: range(0, text.length), + }); + const visible = visibleSqlRelationBindingsAt(generated, 0); + expect(visible.status === "ready" && visible.bindings).toHaveLength(count); + } + }); +}); diff --git a/src/vnext/__tests__/relation-catalog-search-work.test.ts b/src/vnext/__tests__/relation-catalog-search-work.test.ts index a6914c7..f6dbbff 100644 --- a/src/vnext/__tests__/relation-catalog-search-work.test.ts +++ b/src/vnext/__tests__/relation-catalog-search-work.test.ts @@ -1045,7 +1045,9 @@ describe("catalog search ownership and active-slot lifecycle", () => { it("starts observer-only queued work when an active slot becomes free", async () => { const provider = providerHarness(); - const service = coordinator(provider.captured); + const service = coordinator(provider.captured, { + deadlineScheduler: new ManualDeadlineScheduler(), + }); const active = Array.from( { length: MAX_CATALOG_ACTIVE_SEARCH_WORK }, (_, index) => diff --git a/src/vnext/__tests__/relation-completion.test.ts b/src/vnext/__tests__/relation-completion.test.ts index 8a25702..b33efba 100644 --- a/src/vnext/__tests__/relation-completion.test.ts +++ b/src/vnext/__tests__/relation-completion.test.ts @@ -232,11 +232,16 @@ describe("relation completion composition", () => { ); expect( - value.items.map((item) => [ - item.relationKind, - item.label, - item.edit.insert, - ]), + value.items.map((item) => { + if (item.kind !== "relation") { + throw new Error("Expected a relation completion"); + } + return [ + item.relationKind, + item.label, + item.edit.insert, + ]; + }), ).toEqual([ ["cte", "users", "users"], ["cte", "zed", "zed"], diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 14c3ad0..c8d3ead 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -6,6 +6,8 @@ import { duckdbDialect, postgresDialect, SqlSessionError, + type SqlColumnCatalogProvider, + type SqlNamespaceCatalogProvider, } from "../index.js"; import { DefaultSqlLanguageService, @@ -598,7 +600,7 @@ describe("relation completion session integration", () => { }); it("does not correlate catalog invalidation after a soft lease expires", async () => { - vi.useFakeTimers(); + vi.useFakeTimers({ shouldAdvanceTime: true }); let service: | ReturnType> | undefined; @@ -1032,209 +1034,1282 @@ describe("relation completion session integration", () => { service.dispose(); }); - it("stops a stale catalog event after a listener updates the session", () => { - let invalidate: - | ((event: SqlCatalogInvalidation) => void) - | undefined; + it("stops a stale catalog event after a listener updates the session", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlRevision[] = []; + session.onDidChange((event) => { + events.push(event.revision); + session.update({ + baseRevision: event.revision, + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "remote", + }, + }); + }); + session.onDidChange((event) => { + events.push(event.revision); + }); + + invalidate?.({ + epoch: { generation: 1, token: "changed" }, + }); + + expect(events).toHaveLength(1); + expect(session.revision).not.toBe(events[0]); + service.dispose(); + }); + + it("does not deliver an outer catalog event after nested invalidation", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: SqlRevision[] = []; + let nested = false; + session.onDidChange((event) => { + events.push(event.revision); + if (!nested) { + nested = true; + invalidate?.({ + epoch: { generation: 2, token: "nested" }, + }); + } + }); + session.onDidChange((event) => { + events.push(event.revision); + }); + + invalidate?.({ + epoch: { generation: 1, token: "outer" }, + }); + + expect(events).toHaveLength(4); + expect(events[0]).toBe(events[1]); + expect(events[1]).not.toBe(events[2]); + expect(events[2]).toBe(events[3]); + expect(session.revision).toBe(events[2]); + service.dispose(); + }); + + it("keeps duplicate listener subscriptions independent", () => { + let invalidate: + | ((event: SqlCatalogInvalidation) => void) + | undefined; + const service = createSqlLanguageService({ + catalog: catalogProvider( + async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "initial" }, + relations: [], + status: "ready", + }), + (_scope, listener) => { + invalidate = listener; + return () => undefined; + }, + ), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const events: string[] = []; + const listener = (event: SqlSessionChangeEvent): void => { + events.push(event.reason); + }; + const first = session.onDidChange(listener); + const second = session.onDidChange(listener); + + first.dispose(); + invalidate?.({ + epoch: { generation: 1, token: "first" }, + }); + expect(events).toEqual(["catalog"]); + + second.dispose(); + invalidate?.({ + epoch: { generation: 2, token: "second" }, + }); + expect(events).toEqual(["catalog"]); + service.dispose(); + }); + + it("validates completion configuration and request input", async () => { + expectSessionError("invalid-service-options", () => { + createSqlLanguageService({ + completion: { catalogResponseBudgetMs: 51 }, + dialects: [duckdb], + }); + }); + const { service, session } = openSession(); + await expect( + session.complete(null as never), + ).rejects.toMatchObject({ + code: "invalid-completion-request", + }); + service.dispose(); + }); + + it("rejects catalog context without a configured provider atomically", () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + expectSessionError("invalid-context", () => { + service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + }); + const session = service.openDocument({ + context: { + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + const revision = session.revision; + expectSessionError("invalid-context", () => { + session.update({ + baseRevision: revision, + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + }); + }); + expect(session.revision).toBe(revision); + service.dispose(); + }); +}); + +describe("column completion session integration", () => { + const relationCatalog: SqlRelationCatalogProvider = { + id: "relations", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + status: "ready", + }), + }; + + function serviceWithColumns( + loadColumns: SqlColumnCatalogProvider["loadColumns"], + ) { + return createSqlLanguageService({ + catalog: relationCatalog, + columns: { id: "columns", loadColumns }, + completion: { catalogResponseBudgetMs: 40 }, + dialects: [duckdb], + }); + } + + it("batches and completes a qualified alias through the session", async () => { + const requests: Parameters< + SqlColumnCatalogProvider["loadColumns"] + >[0][] = []; + const service = serviceWithColumns(async (request) => { + requests.push(request); + const relation = request.relations[0]; + if (!relation) throw new Error("Expected one relation"); + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: [{ + columns: [{ + columnEntityId: "users:name", + dataType: "VARCHAR", + identifier: { quoted: false, value: "name" }, + insertText: "name", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "users", + requestKey: relation.requestKey, + status: "ready", + }], + }; + }); + const text = "SELECT u.na FROM app.users AS u"; + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:1", + searchPath: [[{ quoted: false, value: "app" }]], + }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect( + session.complete({ + position: text.indexOf("na") + 2, + trigger: { kind: "invoked" }, + }), + ).resolves.toMatchObject({ + sources: [{ + feature: "column-catalog", + outcome: "ready", + providerId: "columns", + }], + status: "ready", + value: { + isIncomplete: false, + items: [{ + dataType: "VARCHAR", + edit: { from: 9, insert: "name", to: 11 }, + kind: "column", + label: "name", + provenance: { + columnEntityId: "users:name", + kind: "column-catalog", + providerId: "columns", + relationEntityId: "users", + scope: "connection:1", + }, + }], + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + dialectId: "duckdb", + expectedEpoch: null, + relations: [{ + path: [ + { quoted: false, value: "app" }, + { quoted: false, value: "users" }, + ], + requestKey: "binding:0", + }], + scope: "connection:1", + }); + service.dispose(); + }); + + it("loads all visible relations in one unqualified batch", async () => { + let calls = 0; + const service = serviceWithColumns(async (request) => { + calls += 1; + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: request.relations.map((relation, index) => ({ + columns: [{ + columnEntityId: `relation-${index}:id`, + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: `relation-${index}`, + requestKey: relation.requestKey, + status: "ready", + })), + }; + }); + const text = + "SELECT i FROM users u JOIN orders o ON o.user_id = u.id"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const result = await session.complete({ + position: "SELECT i".length, + trigger: { kind: "invoked" }, + }); + + expect(calls).toBe(1); + expect(result).toMatchObject({ + status: "ready", + value: { + items: [ + { detail: "o", label: "id" }, + { detail: "u", label: "id" }, + ], + }, + }); + service.dispose(); + }); + + it("completes a qualified correlated outer relation", async () => { + const requests: Parameters< + SqlColumnCatalogProvider["loadColumns"] + >[0][] = []; + const service = serviceWithColumns(async (request) => { + requests.push(request); + const relation = request.relations[0]; + if (!relation) throw new Error("Expected the correlated relation"); + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: [{ + columns: [{ + columnEntityId: "users:id", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "users", + requestKey: relation.requestKey, + status: "ready", + }], + }; + }); + const text = + "SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.)"; + const position = text.indexOf("u.)") + 2; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:correlated" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [{ label: "id" }], + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.relations).toEqual([{ + path: [{ quoted: false, value: "users" }], + requestKey: "binding:1", + }]); + service.dispose(); + }); + + it("completes LATERAL references without loading later siblings", async () => { + for (const dialect of [postgres, duckdb]) { + for (const expression of ["u.", "na"]) { + const requests: Parameters< + SqlColumnCatalogProvider["loadColumns"] + >[0][] = []; + const service = createSqlLanguageService({ + catalog: relationCatalog, + columns: { + id: "columns", + loadColumns: async (request) => { + requests.push(request); + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: request.relations.map((relation, index) => ({ + columns: [{ + columnEntityId: `column-${index}`, + identifier: { quoted: false, value: "name" }, + insertText: "name", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: `relation-${index}`, + requestKey: relation.requestKey, + status: "ready", + })), + }; + }, + }, + dialects: [dialect], + }); + const text = + `SELECT * FROM users u, LATERAL (SELECT ${expression} FROM orders o) x, secrets s`; + const session = service.openDocument({ + context: { + catalog: { + scope: `connection:lateral:${dialect.id}:${expression}`, + }, + dialect: dialect.id, + engine: "local", + }, + text, + }); + const position = text.indexOf(expression) + expression.length; + + await expect(session.complete({ + position, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ status: "ready" }); + expect(requests).toHaveLength(1); + expect(requests[0]?.relations.map((relation) => + relation.path.at(-1)?.value + )).toEqual(expression === "u." + ? ["users"] + : ["orders", "users"]); + service.dispose(); + } + } + }); + + it("never resolves a visible CTE as a physical relation", async () => { + let calls = 0; + const service = serviceWithColumns(async () => { + calls += 1; + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + }; + }); + const text = + "WITH users AS (SELECT 1 AS local_id) SELECT users. FROM users"; + const position = text.indexOf("users.") + "users.".length; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "unavailable", + }); + expect(calls).toBe(0); + service.dispose(); + }); + + it("keeps slow providers inside the completion response budget", async () => { + vi.useFakeTimers(); + try { + const service = serviceWithColumns(() => new Promise(() => {})); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const completion = session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(40); + const result = await completion; + expect(result).toMatchObject({ + refreshToken: expect.anything(), + sources: [{ + feature: "column-catalog", + outcome: "loading", + }], + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "column-catalog-loading" }], + items: [], + }, + }); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("charges column analysis against the total response budget", async () => { + vi.useFakeTimers(); + const performanceNow = vi.spyOn(performance, "now") + .mockReturnValueOnce(0) + .mockReturnValue(30); + try { + const service = serviceWithColumns(() => new Promise(() => {})); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:column-total-budget" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + let settled = false; + const completion = session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + void completion.then(() => { + settled = true; + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(9); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(completion).resolves.toMatchObject({ + status: "ready", + value: { + issues: [{ reason: "column-catalog-loading" }], + }, + }); + service.dispose(); + } finally { + performanceNow.mockRestore(); + vi.useRealTimers(); + } + }); + + it("bounds provider-declared column loading to one automatic retry", async () => { + vi.useFakeTimers(); + try { + let calls = 0; + const service = serviceWithColumns(async (request) => { + calls += 1; + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: request.relations.map((relation) => ({ + requestKey: relation.requestKey, + status: "loading", + })), + }; + }); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:columns-loading" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => events.push(event)); + const result = await session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected retained column loading"); + } + + await vi.advanceTimersByTimeAsync(100); + expect(events).toMatchObject([{ + reason: "catalog-availability", + refreshToken: result.refreshToken, + }]); + const retry = await session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + expect(retry).toMatchObject({ + refreshToken: null, + sources: [{ + feature: "column-catalog", + outcome: "loading", + }], + }); + await vi.advanceTimersByTimeAsync(500); + expect(calls).toBe(2); + expect(events).toHaveLength(1); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("emits readiness when a timed-out column batch settles", async () => { + vi.useFakeTimers(); + try { + let resolveBatch: + | ((value: Awaited< + ReturnType + >) => void) + | undefined; + const batch = new Promise< + Awaited> + >((resolve) => { + resolveBatch = resolve; + }); + const service = createSqlLanguageService({ + columns: { id: "columns", loadColumns: () => batch }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:columns-ready" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => events.push(event)); + const pending = session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + const result = await pending; + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected retained column loading"); + } + + resolveBatch?.({ + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + }); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toMatchObject([{ + reason: "catalog-availability", + refreshToken: result.refreshToken, + }]); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("emits readiness when a timed-out column batch rejects", async () => { + vi.useFakeTimers(); + try { + let rejectBatch: ((reason: Error) => void) | undefined; + const batch = new Promise< + Awaited> + >((_resolve, reject) => { + rejectBatch = reject; + }); + const service = createSqlLanguageService({ + columns: { id: "columns", loadColumns: () => batch }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:columns-reject" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => events.push(event)); + const pending = session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + const result = await pending; + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected retained column loading"); + } + + rejectBatch?.(new Error("offline")); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toMatchObject([{ + reason: "catalog-availability", + refreshToken: result.refreshToken, + }]); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("expires timed-out column readiness without a stale event", async () => { + vi.useFakeTimers(); + try { + let resolveBatch: + | ((value: Awaited< + ReturnType + >) => void) + | undefined; + const batch = new Promise< + Awaited> + >((resolve) => { + resolveBatch = resolve; + }); + const service = createSqlLanguageService({ + columns: { id: "columns", loadColumns: () => batch }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = "SELECT u. FROM users u"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:columns-expire" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => events.push(event)); + const completion = session.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + await completion; + await vi.advanceTimersByTimeAsync(1_000); + + resolveBatch?.({ + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + }); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual([]); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("namespace completion session integration", () => { + const relationCatalog: SqlRelationCatalogProvider = { + id: "relations", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + status: "ready", + }), + }; + + function serviceWithNamespaces( + search: SqlNamespaceCatalogProvider["search"], + budget = 40, + ) { + return createSqlLanguageService({ + catalog: relationCatalog, + completion: { catalogResponseBudgetMs: budget }, + dialects: [duckdb], + namespaces: { id: "namespaces", search }, + }); + } + + it("merges namespace containers into relation-site completion", async () => { + const requests: Parameters< + SqlNamespaceCatalogProvider["search"] + >[0][] = []; + const service = serviceWithNamespaces(async (request) => { + requests.push(request); + return { + containers: [{ + canonicalPath: [{ + quoted: false, + role: "schema", + value: "main", + }], + containerEntityId: "schema:main", + detail: "DuckDB schema", + insertText: "main", + matchQuality: "exact", + }], + coverage: "complete", + epoch: { generation: 1, token: "epoch-1" }, + status: "ready", + }; + }); + const text = "SELECT * FROM ma"; + const session = service.openDocument({ + context: { + catalog: { + scope: "connection:1", + searchPath: [[{ quoted: false, value: "main" }]], + }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [ + { feature: "relation-catalog" }, + { + feature: "namespace-catalog", + outcome: "ready", + providerId: "namespaces", + }, + ], + status: "ready", + value: { + isIncomplete: false, + items: [{ + detail: "DuckDB schema", + edit: { from: 14, insert: "main", to: 16 }, + kind: "namespace", + label: "main", + provenance: { + containerEntityId: "schema:main", + kind: "namespace-catalog", + providerId: "namespaces", + scope: "connection:1", + }, + role: "schema", + }], + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + dialectId: "duckdb", + expectedEpoch: null, + limit: 128, + prefix: { quoted: false, value: "ma" }, + qualifier: [], + scope: "connection:1", + }); + service.dispose(); + }); + + it("keeps slow namespace providers inside the shared budget", async () => { + vi.useFakeTimers(); + try { + const service = serviceWithNamespaces( + () => new Promise(() => {}), + 0, + ); + const text = "SELECT * FROM "; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const completion = session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + await vi.advanceTimersByTimeAsync(0); + await expect(completion).resolves.toMatchObject({ + sources: [ + { feature: "relation-catalog" }, + { + feature: "namespace-catalog", + outcome: "loading", + }, + ], + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "namespace-catalog-loading" }], + }, + }); + service.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("supports a namespace-only service and preserves partial evidence", async () => { const service = createSqlLanguageService({ - catalog: catalogProvider( - async () => ({ - coverage: { kind: "complete" }, - epoch: { generation: 0, token: "initial" }, - relations: [], + dialects: [duckdb], + namespaces: { + id: "namespace-only", + search: async () => ({ + containers: [{ + canonicalPath: [{ + quoted: false, + role: "schema", + value: "main", + }], + containerEntityId: "schema:main", + insertText: "main", + matchQuality: "equivalent", + }], + coverage: "partial", + epoch: { generation: 1, token: "epoch-1" }, status: "ready", }), - (_scope, listener) => { - invalidate = listener; - return () => undefined; - }, - ), - dialects: [duckdb], + }, }); + const text = "SELECT * FROM m"; const session = service.openDocument({ context: { - catalog: { scope: "connection:1" }, + catalog: { scope: "connection:namespace-only" }, dialect: "duckdb", engine: "local", }, - text: "SELECT * FROM ", + text, }); - const events: SqlRevision[] = []; - session.onDidChange((event) => { - events.push(event.revision); - session.update({ - baseRevision: event.revision, + + await expect(session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ + coverage: "partial", + feature: "namespace-catalog", + }], + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "namespace-catalog-partial" }], + items: [{ kind: "namespace", label: "main" }], + }, + }); + service.dispose(); + }); + + it("polls a provider-declared loading namespace with token correlation", async () => { + vi.useFakeTimers(); + try { + let calls = 0; + const service = serviceWithNamespaces(async () => { + calls += 1; + return { + epoch: { generation: 1, token: "epoch-1" }, + status: "loading", + }; + }); + const text = "SELECT * FROM ma"; + const session = service.openDocument({ context: { - catalog: { scope: "connection:1" }, + catalog: { scope: "connection:namespaces-loading" }, dialect: "duckdb", - engine: "remote", + engine: "local", }, + text, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => events.push(event)); + const result = await session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + if (result.status !== "ready" || result.refreshToken === null) { + throw new Error("Expected retained namespace loading"); + } + expect(result.value).toMatchObject({ + issues: [{ + reason: "namespace-catalog-loading", + remainingIntentLeaseMs: 1_000, + }], }); - }); - session.onDidChange((event) => { - events.push(event.revision); - }); - invalidate?.({ - epoch: { generation: 1, token: "changed" }, - }); + await vi.advanceTimersByTimeAsync(100); + expect(events).toMatchObject([{ + reason: "catalog-availability", + refreshToken: result.refreshToken, + }]); - expect(events).toHaveLength(1); - expect(session.revision).not.toBe(events[0]); - service.dispose(); + const retry = await session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + }); + expect(retry).toMatchObject({ + refreshToken: null, + sources: [ + { feature: "relation-catalog" }, + { + feature: "namespace-catalog", + outcome: "loading", + }, + ], + }); + await vi.advanceTimersByTimeAsync(500); + expect(calls).toBe(2); + expect(events).toHaveLength(1); + service.dispose(); + } finally { + vi.useRealTimers(); + } }); - it("does not deliver an outer catalog event after nested invalidation", () => { - let invalidate: - | ((event: SqlCatalogInvalidation) => void) - | undefined; + it("retains auxiliary owners across unrelated context changes", () => { const service = createSqlLanguageService({ - catalog: catalogProvider( - async () => ({ - coverage: { kind: "complete" }, - epoch: { generation: 0, token: "initial" }, + columns: { + id: "columns", + loadColumns: async () => ({ + epoch: { generation: 1, token: "epoch-1" }, relations: [], - status: "ready", }), - (_scope, listener) => { - invalidate = listener; - return () => undefined; - }, - ), + }, dialects: [duckdb], + namespaces: { + id: "namespaces", + search: async () => ({ + containers: [], + coverage: "complete", + epoch: { generation: 1, token: "epoch-1" }, + status: "ready", + }), + }, }); const session = service.openDocument({ context: { - catalog: { scope: "connection:1" }, + catalog: { scope: "connection:stable" }, dialect: "duckdb", - engine: "local", + engine: "before", }, - text: "SELECT * FROM ", - }); - const events: SqlRevision[] = []; - let nested = false; - session.onDidChange((event) => { - events.push(event.revision); - if (!nested) { - nested = true; - invalidate?.({ - epoch: { generation: 2, token: "nested" }, - }); - } - }); - session.onDidChange((event) => { - events.push(event.revision); + text: "SELECT 1", }); - - invalidate?.({ - epoch: { generation: 1, token: "outer" }, + const revision = session.update({ + baseRevision: session.revision, + context: { + catalog: { scope: "connection:stable" }, + dialect: "duckdb", + engine: "after", + }, }); - expect(events).toHaveLength(4); - expect(events[0]).toBe(events[1]); - expect(events[1]).not.toBe(events[2]); - expect(events[2]).toBe(events[3]); - expect(session.revision).toBe(events[2]); + expect(session.revision).toBe(revision); service.dispose(); }); - it("keeps duplicate listener subscriptions independent", () => { + it("invalidates auxiliary caches with the relation catalog epoch", async () => { + let generation = 1; + let columnCalls = 0; + let namespaceCalls = 0; let invalidate: | ((event: SqlCatalogInvalidation) => void) | undefined; const service = createSqlLanguageService({ - catalog: catalogProvider( - async () => ({ + catalog: { + id: "relations", + search: async () => ({ coverage: { kind: "complete" }, - epoch: { generation: 0, token: "initial" }, + epoch: { generation, token: `epoch-${generation}` }, relations: [], status: "ready", }), - (_scope, listener) => { + subscribe: (_scope, listener) => { invalidate = listener; return () => undefined; }, - ), + }, + columns: { + id: "columns", + loadColumns: async (request) => { + columnCalls += 1; + return { + epoch: { generation, token: `epoch-${generation}` }, + relations: request.relations.map((relation) => ({ + columns: [], + coverage: "complete", + relationEntityId: relation.requestKey, + requestKey: relation.requestKey, + status: "ready", + })), + }; + }, + }, dialects: [duckdb], - }); - const session = service.openDocument({ - context: { - catalog: { scope: "connection:1" }, - dialect: "duckdb", - engine: "local", + namespaces: { + id: "namespaces", + search: async () => { + namespaceCalls += 1; + return { + containers: [], + coverage: "complete", + epoch: { generation, token: `epoch-${generation}` }, + status: "ready", + }; + }, }, - text: "SELECT * FROM ", }); - const events: string[] = []; - const listener = (event: SqlSessionChangeEvent): void => { - events.push(event.reason); - }; - const first = session.onDidChange(listener); - const second = session.onDidChange(listener); + const open = (text: string) => + service.openDocument({ + context: { + catalog: { scope: "connection:invalidate" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + const columns = open("SELECT u. FROM users u"); + const namespaces = open("SELECT * FROM ma"); + const completeColumns = () => + columns.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + const completeNamespaces = () => + namespaces.complete({ + position: "SELECT * FROM ma".length, + trigger: { kind: "invoked" }, + }); - first.dispose(); - invalidate?.({ - epoch: { generation: 1, token: "first" }, + await completeColumns(); + await completeNamespaces(); + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 1, + namespaceCalls: 1, }); - expect(events).toEqual(["catalog"]); - second.dispose(); + generation = 2; invalidate?.({ - epoch: { generation: 2, token: "second" }, - }); - expect(events).toEqual(["catalog"]); - service.dispose(); - }); - - it("validates completion configuration and request input", async () => { - expectSessionError("invalid-service-options", () => { - createSqlLanguageService({ - completion: { catalogResponseBudgetMs: 51 }, - dialects: [duckdb], - }); + epoch: { generation, token: `epoch-${generation}` }, }); - const { service, session } = openSession(); - await expect( - session.complete(null as never), - ).rejects.toMatchObject({ - code: "invalid-completion-request", + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 2, + namespaceCalls: 2, }); service.dispose(); }); - it("rejects catalog context without a configured provider atomically", () => { + it("manually invalidates auxiliary-only catalog authority", async () => { + let generation = 1; + let columnCalls = 0; + let namespaceCalls = 0; const service = createSqlLanguageService({ + columns: { + id: "columns", + loadColumns: async (request) => { + columnCalls += 1; + return { + epoch: { generation, token: `epoch-${generation}` }, + relations: request.relations.map((relation) => ({ + columns: [], + coverage: "complete", + relationEntityId: relation.requestKey, + requestKey: relation.requestKey, + status: "ready", + })), + }; + }, + }, dialects: [duckdb], - }); - expectSessionError("invalid-context", () => { - service.openDocument({ - context: { - catalog: { scope: "connection:1" }, - dialect: "duckdb", - engine: "local", + namespaces: { + id: "namespaces", + search: async () => { + namespaceCalls += 1; + return { + containers: [], + coverage: "complete", + epoch: { generation, token: `epoch-${generation}` }, + status: "ready", + }; }, - text: "SELECT * FROM ", - }); + }, }); - const session = service.openDocument({ + const columns = service.openDocument({ context: { + catalog: { scope: "connection:manual-invalidate" }, dialect: "duckdb", engine: "local", }, - text: "SELECT * FROM ", + text: "SELECT u. FROM users u", }); - const revision = session.revision; - expectSessionError("invalid-context", () => { - session.update({ - baseRevision: revision, - context: { - catalog: { scope: "connection:1" }, - dialect: "duckdb", - engine: "local", - }, - }); + const namespaces = service.openDocument({ + context: { + catalog: { scope: "connection:manual-invalidate" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ma", }); - expect(session.revision).toBe(revision); + const completeColumns = () => + columns.complete({ + position: "SELECT u.".length, + trigger: { kind: "invoked" }, + }); + const completeNamespaces = () => + namespaces.complete({ + position: "SELECT * FROM ma".length, + trigger: { kind: "invoked" }, + }); + + await completeColumns(); + await completeNamespaces(); + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 1, + namespaceCalls: 1, + }); + + generation = 2; + const columnRevision = columns.invalidateCatalog(); + const namespaceRevision = namespaces.invalidateCatalog(); + expect(columns.revision).toBe(columnRevision); + expect(namespaces.revision).toBe(namespaceRevision); + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 2, + namespaceCalls: 2, + }); + + columns.dispose(); + expect(() => columns.invalidateCatalog()).toThrow( + "SQL document session is disposed", + ); + namespaces.dispose(); service.dispose(); }); }); @@ -3550,6 +4625,18 @@ describe("session coverage hardening", () => { }); }); + it.each([ + { columns: { id: "", loadColumns: async () => ({}) } }, + { namespaces: { id: "", search: async () => ({}) } }, + ])("rejects a malformed auxiliary provider %#", (provider) => { + expectSessionError("invalid-service-options", () => { + Reflect.apply(createSqlLanguageService, undefined, [{ + ...provider, + dialects: [duckdb], + }]); + }); + }); + it.each([ { expectedIssue: "catalog-failed", @@ -4014,6 +5101,15 @@ describe("session coverage hardening", () => { }, completion: { catalogResponseBudgetMs: 0 }, dialects: [duckdb], + namespaces: { + id: "retained-intent-namespaces", + search: async () => ({ + containers: [], + coverage: "complete", + epoch: { generation: 0, token: "ready" }, + status: "ready", + }), + }, }); const session = service.openDocument({ context: { diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js index 530f6a4..1ec2a4f 100644 --- a/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js @@ -1,6 +1,6 @@ globalThis.postMessage({ kind: "ready", - protocolVersion: 1, + protocolVersion: 2, }); globalThis.addEventListener("message", () => { throw new Error("Intentional parser executor crash fixture"); diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js index b2c2edd..7ae4382 100644 --- a/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js @@ -1,5 +1,5 @@ globalThis.postMessage({ kind: "ready", - protocolVersion: 1, + protocolVersion: 2, }); globalThis.addEventListener("message", () => {}); diff --git a/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts index 08a6b9b..eac837f 100644 --- a/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts +++ b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts @@ -177,6 +177,7 @@ test( ), ).resolves.toStrictEqual({ kind: "parsed", + queryBindings: expect.any(Object), statementKind: "query", }); await expect( @@ -187,6 +188,7 @@ test( ), ).resolves.toStrictEqual({ kind: "parsed", + queryBindings: expect.any(Object), statementKind: "query", }); await expect( @@ -197,6 +199,7 @@ test( ), ).resolves.toStrictEqual({ kind: "parsed", + queryBindings: expect.any(Object), statementKind: "query", }); } finally { @@ -232,6 +235,7 @@ test( submitQuery(executor, "postgresql", "SELECT 2"), ).resolves.toStrictEqual({ kind: "parsed", + queryBindings: expect.any(Object), statementKind: "query", }); expect(generation).toBe(2); @@ -268,6 +272,7 @@ test( submitQuery(executor, "bigquery", "SELECT 2"), ).resolves.toStrictEqual({ kind: "parsed", + queryBindings: expect.any(Object), statementKind: "query", }); expect(generation).toBe(2); 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 index 714a853..968dcf6 100644 --- a/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts +++ b/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts @@ -151,6 +151,7 @@ test( expect(firstPostgresql).toStrictEqual({ kind: "parsed", protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings: expect.any(Object), requestId: 1, statementKind: "query", }); @@ -166,6 +167,7 @@ test( ).resolves.toStrictEqual({ kind: "parsed", protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings: expect.any(Object), requestId: 2, statementKind: "query", }); @@ -180,6 +182,7 @@ test( ).resolves.toStrictEqual({ kind: "parsed", protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings: expect.any(Object), requestId: 3, statementKind: "query", }); diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts index 39ebef4..063c6aa 100644 --- a/src/vnext/codemirror/__tests__/sql-editor.test.ts +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -11,6 +11,7 @@ import { EditorSelection, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + bigQueryDialect, createSqlLanguageService, duckdbDialect, type SqlCatalogSearchRequest, @@ -27,6 +28,7 @@ import { type SqlRelationCatalogProvider, type SqlRevision, type SqlSessionChangeEvent, + type SqlTextRange, } from "../../index.js"; import { createSqlCompletionRefreshToken, @@ -46,8 +48,11 @@ interface FakeServiceHarness { readonly completeSignals: AbortSignal[]; readonly emit: (event: SqlSessionChangeEvent) => void; readonly getLastToken: () => SqlCompletionRefreshToken | null; + readonly invalidations: () => number; readonly service: SqlLanguageService; readonly sessionDisposals: () => number; + readonly statementBoundaryCalls: () => number; + readonly statementIntersectionCalls: () => number; readonly updates: readonly SqlDocumentUpdate[]; } @@ -126,6 +131,7 @@ function fakeService( token: SqlCompletionRefreshToken, ) => SqlCompletionResult | Promise, rejectUpdates = false, + statementCode: SqlTextRange | null = null, ): FakeServiceHarness { const completeSignals: AbortSignal[] = []; const updates: SqlDocumentUpdate[] = []; @@ -133,7 +139,10 @@ function fakeService( | ((event: SqlSessionChangeEvent) => void) | null = null; let lastToken: SqlCompletionRefreshToken | null = null; + let invalidationCount = 0; let sessionDisposalCount = 0; + let statementBoundaryCallCount = 0; + let statementIntersectionCallCount = 0; const service: SqlLanguageService = { dispose: () => undefined, openDocument: () => { @@ -154,6 +163,11 @@ function fakeService( disposed = true; sessionDisposalCount += 1; }, + invalidateCatalog: () => { + invalidationCount += 1; + revision = createSqlRevisionToken(); + return revision; + }, isCurrent: (candidate) => !disposed && candidate === revision, onDidChange: (nextListener) => { listener = nextListener; @@ -166,30 +180,56 @@ function fakeService( get revision() { return revision; }, - statementBoundaryAt: () => ({ - boundary: { - boundaryQuality: "exact", - code: null, - endState: { kind: "normal" }, - extent: { from: 0, to: 0 }, - hasCode: false, - source: { from: 0, to: 0 }, - terminator: null, - }, - revision, - }), - statementBoundariesIntersecting: () => ({ - boundaries: [{ - boundaryQuality: "exact", - code: null, - endState: { kind: "normal" }, - extent: { from: 0, to: 0 }, - hasCode: false, - source: { from: 0, to: 0 }, - terminator: null, - }], - revision, - }), + statementBoundaryAt: () => { + statementBoundaryCallCount += 1; + return { + boundary: statementCode === null + ? { + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + } + : { + boundaryQuality: "exact", + code: statementCode, + endState: { kind: "normal" }, + extent: statementCode, + hasCode: true, + source: statementCode, + terminator: null, + }, + revision, + }; + }, + statementBoundariesIntersecting: () => { + statementIntersectionCallCount += 1; + return { + boundaries: [statementCode === null + ? { + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + } + : { + boundaryQuality: "exact", + code: statementCode, + endState: { kind: "normal" }, + extent: statementCode, + hasCode: true, + source: statementCode, + terminator: null, + }], + revision, + }; + }, update: (update) => { updates.push(update); if (rejectUpdates) { @@ -206,8 +246,12 @@ function fakeService( completeSignals, emit: (event) => listener?.(event), getLastToken: () => lastToken, + invalidations: () => invalidationCount, service, sessionDisposals: () => sessionDisposalCount, + statementBoundaryCalls: () => statementBoundaryCallCount, + statementIntersectionCalls: () => + statementIntersectionCallCount, updates, }; } @@ -320,6 +364,339 @@ async function resolveCompletionInfo( } describe("sqlEditor", () => { + it("exposes session controls only for owned views", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + }); + const view = createView(support.extension, "SELECT 1;SELECT 2"); + const foreign = createView([]); + + expect(support.statementBoundaryAt(view, { + affinity: "left", + position: 9, + })?.boundary).toMatchObject({ + boundaryQuality: "exact", + code: { from: 0, to: 8 }, + hasCode: true, + }); + expect(support.statementBoundariesIntersecting(view, { + from: 0, + to: view.state.doc.length, + })?.boundaries).toHaveLength(2); + expect(support.invalidateCatalog(view)).not.toBeNull(); + expect(support.invalidateCatalog(foreign)).toBeNull(); + expect(support.statementBoundaryAt(foreign, { + affinity: "left", + position: 0, + })).toBeNull(); + expect(support.statementBoundariesIntersecting(foreign, { + from: 0, + to: 0, + })).toBeNull(); + expect(support.statementBoundaryAt(view, { + affinity: "left", + position: 100, + })).toBeNull(); + + view.destroy(); + expect(support.invalidateCatalog(view)).toBeNull(); + expect(support.statementBoundaryAt(view, { + affinity: "left", + position: 0, + })).toBeNull(); + expect(support.statementBoundariesIntersecting(view, { + from: 0, + to: 0, + })).toBeNull(); + service.dispose(); + }); + + it("proxies catalog invalidation only to its owned live session", () => { + const harness = fakeService((revision) => readyResult(revision, [])); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + const foreign = createView([]); + + expect(support.invalidateCatalog(view)).not.toBeNull(); + expect(harness.invalidations()).toBe(1); + expect(support.invalidateCatalog(foreign)).toBeNull(); + expect(harness.invalidations()).toBe(1); + view.destroy(); + expect(support.invalidateCatalog(view)).toBeNull(); + expect(harness.invalidations()).toBe(1); + }); + + it("renders an opt-in visible-line statement gutter", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: {}, + }); + const view = createView( + support.extension, + "SELECT 1;\n\n/* separator */\nSELECT 2;", + ); + + await vi.waitFor(() => { + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(2); + }); + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker-active"), + ).toHaveLength(1); + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker-inactive"), + ).toHaveLength(1); + expect(Array.from( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).findIndex((marker) => + marker.classList.contains("cm-sql-statement-marker-active") + )).toBe(1); + + view.dispatch({ selection: { anchor: 1 } }); + await vi.waitFor(() => { + const markers = view.dom.querySelectorAll( + ".cm-sql-statement-marker", + ); + expect(markers).toHaveLength(2); + expect( + view.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ), + ).toHaveLength(1); + expect(Array.from(markers).findIndex((marker) => + marker.classList.contains( + "cm-sql-statement-marker-active", + ) + )).toBe(0); + }); + service.dispose(); + }); + + it("does not install a statement gutter by default", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + }); + const view = createView(support.extension, "SELECT 1"); + + expect( + view.dom.querySelector(".cm-sql-statement-gutter"), + ).toBeNull(); + service.dispose(); + }); + + it("renders no marker for an empty document", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: {}, + }); + const view = createView(support.extension, ""); + + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(0); + service.dispose(); + }); + + it("supports hidden and active-only gutter policies", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const hidden = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: { hideWhenNotFocused: true }, + }); + const hiddenView = createView(hidden.extension, "SELECT 1"); + expect( + hiddenView.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(0); + hiddenView.focus(); + await vi.waitFor(() => { + expect( + hiddenView.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(1); + }); + + const activeOnly = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: { showInactive: false }, + }); + const activeView = createView( + activeOnly.extension, + "SELECT 1;\nSELECT 2", + ); + await vi.waitFor(() => { + expect( + activeView.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ), + ).toHaveLength(1); + expect( + activeView.dom.querySelectorAll( + ".cm-sql-statement-marker-inactive", + ), + ).toHaveLength(0); + }); + activeOnly.setContext(activeView, { + dialect: "duckdb", + engine: "remote", + }); + await vi.waitFor(() => { + expect( + activeView.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ), + ).toHaveLength(1); + }); + service.dispose(); + }); + + it("shows only inactive markers when no code boundary is current", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: {}, + }); + const view = createView( + support.extension, + "SELECT 1;\n/* trailing */", + ); + + await vi.waitFor(() => { + expect( + view.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ), + ).toHaveLength(0); + expect( + view.dom.querySelectorAll( + ".cm-sql-statement-marker-inactive", + ), + ).toHaveLength(1); + }); + service.dispose(); + expect(() => { + view.dispatch({ selection: { anchor: 0 } }); + }).not.toThrow(); + }); + + it("accepts an explicit false gutter option", () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: false, + }); + const view = createView(support.extension, "SELECT 1"); + expect( + view.dom.querySelector(".cm-sql-statement-gutter"), + ).toBeNull(); + service.dispose(); + }); + + it("marks internal blank lines but not separator trivia", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + statementGutter: {}, + }); + const view = createView( + support.extension, + "SELECT\n\n 1;\n\nSELECT 2", + ); + + await vi.waitFor(() => { + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(4); + }); + service.dispose(); + }); + + it("does not fall back across an opaque right boundary", async () => { + const service = createSqlLanguageService({ + dialects: [bigQueryDialect()], + }); + const support = sqlEditor({ + initialContext: { dialect: "bigquery", engine: "local" }, + service, + statementGutter: {}, + }); + const documentText = + "SELECT 1; IF condition THEN SELECT 2; END IF;"; + const sharedBoundary = documentText.indexOf(";") + 1; + const view = createView(support.extension, documentText); + view.dispatch({ selection: { anchor: sharedBoundary } }); + + expect( + support.statementBoundaryAt(view, { + affinity: "right", + position: sharedBoundary, + })?.boundary.boundaryQuality, + ).toBe("opaque"); + await vi.waitFor(() => { + expect( + view.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ), + ).toHaveLength(0); + }); + service.dispose(); + }); + + it("queries structural boundaries once per relevant redraw", () => { + const documentText = "SELECT\n\n 1;\nSELECT 2"; + const harness = fakeService( + (revision) => readyResult(revision, []), + false, + { from: 0, to: documentText.length }, + ); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service: harness.service, + statementGutter: {}, + }); + const view = createView(support.extension, documentText); + + expect(harness.statementBoundaryCalls()).toBe(1); + expect(harness.statementIntersectionCalls()).toBe(1); + view.dispatch({ selection: { anchor: 1 } }); + expect(harness.statementBoundaryCalls()).toBe(2); + expect(harness.statementIntersectionCalls()).toBe(2); + view.dispatch({}); + expect(harness.statementBoundaryCalls()).toBe(2); + expect(harness.statementIntersectionCalls()).toBe(2); + }); + it("maps current service completions and applies the exact core edit", async () => { const service = createSqlLanguageService({ catalog: { @@ -748,6 +1125,32 @@ describe("sqlEditor", () => { expect(harness.sessionDisposals()).toBe(0); }); + it("refuses a completion edit that overlaps an embedded region", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem(14, 16)]) + ); + const support = sqlEditor({ + initialContext: context(), + initialEmbeddedRegions: [{ + from: 15, + language: "host", + to: 16, + }], + service: harness.service, + }); + const view = createView(support.extension); + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + const completion = currentCompletions(view.state)[0]; + if (!completion || typeof completion.apply !== "function") { + throw new Error("Expected completion apply callback"); + } + + completion.apply(view, completion, 14, 16); + expect(view.state.doc.toString()).toBe("SELECT * FROM us"); + expect(harness.updates).toEqual([]); + }); + it("combines document and final context effects into one current input", async () => { const requests: SqlCatalogSearchRequest[] = []; const service = createSqlLanguageService({ @@ -927,6 +1330,294 @@ describe("sqlEditor", () => { ); }); + it("maps column and namespace completion presentation", async () => { + const epoch = { generation: 1, token: "epoch-1" }; + const items: readonly SqlCompletionItem[] = [{ + edit: { from: 14, insert: "users_column", to: 16 }, + kind: "column", + label: "users_column", + provenance: { + columnEntityId: "users:name", + epoch, + kind: "column-catalog", + providerId: "columns", + relationEntityId: "users", + scope: "connection:fake", + }, + relationRequestKey: "binding:0", + }, { + edit: { from: 14, insert: "users_namespace", to: 16 }, + kind: "namespace", + label: "users_namespace", + provenance: { + containerEntityId: "schema:main", + epoch, + kind: "namespace-catalog", + providerId: "namespaces", + scope: "connection:fake", + }, + role: "schema", + }]; + const harness = fakeService((revision) => + readyResult(revision, items) + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(currentCompletions(view.state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "users_column", + type: "property", + }), + expect.objectContaining({ + label: "users_namespace", + type: "namespace", + }), + ]), + ); + }); + + it("denies SQL completion at an unmatched template EOF without removing external sources", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const gate = vi.fn((state, position) => + !state.sliceDoc(0, position).endsWith("{") + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 15, + options: [{ label: "python_variable" }], + })], + isCompletionPositionAllowed: gate, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension, "SELECT * FROM {"); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(harness.completeSignals).toHaveLength(0); + expect(currentCompletions(view.state)).toEqual([ + expect.objectContaining({ label: "python_variable" }), + ]); + expect(gate).toHaveBeenCalledWith(view.state, 15); + }); + + it("allows normal SQL completion through the position gate", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const gate = vi.fn(() => true); + const support = sqlEditor({ + autocomplete: { + isCompletionPositionAllowed: gate, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(harness.completeSignals).toHaveLength(1); + expect(currentCompletions(view.state)).toEqual([ + expect.objectContaining({ label: "users" }), + ]); + expect(gate).toHaveBeenCalledWith(view.state, 16); + }); + + it("fails a throwing position gate closed while retaining external sources", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 15, + options: [{ label: "python_variable" }], + })], + isCompletionPositionAllowed: () => { + throw new Error("host state unavailable"); + }, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension, "SELECT * FROM {"); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(harness.completeSignals).toHaveLength(0); + expect(currentCompletions(view.state)).toEqual([ + expect.objectContaining({ label: "python_variable" }), + ]); + }); + + it("cancels pending SQL completion when the position gate flips false", async () => { + let allowed = true; + const harness = fakeService( + () => new Promise(() => undefined), + ); + const support = sqlEditor({ + autocomplete: { + isCompletionPositionAllowed: () => allowed, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => { + expect(harness.completeSignals).toHaveLength(1); + }); + allowed = false; + view.dispatch({}); + await vi.waitFor(() => { + expect(harness.completeSignals[0]?.aborted).toBe(true); + expect(currentCompletions(view.state)).toEqual([]); + }); + }); + + it("restarts external sources after the SQL gate closes stale options", async () => { + let allowed = true; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + externalSources: [() => ({ + from: 16, + options: [{ label: "external_variable" }], + })], + isCompletionPositionAllowed: () => allowed, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(currentCompletions(view.state).map((item) => item.label).sort()) + .toEqual(["external_variable", "users"]); + + allowed = false; + view.dispatch({}); + await vi.waitFor(() => { + expect(currentCompletions(view.state).map((item) => item.label)) + .toEqual(["external_variable"]); + }); + }); + + it("closes SQL options denied while the provider is resolving", async () => { + const runtime = controlledRuntime(); + let allowed = true; + let resolveResult: + | ((result: SqlCompletionResult) => void) + | undefined; + let revision: SqlRevision | null = null; + const harness = fakeService((currentRevision) => { + revision = currentRevision; + return new Promise((resolve) => { + resolveResult = resolve; + }); + }); + const support = createSqlEditorInternal({ + autocomplete: { + closeOnBlur: false, + isCompletionPositionAllowed: () => allowed, + }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await vi.waitFor(() => + expect(harness.completeSignals).toHaveLength(1) + ); + if (revision === null) { + throw new Error("Expected deferred completion revision"); + } + allowed = false; + resolveResult?.(readyResult(revision, [completionItem()])); + await vi.waitFor(() => expect(runtime.queued).toHaveLength(1)); + + runtime.queued[0]?.(); + expect(runtime.closes).toEqual([view]); + }); + + it("does not let a stale gate-close task affect a newer editor state", async () => { + const runtime = controlledRuntime(); + let allowed = true; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = createSqlEditorInternal({ + autocomplete: { + closeOnBlur: false, + isCompletionPositionAllowed: () => allowed, + }, + initialContext: context(), + service: harness.service, + }, runtime.runtime); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + allowed = false; + view.dispatch({}); + expect(runtime.queued).toHaveLength(1); + + view.dispatch({ selection: { anchor: 15 } }); + runtime.queued[0]?.(); + expect(runtime.closes).toEqual([]); + }); + + it("disposes rich info when the position gate flips false", async () => { + let allowed = true; + const destroys: Array> = []; + const harness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const support = sqlEditor({ + autocomplete: { + infoResolver: () => { + const destroy = vi.fn(); + destroys.push(destroy); + return { + destroy, + dom: document.createElement("div"), + }; + }, + isCompletionPositionAllowed: () => allowed, + }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + await vi.waitFor(() => expect(destroys.length).toBeGreaterThan(0)); + const currentDestroy = destroys.at(-1); + if (!currentDestroy) throw new Error("Expected rich info"); + + allowed = false; + view.dispatch({}); + expect(currentDestroy).toHaveBeenCalledTimes(1); + }); + it.each([ "cancelled", "failed", diff --git a/src/vnext/codemirror/browser_tests/sql-editor-completion-gate.test.ts b/src/vnext/codemirror/browser_tests/sql-editor-completion-gate.test.ts new file mode 100644 index 0000000..3c9fd35 --- /dev/null +++ b/src/vnext/codemirror/browser_tests/sql-editor-completion-gate.test.ts @@ -0,0 +1,345 @@ +import { + currentCompletions, + startCompletion, +} from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { expect, onTestFinished, test } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../../index.js"; +import { sqlEditor } from "../index.js"; + +test("vNext completion gate routes unmatched template EOF to external sources", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + let catalogSearches = 0; + const service = createSqlLanguageService({ + catalog: { + id: "browser-catalog", + search: async () => { + catalogSearches += 1; + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [ + { + quoted: false, + role: "schema", + value: "main", + }, + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 1, + entityId: "main.users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready" as const, + }; + }, + }, + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + autocomplete: { + externalSources: [(context) => ({ + from: context.pos, + options: [{ label: "python_variable" }], + })], + isCompletionPositionAllowed: (state, position) => { + const prefix = state.sliceDoc(0, position); + return prefix.lastIndexOf("{") <= prefix.lastIndexOf("}"); + }, + }, + initialContext: { + catalog: { + scope: "browser", + searchPath: [[{ quoted: false, value: "main" }]], + }, + dialect: "duckdb", + }, + initialEmbeddedRegions: [{ + from: 14, + language: "python", + to: 15, + }], + service, + }); + const view = new EditorView({ + doc: "SELECT * FROM {", + extensions: support.extension, + parent, + selection: { anchor: 15 }, + }); + onTestFinished(() => view.destroy()); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["python_variable"]); + expect(catalogSearches).toBe(0); +}); + +test("vNext completion gate permits normal SQL in a browser editor", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + catalog: { + id: "browser-catalog", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [ + { + quoted: false, + role: "schema", + value: "main", + }, + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 1, + entityId: "main.users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready" as const, + }), + }, + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + autocomplete: { + isCompletionPositionAllowed: () => true, + }, + initialContext: { + catalog: { + scope: "browser", + searchPath: [[{ quoted: false, value: "main" }]], + }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT * FROM us"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toContain("users"); +}); + +test("vNext completion gate preserves external sources when it closes SQL options", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + let allowed = true; + const service = createSqlLanguageService({ + catalog: { + id: "browser-gate-transition", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [ + { + quoted: false, + role: "schema", + value: "main", + }, + { + quoted: false, + role: "relation", + value: "users", + }, + ], + completionPathStart: 1, + entityId: "main.users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready" as const, + }), + }, + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + autocomplete: { + externalSources: [(context) => ({ + from: context.pos, + options: [{ label: "python_variable" }], + })], + isCompletionPositionAllowed: () => allowed, + }, + initialContext: { + catalog: { scope: "browser-gate-transition" }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT * FROM us"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label).sort() + ).toEqual(["python_variable", "users"]); + + allowed = false; + view.dispatch({}); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["python_variable"]); +}); + +test("vNext editor applies a batched column completion", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + let columnCalls = 0; + const service = createSqlLanguageService({ + catalog: { + id: "browser-relations", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + status: "ready" as const, + }), + }, + columns: { + id: "browser-columns", + loadColumns: async (request) => { + columnCalls += 1; + const relation = request.relations[0]; + if (!relation) throw new Error("Expected one relation"); + return { + epoch: { generation: 1, token: "epoch-1" }, + relations: [{ + columns: [{ + columnEntityId: "users:name", + dataType: "VARCHAR", + identifier: { quoted: false, value: "name" }, + insertText: "name", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "users", + requestKey: relation.requestKey, + status: "ready", + }], + }; + }, + }, + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { + catalog: { scope: "browser-columns" }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT u.na FROM users u"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: "SELECT u.na".length }, + }); + onTestFinished(() => view.destroy()); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => ({ + label: item.label, + type: item.type, + })) + ).toEqual([{ label: "name", type: "property" }]); + expect(columnCalls).toBe(1); +}); + +test("vNext editor exposes namespace containers at relation sites", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + let namespaceCalls = 0; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + namespaces: { + id: "browser-namespaces", + search: async () => { + namespaceCalls += 1; + return { + containers: [{ + canonicalPath: [{ + quoted: false, + role: "schema", + value: "main", + }], + containerEntityId: "schema:main", + insertText: "main", + matchQuality: "exact", + }], + coverage: "complete", + epoch: { generation: 1, token: "epoch-1" }, + status: "ready", + }; + }, + }, + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { + catalog: { scope: "browser-namespaces" }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT * FROM ma"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => ({ + label: item.label, + type: item.type, + })) + ).toEqual([{ label: "main", type: "namespace" }]); + expect(namespaceCalls).toBe(1); +}); diff --git a/src/vnext/codemirror/browser_tests/statement-gutter.test.ts b/src/vnext/codemirror/browser_tests/statement-gutter.test.ts new file mode 100644 index 0000000..2ba440b --- /dev/null +++ b/src/vnext/codemirror/browser_tests/statement-gutter.test.ts @@ -0,0 +1,120 @@ +import { EditorView } from "@codemirror/view"; +import { expect, onTestFinished, test } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../../index.js"; +import { sqlEditor } from "../index.js"; + +test("vNext statement gutter follows the current statement", async () => { + const parent = document.createElement("div"); + parent.style.height = "240px"; + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { dialect: "duckdb" }, + service, + statementGutter: {}, + }); + const documentText = "SELECT 1;\n\n/* separator */\nSELECT 2;"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + view.focus(); + + await expect.poll(() => + view.dom.querySelectorAll(".cm-sql-statement-marker").length + ).toBe(2); + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker-active"), + ).toHaveLength(1); + expect(Array.from( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).findIndex((marker) => + marker.classList.contains("cm-sql-statement-marker-active") + )).toBe(1); + + view.dispatch({ selection: { anchor: 1 } }); + await expect.poll(() => { + const markers = Array.from( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ); + return markers.findIndex((marker) => + marker.classList.contains("cm-sql-statement-marker-active") + ); + }).toBe(0); +}); + +test("vNext statement gutter virtualizes a tall focused editor", async () => { + const parent = document.createElement("div"); + parent.style.height = "120px"; + parent.style.setProperty( + "--cm-sql-statement-color", + "rgb(1, 2, 3)", + ); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { dialect: "duckdb" }, + service, + statementGutter: { hideWhenNotFocused: true }, + }); + const documentText = Array.from( + { length: 200 }, + (_, index) => `SELECT\n\n ${index};`, + ).join("\n/* separator */\n"); + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + }); + onTestFinished(() => view.destroy()); + + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker"), + ).toHaveLength(0); + view.focus(); + await expect.poll(() => + view.dom.querySelectorAll(".cm-sql-statement-marker").length + ).toBeGreaterThan(0); + const initialCount = view.dom.querySelectorAll( + ".cm-sql-statement-marker", + ).length; + expect(initialCount).toBeLessThan(200); + const marker = view.dom.querySelector( + ".cm-sql-statement-marker", + ); + expect(marker).not.toBeNull(); + expect(getComputedStyle(marker!).width).toBe("3px"); + expect(getComputedStyle(marker!).backgroundColor).toBe( + "rgb(1, 2, 3)", + ); + + view.dispatch({ + effects: EditorView.scrollIntoView(documentText.length, { + y: "start", + }), + selection: { anchor: documentText.length }, + }); + await expect.poll(() => view.viewport.from).toBeGreaterThan(0); + await expect.poll(() => + view.dom.querySelectorAll( + ".cm-sql-statement-marker-active", + ).length + ).toBe(3); + expect( + view.dom.querySelectorAll(".cm-sql-statement-marker").length, + ).toBeLessThan(200); +}); diff --git a/src/vnext/codemirror/index.ts b/src/vnext/codemirror/index.ts index e5e1d6f..16e2265 100644 --- a/src/vnext/codemirror/index.ts +++ b/src/vnext/codemirror/index.ts @@ -4,6 +4,9 @@ export { type SqlEditorOptions, type SqlEditorSupport, } from "./sql-editor.js"; +export type { + SqlEditorStatementGutterOptions, +} from "./statement-gutter.js"; export type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, diff --git a/src/vnext/codemirror/sql-editor.ts b/src/vnext/codemirror/sql-editor.ts index 652b143..9171149 100644 --- a/src/vnext/codemirror/sql-editor.ts +++ b/src/vnext/codemirror/sql-editor.ts @@ -15,6 +15,7 @@ import { Prec, StateEffect, StateField, + type EditorState, type EditorSelection, type Extension, type StateEffectType, @@ -36,6 +37,12 @@ import type { SqlCompletionResult, SqlCompletionTask, } from "../relation-completion-types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, +} from "../statement-boundary-types.js"; import type { SqlContextInput, SqlDocumentContext, @@ -45,6 +52,10 @@ import type { SqlRevision, SqlTextChange, } from "../types.js"; +import { + createSqlStatementGutter, + type SqlEditorStatementGutterOptions, +} from "./statement-gutter.js"; export interface SqlEditorAutocompleteOptions { readonly activateOnTyping?: boolean; @@ -53,6 +64,10 @@ export interface SqlEditorAutocompleteOptions { readonly defaultKeymap?: boolean; readonly externalSources?: readonly CompletionSource[]; readonly infoResolver?: SqlCompletionInfoResolver; + readonly isCompletionPositionAllowed?: ( + state: EditorState, + position: number, + ) => boolean; readonly maxRenderedOptions?: number; readonly selectOnOpen?: boolean; readonly updateSyncTime?: number; @@ -67,6 +82,9 @@ export interface SqlEditorOptions< | readonly SqlEmbeddedRegion[] | undefined; readonly service: SqlLanguageService; + readonly statementGutter?: + | false + | SqlEditorStatementGutterOptions; } export interface SqlEditorSupport< @@ -77,6 +95,15 @@ export interface SqlEditorSupport< readonly SqlEmbeddedRegion[] >; readonly extension: Extension; + readonly invalidateCatalog: (view: EditorView) => SqlRevision | null; + readonly statementBoundariesIntersecting: ( + view: EditorView, + request: SqlStatementBoundariesIntersectingRequest, + ) => SqlStatementBoundariesIntersectingResult | null; + readonly statementBoundaryAt: ( + view: EditorView, + request: SqlStatementBoundaryAtRequest, + ) => SqlStatementBoundaryAtResult | null; readonly setContext: ( view: EditorView, context: SqlContextInput, @@ -103,6 +130,7 @@ export interface SqlEditorRuntime { interface CompletionCapture { readonly contextGeneration: number; readonly document: Text; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; readonly selection: EditorSelection; } @@ -155,7 +183,12 @@ function loadingLeaseMs( result: Extract, ): number | null { for (const issue of result.value.issues) { - if (issue.reason === "catalog-loading") { + if ( + (issue.reason === "catalog-loading" || + issue.reason === "column-catalog-loading" || + issue.reason === "namespace-catalog-loading") && + typeof issue.remainingIntentLeaseMs === "number" + ) { return issue.remainingIntentLeaseMs; } } @@ -177,6 +210,8 @@ function haveOneEditRange(items: readonly SqlCompletionItem[]): boolean { } function completionType(item: SqlCompletionItem): string { + if (item.kind === "column") return "property"; + if (item.kind === "namespace") return "namespace"; return item.relationKind === "cte" ? "type" : "table"; } @@ -239,6 +274,7 @@ export function createSqlEditorInternal< const { externalSources = [], infoResolver, + isCompletionPositionAllowed, ...autocompleteOptions } = autocomplete; @@ -252,6 +288,7 @@ export function createSqlEditorInternal< #contextGeneration = 0; #destroyed = false; #disposingInfo = false; + #completionPositionAllowed: boolean; #hasEmbeddedRegions: boolean; #info: ActiveCompletionInfo | null = null; #intent: CompletionIntent | null = null; @@ -273,6 +310,11 @@ export function createSqlEditorInternal< text: view.state.doc.toString(), }); this.#hasEmbeddedRegions = initialRegions.length > 0; + this.#completionPositionAllowed = + this.#completionPositionIsAllowed( + view.state, + view.state.selection.main.head, + ); this.#lastCompletionStatus = completionStatus(view.state); this.#lastSelectedCompletion = selectedCompletion(view.state); this.#lastSelectedCompletionIndex = selectedCompletionIndex( @@ -354,10 +396,32 @@ export function createSqlEditorInternal< !this.#destroyed && capture.contextGeneration === this.#contextGeneration && capture.document === state.doc && + capture.embeddedRegions === state.field(embeddedRegionsField) && capture.selection.eq(state.selection) ); } + #capture(): CompletionCapture { + const state = this.#view.state; + return { + contextGeneration: this.#contextGeneration, + document: state.doc, + embeddedRegions: state.field(embeddedRegionsField), + selection: state.selection, + }; + } + + #completionPositionIsAllowed( + state: EditorState, + position: number, + ): boolean { + try { + return isCompletionPositionAllowed?.(state, position) ?? true; + } catch { + return false; + } + } + #scheduleClose(): void { const sequence = this.#sequence; runtime.queueMicrotask(() => { @@ -371,6 +435,31 @@ export function createSqlEditorInternal< }); } + #scheduleCompletionGateClose(capture: CompletionCapture): void { + runtime.queueMicrotask(() => { + if ( + !this.#captureIsCurrent(capture) || + this.#completionPositionIsAllowed( + this.#view.state, + this.#view.state.selection.main.head, + ) + ) { + return; + } + runtime.closeCompletion(this.#view); + if ( + externalSources.length > 0 && + this.#captureIsCurrent(capture) && + !this.#completionPositionIsAllowed( + this.#view.state, + this.#view.state.selection.main.head, + ) + ) { + runtime.startCompletion(this.#view); + } + }); + } + #scheduleRefresh(capture: CompletionCapture): void { if ( this.#refreshScheduled || @@ -518,11 +607,15 @@ export function createSqlEditorInternal< context: CompletionContext, ): Promise => { this.#clearCompletionState(); - const capture: CompletionCapture = { - contextGeneration: this.#contextGeneration, - document: this.#view.state.doc, - selection: this.#view.state.selection, - }; + if ( + !this.#completionPositionIsAllowed( + context.state, + context.pos, + ) + ) { + return null; + } + const capture = this.#capture(); const controller = new AbortController(); let task: SqlCompletionTask; try { @@ -565,6 +658,18 @@ export function createSqlEditorInternal< } return null; } + if ( + !this.#completionPositionIsAllowed( + this.#view.state, + context.pos, + ) + ) { + if (this.#active === active) { + this.#clearCompletionState(); + this.#scheduleCompletionGateClose(capture); + } + return null; + } if ( controller.signal.aborted || this.#active !== active || @@ -607,7 +712,51 @@ export function createSqlEditorInternal< this.#clearCompletionState(); }; + readonly invalidateCatalog = (): SqlRevision | null => { + if (this.#destroyed) return null; + try { + return this.#session.invalidateCatalog(); + } catch { + return null; + } + }; + + readonly statementBoundariesIntersecting = ( + request: SqlStatementBoundariesIntersectingRequest, + ): SqlStatementBoundariesIntersectingResult | null => { + if (this.#destroyed) return null; + try { + return this.#session.statementBoundariesIntersecting(request); + } catch { + return null; + } + }; + + readonly statementBoundaryAt = ( + request: SqlStatementBoundaryAtRequest, + ): SqlStatementBoundaryAtResult | null => { + if (this.#destroyed) return null; + try { + return this.#session.statementBoundaryAt(request); + } catch { + return null; + } + }; + readonly update = (update: ViewUpdate): void => { + const completionPositionAllowed = + this.#completionPositionIsAllowed( + update.state, + update.state.selection.main.head, + ); + const completionPositionBecameDenied = + this.#completionPositionAllowed && + !completionPositionAllowed; + this.#completionPositionAllowed = completionPositionAllowed; + const completionPositionDenied = !completionPositionAllowed; + if (completionPositionDenied) { + this.#clearCompletionState(); + } let contextChanged = false; let regionsChanged = false; for (const transaction of update.transactions) { @@ -691,6 +840,12 @@ export function createSqlEditorInternal< this.#clearCompletionState(); } const nextCompletionStatus = completionStatus(update.state); + if ( + completionPositionBecameDenied && + nextCompletionStatus !== null + ) { + this.#scheduleCompletionGateClose(this.#capture()); + } const nextSelectedCompletion = selectedCompletion(update.state); const nextSelectedCompletionIndex = selectedCompletionIndex( update.state, @@ -744,6 +899,23 @@ export function createSqlEditorInternal< }, }]), ); + const statementGutter = options.statementGutter === false || + options.statementGutter === undefined + ? [] + : createSqlStatementGutter(options.statementGutter, { + boundariesIntersecting: (view, range) => + view.plugin(plugin)?.statementBoundariesIntersecting(range) ?? + null, + boundaryAt: (view, position, affinity) => + view.plugin(plugin)?.statementBoundaryAt({ + affinity, + position, + }) ?? null, + inputKeys: (view) => [ + view.state.field(contextField), + view.state.field(embeddedRegionsField), + ], + }); return Object.freeze({ contextEffect, embeddedRegionsEffect, @@ -752,11 +924,25 @@ export function createSqlEditorInternal< embeddedRegionsField, plugin, escapeKeymap, + statementGutter, autocompletion({ ...autocompleteOptions, override: [completionSource, ...externalSources], }), ], + invalidateCatalog: (view: EditorView): SqlRevision | null => + view.plugin(plugin)?.invalidateCatalog() ?? null, + statementBoundariesIntersecting: ( + view: EditorView, + request: SqlStatementBoundariesIntersectingRequest, + ) => + view.plugin(plugin)?.statementBoundariesIntersecting(request) ?? + null, + statementBoundaryAt: ( + view: EditorView, + request: SqlStatementBoundaryAtRequest, + ) => + view.plugin(plugin)?.statementBoundaryAt(request) ?? null, setContext: ( view: EditorView, context: SqlContextInput, diff --git a/src/vnext/codemirror/statement-gutter.ts b/src/vnext/codemirror/statement-gutter.ts new file mode 100644 index 0000000..392adee --- /dev/null +++ b/src/vnext/codemirror/statement-gutter.ts @@ -0,0 +1,238 @@ +import { + RangeSet, + type Extension, + type Text, +} from "@codemirror/state"; +import { + EditorView, + GutterMarker, + gutter, +} from "@codemirror/view"; +import type { + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtResult, + SqlTextRange, +} from "../index.js"; + +export interface SqlEditorStatementGutterOptions { + readonly hideWhenNotFocused?: boolean; + readonly showInactive?: boolean; +} + +export interface SqlStatementGutterAccess { + readonly boundaryAt: ( + view: EditorView, + position: number, + affinity: "left" | "right", + ) => SqlStatementBoundaryAtResult | null; + readonly boundariesIntersecting: ( + view: EditorView, + range: SqlTextRange, + ) => SqlStatementBoundariesIntersectingResult | null; + readonly inputKeys: ( + view: EditorView, + ) => readonly [unknown, unknown]; +} + +class SqlStatementGutterMarker extends GutterMarker { + constructor(readonly active: boolean) { + super(); + } + + override eq(other: SqlStatementGutterMarker): boolean { + return this.active === other.active; + } + + override toDOM(view: EditorView): Node { + const marker = view.dom.ownerDocument.createElement("div"); + marker.className = this.active + ? "cm-sql-statement-marker cm-sql-statement-marker-active" + : "cm-sql-statement-marker cm-sql-statement-marker-inactive"; + return marker; + } +} + +const activeMarker = new SqlStatementGutterMarker(true); +const inactiveMarker = new SqlStatementGutterMarker(false); +const emptyMarkers: RangeSet = RangeSet.empty; + +interface SqlStatementGutterSnapshot { + readonly contextKey: unknown; + readonly document: Text; + readonly embeddedRegionsKey: unknown; + readonly focused: boolean; + readonly markers: RangeSet; + readonly selectionHead: number; + readonly viewportFrom: number; + readonly viewportTo: number; +} + +function sameRange( + left: SqlTextRange, + right: SqlTextRange, +): boolean { + return left.from === right.from && left.to === right.to; +} + +function exactCode( + result: SqlStatementBoundaryAtResult | null, +): SqlTextRange | null { + const boundary = result?.boundary; + return boundary?.boundaryQuality === "exact" && + boundary.hasCode + ? boundary.code + : null; +} + +function currentCodeRange( + view: EditorView, + access: SqlStatementGutterAccess, +): SqlTextRange | null { + const position = view.state.selection.main.head; + const right = access.boundaryAt(view, position, "right"); + if ( + right?.boundary.boundaryQuality === "opaque" + ) { + return null; + } + const rightCode = exactCode(right); + if (rightCode !== null) return rightCode; + if ( + right?.boundary.boundaryQuality !== "exact" || + right.boundary.hasCode + ) { + return null; + } + return exactCode( + access.boundaryAt(view, position, "left"), + ); +} + +function buildMarkers( + view: EditorView, + options: SqlEditorStatementGutterOptions, + access: SqlStatementGutterAccess, +): RangeSet { + if ( + options.hideWhenNotFocused === true && !view.hasFocus + ) { + return emptyMarkers; + } + const { from, to } = view.viewport; + if (from === to) return emptyMarkers; + const visible = access.boundariesIntersecting(view, { from, to }); + if (visible === null) return emptyMarkers; + const current = currentCodeRange(view, access); + const lineMarkers = new Map(); + for (const boundary of visible.boundaries) { + if ( + boundary.boundaryQuality !== "exact" || + !boundary.hasCode + ) { + continue; + } + const codeFrom = Math.max(from, boundary.code.from); + const codeTo = Math.min(to, boundary.code.to); + if (codeFrom >= codeTo) continue; + const active = current !== null && + sameRange(boundary.code, current); + let line = view.state.doc.lineAt(codeFrom); + while (line.from < codeTo) { + if (active || options.showInactive !== false) { + lineMarkers.set( + line.from, + active || lineMarkers.get(line.from) === true, + ); + } + if (line.to >= codeTo || line.to === view.state.doc.length) { + break; + } + line = view.state.doc.line(line.number + 1); + } + } + return RangeSet.of( + Array.from(lineMarkers, ([position, active]) => + (active ? activeMarker : inactiveMarker).range(position) + ), + true, + ); +} + +function snapshotMatches( + snapshot: SqlStatementGutterSnapshot, + view: EditorView, + contextKey: unknown, + embeddedRegionsKey: unknown, +): boolean { + return snapshot.document === view.state.doc && + snapshot.selectionHead === view.state.selection.main.head && + snapshot.viewportFrom === view.viewport.from && + snapshot.viewportTo === view.viewport.to && + snapshot.focused === view.hasFocus && + snapshot.contextKey === contextKey && + snapshot.embeddedRegionsKey === embeddedRegionsKey; +} + +export function createSqlStatementGutter( + options: SqlEditorStatementGutterOptions, + access: SqlStatementGutterAccess, +): Extension { + const snapshots = new WeakMap< + EditorView, + SqlStatementGutterSnapshot + >(); + return [ + gutter({ + class: "cm-sql-statement-gutter", + markers: (view) => { + const [contextKey, embeddedRegionsKey] = + access.inputKeys(view); + const previous = snapshots.get(view); + if ( + previous !== undefined && + snapshotMatches( + previous, + view, + contextKey, + embeddedRegionsKey, + ) + ) { + return previous.markers; + } + const markers = buildMarkers(view, options, access); + snapshots.set(view, { + contextKey, + document: view.state.doc, + embeddedRegionsKey, + focused: view.hasFocus, + markers, + selectionHead: view.state.selection.main.head, + viewportFrom: view.viewport.from, + viewportTo: view.viewport.to, + }); + return markers; + }, + }), + EditorView.baseTheme({ + ".cm-sql-statement-gutter": { + minWidth: "3px", + width: "3px", + }, + ".cm-sql-statement-gutter .cm-gutterElement": { + margin: "0", + padding: "0", + width: "3px", + }, + ".cm-sql-statement-marker": { + backgroundColor: "var(--cm-sql-statement-color, #3b82f6)", + borderRadius: "1px", + display: "block", + height: "100%", + width: "3px", + }, + ".cm-sql-statement-marker-inactive": { + opacity: "var(--cm-sql-statement-inactive-opacity, 0.3)", + }, + }), + ]; +} diff --git a/src/vnext/column-catalog-batch-coordinator.ts b/src/vnext/column-catalog-batch-coordinator.ts new file mode 100644 index 0000000..38ba9d7 --- /dev/null +++ b/src/vnext/column-catalog-batch-coordinator.ts @@ -0,0 +1,566 @@ +import { + captureSqlColumnCatalogProvider, + createSqlColumnCatalogBatchRequest, + decodeSqlColumnCatalogBatchResponse, + MAX_COLUMN_DIALECT_ID_LENGTH, + MAX_COLUMN_SCOPE_LENGTH, + resolveSqlColumnCatalogProvider, + type CapturedSqlColumnCatalogProvider, + type SqlCapturedColumnCatalogProviderContext, +} from "./column-catalog-boundary.js"; +import type { + SqlColumnCatalogBatchRequest, + SqlColumnCatalogRelationReference, + SqlColumnCatalogRelationResult, +} from "./column-catalog-types.js"; +import type { + SqlCatalogEpoch, +} from "./relation-completion-types.js"; +import type { SqlIdentifierPath } from "./types.js"; + +export const DEFAULT_COLUMN_CACHE_ENTRIES = 1_024; +export const MAX_COLUMN_CACHE_ENTRIES = 8_192; + +export interface SqlColumnCatalogOwnerOptions { + readonly dialectId: string; + readonly scope: string; +} + +export interface SqlColumnCatalogBatchInput { + readonly expectedEpoch: SqlCatalogEpoch | null; + readonly relations: readonly SqlColumnCatalogRelationReference[]; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +export type SqlColumnCatalogBatchOutcome = + | { + readonly epoch: SqlCatalogEpoch; + readonly providerId: string; + readonly relations: readonly SqlColumnCatalogRelationResult[]; + readonly scope: string; + readonly status: "usable"; + } + | { + readonly status: "cancelled"; + } + | { + readonly status: "superseded"; + } + | { + readonly reason: + | "disposed" + | "invalid-request" + | "malformed-response" + | "provider-failed"; + readonly status: "unavailable"; + }; + +export interface SqlColumnCatalogBatchTicket { + readonly cancel: (this: void) => void; + readonly result: Promise; +} + +export interface SqlColumnCatalogBatchOwner { + readonly dispose: (this: void) => void; + readonly request: ( + this: void, + input: SqlColumnCatalogBatchInput, + ) => SqlColumnCatalogBatchTicket; +} + +export type SqlColumnCatalogBatchOwnerResult = + | { + readonly owner: SqlColumnCatalogBatchOwner; + readonly status: "prepared"; + } + | { + readonly reason: "disposed" | "invalid-options"; + readonly status: "unavailable"; + }; + +export interface SqlColumnCatalogBatchCoordinator { + readonly dispose: (this: void) => void; + readonly prepareOwner: ( + this: void, + options: SqlColumnCatalogOwnerOptions, + ) => SqlColumnCatalogBatchOwnerResult; + readonly providerId: string; +} + +export type SqlColumnCatalogBatchCoordinatorResult = + | { + readonly coordinator: SqlColumnCatalogBatchCoordinator; + readonly status: "created"; + } + | { + readonly reason: "invalid-options" | "invalid-provider"; + readonly status: "unavailable"; + }; + +interface CoordinatorState { + readonly map: Map; + off: boolean; + readonly id: string; + readonly limit: number; + readonly load: SqlCapturedColumnCatalogProviderContext["loadColumns"]; + readonly pool: Set; + readonly wire: CapturedSqlColumnCatalogProvider; +} + +interface OwnerState { + readonly lang: string; + gen: number; + job: ConsumerState | null; + off: boolean; + rev: SqlCatalogEpoch | null; + root: CoordinatorState | null; + readonly scope: string; +} + +interface ConsumerState { + readonly abort: AbortController; + done: boolean; + end: ((value: SqlColumnCatalogBatchOutcome) => void) | null; + readonly host: OwnerState; +} + +type ReadyRelation = Extract< + SqlColumnCatalogRelationResult, + { readonly status: "ready" } +>; + +const CANCELLED: SqlColumnCatalogBatchOutcome = + Object.freeze({ status: "cancelled" }); +const SUPERSEDED: SqlColumnCatalogBatchOutcome = + Object.freeze({ status: "superseded" }); + +function unavailable( + reason: Extract< + SqlColumnCatalogBatchOutcome, + { readonly status: "unavailable" } + >["reason"], +): SqlColumnCatalogBatchOutcome { + return Object.freeze({ reason, status: "unavailable" }); +} + +function dataProperty( + value: unknown, + key: string, +): { readonly found: true; readonly value: unknown } | null { + if (value === null || typeof value !== "object") return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + return descriptor && "value" in descriptor + ? { found: true, value: descriptor.value } + : null; +} + +function boundedString(value: unknown, maximum: number): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= maximum + ? value + : null; +} + +function cacheKey( + request: SqlColumnCatalogBatchRequest, + reference: SqlColumnCatalogRelationReference, + epoch: SqlCatalogEpoch, +): string { + const segment = (value: string): string => + `${value.length}:${value}`; + const path = reference.path.map((component) => + segment(`${component.quoted ? "q" : "u"}${component.value}`) + ).join(""); + const searchPaths = request.searchPaths.map((searchPath) => + segment(searchPath.map((component) => + segment(`${component.quoted ? "q" : "u"}${component.value}`) + ).join("")) + ).join(""); + return [ + segment(request.scope), + segment(request.dialectId), + segment(String(epoch.generation)), + segment(epoch.token), + segment(path), + segment(searchPaths), + ].join(""); +} + +function cacheGet( + state: CoordinatorState, + key: string, +): ReadyRelation | null { + const value = state.map.get(key); + if (!value) return null; + state.map.delete(key); + state.map.set(key, value); + return value; +} + +function cacheSet( + state: CoordinatorState, + key: string, + value: ReadyRelation, +): void { + state.map.delete(key); + state.map.set(key, value); + while (state.map.size > state.limit) { + for (const oldest of state.map.keys()) { + state.map.delete(oldest); + break; + } + } +} + +function withRequestKey( + relation: ReadyRelation, + requestKey: string, +): ReadyRelation { + return relation.requestKey === requestKey + ? relation + : Object.freeze({ ...relation, requestKey }); +} + +function settle( + consumer: ConsumerState, + outcome: SqlColumnCatalogBatchOutcome, +): void { + if (consumer.host.job === consumer) { + consumer.host.job = null; + } + if (consumer.done) return; + consumer.done = true; + const resolve = consumer.end; + consumer.end = null; + resolve?.(outcome); +} + +function cancelConsumer( + consumer: ConsumerState, + outcome: SqlColumnCatalogBatchOutcome, +): void { + if (consumer.done) return; + consumer.abort.abort(); + settle(consumer, outcome); +} + +function makeSettledTicket( + outcome: SqlColumnCatalogBatchOutcome, +): SqlColumnCatalogBatchTicket { + return Object.freeze({ + cancel: (): void => {}, + result: Promise.resolve(outcome), + }); +} + +function combineRelations( + cached: ReadonlyMap, + loaded: readonly SqlColumnCatalogRelationResult[], +): readonly SqlColumnCatalogRelationResult[] { + const output: SqlColumnCatalogRelationResult[] = [...loaded]; + for (const [requestKey, relation] of cached) { + output.push(withRequestKey(relation, requestKey)); + } + output.sort((left, right) => + left.requestKey < right.requestKey + ? -1 + : left.requestKey > right.requestKey + ? 1 + : 0 + ); + return Object.freeze(output); +} + +function startProviderWork( + state: CoordinatorState, + owner: OwnerState, + missingRequest: SqlColumnCatalogBatchRequest, + cached: ReadonlyMap, + consumer: ConsumerState, +): void { + let providerResult: unknown; + try { + providerResult = state.load( + missingRequest, + consumer.abort.signal, + ); + } catch { + settle(consumer, unavailable("provider-failed")); + return; + } + Promise.resolve(providerResult).then( + (value) => { + if (consumer.done) return; + let decoded: ReturnType< + typeof decodeSqlColumnCatalogBatchResponse + >; + try { + decoded = decodeSqlColumnCatalogBatchResponse( + state.wire, + missingRequest, + value, + ); + } catch { + settle(consumer, unavailable("malformed-response")); + return; + } + if (decoded.status === "malformed") { + settle(consumer, unavailable("malformed-response")); + return; + } + owner.rev = decoded.value.epoch; + for (const relation of decoded.value.relations) { + if ( + relation.status !== "ready" || + relation.coverage !== "complete" + ) continue; + const reference = missingRequest.relations.find( + (candidate) => candidate.requestKey === relation.requestKey, + ); + if (reference) { + cacheSet( + state, + cacheKey(missingRequest, reference, decoded.value.epoch), + relation, + ); + } + } + const relations = combineRelations( + cached, + decoded.value.relations, + ); + settle( + consumer, + Object.freeze({ + providerId: state.id, + epoch: decoded.value.epoch, + relations, + scope: owner.scope, + status: "usable", + }), + ); + }, + () => { + if (!consumer.done) { + settle(consumer, unavailable("provider-failed")); + } + }, + ); +} + +function requestColumns( + owner: OwnerState, + input: unknown, +): SqlColumnCatalogBatchTicket { + const state = owner.root; + if (!state || state.off || owner.off) { + return makeSettledTicket(unavailable("disposed")); + } + const expectedEpoch = dataProperty(input, "expectedEpoch"); + const relations = dataProperty(input, "relations"); + const searchPaths = dataProperty(input, "searchPaths"); + if (!expectedEpoch || !relations || !searchPaths) { + return makeSettledTicket(unavailable("invalid-request")); + } + const created = createSqlColumnCatalogBatchRequest({ + dialectId: owner.lang, + expectedEpoch: expectedEpoch.value === null + ? owner.rev + : expectedEpoch.value, + relations: relations.value, + scope: owner.scope, + searchPaths: searchPaths.value, + }); + if (created.status === "malformed") { + return makeSettledTicket(unavailable("invalid-request")); + } + const request = created.value; + owner.gen += 1; + const generation = owner.gen; + if (owner.job) cancelConsumer(owner.job, SUPERSEDED); + if (generation !== owner.gen) { + return makeSettledTicket( + owner.off ? unavailable("disposed") : SUPERSEDED, + ); + } + + const cached = new Map(); + const missing: SqlColumnCatalogRelationReference[] = []; + for (const reference of request.relations) { + const key = request.expectedEpoch === null + ? null + : cacheKey(request, reference, request.expectedEpoch); + const cachedRelation = key === null ? null : cacheGet(state, key); + if (cachedRelation) { + cached.set(reference.requestKey, cachedRelation); + } else { + missing.push(reference); + } + } + if (request.expectedEpoch !== null && missing.length === 0) { + return makeSettledTicket( + Object.freeze({ + epoch: request.expectedEpoch, + providerId: state.id, + relations: combineRelations(cached, []), + scope: owner.scope, + status: "usable", + }), + ); + } + const missingRequest: SqlColumnCatalogBatchRequest = Object.freeze({ + ...request, + relations: Object.freeze(missing), + }); + let resolveResult: ( + value: SqlColumnCatalogBatchOutcome, + ) => void = (): void => {}; + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + const consumer: ConsumerState = { + abort: new AbortController(), + done: false, + end: resolveResult, + host: owner, + }; + owner.job = consumer; + const ticket = Object.freeze({ + cancel: (): void => cancelConsumer(consumer, CANCELLED), + result, + }); + startProviderWork( + state, + owner, + missingRequest, + cached, + consumer, + ); + return ticket; +} + +function disposeOwner(owner: OwnerState): void { + if (owner.off) return; + owner.off = true; + owner.gen += 1; + if (owner.job) { + cancelConsumer(owner.job, unavailable("disposed")); + } + owner.root?.pool.delete(owner); + owner.root = null; +} + +function prepareOwner( + state: CoordinatorState, + input: unknown, +): SqlColumnCatalogBatchOwnerResult { + if (state.off) { + return Object.freeze({ reason: "disposed", status: "unavailable" }); + } + const scopeProperty = dataProperty(input, "scope"); + const dialectProperty = dataProperty(input, "dialectId"); + const scope = boundedString( + scopeProperty?.value, + MAX_COLUMN_SCOPE_LENGTH, + ); + const dialectId = boundedString( + dialectProperty?.value, + MAX_COLUMN_DIALECT_ID_LENGTH, + ); + if (scope === null || dialectId === null) { + return Object.freeze({ + reason: "invalid-options", + status: "unavailable", + }); + } + const owner: OwnerState = { + gen: 0, + job: null, + lang: dialectId, + off: false, + rev: null, + root: state, + scope, + }; + state.pool.add(owner); + return Object.freeze({ + owner: Object.freeze({ + dispose: (): void => disposeOwner(owner), + request: (input_: SqlColumnCatalogBatchInput) => + requestColumns(owner, input_), + }), + status: "prepared", + }); +} + +function disposeCoordinator(state: CoordinatorState): void { + if (state.off) return; + state.off = true; + state.map.clear(); + for (const owner of state.pool) disposeOwner(owner); +} + +export function createSqlColumnCatalogBatchCoordinator( + input: unknown, +): SqlColumnCatalogBatchCoordinatorResult { + const providerProperty = dataProperty(input, "provider"); + if (!providerProperty) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const captured = captureSqlColumnCatalogProvider( + providerProperty.value, + ); + if (captured.status === "malformed") { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const context = resolveSqlColumnCatalogProvider(captured.value); + if (!context) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const maximumProperty = dataProperty(input, "maxCacheEntries"); + const maximum = maximumProperty?.value ?? DEFAULT_COLUMN_CACHE_ENTRIES; + if ( + typeof maximum !== "number" || + !Number.isSafeInteger(maximum) || + maximum < 1 || + maximum > MAX_COLUMN_CACHE_ENTRIES + ) { + return Object.freeze({ + reason: "invalid-options", + status: "unavailable", + }); + } + const state: CoordinatorState = { + id: context.id, + limit: maximum, + load: context.loadColumns, + map: new Map(), + off: false, + pool: new Set(), + wire: captured.value, + }; + return Object.freeze({ + coordinator: Object.freeze({ + dispose: (): void => disposeCoordinator(state), + prepareOwner: (options: SqlColumnCatalogOwnerOptions) => + prepareOwner(state, options), + providerId: state.id, + }), + status: "created", + }); +} diff --git a/src/vnext/column-catalog-boundary.ts b/src/vnext/column-catalog-boundary.ts new file mode 100644 index 0000000..3c1f573 --- /dev/null +++ b/src/vnext/column-catalog-boundary.ts @@ -0,0 +1,694 @@ +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; +import type { + SqlColumnCatalogBatchRequest, + SqlColumnCatalogBatchResponse, + SqlColumnCatalogColumn, + SqlColumnCatalogRelationReference, + SqlColumnCatalogRelationResult, + SqlColumnCatalogResolvedColumn, +} from "./column-catalog-types.js"; +import { + isDataArray, + type SqlIdentifierComponent, + type SqlIdentifierPath, +} from "./types.js"; + +export const MAX_COLUMN_PROVIDER_ID_LENGTH = 256; +export const MAX_COLUMN_SCOPE_LENGTH = 512; +export const MAX_COLUMN_DIALECT_ID_LENGTH = 128; +export const MAX_COLUMN_EPOCH_TOKEN_LENGTH = 256; +export const MAX_COLUMN_ENTITY_ID_LENGTH = 256; +export const MAX_COLUMN_NAME_LENGTH = 256; +export const MAX_COLUMN_INSERT_TEXT_LENGTH = 1_024; +export const MAX_COLUMN_TYPE_LENGTH = 512; +export const MAX_COLUMN_DETAIL_LENGTH = 1_024; +export const MAX_COLUMN_BATCH_RELATIONS = 64; +export const MAX_COLUMN_RELATION_PATH_COMPONENTS = 32; +export const MAX_COLUMN_SEARCH_PATHS = 32; +export const MAX_COLUMN_SEARCH_PATH_COMPONENTS = 8; +export const MAX_COLUMNS_PER_RELATION = 256; +export const MAX_COLUMNS_PER_BATCH = 4_096; + +const capturedColumnProviders = new WeakMap< + object, + { + readonly id: string; + readonly loadColumns: Function; + } +>(); +const capturedColumnProviderBrand: unique symbol = Symbol( + "CapturedSqlColumnCatalogProvider", +); + +export interface CapturedSqlColumnCatalogProvider { + readonly [capturedColumnProviderBrand]: + "CapturedSqlColumnCatalogProvider"; +} + +export interface SqlCapturedColumnCatalogProviderContext { + readonly id: string; + readonly loadColumns: ( + this: void, + request: SqlColumnCatalogBatchRequest, + signal: AbortSignal, + ) => unknown; +} + +export type SqlColumnBoundaryFailureReason = + | "duplicate-column-entity-id" + | "duplicate-request-key" + | "invalid-shape" + | "resource-limit" + | "unexpected-relation"; + +export type SqlColumnBoundaryResult = + | { + readonly status: "accepted"; + readonly value: Value; + } + | { + readonly reason: SqlColumnBoundaryFailureReason; + readonly status: "malformed"; + }; + +interface DataRecord { + readonly fields: ReadonlyMap; +} + +const FAILURE_CODES = new Set([ + "authentication", + "authorization", + "invalid-configuration", + "rate-limited", + "unavailable", + "unknown", +]); +const RETRY_POLICIES = new Set([ + "after-invalidation", + "never", + "next-request", +]); + +type FailureCode = Extract< + SqlColumnCatalogRelationResult, + { readonly status: "failed" } +>["code"]; +type RetryPolicy = Extract< + SqlColumnCatalogRelationResult, + { readonly status: "failed" } +>["retry"]; + +function isFailureCode(value: unknown): value is FailureCode { + return typeof value === "string" && FAILURE_CODES.has(value); +} + +function isRetryPolicy(value: unknown): value is RetryPolicy { + return typeof value === "string" && RETRY_POLICIES.has(value); +} + +function accepted( + value: Value, +): SqlColumnBoundaryResult { + return Object.freeze({ status: "accepted", value }); +} + +function malformed( + reason: SqlColumnBoundaryFailureReason, +): SqlColumnBoundaryResult { + return Object.freeze({ reason, status: "malformed" }); +} + +function readRecord( + value: unknown, + allowedKeys: ReadonlySet, +): DataRecord | null { + if (value === null || typeof value !== "object") { + return null; + } + let keys: readonly PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + return null; + } + const fields = new Map(); + for (const key of keys) { + if (typeof key !== "string" || !allowedKeys.has(key)) return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor || !("value" in descriptor)) return null; + fields.set(key, descriptor.value); + } + return { fields }; +} + +function required(record: DataRecord, key: string): unknown { + return record.fields.has(key) ? record.fields.get(key) : undefined; +} + +function boundedString(value: unknown, maximum: number): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= maximum + ? value + : null; +} + +function optionalBoundedString( + value: unknown, + maximum: number, +): string | undefined | null { + return value === undefined + ? undefined + : boundedString(value, maximum); +} + +function decodeEpoch(value: unknown): SqlCatalogEpoch | null { + const record = readRecord( + value, + new Set(["generation", "token"]), + ); + if (!record) return null; + const generation = required(record, "generation"); + const token = boundedString( + required(record, "token"), + MAX_COLUMN_EPOCH_TOKEN_LENGTH, + ); + if ( + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 0 || + token === null + ) { + return null; + } + return Object.freeze({ generation, token }); +} + +function decodeExpectedEpoch( + value: unknown, +): SqlCatalogEpoch | null | undefined { + return value === null ? null : decodeEpoch(value) ?? undefined; +} + +function readArrayElement( + values: readonly unknown[], + index: number, +): { readonly found: true; readonly value: unknown } | null { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(values, index); + } catch { + return null; + } + return descriptor && "value" in descriptor + ? { found: true, value: descriptor.value } + : null; +} + +function readArrayLength(value: unknown, maximum: number): number | null { + if (!isDataArray(value)) return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, "length"); + } catch { + return null; + } + if ( + !descriptor || + !("value" in descriptor) || + typeof descriptor.value !== "number" || + !Number.isSafeInteger(descriptor.value) || + descriptor.value < 0 || + descriptor.value > maximum + ) { + return null; + } + return descriptor.value; +} + +function decodeIdentifierPath( + value: unknown, + maximumComponents: number, +): SqlIdentifierPath | null { + const length = readArrayLength(value, maximumComponents); + if (length === null || length === 0 || !Array.isArray(value)) return null; + const output: SqlIdentifierComponent[] = []; + for (let index = 0; index < length; index += 1) { + const item = readArrayElement(value, index); + if (!item) return null; + const record = readRecord( + item.value, + new Set(["quoted", "value"]), + ); + if (!record) return null; + const identifier = boundedString( + required(record, "value"), + MAX_COLUMN_NAME_LENGTH, + ); + const quoted = required(record, "quoted"); + if (identifier === null || typeof quoted !== "boolean") return null; + output.push(Object.freeze({ quoted, value: identifier })); + } + return Object.freeze(output); +} + +function decodeSearchPaths(value: unknown): readonly SqlIdentifierPath[] | null { + const length = readArrayLength(value, MAX_COLUMN_SEARCH_PATHS); + if (length === null || !Array.isArray(value)) return null; + const paths: SqlIdentifierPath[] = []; + for (let index = 0; index < length; index += 1) { + const item = readArrayElement(value, index); + if (!item) return null; + const path = decodeIdentifierPath( + item.value, + MAX_COLUMN_SEARCH_PATH_COMPONENTS, + ); + if (!path) return null; + paths.push(path); + } + return Object.freeze(paths); +} + +function decodeRelationReference( + value: unknown, +): SqlColumnCatalogRelationReference | null { + const record = readRecord( + value, + new Set(["path", "requestKey"]), + ); + if (!record) return null; + const requestKey = boundedString( + required(record, "requestKey"), + MAX_COLUMN_ENTITY_ID_LENGTH, + ); + const path = decodeIdentifierPath( + required(record, "path"), + MAX_COLUMN_RELATION_PATH_COMPONENTS, + ); + if (requestKey === null || !path) return null; + return Object.freeze({ + path, + requestKey, + }); +} + +function sameEpoch(left: SqlCatalogEpoch, right: SqlCatalogEpoch): boolean { + return left.generation === right.generation && left.token === right.token; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function captureSqlColumnCatalogProvider( + provider: unknown, +): SqlColumnBoundaryResult { + const record = readRecord(provider, new Set(["id", "loadColumns"])); + if (!record) return malformed("invalid-shape"); + const id = boundedString( + required(record, "id"), + MAX_COLUMN_PROVIDER_ID_LENGTH, + ); + const loadColumns = required(record, "loadColumns"); + if (id === null || typeof loadColumns !== "function") { + return malformed("invalid-shape"); + } + const capturedValue: CapturedSqlColumnCatalogProvider = { + [capturedColumnProviderBrand]: "CapturedSqlColumnCatalogProvider", + }; + const captured = Object.freeze(capturedValue); + capturedColumnProviders.set(captured, { id, loadColumns }); + return accepted(captured); +} + +export function resolveSqlColumnCatalogProvider( + provider: unknown, +): SqlCapturedColumnCatalogProviderContext | null { + if (provider === null || typeof provider !== "object") return null; + const captured = capturedColumnProviders.get(provider); + if (!captured) return null; + return Object.freeze({ + id: captured.id, + loadColumns: ( + request: SqlColumnCatalogBatchRequest, + signal: AbortSignal, + ): unknown => + Reflect.apply( + captured.loadColumns, + undefined, + [request, signal], + ), + }); +} + +export function createSqlColumnCatalogBatchRequest( + input: unknown, +): SqlColumnBoundaryResult { + const record = readRecord( + input, + new Set([ + "dialectId", + "expectedEpoch", + "relations", + "scope", + "searchPaths", + ]), + ); + if (!record) return malformed("invalid-shape"); + const scope = boundedString( + required(record, "scope"), + MAX_COLUMN_SCOPE_LENGTH, + ); + const dialectId = boundedString( + required(record, "dialectId"), + MAX_COLUMN_DIALECT_ID_LENGTH, + ); + const expectedEpoch = decodeExpectedEpoch( + required(record, "expectedEpoch"), + ); + const relationsValue = required(record, "relations"); + const searchPaths = decodeSearchPaths(required(record, "searchPaths")); + const relationCount = readArrayLength( + relationsValue, + MAX_COLUMN_BATCH_RELATIONS, + ); + if ( + scope === null || + dialectId === null || + expectedEpoch === undefined || + relationCount === null || + !Array.isArray(relationsValue) || + searchPaths === null + ) { + return malformed("invalid-shape"); + } + const keys = new Set(); + const relations: SqlColumnCatalogRelationReference[] = []; + for (let index = 0; index < relationCount; index += 1) { + const item = readArrayElement(relationsValue, index); + if (!item) return malformed("invalid-shape"); + const reference = decodeRelationReference(item.value); + if (!reference) return malformed("invalid-shape"); + if (keys.has(reference.requestKey)) { + return malformed("duplicate-request-key"); + } + keys.add(reference.requestKey); + relations.push(reference); + } + if (relations.length === 0) return malformed("invalid-shape"); + relations.sort((left, right) => + compareText(left.requestKey, right.requestKey) + ); + return accepted(Object.freeze({ + dialectId, + expectedEpoch, + relations: Object.freeze(relations), + searchPaths, + scope, + })); +} + +function decodeColumn( + value: unknown, + providerId: string, + request: SqlColumnCatalogBatchRequest, + epoch: SqlCatalogEpoch, + relationEntityId: string, +): SqlColumnCatalogResolvedColumn | null { + const record = readRecord( + value, + new Set([ + "columnEntityId", + "dataType", + "detail", + "identifier", + "insertText", + "ordinal", + ]), + ); + if (!record) return null; + const columnEntityId = boundedString( + required(record, "columnEntityId"), + MAX_COLUMN_ENTITY_ID_LENGTH, + ); + const identifierRecord = readRecord( + required(record, "identifier"), + new Set(["quoted", "value"]), + ); + const identifierValue = identifierRecord + ? boundedString( + required(identifierRecord, "value"), + MAX_COLUMN_NAME_LENGTH, + ) + : null; + const quoted = identifierRecord + ? required(identifierRecord, "quoted") + : null; + const insertText = boundedString( + required(record, "insertText"), + MAX_COLUMN_INSERT_TEXT_LENGTH, + ); + const ordinal = required(record, "ordinal"); + const dataType = optionalBoundedString( + required(record, "dataType"), + MAX_COLUMN_TYPE_LENGTH, + ); + const detail = optionalBoundedString( + required(record, "detail"), + MAX_COLUMN_DETAIL_LENGTH, + ); + if ( + columnEntityId === null || + identifierValue === null || + typeof quoted !== "boolean" || + insertText === null || + dataType === null || + detail === null || + typeof ordinal !== "number" || + !Number.isSafeInteger(ordinal) || + ordinal < 0 + ) { + return null; + } + const column: SqlColumnCatalogColumn = { + columnEntityId, + identifier: Object.freeze({ + quoted, + value: identifierValue, + }), + insertText, + ordinal, + ...(dataType === undefined ? {} : { dataType }), + ...(detail === undefined ? {} : { detail }), + }; + return Object.freeze({ + ...column, + provenance: Object.freeze({ + columnEntityId, + epoch, + providerId, + relationEntityId, + scope: request.scope, + }), + }); +} + +function sameColumn( + left: SqlColumnCatalogResolvedColumn, + right: SqlColumnCatalogResolvedColumn, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function decodeReadyRelation( + record: DataRecord, + providerId: string, + request: SqlColumnCatalogBatchRequest, + epoch: SqlCatalogEpoch, + requestKey: string, + relationEntityId: string, +): SqlColumnBoundaryResult { + const coverage = required(record, "coverage"); + const columnsValue = required(record, "columns"); + const columnCount = readArrayLength( + columnsValue, + MAX_COLUMNS_PER_RELATION, + ); + if ( + (coverage !== "complete" && coverage !== "partial") || + columnCount === null || + !isDataArray(columnsValue) + ) { + return malformed("invalid-shape"); + } + const byId = new Map(); + for (let index = 0; index < columnCount; index += 1) { + const item = readArrayElement(columnsValue, index); + if (!item) return malformed("invalid-shape"); + const column = decodeColumn( + item.value, + providerId, + request, + epoch, + relationEntityId, + ); + if (!column) return malformed("invalid-shape"); + const previous = byId.get(column.columnEntityId); + if (previous && !sameColumn(previous, column)) { + return malformed("duplicate-column-entity-id"); + } + byId.set(column.columnEntityId, column); + } + const columns = [...byId.values()].sort((left, right) => + left.ordinal - right.ordinal || + compareText(left.identifier.value, right.identifier.value) || + compareText(left.columnEntityId, right.columnEntityId) + ); + return accepted(Object.freeze({ + columns: Object.freeze(columns), + coverage, + relationEntityId, + requestKey, + status: "ready", + })); +} + +function decodeRelation( + value: unknown, + providerId: string, + request: SqlColumnCatalogBatchRequest, + epoch: SqlCatalogEpoch, +): SqlColumnBoundaryResult { + const record = readRecord( + value, + new Set([ + "code", + "columns", + "coverage", + "relationEntityId", + "requestKey", + "retry", + "status", + ]), + ); + if (!record) return malformed("invalid-shape"); + const requestKey = boundedString( + required(record, "requestKey"), + MAX_COLUMN_ENTITY_ID_LENGTH, + ); + const status = required(record, "status"); + if (requestKey === null) return malformed("invalid-shape"); + if (status === "ready") { + const relationEntityId = boundedString( + required(record, "relationEntityId"), + MAX_COLUMN_ENTITY_ID_LENGTH, + ); + if ( + relationEntityId === null || + record.fields.size !== 5 + ) return malformed("invalid-shape"); + return decodeReadyRelation( + record, + providerId, + request, + epoch, + requestKey, + relationEntityId, + ); + } + if (status === "loading") { + if (record.fields.size !== 2) return malformed("invalid-shape"); + return accepted(Object.freeze({ + requestKey, + status, + })); + } + const code = required(record, "code"); + const retry = required(record, "retry"); + if ( + status !== "failed" || + record.fields.size !== 4 || + !isFailureCode(code) || + !isRetryPolicy(retry) + ) { + return malformed("invalid-shape"); + } + return accepted(Object.freeze({ + code, + requestKey, + retry, + status, + })); +} + +export function decodeSqlColumnCatalogBatchResponse( + provider: CapturedSqlColumnCatalogProvider, + request: SqlColumnCatalogBatchRequest, + value: unknown, +): SqlColumnBoundaryResult { + const context = resolveSqlColumnCatalogProvider(provider); + const record = readRecord(value, new Set(["epoch", "relations"])); + if (!context || !record) return malformed("invalid-shape"); + const epoch = decodeEpoch(required(record, "epoch")); + const relationsValue = required(record, "relations"); + const relationCount = readArrayLength( + relationsValue, + MAX_COLUMN_BATCH_RELATIONS, + ); + if ( + epoch === null || + (request.expectedEpoch !== null && + !sameEpoch(epoch, request.expectedEpoch)) || + relationCount === null || + !Array.isArray(relationsValue) + ) { + return malformed("invalid-shape"); + } + if (relationCount > request.relations.length) { + return malformed("resource-limit"); + } + const requested = new Set( + request.relations.map((relation) => relation.requestKey), + ); + const byId = new Map(); + let columnCount = 0; + for (let index = 0; index < relationCount; index += 1) { + const item = readArrayElement(relationsValue, index); + if (!item) return malformed("invalid-shape"); + const decoded = decodeRelation( + item.value, + context.id, + request, + epoch, + ); + if (decoded.status === "malformed") return decoded; + const relation = decoded.value; + if (!requested.has(relation.requestKey)) { + return malformed("unexpected-relation"); + } + if (byId.has(relation.requestKey)) { + return malformed("duplicate-request-key"); + } + if (relation.status === "ready") { + columnCount += relation.columns.length; + if (columnCount > MAX_COLUMNS_PER_BATCH) { + return malformed("resource-limit"); + } + } + byId.set(relation.requestKey, relation); + } + if (byId.size !== requested.size) return malformed("invalid-shape"); + return accepted(Object.freeze({ + epoch, + relations: Object.freeze( + [...byId.values()].sort((left, right) => + compareText(left.requestKey, right.requestKey) + ), + ), + })); +} diff --git a/src/vnext/column-catalog-types.ts b/src/vnext/column-catalog-types.ts new file mode 100644 index 0000000..f2a4690 --- /dev/null +++ b/src/vnext/column-catalog-types.ts @@ -0,0 +1,100 @@ +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export type SqlColumnCatalogCoverage = "complete" | "partial"; + +export interface SqlColumnCatalogRelationReference { + readonly path: SqlIdentifierPath; + readonly requestKey: string; +} + +export interface SqlColumnCatalogBatchRequest { + readonly dialectId: string; + readonly expectedEpoch: SqlCatalogEpoch | null; + readonly relations: readonly SqlColumnCatalogRelationReference[]; + readonly searchPaths: readonly SqlIdentifierPath[]; + readonly scope: string; +} + +export interface SqlColumnCatalogColumn { + readonly columnEntityId: string; + readonly dataType?: string; + readonly detail?: string; + readonly identifier: SqlIdentifierComponent; + readonly insertText: string; + readonly ordinal: number; +} + +export interface SqlColumnCatalogProvenance { + readonly columnEntityId: string; + readonly epoch: SqlCatalogEpoch; + readonly providerId: string; + readonly relationEntityId: string; + readonly scope: string; +} + +export interface SqlColumnCatalogResolvedColumn + extends SqlColumnCatalogColumn { + readonly provenance: SqlColumnCatalogProvenance; +} + +export type SqlColumnCatalogRelationResult = + | { + readonly columns: readonly SqlColumnCatalogResolvedColumn[]; + readonly coverage: SqlColumnCatalogCoverage; + readonly relationEntityId: string; + readonly requestKey: string; + readonly status: "ready"; + } + | { + readonly requestKey: string; + readonly status: "loading"; + } + | { + readonly code: + | "authentication" + | "authorization" + | "invalid-configuration" + | "rate-limited" + | "unavailable" + | "unknown"; + readonly requestKey: string; + readonly retry: "after-invalidation" | "never" | "next-request"; + readonly status: "failed"; + }; + +export interface SqlColumnCatalogBatchResponse { + readonly epoch: SqlCatalogEpoch; + readonly relations: readonly SqlColumnCatalogRelationResult[]; +} + +export type SqlColumnCatalogProviderRelationResult = + | { + readonly columns: readonly SqlColumnCatalogColumn[]; + readonly coverage: SqlColumnCatalogCoverage; + readonly relationEntityId: string; + readonly requestKey: string; + readonly status: "ready"; + } + | Extract< + SqlColumnCatalogRelationResult, + { readonly status: "loading" | "failed" } + >; + +export interface SqlColumnCatalogProviderResponse { + readonly epoch: SqlCatalogEpoch; + readonly relations: + readonly SqlColumnCatalogProviderRelationResult[]; +} + +export interface SqlColumnCatalogProvider { + readonly id: string; + readonly loadColumns: ( + this: void, + request: SqlColumnCatalogBatchRequest, + signal: AbortSignal, + ) => Promise; +} diff --git a/src/vnext/column-completion.ts b/src/vnext/column-completion.ts new file mode 100644 index 0000000..8ea49e9 --- /dev/null +++ b/src/vnext/column-completion.ts @@ -0,0 +1,350 @@ +import type { + SqlColumnCatalogBatchOutcome, +} from "./column-catalog-batch-coordinator.js"; +import { + MAX_COLUMN_BATCH_RELATIONS, +} from "./column-catalog-boundary.js"; +import type { + SqlColumnCatalogRelationReference, + SqlColumnCatalogRelationResult, +} from "./column-catalog-types.js"; +import type { + SqlColumnQueryRelation, + SqlColumnQuerySiteResult, +} from "./column-query-site.js"; +import type { + SqlColumnCatalogProviderReport, + SqlColumnCatalogFailure, + SqlCompletionIssue, + SqlCompletionItem, + SqlCompletionList, +} from "./relation-completion-types.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +type ReadyColumnSite = Extract< + SqlColumnQuerySiteResult, + { readonly status: "ready" } +>; + +export interface SqlPreparedColumnCatalogRelations { + readonly coverage: "complete" | "partial"; + readonly references: readonly SqlColumnCatalogRelationReference[]; + readonly relationsByRequestKey: ReadonlyMap< + string, + SqlColumnQueryRelation + >; +} + +export interface SqlColumnCompletionComposition { + readonly sources: readonly SqlColumnCatalogProviderReport[]; + readonly value: SqlCompletionList; +} + +function sameIdentifier( + dialect: SqlRelationDialectRuntime, + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): boolean { + return dialect.completion.compareCteIdentifiers(left, right) === "equal"; +} + +function pathEndsWith( + dialect: SqlRelationDialectRuntime, + path: SqlIdentifierPath, + suffix: SqlIdentifierPath, +): boolean { + if (suffix.length === 0 || suffix.length > path.length) return false; + const offset = path.length - suffix.length; + return suffix.every((component, index) => { + const candidate = path[offset + index]; + return candidate !== undefined && + sameIdentifier(dialect, candidate, component); + }); +} + +function relationMatchesQualifier( + dialect: SqlRelationDialectRuntime, + relation: SqlColumnQueryRelation, + qualifier: SqlIdentifierPath, +): boolean { + if (qualifier.length === 0) return true; + if ( + qualifier.length === 1 && + relation.alias !== null && + qualifier[0] !== undefined && + sameIdentifier(dialect, relation.alias, qualifier[0]) + ) { + return true; + } + return relation.alias === null && + pathEndsWith(dialect, relation.path, qualifier); +} + +export function prepareSqlColumnCatalogRelations( + site: ReadyColumnSite, + dialect: SqlRelationDialectRuntime, +): SqlPreparedColumnCatalogRelations { + const references: SqlColumnCatalogRelationReference[] = []; + const relationsByRequestKey = new Map< + string, + SqlColumnQueryRelation + >(); + let coverage: "complete" | "partial" = "complete"; + for (let index = 0; index < site.relations.length; index += 1) { + const relation = site.relations[index]; + if ( + relation === undefined || + !relationMatchesQualifier( + dialect, + relation, + site.qualifier, + ) + ) { + continue; + } + if (references.length === MAX_COLUMN_BATCH_RELATIONS) { + coverage = "partial"; + break; + } + const requestKey = `binding:${index}`; + references.push(Object.freeze({ + path: relation.path, + requestKey, + })); + relationsByRequestKey.set(requestKey, relation); + } + return Object.freeze({ + coverage, + references: Object.freeze(references), + relationsByRequestKey, + }); +} + +function relationLabel(relation: SqlColumnQueryRelation): string { + return relation.alias?.value ?? + relation.path.map((component) => component.value).join("."); +} + +function issue( + reason: Exclude, +): SqlCompletionIssue { + return Object.freeze({ reason }); +} + +function resultIssues( + relation: SqlColumnCatalogRelationResult, +): readonly SqlCompletionIssue[] { + if (relation.status === "loading") { + return Object.freeze([issue("column-catalog-loading")]); + } + if (relation.status === "failed") { + return Object.freeze([issue("column-catalog-failed")]); + } + return relation.coverage === "partial" + ? Object.freeze([issue("column-catalog-partial")]) + : Object.freeze([]); +} + +function list( + items: readonly SqlCompletionItem[], + issues: readonly SqlCompletionIssue[], +): SqlCompletionList { + const first = issues[0]; + if (first === undefined) { + return Object.freeze({ + isIncomplete: false, + issues: Object.freeze([] as const), + items: Object.freeze(items), + }); + } + const incompleteIssues: [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ] = [first, ...issues.slice(1)]; + return Object.freeze({ + isIncomplete: true, + issues: Object.freeze(incompleteIssues), + items: Object.freeze(items), + }); +} + +function unavailableComposition( + providerId: string, + reason: Extract< + SqlColumnCatalogBatchOutcome, + { readonly status: "unavailable" } + >["reason"], +): SqlColumnCompletionComposition { + return Object.freeze({ + sources: Object.freeze([Object.freeze({ + feature: "column-catalog", + outcome: "unavailable", + providerId, + reason, + })]), + value: list([], [issue( + reason === "malformed-response" + ? "column-catalog-malformed" + : "column-catalog-failed", + )]), + }); +} + +function compareItems( + left: SqlCompletionItem, + right: SqlCompletionItem, +): number { + return compareText(left.label, right.label) || + compareText(left.detail ?? "", right.detail ?? "") || + compareText(left.edit.insert, right.edit.insert) || + left.edit.from - right.edit.from || + left.edit.to - right.edit.to; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function composeSqlColumnCompletion( + input: { + readonly dialect: SqlRelationDialectRuntime; + readonly outcome: SqlColumnCatalogBatchOutcome; + readonly prepared: SqlPreparedColumnCatalogRelations; + readonly providerId: string; + readonly site: ReadyColumnSite; + }, +): SqlColumnCompletionComposition | null { + if ( + input.outcome.status === "cancelled" || + input.outcome.status === "superseded" + ) { + return null; + } + if (input.outcome.status === "unavailable") { + return unavailableComposition( + input.providerId, + input.outcome.reason, + ); + } + const items: SqlCompletionItem[] = []; + const issues: SqlCompletionIssue[] = []; + let hasPartial = + input.site.coverage === "partial" || + input.prepared.coverage === "partial"; + if (hasPartial) issues.push(issue("query-binding-partial")); + let hasLoading = false; + let hasFailure = false; + const seen = new Set(); + const failures: SqlColumnCatalogFailure[] = []; + for (const result of input.outcome.relations) { + issues.push(...resultIssues(result)); + if (result.status === "loading") { + hasLoading = true; + continue; + } + if (result.status === "failed") { + hasFailure = true; + failures.push(Object.freeze({ + code: result.code, + requestKey: result.requestKey, + retry: result.retry, + })); + continue; + } + if (result.coverage === "partial") hasPartial = true; + const relation = input.prepared.relationsByRequestKey.get( + result.requestKey, + ); + if (!relation) { + issues.push(issue("column-catalog-malformed")); + continue; + } + for (const column of result.columns) { + if ( + input.dialect.completion.cteIdentifierMatchesPrefix( + column.identifier, + input.site.prefix, + ) !== "match" + ) { + continue; + } + const identity = [ + column.provenance.providerId, + column.provenance.scope, + column.provenance.relationEntityId, + column.provenance.columnEntityId, + column.insertText, + ].join("\u0000"); + if (seen.has(identity)) continue; + seen.add(identity); + const relationName = relationLabel(relation); + const metadata = column.detail ?? column.dataType; + items.push(Object.freeze({ + ...(column.dataType === undefined + ? {} + : { dataType: column.dataType }), + detail: metadata === undefined + ? relationName + : `${metadata} — ${relationName}`, + edit: Object.freeze({ + from: input.site.replacementRange.from, + insert: column.insertText, + to: input.site.replacementRange.to, + }), + kind: "column", + label: column.identifier.value, + provenance: Object.freeze({ + columnEntityId: column.provenance.columnEntityId, + epoch: column.provenance.epoch, + kind: "column-catalog", + providerId: column.provenance.providerId, + relationEntityId: column.provenance.relationEntityId, + scope: column.provenance.scope, + }), + relationRequestKey: result.requestKey, + })); + } + } + const deduplicatedIssues = Array.from( + new Map(issues.map((item) => [item.reason, item])).values(), + ); + const coverage = hasPartial || hasFailure ? "partial" : "complete"; + const frozenFailures = Object.freeze(failures); + const firstFailure = failures[0]; + const source: SqlColumnCatalogProviderReport = hasLoading + ? { + feature: "column-catalog", + failures: frozenFailures, + outcome: "loading", + providerId: input.outcome.providerId, + } + : hasFailure && items.length === 0 && + firstFailure !== undefined + ? { + feature: "column-catalog", + failures: Object.freeze([ + firstFailure, + ...failures.slice(1), + ]), + outcome: "failed", + providerId: input.outcome.providerId, + } + : { + coverage, + feature: "column-catalog", + failures: frozenFailures, + outcome: "ready", + providerId: input.outcome.providerId, + }; + return Object.freeze({ + sources: Object.freeze([Object.freeze(source)]), + value: list( + Object.freeze(items.sort(compareItems)), + Object.freeze(deduplicatedIssues), + ), + }); +} diff --git a/src/vnext/column-query-site.ts b/src/vnext/column-query-site.ts new file mode 100644 index 0000000..dc8e8a6 --- /dev/null +++ b/src/vnext/column-query-site.ts @@ -0,0 +1,681 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme, +} from "./bounded-sql-lexer.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isSqlStatementSlotSnapshot, + type SqlStatementSlot, +} from "./statement-index.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlTextRange, +} from "./types.js"; + +export const MAX_COLUMN_QUERY_RELATIONS = 256; + +export interface SqlColumnQueryRelation { + readonly alias: SqlIdentifierComponent | null; + readonly path: SqlIdentifierPath; + readonly range: SqlTextRange; +} + +export type SqlColumnQuerySiteIssue = + | "derived-relation" + | "incomplete-relation" + | "nested-query" + | "opaque-template-context" + | "table-function"; + +export type SqlColumnQuerySiteResult = + | { + readonly status: "inactive"; + readonly reason: + | "cursor-in-comment" + | "cursor-in-string" + | "cursor-in-embedded-region" + | "not-column-position" + | "not-select-query"; + } + | { + readonly status: "unavailable"; + readonly reason: + | "ambiguous-query-site" + | "opaque-statement" + | "resource-limit"; + } + | { + readonly status: "ready"; + readonly coverage: "complete" | "partial"; + readonly issues: readonly SqlColumnQuerySiteIssue[]; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly relations: readonly SqlColumnQueryRelation[]; + readonly replacementRange: SqlTextRange; + }; + +interface Token extends BoundedSqlLexeme { + readonly depth: number; +} + +type Clause = + | "from" + | "group" + | "having" + | "join-condition" + | "limit" + | "order" + | "qualify" + | "select-list" + | "where"; + +const RELATION_END_WORDS: ReadonlySet = new Set([ + "cross", + "except", + "fetch", + "full", + "group", + "having", + "inner", + "intersect", + "join", + "left", + "limit", + "natural", + "offset", + "on", + "order", + "qualify", + "right", + "union", + "using", + "where", + "window", +]); + +function inactive( + reason: Extract< + SqlColumnQuerySiteResult, + { status: "inactive" } + >["reason"], +): SqlColumnQuerySiteResult { + return Object.freeze({ reason, status: "inactive" }); +} + +function unavailable( + reason: Extract< + SqlColumnQuerySiteResult, + { status: "unavailable" } + >["reason"], +): SqlColumnQuerySiteResult { + return Object.freeze({ reason, status: "unavailable" }); +} + +function word( + source: SqlSourceSnapshot, + token: Token | undefined, +): string | null { + return token?.kind === "word" + ? source.analysisText.slice(token.from, token.to).toLowerCase() + : null; +} + +function isIdentifier(token: Token | undefined): boolean { + return token?.kind === "word" || + token?.kind === "quoted-identifier"; +} + +function punctuation( + source: SqlSourceSnapshot, + token: Token | undefined, + expected: string, +): boolean { + return token?.kind === "punctuation" && + source.analysisText.slice(token.from, token.to) === expected; +} + +function tokenize( + source: SqlSourceSnapshot, + slot: Extract, + dialect: SqlRelationDialectRuntime, +): readonly Token[] | null { + const lexer = new BoundedSqlLexer( + source, + slot.source.from, + slot.source.to, + dialect.querySite.lexicalProfile, + ); + const tokens: Token[] = []; + let depth = 0; + for (let lexeme = lexer.next(); lexeme; lexeme = lexer.next()) { + if (punctuation(source, { ...lexeme, depth }, ")")) { + depth = Math.max(0, depth - 1); + } + tokens.push(Object.freeze({ ...lexeme, depth })); + if (punctuation(source, { ...lexeme, depth }, "(")) { + depth += 1; + } + } + return lexer.resource === null ? Object.freeze(tokens) : null; +} + +function decodePath( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + from: number, + to: number, +): SqlIdentifierPath | null { + const raw = source.analysisText.slice(from, to); + const decoded = dialect.querySite.decodeRelationPath(raw, raw.length); + if ( + decoded.status !== "decoded" || + decoded.prefix.value.length === 0 + ) { + return null; + } + return Object.freeze([ + ...decoded.qualifier, + decoded.prefix, + ]); +} + +function parseNamedRelation( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + tokens: readonly Token[], + start: number, + depth: number, +): { + readonly next: number; + readonly relation: SqlColumnQueryRelation | null; + readonly issue: SqlColumnQuerySiteIssue | null; +} { + const first = tokens[start]; + if (!isIdentifier(first) || first?.depth !== depth) { + return { + issue: punctuation(source, first, "(") + ? "derived-relation" + : "incomplete-relation", + next: start + 1, + relation: null, + }; + } + let end = start + 1; + while ( + punctuation(source, tokens[end], ".") && + tokens[end]?.depth === depth && + isIdentifier(tokens[end + 1]) && + tokens[end + 1]?.depth === depth + ) { + end += 2; + } + const lastPathToken = tokens[end - 1] ?? first; + const path = decodePath( + source, + dialect, + first.from, + lastPathToken.to, + ); + if (path === null) { + return { + issue: "incomplete-relation", + next: end, + relation: null, + }; + } + if (punctuation(source, tokens[end], "(")) { + return { + issue: "table-function", + next: end + 1, + relation: null, + }; + } + let alias: SqlIdentifierComponent | null = null; + const maybeAs = word(source, tokens[end]); + if (maybeAs === "as") end += 1; + const aliasToken = tokens[end]; + const aliasWord = aliasToken ? word(source, aliasToken) : null; + if ( + isIdentifier(aliasToken) && + aliasToken?.depth === depth && + (aliasWord === null || + !RELATION_END_WORDS.has(aliasWord)) + ) { + const aliasPath = decodePath( + source, + dialect, + aliasToken.from, + aliasToken.to, + ); + if (aliasPath?.length === 1) { + alias = aliasPath[0] ?? null; + end += 1; + } + } + return { + issue: + maybeAs === "as" && alias === null + ? "incomplete-relation" + : null, + next: end, + relation: Object.freeze({ + alias, + path, + range: Object.freeze({ + from: first.from, + to: lastPathToken.to, + }), + }), + }; +} + +function clauseAt( + source: SqlSourceSnapshot, + tokens: readonly Token[], + selectIndex: number, + depth: number, + position: number, +): Clause { + let clause: Clause = "select-list"; + for ( + let index = selectIndex + 1; + index < tokens.length && tokens[index]!.from < position; + index += 1 + ) { + const token = tokens[index]!; + if (token.depth !== depth) continue; + switch (word(source, token)) { + case "from": + clause = "from"; + break; + case "group": + clause = "group"; + break; + case "having": + clause = "having"; + break; + case "limit": + case "fetch": + case "offset": + clause = "limit"; + break; + case "on": + case "using": + clause = "join-condition"; + break; + case "order": + clause = "order"; + break; + case "qualify": + clause = "qualify"; + break; + case "where": + clause = "where"; + break; + } + } + return clause; +} + +function typedPath( + source: SqlSourceSnapshot, + tokens: readonly Token[], + position: number, + dialect: SqlRelationDialectRuntime, +): { + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly replacementRange: SqlTextRange; +} | null { + let endIndex = tokens.findIndex((token) => + token.from < position && position <= token.to + ); + const endToken = tokens[endIndex]; + if ( + endIndex < 0 || + (!isIdentifier(endToken) && + !punctuation(source, endToken, ".")) + ) { + return Object.freeze({ + prefix: Object.freeze({ quoted: false, value: "" }), + qualifier: Object.freeze([]), + replacementRange: Object.freeze({ from: position, to: position }), + }); + } + let startIndex = endIndex; + let expectIdentifier = punctuation(source, endToken, "."); + while (startIndex > 0) { + const previous = tokens[startIndex - 1]; + if ( + expectIdentifier + ? !isIdentifier(previous) + : !punctuation(source, previous, ".") + ) { + break; + } + startIndex -= 1; + expectIdentifier = !expectIdentifier; + } + const from = tokens[startIndex]?.from ?? position; + const raw = source.analysisText.slice(from, position); + const decoded = dialect.querySite.decodeRelationPath(raw, raw.length); + if (decoded.status !== "decoded") return null; + return Object.freeze({ + prefix: decoded.prefix, + qualifier: decoded.qualifier, + replacementRange: Object.freeze({ + from: from + decoded.finalSegment.from, + to: from + decoded.finalSegment.to, + }), + }); +} + +function cursorTokenReason( + tokens: readonly Token[], + position: number, +): Extract< + SqlColumnQuerySiteResult, + { status: "inactive" } +>["reason"] | null { + const token = tokens.find((candidate) => + candidate.from <= position && + (position < candidate.to || + (candidate.kind === "line-comment" && + position === candidate.to)) + ); + switch (token?.kind) { + case "barrier": + return "cursor-in-embedded-region"; + case "comment": + case "line-comment": + return "cursor-in-comment"; + case "string": + return "cursor-in-string"; + default: + return null; + } +} + +function collectRelations( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + tokens: readonly Token[], + selectIndex: number, + visibilityPosition: number, + precedingOnly: boolean, + relations: SqlColumnQueryRelation[], + issues: Set, +): boolean { + const selectDepth = tokens[selectIndex]?.depth; + if (selectDepth === undefined) return false; + const clause = clauseAt( + source, + tokens, + selectIndex, + selectDepth, + visibilityPosition, + ); + let commaStartsRelation = false; + let inFrom = false; + for ( + let index = selectIndex + 1; + index < tokens.length; + index += 1 + ) { + const token = tokens[index]!; + if ( + (precedingOnly || clause === "join-condition") && + token.from >= visibilityPosition + ) { + break; + } + if (token.depth < selectDepth) break; + if (token.depth !== selectDepth) { + if (word(source, token) === "select") issues.add("nested-query"); + continue; + } + const tokenWord = word(source, token); + if ( + tokenWord === "union" || + tokenWord === "intersect" || + tokenWord === "except" + ) { + break; + } + if ( + tokenWord === "where" || + tokenWord === "group" || + tokenWord === "having" || + tokenWord === "qualify" || + tokenWord === "order" || + tokenWord === "limit" + ) { + inFrom = false; + commaStartsRelation = false; + continue; + } + if (tokenWord === "on" || tokenWord === "using") { + commaStartsRelation = false; + continue; + } + const startsRelation = + tokenWord === "from" || + tokenWord === "join" || + (inFrom && + commaStartsRelation && + punctuation(source, token, ",")); + if (!startsRelation) continue; + inFrom = true; + let relationIndex = index + 1; + if ( + (dialect.id === "postgresql" || dialect.id === "duckdb") && + word(source, tokens[relationIndex]) === "lateral" + ) { + relationIndex += 1; + } + const parsed = parseNamedRelation( + source, + dialect, + tokens, + relationIndex, + selectDepth, + ); + if (parsed.issue !== null) issues.add(parsed.issue); + if (parsed.relation !== null) { + relations.push(parsed.relation); + if (relations.length > MAX_COLUMN_QUERY_RELATIONS) return false; + } + commaStartsRelation = true; + index = Math.max(index, parsed.next - 1); + } + return true; +} + +function openingBefore( + source: SqlSourceSnapshot, + tokens: readonly Token[], + beforeIndex: number, + depth: number, +): number { + for (let index = beforeIndex - 1; index >= 0; index -= 1) { + const token = tokens[index]!; + if ( + token.depth === depth && + punctuation(source, token, "(") + ) { + return index; + } + } + return -1; +} + +function correlatedParentSelectIndex( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + tokens: readonly Token[], + childIndex: number, +): readonly [selectIndex: number, precedingOnly: boolean] | null { + const child = tokens[childIndex]; + if (!child || child.depth === 0) return null; + for (let depth = child.depth - 1; depth >= 0; depth -= 1) { + const lowerBound = depth === 0 + ? -1 + : tokens[ + openingBefore(source, tokens, childIndex, depth - 1) + ]?.from ?? -1; + for (let index = childIndex - 1; index >= 0; index -= 1) { + const token = tokens[index]!; + if (token.from <= lowerBound) break; + if (token.depth === depth && word(source, token) === "select") { + const openingIndex = openingBefore( + source, + tokens, + childIndex, + depth, + ); + const opening = tokens[openingIndex]?.from ?? -1; + if (opening <= token.from) return null; + const inFrom = + clauseAt( + source, + tokens, + index, + depth, + opening, + ) === "from"; + if (!inFrom) return [index, false]; + return (dialect.id === "postgresql" || dialect.id === "duckdb") && + word(source, tokens[openingIndex - 1]) === "lateral" + ? [index, true] + : null; + } + } + } + return null; +} + +export function recognizeSqlColumnQuerySite( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, + position: number, + dialect: SqlRelationDialectRuntime, +): SqlColumnQuerySiteResult { + if ( + !isSqlSourceSnapshot(source) || + !isSqlStatementSlotSnapshot(slot) || + !isSqlRelationDialectRuntime(dialect) || + !Number.isSafeInteger(position) || + position < 0 || + position > source.analysisText.length + ) { + return unavailable("ambiguous-query-site"); + } + if (slot.boundaryQuality === "opaque") { + return unavailable("opaque-statement"); + } + if ( + position < slot.source.from || + position > slot.source.to + ) { + return inactive("not-column-position"); + } + const tokens = tokenize(source, slot, dialect); + if (tokens === null) return unavailable("resource-limit"); + const cursorReason = cursorTokenReason(tokens, position); + if (cursorReason !== null) return inactive(cursorReason); + + let cursorDepth = 0; + for (const token of tokens) { + if (token.from >= position) break; + cursorDepth = token.depth; + if (punctuation(source, token, "(")) cursorDepth += 1; + } + let selectIndex = -1; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if ( + token.from <= position && + token.depth <= cursorDepth && + word(source, token) === "select" + ) { + selectIndex = index; + } + } + if (selectIndex < 0) return inactive("not-select-query"); + const selectDepth = tokens[selectIndex]!.depth; + const clause = clauseAt( + source, + tokens, + selectIndex, + selectDepth, + position, + ); + if (clause === "from" || clause === "limit") { + return inactive("not-column-position"); + } + const path = typedPath(source, tokens, position, dialect); + if (path === null) { + return inactive("not-column-position"); + } + + const relations: SqlColumnQueryRelation[] = []; + const issues = new Set(); + if ( + !collectRelations( + source, + dialect, + tokens, + selectIndex, + position, + false, + relations, + issues, + ) + ) { + return unavailable("resource-limit"); + } + let childIndex = selectIndex; + for (;;) { + const parent = correlatedParentSelectIndex( + source, + dialect, + tokens, + childIndex, + ); + if (parent === null) break; + const [parentIndex, precedingOnly] = parent; + const childPosition = tokens[childIndex]?.from; + if ( + childPosition === undefined || + !collectRelations( + source, + dialect, + tokens, + parentIndex, + childPosition, + precedingOnly, + relations, + issues, + ) + ) { + return unavailable("resource-limit"); + } + childIndex = parentIndex; + } + const issueList = Object.freeze(Array.from(issues).sort()); + return Object.freeze({ + coverage: issueList.length === 0 ? "complete" : "partial", + issues: issueList, + prefix: path.prefix, + qualifier: path.qualifier, + relations: Object.freeze(relations), + replacementRange: path.replacementRange, + status: "ready", + }); +} diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 5a98211..d5bc733 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -5,6 +5,32 @@ export { duckdbDialect, postgresDialect, } from "./session.js"; +export type { + SqlColumnCatalogBatchRequest, + SqlColumnCatalogBatchResponse, + SqlColumnCatalogColumn, + SqlColumnCatalogCoverage, + SqlColumnCatalogProvider, + SqlColumnCatalogProvenance, + SqlColumnCatalogProviderRelationResult, + SqlColumnCatalogProviderResponse, + SqlColumnCatalogRelationReference, + SqlColumnCatalogRelationResult, + SqlColumnCatalogResolvedColumn, +} from "./column-catalog-types.js"; +export type { + SqlCanonicalNamespacePath, + SqlNamespaceCatalogContainer, + SqlNamespaceCatalogProvider, + SqlNamespaceCatalogProviderResponse, + SqlNamespaceCatalogProvenance, + SqlNamespaceCatalogResolvedContainer, + SqlNamespaceCatalogSearchRequest, + SqlNamespaceCatalogSearchResponse, + SqlNamespaceContainerRole, + SqlNamespacePathComponent, + SqlNamespaceQuerySite, +} from "./namespace-catalog-types.js"; export type { OpenSqlDocument, SqlCatalogContext, @@ -53,9 +79,15 @@ export type { SqlCatalogSearchRequest, SqlCatalogSearchResponse, SqlCompletionCancellationReason, + SqlColumnCatalogProviderReport, + SqlColumnCatalogFailure, + SqlColumnCompletionProvenance, + SqlCompletionProviderReport, SqlCompletionIssue, SqlCompletionRequest, SqlCompletionRefreshToken, + SqlNamespaceCatalogProviderReport, + SqlNamespaceCompletionProvenance, SqlCompletionTrigger, SqlDisposable, SqlRelationCatalogProvider, diff --git a/src/vnext/local-relation-site.ts b/src/vnext/local-relation-site.ts index 5199eb0..27ad6cd 100644 --- a/src/vnext/local-relation-site.ts +++ b/src/vnext/local-relation-site.ts @@ -4,6 +4,10 @@ import { type SqlCteVisibility, visibleSqlCtesAt, } from "./cte-layout.js"; +import { + recognizeSqlColumnQuerySite, + type SqlColumnQuerySiteResult, +} from "./column-query-site.js"; import { recognizeSqlRelationQuerySiteWithEntrypoints, type SqlQuerySiteResult, @@ -179,13 +183,6 @@ export function analyzeSqlLocalRelationSite( }); } const relativePosition = position - context.slot.source.from; - if ( - !Number.isSafeInteger(relativePosition) || - relativePosition < 0 || - relativePosition > context.layout.statementLength - ) { - return unavailableSite(); - } return Object.freeze({ local: Object.freeze({ cteVisibility: visibleSqlCtesAt( @@ -198,3 +195,54 @@ export function analyzeSqlLocalRelationSite( status: "ready", }); } + +export function analyzeSqlLocalColumnSite( + statement: SqlLocalRelationStatement, + position: number, +): SqlColumnQuerySiteResult { + if ( + statement === null || + typeof statement !== "object" + ) { + return Object.freeze({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + } + const context = localRelationStatements.get(statement); + if (!context) { + return Object.freeze({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + } + const result = recognizeSqlColumnQuerySite( + context.source, + context.slot, + position, + context.dialect, + ); + if (result.status !== "ready") return result; + const visibility = visibleSqlCtesAt( + context.layout, + position - context.slot.source.from, + ); + const relations = result.relations.filter((relation) => { + const name = relation.path.length === 1 + ? relation.path[0] + : undefined; + return name === undefined || + !visibility.ctes.some((cte) => + context.dialect.completion.compareCteIdentifiers( + name, + cte.name, + ) === "equal" + ); + }); + return relations.length === result.relations.length + ? result + : Object.freeze({ + ...result, + relations: Object.freeze(relations), + }); +} diff --git a/src/vnext/namespace-catalog-boundary.ts b/src/vnext/namespace-catalog-boundary.ts new file mode 100644 index 0000000..efe718b --- /dev/null +++ b/src/vnext/namespace-catalog-boundary.ts @@ -0,0 +1,575 @@ +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; +import type { + SqlCanonicalNamespacePath, + SqlNamespaceCatalogContainer, + SqlNamespaceCatalogResolvedContainer, + SqlNamespaceCatalogSearchRequest, + SqlNamespaceCatalogSearchResponse, + SqlNamespaceContainerRole, + SqlNamespacePathComponent, +} from "./namespace-catalog-types.js"; +import { + isDataArray, + type SqlIdentifierComponent, + type SqlIdentifierPath, +} from "./types.js"; + +export const MAX_NAMESPACE_PROVIDER_ID_LENGTH = 256; +export const MAX_NAMESPACE_SCOPE_LENGTH = 512; +export const MAX_NAMESPACE_DIALECT_ID_LENGTH = 128; +export const MAX_NAMESPACE_EPOCH_TOKEN_LENGTH = 256; +export const MAX_NAMESPACE_ENTITY_ID_LENGTH = 256; +export const MAX_NAMESPACE_IDENTIFIER_LENGTH = 256; +export const MAX_NAMESPACE_INSERT_TEXT_LENGTH = 1_024; +export const MAX_NAMESPACE_DETAIL_LENGTH = 1_024; +export const MAX_NAMESPACE_PATH_COMPONENTS = 32; +export const MAX_NAMESPACE_SEARCH_PATHS = 32; +export const MAX_NAMESPACE_SEARCH_PATH_COMPONENTS = 8; +export const MAX_NAMESPACE_RESULTS = 128; + +const capturedProviderBrand: unique symbol = Symbol( + "CapturedSqlNamespaceCatalogProvider", +); + +export interface CapturedSqlNamespaceCatalogProvider { + readonly [capturedProviderBrand]: + "CapturedSqlNamespaceCatalogProvider"; +} + +export interface SqlCapturedNamespaceCatalogProviderContext { + readonly id: string; + readonly search: ( + this: void, + request: SqlNamespaceCatalogSearchRequest, + signal: AbortSignal, + ) => unknown; +} + +export type SqlNamespaceBoundaryFailureReason = + | "duplicate-entity-id" + | "invalid-shape" + | "resource-limit"; + +export type SqlNamespaceBoundaryResult = + | { + readonly status: "accepted"; + readonly value: Value; + } + | { + readonly reason: SqlNamespaceBoundaryFailureReason; + readonly status: "malformed"; + }; + +interface DataRecord { + readonly fields: ReadonlyMap; +} + +const capturedProviders = new WeakMap< + object, + { readonly id: string; readonly search: Function } +>(); + +function accepted( + value: Value, +): SqlNamespaceBoundaryResult { + return Object.freeze({ status: "accepted", value }); +} + +function malformed( + reason: SqlNamespaceBoundaryFailureReason, +): SqlNamespaceBoundaryResult { + return Object.freeze({ reason, status: "malformed" }); +} + +function record( + value: unknown, + allowed: ReadonlySet, +): DataRecord | null { + if ( + value === null || + typeof value !== "object" + ) return null; + let keys: readonly PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + return null; + } + const fields = new Map(); + for (const key of keys) { + if (typeof key !== "string" || !allowed.has(key)) return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor || !("value" in descriptor)) return null; + fields.set(key, descriptor.value); + } + return { fields }; +} + +function required( + source: DataRecord, + key: string, +): unknown { + return source.fields.has(key) + ? source.fields.get(key) + : undefined; +} + +function boundedString( + value: unknown, + maximum: number, + allowEmpty = false, +): string | null { + return typeof value === "string" && + value.length <= maximum && + (allowEmpty || value.length > 0) + ? value + : null; +} + +function arrayLength( + value: unknown, + maximum: number, +): number | null { + if (!isDataArray(value)) return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, "length"); + } catch { + return null; + } + const length = descriptor && "value" in descriptor + ? descriptor.value + : undefined; + return typeof length === "number" && + Number.isSafeInteger(length) && + length >= 0 && + length <= maximum + ? length + : null; +} + +function arrayElement( + value: unknown, + index: number, +): unknown | null { + if (!isDataArray(value)) return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + } catch { + return null; + } + return descriptor && "value" in descriptor + ? descriptor.value + : null; +} + +function identifier( + value: unknown, + allowEmpty = false, +): SqlIdentifierComponent | null { + const source = record(value, new Set(["quoted", "value"])); + if (!source) return null; + const quoted = required(source, "quoted"); + const text = boundedString( + required(source, "value"), + MAX_NAMESPACE_IDENTIFIER_LENGTH, + allowEmpty, + ); + return typeof quoted === "boolean" && text !== null + ? Object.freeze({ quoted, value: text }) + : null; +} + +function identifierPath( + value: unknown, + maximum: number, +): SqlIdentifierPath | null { + const length = arrayLength(value, maximum); + if (length === null) return null; + const output: SqlIdentifierComponent[] = []; + for (let index = 0; index < length; index += 1) { + const component = identifier(arrayElement(value, index)); + if (!component) return null; + output.push(component); + } + return Object.freeze(output); +} + +function searchPaths( + value: unknown, +): readonly SqlIdentifierPath[] | null { + const length = arrayLength(value, MAX_NAMESPACE_SEARCH_PATHS); + if (length === null) return null; + const output: SqlIdentifierPath[] = []; + for (let index = 0; index < length; index += 1) { + const path = identifierPath( + arrayElement(value, index), + MAX_NAMESPACE_SEARCH_PATH_COMPONENTS, + ); + if (!path) return null; + output.push(path); + } + return Object.freeze(output); +} + +function epoch(value: unknown): SqlCatalogEpoch | null { + const source = record(value, new Set(["generation", "token"])); + if (!source) return null; + const generation = required(source, "generation"); + const token = boundedString( + required(source, "token"), + MAX_NAMESPACE_EPOCH_TOKEN_LENGTH, + ); + return typeof generation === "number" && + Number.isSafeInteger(generation) && + generation >= 0 && + token !== null + ? Object.freeze({ generation, token }) + : null; +} + +function sameEpoch( + left: SqlCatalogEpoch, + right: SqlCatalogEpoch, +): boolean { + return left.generation === right.generation && + left.token === right.token; +} + +function role(value: unknown): SqlNamespaceContainerRole | null { + return value === "catalog" || + value === "schema" || + value === "project" || + value === "dataset" + ? value + : null; +} + +function namespacePath( + value: unknown, +): SqlCanonicalNamespacePath | null { + const length = arrayLength(value, MAX_NAMESPACE_PATH_COMPONENTS); + if (length === null || length === 0) return null; + const output: SqlNamespacePathComponent[] = []; + for (let index = 0; index < length; index += 1) { + const source = record( + arrayElement(value, index), + new Set(["quoted", "role", "value"]), + ); + if (!source) return null; + const componentRole = role(required(source, "role")); + const component = identifier({ + quoted: required(source, "quoted"), + value: required(source, "value"), + }); + if (!componentRole || !component) return null; + output.push(Object.freeze({ ...component, role: componentRole })); + } + const first = output[0]; + if (!first) return null; + return Object.freeze([first, ...output.slice(1)]); +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function comparePath( + left: SqlCanonicalNamespacePath, + right: SqlCanonicalNamespacePath, +): number { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftComponent = left[index]; + const rightComponent = right[index]; + if (!leftComponent || !rightComponent) continue; + const compared = + compareText(leftComponent.role, rightComponent.role) || + Number(rightComponent.quoted) - Number(leftComponent.quoted) || + compareText(leftComponent.value, rightComponent.value); + if (compared !== 0) return compared; + } + return left.length - right.length; +} + +function sameContainer( + left: SqlNamespaceCatalogContainer, + right: SqlNamespaceCatalogContainer, +): boolean { + return left.containerEntityId === right.containerEntityId && + left.detail === right.detail && + left.insertText === right.insertText && + left.matchQuality === right.matchQuality && + comparePath(left.canonicalPath, right.canonicalPath) === 0; +} + +export function captureSqlNamespaceCatalogProvider( + value: unknown, +): SqlNamespaceBoundaryResult { + const source = record(value, new Set(["id", "search"])); + const id = source + ? boundedString( + required(source, "id"), + MAX_NAMESPACE_PROVIDER_ID_LENGTH, + ) + : null; + const search = source ? required(source, "search") : null; + if (!source || id === null || typeof search !== "function") { + return malformed("invalid-shape"); + } + const capturedValue: CapturedSqlNamespaceCatalogProvider = { + [capturedProviderBrand]: "CapturedSqlNamespaceCatalogProvider", + }; + const captured = Object.freeze(capturedValue); + capturedProviders.set(captured, { id, search }); + return accepted(captured); +} + +export function resolveSqlNamespaceCatalogProvider( + provider: CapturedSqlNamespaceCatalogProvider, +): SqlCapturedNamespaceCatalogProviderContext | null { + const captured = capturedProviders.get(provider); + return captured + ? Object.freeze({ + id: captured.id, + search: ( + request: SqlNamespaceCatalogSearchRequest, + signal: AbortSignal, + ): unknown => + Reflect.apply(captured.search, undefined, [request, signal]), + }) + : null; +} + +export function createSqlNamespaceCatalogSearchRequest( + value: unknown, +): SqlNamespaceBoundaryResult { + const source = record( + value, + new Set([ + "dialectId", + "expectedEpoch", + "limit", + "prefix", + "qualifier", + "scope", + "searchPaths", + ]), + ); + if (!source) return malformed("invalid-shape"); + const dialectId = boundedString( + required(source, "dialectId"), + MAX_NAMESPACE_DIALECT_ID_LENGTH, + ); + const scope = boundedString( + required(source, "scope"), + MAX_NAMESPACE_SCOPE_LENGTH, + ); + const expectedValue = required(source, "expectedEpoch"); + const expectedEpoch = expectedValue === null + ? null + : epoch(expectedValue); + const limit = required(source, "limit"); + const prefix = identifier(required(source, "prefix"), true); + const qualifier = identifierPath( + required(source, "qualifier"), + MAX_NAMESPACE_PATH_COMPONENTS, + ); + const paths = searchPaths(required(source, "searchPaths")); + if ( + dialectId === null || + scope === null || + expectedEpoch === null && expectedValue !== null || + typeof limit !== "number" || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > MAX_NAMESPACE_RESULTS || + !prefix || + !qualifier || + !paths + ) { + return malformed("invalid-shape"); + } + return accepted(Object.freeze({ + dialectId, + expectedEpoch, + limit, + prefix, + qualifier, + scope, + searchPaths: paths, + })); +} + +function decodeContainer( + value: unknown, + providerId: string, + scope: string, + responseEpoch: SqlCatalogEpoch, +): SqlNamespaceCatalogResolvedContainer | null { + const source = record( + value, + new Set([ + "canonicalPath", + "containerEntityId", + "detail", + "insertText", + "matchQuality", + ]), + ); + if (!source) return null; + const canonicalPath = namespacePath( + required(source, "canonicalPath"), + ); + const containerEntityId = boundedString( + required(source, "containerEntityId"), + MAX_NAMESPACE_ENTITY_ID_LENGTH, + ); + const insertText = boundedString( + required(source, "insertText"), + MAX_NAMESPACE_INSERT_TEXT_LENGTH, + ); + const matchQuality = required(source, "matchQuality"); + const rawDetail = required(source, "detail"); + const detail = rawDetail === undefined + ? undefined + : boundedString(rawDetail, MAX_NAMESPACE_DETAIL_LENGTH, true); + if ( + !canonicalPath || + containerEntityId === null || + insertText === null || + (matchQuality !== "exact" && matchQuality !== "equivalent") || + detail === null + ) { + return null; + } + return Object.freeze({ + canonicalPath, + containerEntityId, + ...(detail === undefined ? {} : { detail }), + insertText, + matchQuality, + provenance: Object.freeze({ + containerEntityId, + epoch: responseEpoch, + providerId, + scope, + }), + }); +} + +export function decodeSqlNamespaceCatalogSearchResponse( + provider: CapturedSqlNamespaceCatalogProvider, + request: SqlNamespaceCatalogSearchRequest, + value: unknown, +): SqlNamespaceBoundaryResult { + const context = resolveSqlNamespaceCatalogProvider(provider); + if (!context) return malformed("invalid-shape"); + const source = record( + value, + new Set([ + "code", + "containers", + "coverage", + "epoch", + "retry", + "status", + ]), + ); + if (!source) return malformed("invalid-shape"); + const responseEpoch = epoch(required(source, "epoch")); + if ( + !responseEpoch || + request.expectedEpoch !== null && + !sameEpoch(request.expectedEpoch, responseEpoch) + ) { + return malformed("invalid-shape"); + } + const status = required(source, "status"); + if (status === "loading") { + if (source.fields.size !== 2) return malformed("invalid-shape"); + return accepted(Object.freeze({ + epoch: responseEpoch, + status, + })); + } + if (status === "failed") { + const code = required(source, "code"); + const retry = required(source, "retry"); + if ( + source.fields.size !== 4 || + ( + code !== "authentication" && + code !== "authorization" && + code !== "invalid-configuration" && + code !== "rate-limited" && + code !== "unavailable" && + code !== "unknown" + ) || + ( + retry !== "after-invalidation" && + retry !== "never" && + retry !== "next-request" + ) + ) { + return malformed("invalid-shape"); + } + return accepted(Object.freeze({ + code, + epoch: responseEpoch, + retry, + status, + })); + } + const coverage = required(source, "coverage"); + const rawContainers = required(source, "containers"); + const length = arrayLength(rawContainers, request.limit); + if ( + status !== "ready" || + source.fields.size !== 4 || + (coverage !== "complete" && coverage !== "partial") || + length === null + ) { + return malformed("invalid-shape"); + } + const byId = new Map< + string, + SqlNamespaceCatalogResolvedContainer + >(); + for (let index = 0; index < length; index += 1) { + const container = decodeContainer( + arrayElement(rawContainers, index), + context.id, + request.scope, + responseEpoch, + ); + if (!container) return malformed("invalid-shape"); + const prior = byId.get(container.containerEntityId); + if (prior && !sameContainer(prior, container)) { + return malformed("duplicate-entity-id"); + } + byId.set(container.containerEntityId, container); + } + const containers = [...byId.values()].sort((left, right) => + (left.matchQuality === right.matchQuality + ? 0 + : left.matchQuality === "exact" + ? -1 + : 1) || + left.canonicalPath.length - right.canonicalPath.length || + comparePath(left.canonicalPath, right.canonicalPath) || + compareText(left.containerEntityId, right.containerEntityId) + ); + return accepted(Object.freeze({ + containers: Object.freeze(containers), + coverage, + epoch: responseEpoch, + status, + })); +} diff --git a/src/vnext/namespace-catalog-coordinator.ts b/src/vnext/namespace-catalog-coordinator.ts new file mode 100644 index 0000000..ca9e731 --- /dev/null +++ b/src/vnext/namespace-catalog-coordinator.ts @@ -0,0 +1,501 @@ +import { + captureSqlNamespaceCatalogProvider, + createSqlNamespaceCatalogSearchRequest, + decodeSqlNamespaceCatalogSearchResponse, + MAX_NAMESPACE_DIALECT_ID_LENGTH, + MAX_NAMESPACE_SCOPE_LENGTH, + resolveSqlNamespaceCatalogProvider, + type CapturedSqlNamespaceCatalogProvider, + type SqlCapturedNamespaceCatalogProviderContext, +} from "./namespace-catalog-boundary.js"; +import type { + SqlNamespaceCatalogSearchRequest, + SqlNamespaceCatalogSearchResponse, +} from "./namespace-catalog-types.js"; +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, +} from "./types.js"; + +export const DEFAULT_NAMESPACE_CACHE_ENTRIES = 256; +export const MAX_NAMESPACE_CACHE_ENTRIES = 4_096; + +export interface SqlNamespaceCatalogOwnerOptions { + readonly dialectId: string; + readonly scope: string; +} + +export interface SqlNamespaceCatalogSearchInput { + readonly expectedEpoch: SqlCatalogEpoch | null; + readonly limit: number; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +export type SqlNamespaceCatalogSearchOutcome = + | { + readonly providerId: string; + readonly response: SqlNamespaceCatalogSearchResponse; + readonly scope: string; + readonly status: "usable"; + } + | { + readonly status: "cancelled"; + } + | { + readonly status: "superseded"; + } + | { + readonly reason: + | "disposed" + | "invalid-request" + | "malformed-response" + | "provider-failed"; + readonly status: "unavailable"; + }; + +export interface SqlNamespaceCatalogSearchTicket { + readonly cancel: (this: void) => void; + readonly result: Promise; +} + +export interface SqlNamespaceCatalogOwner { + readonly dispose: (this: void) => void; + readonly request: ( + this: void, + input: SqlNamespaceCatalogSearchInput, + ) => SqlNamespaceCatalogSearchTicket; +} + +export type SqlNamespaceCatalogOwnerResult = + | { + readonly owner: SqlNamespaceCatalogOwner; + readonly status: "prepared"; + } + | { + readonly reason: "disposed" | "invalid-options"; + readonly status: "unavailable"; + }; + +export interface SqlNamespaceCatalogCoordinator { + readonly dispose: (this: void) => void; + readonly prepareOwner: ( + this: void, + options: SqlNamespaceCatalogOwnerOptions, + ) => SqlNamespaceCatalogOwnerResult; + readonly providerId: string; +} + +export type SqlNamespaceCatalogCoordinatorResult = + | { + readonly coordinator: SqlNamespaceCatalogCoordinator; + readonly status: "created"; + } + | { + readonly reason: "invalid-options" | "invalid-provider"; + readonly status: "unavailable"; + }; + +interface CoordinatorState { + readonly map: Map; + off: boolean; + readonly id: string; + readonly limit: number; + readonly load: SqlCapturedNamespaceCatalogProviderContext["search"]; + readonly pool: Set; + readonly wire: CapturedSqlNamespaceCatalogProvider; +} + +interface OwnerState { + job: ConsumerState | null; + readonly lang: string; + gen: number; + off: boolean; + rev: SqlCatalogEpoch | null; + root: CoordinatorState | null; + readonly scope: string; +} + +interface ConsumerState { + readonly abort: AbortController; + done: boolean; + end: + | ((value: SqlNamespaceCatalogSearchOutcome) => void) + | null; + readonly host: OwnerState; +} + +const CANCELLED: SqlNamespaceCatalogSearchOutcome = + Object.freeze({ status: "cancelled" }); +const SUPERSEDED: SqlNamespaceCatalogSearchOutcome = + Object.freeze({ status: "superseded" }); + +function unavailable( + reason: Extract< + SqlNamespaceCatalogSearchOutcome, + { readonly status: "unavailable" } + >["reason"], +): SqlNamespaceCatalogSearchOutcome { + return Object.freeze({ reason, status: "unavailable" }); +} + +function property( + value: unknown, + key: string, +): { readonly value: unknown } | null { + if (value === null || typeof value !== "object") return null; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + return descriptor && "value" in descriptor + ? { value: descriptor.value } + : null; +} + +function boundedString(value: unknown, maximum: number): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= maximum + ? value + : null; +} + +function segment(value: string): string { + return `${value.length}:${value}`; +} + +function componentKey(component: SqlIdentifierComponent): string { + return segment( + `${component.quoted ? "q" : "u"}${component.value}`, + ); +} + +function pathKey(path: SqlIdentifierPath): string { + return segment(path.map(componentKey).join("")); +} + +function cacheKey( + request: SqlNamespaceCatalogSearchRequest, + responseEpoch: SqlCatalogEpoch, +): string { + return [ + segment(request.scope), + segment(request.dialectId), + segment(String(responseEpoch.generation)), + segment(responseEpoch.token), + pathKey(request.qualifier), + componentKey(request.prefix), + segment(request.searchPaths.map(pathKey).join("")), + segment(String(request.limit)), + ].join(""); +} + +function cacheGet( + state: CoordinatorState, + key: string, +): SqlNamespaceCatalogSearchResponse | null { + const value = state.map.get(key); + if (!value) return null; + state.map.delete(key); + state.map.set(key, value); + return value; +} + +function cacheSet( + state: CoordinatorState, + key: string, + value: SqlNamespaceCatalogSearchResponse, +): void { + state.map.delete(key); + state.map.set(key, value); + while (state.map.size > state.limit) { + for (const oldest of state.map.keys()) { + state.map.delete(oldest); + break; + } + } +} + +function settle( + consumer: ConsumerState, + outcome: SqlNamespaceCatalogSearchOutcome, +): void { + if (consumer.host.job === consumer) { + consumer.host.job = null; + } + if (consumer.done) return; + consumer.done = true; + const resolve = consumer.end; + consumer.end = null; + resolve?.(outcome); +} + +function cancel( + consumer: ConsumerState, + outcome: SqlNamespaceCatalogSearchOutcome, +): void { + if (consumer.done) return; + consumer.abort.abort(); + settle(consumer, outcome); +} + +function settledTicket( + outcome: SqlNamespaceCatalogSearchOutcome, +): SqlNamespaceCatalogSearchTicket { + return Object.freeze({ + cancel: (): void => {}, + result: Promise.resolve(outcome), + }); +} + +function providerWork( + state: CoordinatorState, + owner: OwnerState, + request: SqlNamespaceCatalogSearchRequest, + consumer: ConsumerState, +): void { + let pending: unknown; + try { + pending = state.load( + request, + consumer.abort.signal, + ); + } catch { + settle(consumer, unavailable("provider-failed")); + return; + } + Promise.resolve(pending).then( + (value) => { + if ( + consumer.done || + consumer.abort.signal.aborted || + state.off || + owner.off || + owner.root !== state + ) { + return; + } + const decoded = decodeSqlNamespaceCatalogSearchResponse( + state.wire, + request, + value, + ); + if (decoded.status === "malformed") { + settle(consumer, unavailable("malformed-response")); + return; + } + owner.rev = decoded.value.epoch; + if ( + decoded.value.status === "ready" && + decoded.value.coverage === "complete" + ) { + cacheSet( + state, + cacheKey(request, decoded.value.epoch), + decoded.value, + ); + } + settle(consumer, Object.freeze({ + providerId: state.id, + response: decoded.value, + scope: owner.scope, + status: "usable", + })); + }, + () => { + if (!consumer.done) { + settle(consumer, unavailable("provider-failed")); + } + }, + ); +} + +function request( + owner: OwnerState, + input: unknown, +): SqlNamespaceCatalogSearchTicket { + const state = owner.root; + if (!state || state.off || owner.off) { + return settledTicket(unavailable("disposed")); + } + const expected = property(input, "expectedEpoch"); + const limit = property(input, "limit"); + const prefix = property(input, "prefix"); + const qualifier = property(input, "qualifier"); + const paths = property(input, "searchPaths"); + if (!expected || !limit || !prefix || !qualifier || !paths) { + return settledTicket(unavailable("invalid-request")); + } + const created = createSqlNamespaceCatalogSearchRequest({ + dialectId: owner.lang, + expectedEpoch: expected.value === null + ? owner.rev + : expected.value, + limit: limit.value, + prefix: prefix.value, + qualifier: qualifier.value, + scope: owner.scope, + searchPaths: paths.value, + }); + if (created.status === "malformed") { + return settledTicket(unavailable("invalid-request")); + } + owner.gen += 1; + const generation = owner.gen; + if (owner.job) cancel(owner.job, SUPERSEDED); + if (generation !== owner.gen) { + return settledTicket( + owner.off ? unavailable("disposed") : SUPERSEDED, + ); + } + const normalized = created.value; + const key = normalized.expectedEpoch === null + ? null + : cacheKey(normalized, normalized.expectedEpoch); + const cached = key === null ? null : cacheGet(state, key); + if (cached) { + return settledTicket(Object.freeze({ + providerId: state.id, + response: cached, + scope: owner.scope, + status: "usable", + })); + } + let resolveResult: ( + value: SqlNamespaceCatalogSearchOutcome + ) => void = (): void => {}; + const result = new Promise( + (resolve) => { + resolveResult = resolve; + }, + ); + const consumer: ConsumerState = { + abort: new AbortController(), + done: false, + end: resolveResult, + host: owner, + }; + owner.job = consumer; + const ticket = Object.freeze({ + cancel: (): void => cancel(consumer, CANCELLED), + result, + }); + providerWork(state, owner, normalized, consumer); + return ticket; +} + +function disposeOwner(owner: OwnerState): void { + if (owner.off) return; + owner.off = true; + owner.gen += 1; + if (owner.job) cancel(owner.job, unavailable("disposed")); + owner.root?.pool.delete(owner); + owner.root = null; +} + +function prepareOwner( + state: CoordinatorState, + value: unknown, +): SqlNamespaceCatalogOwnerResult { + if (state.off) { + return Object.freeze({ reason: "disposed", status: "unavailable" }); + } + const dialectId = boundedString( + property(value, "dialectId")?.value, + MAX_NAMESPACE_DIALECT_ID_LENGTH, + ); + const scope = boundedString( + property(value, "scope")?.value, + MAX_NAMESPACE_SCOPE_LENGTH, + ); + if (!dialectId || !scope) { + return Object.freeze({ + reason: "invalid-options", + status: "unavailable", + }); + } + const owner: OwnerState = { + job: null, + gen: 0, + lang: dialectId, + off: false, + rev: null, + root: state, + scope, + }; + state.pool.add(owner); + return Object.freeze({ + owner: Object.freeze({ + dispose: (): void => disposeOwner(owner), + request: (input: SqlNamespaceCatalogSearchInput) => + request(owner, input), + }), + status: "prepared", + }); +} + +function dispose(state: CoordinatorState): void { + if (state.off) return; + state.off = true; + state.map.clear(); + for (const owner of state.pool) disposeOwner(owner); +} + +export function createSqlNamespaceCatalogCoordinator( + value: unknown, +): SqlNamespaceCatalogCoordinatorResult { + const captured = captureSqlNamespaceCatalogProvider( + property(value, "provider")?.value, + ); + if (captured.status === "malformed") { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const context = resolveSqlNamespaceCatalogProvider(captured.value); + if (!context) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + const rawMaximum = property(value, "maxCacheEntries"); + const maximum = rawMaximum === null + ? DEFAULT_NAMESPACE_CACHE_ENTRIES + : rawMaximum.value; + if ( + typeof maximum !== "number" || + !Number.isSafeInteger(maximum) || + maximum < 1 || + maximum > MAX_NAMESPACE_CACHE_ENTRIES + ) { + return Object.freeze({ + reason: "invalid-options", + status: "unavailable", + }); + } + const state: CoordinatorState = { + id: context.id, + limit: maximum, + load: context.search, + map: new Map(), + off: false, + pool: new Set(), + wire: captured.value, + }; + return Object.freeze({ + coordinator: Object.freeze({ + dispose: (): void => dispose(state), + prepareOwner: (options: SqlNamespaceCatalogOwnerOptions) => + prepareOwner(state, options), + providerId: state.id, + }), + status: "created", + }); +} diff --git a/src/vnext/namespace-catalog-types.ts b/src/vnext/namespace-catalog-types.ts new file mode 100644 index 0000000..bcf62f1 --- /dev/null +++ b/src/vnext/namespace-catalog-types.ts @@ -0,0 +1,108 @@ +import type { SqlCatalogEpoch } from "./relation-completion-types.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlTextRange, +} from "./types.js"; + +export type SqlNamespaceContainerRole = + | "catalog" + | "schema" + | "project" + | "dataset"; + +export interface SqlNamespacePathComponent + extends SqlIdentifierComponent { + readonly role: SqlNamespaceContainerRole; +} + +export type SqlCanonicalNamespacePath = + readonly [ + SqlNamespacePathComponent, + ...SqlNamespacePathComponent[], + ]; + +export interface SqlNamespaceCatalogSearchRequest { + readonly dialectId: string; + readonly expectedEpoch: SqlCatalogEpoch | null; + readonly limit: number; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly scope: string; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +export interface SqlNamespaceCatalogContainer { + readonly canonicalPath: SqlCanonicalNamespacePath; + readonly containerEntityId: string; + readonly detail?: string; + readonly insertText: string; + readonly matchQuality: "equivalent" | "exact"; +} + +export type SqlNamespaceCatalogSearchResponse = + | { + readonly containers: + readonly SqlNamespaceCatalogResolvedContainer[]; + readonly coverage: "complete" | "partial"; + readonly epoch: SqlCatalogEpoch; + readonly status: "ready"; + } + | { + readonly epoch: SqlCatalogEpoch; + readonly status: "loading"; + } + | { + readonly code: + | "authentication" + | "authorization" + | "invalid-configuration" + | "rate-limited" + | "unavailable" + | "unknown"; + readonly epoch: SqlCatalogEpoch; + readonly retry: + | "after-invalidation" + | "never" + | "next-request"; + readonly status: "failed"; + }; + +export type SqlNamespaceCatalogProviderResponse = + | { + readonly containers: readonly SqlNamespaceCatalogContainer[]; + readonly coverage: "complete" | "partial"; + readonly epoch: SqlCatalogEpoch; + readonly status: "ready"; + } + | Extract< + SqlNamespaceCatalogSearchResponse, + { readonly status: "loading" | "failed" } + >; + +export interface SqlNamespaceCatalogProvenance { + readonly containerEntityId: string; + readonly epoch: SqlCatalogEpoch; + readonly providerId: string; + readonly scope: string; +} + +export interface SqlNamespaceCatalogResolvedContainer + extends SqlNamespaceCatalogContainer { + readonly provenance: SqlNamespaceCatalogProvenance; +} + +export interface SqlNamespaceCatalogProvider { + readonly id: string; + readonly search: ( + this: void, + request: SqlNamespaceCatalogSearchRequest, + signal: AbortSignal, + ) => Promise; +} + +export interface SqlNamespaceQuerySite { + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly replacementRange: SqlTextRange; +} diff --git a/src/vnext/namespace-completion.ts b/src/vnext/namespace-completion.ts new file mode 100644 index 0000000..d77215e --- /dev/null +++ b/src/vnext/namespace-completion.ts @@ -0,0 +1,270 @@ +import type { + SqlNamespaceCatalogSearchInput, + SqlNamespaceCatalogSearchOutcome, +} from "./namespace-catalog-coordinator.js"; +import type { + SqlCanonicalNamespacePath, + SqlNamespaceCatalogResolvedContainer, + SqlNamespaceContainerRole, + SqlNamespacePathComponent, + SqlNamespaceQuerySite, +} from "./namespace-catalog-types.js"; +import type { + SqlCatalogEpoch, + SqlNamespaceCatalogProviderReport, + SqlNamespaceCompletionProvenance, +} from "./relation-completion-types.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlTextRange, +} from "./types.js"; + +export interface SqlNamespaceCompletionItem { + readonly detail?: string; + readonly edit: { + readonly from: number; + readonly insert: string; + readonly to: number; + }; + readonly label: string; + readonly role: SqlNamespaceContainerRole; + readonly provenance: SqlNamespaceCompletionProvenance; +} + +export type SqlNamespaceCompletionIssue = + | "namespace-catalog-failed" + | "namespace-catalog-loading" + | "namespace-catalog-malformed" + | "namespace-catalog-partial" + | "namespace-prefix-uncertain" + | "result-limit"; + +export interface SqlNamespaceCompletionList { + readonly isIncomplete: boolean; + readonly issues: readonly SqlNamespaceCompletionIssue[]; + readonly items: readonly SqlNamespaceCompletionItem[]; +} + +export interface SqlNamespaceCompletionComposition { + readonly source: SqlNamespaceCatalogProviderReport; + readonly value: SqlNamespaceCompletionList; +} + +export type SqlNamespacePrefixMatcher = ( + this: void, + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, +) => "match" | "no-match" | "unknown"; + +const ROLE_ORDER: Readonly> = + Object.freeze({ + catalog: 0, + project: 1, + schema: 2, + dataset: 3, + }); + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function finalComponent( + path: SqlCanonicalNamespacePath, +): SqlNamespacePathComponent { + let current = path[0]; + for (const component of path.slice(1)) current = component; + return current; +} + +function pathText( + container: SqlNamespaceCatalogResolvedContainer, +): string { + return container.canonicalPath.map((component) => + `${component.role}:${component.quoted ? "q" : "u"}:${component.value}` + ).join("\u0000"); +} + +function compareContainers( + left: SqlNamespaceCatalogResolvedContainer, + right: SqlNamespaceCatalogResolvedContainer, +): number { + const leftLast = finalComponent(left.canonicalPath); + const rightLast = finalComponent(right.canonicalPath); + return ( + (left.matchQuality === right.matchQuality + ? 0 + : left.matchQuality === "exact" + ? -1 + : 1) || + left.canonicalPath.length - right.canonicalPath.length || + ( + ROLE_ORDER[leftLast.role] - ROLE_ORDER[rightLast.role] + ) || + compareText(leftLast.value, rightLast.value) || + compareText(pathText(left), pathText(right)) || + compareText( + left.provenance.containerEntityId, + right.provenance.containerEntityId, + ) + ); +} + +function list( + items: readonly SqlNamespaceCompletionItem[], + issues: readonly SqlNamespaceCompletionIssue[], +): SqlNamespaceCompletionList { + return Object.freeze({ + isIncomplete: issues.length > 0, + issues: Object.freeze(issues), + items: Object.freeze(items), + }); +} + +export function prepareSqlNamespaceCatalogSearch( + site: SqlNamespaceQuerySite, + expectedEpoch: SqlCatalogEpoch | null, + searchPaths: readonly SqlIdentifierPath[], + limit: number, +): SqlNamespaceCatalogSearchInput { + return Object.freeze({ + expectedEpoch, + limit, + prefix: site.prefix, + qualifier: site.qualifier, + searchPaths, + }); +} + +function unavailable( + providerId: string, + reason: Extract< + SqlNamespaceCatalogSearchOutcome, + { readonly status: "unavailable" } + >["reason"], +): SqlNamespaceCompletionComposition { + return Object.freeze({ + source: Object.freeze({ + feature: "namespace-catalog", + outcome: "unavailable", + providerId, + reason, + }), + value: list( + [], + [reason === "malformed-response" + ? "namespace-catalog-malformed" + : "namespace-catalog-failed"], + ), + }); +} + +function item( + container: SqlNamespaceCatalogResolvedContainer, + replacementRange: SqlTextRange, +): SqlNamespaceCompletionItem { + const component = finalComponent(container.canonicalPath); + return Object.freeze({ + ...(container.detail === undefined + ? {} + : { detail: container.detail }), + edit: Object.freeze({ + from: replacementRange.from, + insert: container.insertText, + to: replacementRange.to, + }), + label: component.value, + provenance: Object.freeze({ + containerEntityId: container.provenance.containerEntityId, + epoch: container.provenance.epoch, + kind: "namespace-catalog", + providerId: container.provenance.providerId, + scope: container.provenance.scope, + }), + role: component.role, + }); +} + +export function composeSqlNamespaceCompletion( + input: { + readonly matchPrefix: SqlNamespacePrefixMatcher; + readonly outcome: SqlNamespaceCatalogSearchOutcome; + readonly prefix: SqlIdentifierComponent; + readonly providerId: string; + readonly replacementRange: SqlTextRange; + }, +): SqlNamespaceCompletionComposition | null { + if ( + input.outcome.status === "cancelled" || + input.outcome.status === "superseded" + ) { + return null; + } + if (input.outcome.status === "unavailable") { + return unavailable(input.providerId, input.outcome.reason); + } + const response = input.outcome.response; + if (response.status === "loading") { + return Object.freeze({ + source: Object.freeze({ + feature: "namespace-catalog", + outcome: "loading", + providerId: input.outcome.providerId, + }), + value: list([], ["namespace-catalog-loading"]), + }); + } + if (response.status === "failed") { + return Object.freeze({ + source: Object.freeze({ + code: response.code, + feature: "namespace-catalog", + outcome: "failed", + providerId: input.outcome.providerId, + retry: response.retry, + }), + value: list([], ["namespace-catalog-failed"]), + }); + } + const issues = new Set(); + if (response.coverage === "partial") { + issues.add("namespace-catalog-partial"); + } + const seen = new Set(); + const containers: SqlNamespaceCatalogResolvedContainer[] = []; + for (const container of response.containers) { + const component = finalComponent(container.canonicalPath); + let match: ReturnType; + try { + match = input.matchPrefix(component, input.prefix); + } catch { + match = "unknown"; + } + if (match === "unknown") { + issues.add("namespace-prefix-uncertain"); + continue; + } + if (match === "no-match") continue; + const identity = [ + container.provenance.providerId, + container.provenance.scope, + container.provenance.containerEntityId, + ].join("\u0000"); + if (seen.has(identity)) continue; + seen.add(identity); + containers.push(container); + } + containers.sort(compareContainers); + const items = containers.map((container) => + item(container, input.replacementRange) + ); + return Object.freeze({ + source: Object.freeze({ + coverage: response.coverage, + feature: "namespace-catalog", + outcome: "ready", + providerId: input.outcome.providerId, + }), + value: list(items, [...issues]), + }); +} diff --git a/src/vnext/node-sql-parser-adapter.ts b/src/vnext/node-sql-parser-adapter.ts index 86d80d6..197aa4f 100644 --- a/src/vnext/node-sql-parser-adapter.ts +++ b/src/vnext/node-sql-parser-adapter.ts @@ -5,6 +5,11 @@ import { type NodeSqlParserModuleLoadOutcome, type NodeSqlParserModuleLoader, } from "./node-sql-parser-backend.js"; +import { + normalizeNodeSqlParserQueryBindings, + type NodeSqlParserQueryBindingGrammar, +} from "./node-sql-parser-query-bindings.js"; +import type { SqlQueryBindingModel } from "./query-binding-model.js"; import { createCompatibilityParsedAnalysis, createFailedParserAnalysis, @@ -39,6 +44,7 @@ type NodeSqlParserPolicy = interface NodeSqlParserRuntime { readonly authority: SqlParserAuthority; readonly backend: NodeSqlParserBackend; + readonly grammar: NodeSqlParserQueryBindingGrammar; readonly limitations: readonly [ SqlCompatibilityLimitation, ...SqlCompatibilityLimitation[], @@ -61,7 +67,8 @@ type OwnDataProperty = class NodeSqlParserGlobalCleanupError extends Error {} class NodeSqlParserExecutionRealmError extends Error {} -const backendPayloads = new WeakMap(); +const queryBindingModels = + new WeakMap(); function readOwnDataProperty( value: object, @@ -307,7 +314,18 @@ function materializeBackendOutcome( statementText, runtime.authority, ); - backendPayloads.set(artifact, outcome.root); + const bindings = normalizeNodeSqlParserQueryBindings( + outcome.root, + statementText, + runtime.authority, + { + compatibility: runtime.policy === "dialect-compatibility", + grammar: runtime.grammar, + }, + ); + if (bindings.status === "ready") { + queryBindingModels.set(artifact, bindings.model); + } return createCompatibilityParsedAnalysis( artifact, runtime.limitations, @@ -349,6 +367,7 @@ function createAdapter(runtime: NodeSqlParserRuntime): SqlStatementParser { function createRuntime( backendIdentity: SqlSyntaxBackendIdentity, backend: NodeSqlParserBackend, + grammar: NodeSqlParserQueryBindingGrammar, policy: NodeSqlParserPolicy, ): NodeSqlParserRuntime { const authority = createSqlParserAuthority( @@ -359,6 +378,7 @@ function createRuntime( return Object.freeze({ authority, backend, + grammar, limitations: policy === "dialect-compatibility" ? DIALECT_COMPATIBILITY_LIMITATIONS @@ -378,6 +398,7 @@ const postgresqlParser = createAdapter( createRuntime( postgresqlBackendIdentity, postgresqlBackend, + "postgresql", "target-grammar", ), ); @@ -385,6 +406,7 @@ const bigQueryParser = createAdapter( createRuntime( bigQueryBackendIdentity, bigQueryBackend, + "bigquery", "target-grammar", ), ); @@ -392,6 +414,7 @@ const duckDbParser = createAdapter( createRuntime( postgresqlBackendIdentity, postgresqlBackend, + "postgresql", "dialect-compatibility", ), ); @@ -449,12 +472,19 @@ export const exportedForTesting = Object.freeze({ createNodeSqlParserBackend( adaptModuleLoaderForTesting(loadModule), ), + "postgresql", policy, ), ); }, - hasBackendPayload(artifact: SqlSyntaxArtifact): boolean { - return backendPayloads.has(artifact); + hasBackendPayload(_artifact: SqlSyntaxArtifact): boolean { + return false; }, createSynchronousModuleLoader, }); + +export function getNodeSqlParserQueryBindingModel( + artifact: SqlSyntaxArtifact, +): SqlQueryBindingModel | null { + return queryBindingModels.get(artifact) ?? null; +} diff --git a/src/vnext/node-sql-parser-browser-executor.ts b/src/vnext/node-sql-parser-browser-executor.ts index f250ec6..0c02e35 100644 --- a/src/vnext/node-sql-parser-browser-executor.ts +++ b/src/vnext/node-sql-parser-browser-executor.ts @@ -7,6 +7,7 @@ import { type NodeSqlParserWireFailureCode, type NodeSqlParserWireGrammar, } from "./node-sql-parser-wire.js"; +import type { SqlQueryBindingModelData } from "./query-binding-model.js"; import type { SqlStatementKind } from "./syntax.js"; export interface NodeSqlParserBrowserExecutorLimits { @@ -68,6 +69,7 @@ export type NodeSqlParserBrowserExecutorFailureCode = export type NodeSqlParserBrowserExecutorOutcome = | { readonly kind: "parsed"; + readonly queryBindings: SqlQueryBindingModelData | null; readonly statementKind: SqlStatementKind; } | { @@ -836,6 +838,7 @@ export function createNodeSqlParserBrowserExecutor( request, Object.freeze({ kind: "parsed", + queryBindings: message.queryBindings, statementKind: message.statementKind, }), ); diff --git a/src/vnext/node-sql-parser-browser-worker-endpoint.ts b/src/vnext/node-sql-parser-browser-worker-endpoint.ts index 7023fbe..8af377f 100644 --- a/src/vnext/node-sql-parser-browser-worker-endpoint.ts +++ b/src/vnext/node-sql-parser-browser-worker-endpoint.ts @@ -3,6 +3,10 @@ import { type NodeSqlParserBackendOutcome, type NodeSqlParserModuleLoadOutcome, } from "./node-sql-parser-backend.js"; +import { + normalizeNodeSqlParserQueryBindings, +} from "./node-sql-parser-query-bindings.js"; +import type { SqlQueryBindingModel } from "./query-binding-model.js"; import { decodeNodeSqlParserWireRequest, encodeNodeSqlParserWireBackendOutcome, @@ -250,6 +254,10 @@ export function installNodeSqlParserBrowserWorkerEndpoint( createModuleLoader(loaders.postgresql), ), } satisfies Record); + const bindingAuthorities = Object.freeze({ + bigquery: Object.freeze({}), + postgresql: Object.freeze({}), + } satisfies Record); function closeEndpoint(): void { state = "closed"; @@ -293,11 +301,27 @@ export function installNodeSqlParserBrowserWorkerEndpoint( guard, ); state = closeAfterSettlement ? "closed" : "idle"; + let queryBindings: SqlQueryBindingModel | null = null; + if (outcome.kind === "parsed") { + const normalized = normalizeNodeSqlParserQueryBindings( + outcome.root, + request.text, + bindingAuthorities[request.grammar], + { + compatibility: false, + grammar: request.grammar, + }, + ); + if (normalized.status === "ready") { + queryBindings = normalized.model; + } + } try { scope.postMessage( encodeNodeSqlParserWireBackendOutcome( request.requestId, outcome, + queryBindings, ), ); } catch { diff --git a/src/vnext/node-sql-parser-query-bindings.ts b/src/vnext/node-sql-parser-query-bindings.ts new file mode 100644 index 0000000..786849e --- /dev/null +++ b/src/vnext/node-sql-parser-query-bindings.ts @@ -0,0 +1,1251 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme, +} from "./bounded-sql-lexer.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "./lexical.js"; +import { + createSqlQueryBindingModel, + MAX_QUERY_BINDING_BLOCKS, + MAX_QUERY_BINDINGS, + type SqlQueryBindingIssue, + type SqlQueryBindingModel, + type SqlRelationBinding, + type SqlRelationScope, + type SqlVisibilityRegion, +} from "./query-binding-model.js"; +import { createIdentitySqlSource } from "./source.js"; +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlTextRange, +} from "./types.js"; + +export type NodeSqlParserQueryBindingGrammar = + | "bigquery" + | "postgresql"; + +export interface NodeSqlParserQueryBindingOptions { + readonly compatibility: boolean; + readonly grammar: NodeSqlParserQueryBindingGrammar; +} + +export type NodeSqlParserQueryBindingResult = + | { + readonly model: SqlQueryBindingModel; + readonly status: "ready"; + } + | { + readonly reason: + | "malformed-ast" + | "not-query" + | "resource-limit" + | "unsupported-shape"; + readonly status: "unavailable"; + }; + +interface Token extends BoundedSqlLexeme { + readonly depth: number; + readonly text: string; +} + +interface SelectSkeleton { + readonly depth: number; + readonly from: number; + readonly selectToken: number; + readonly to: number; +} + +interface AstSelect { + readonly cteChildren: AstSelect[]; + readonly derivedChildren: AstSelect[]; + readonly kind: "compound" | "select"; + readonly node: object; + skeleton: SelectSkeleton | null; +} + +interface RelationSite { + readonly aliasToken: Token | null; + readonly derived: boolean; + readonly explicitAlias: boolean; + readonly from: number; + readonly lexicalIdentifiers: readonly SqlIdentifierComponent[]; + readonly to: number; +} + +interface BlockBuild { + readonly ast: AstSelect; + readonly id: number; + readonly parent: number | null; + readonly range: SqlTextRange; + readonly skeleton: SelectSkeleton; +} + +interface MutableCoverage { + queryBlocks: "complete" | "partial"; + relationBindings: "complete" | "partial"; + visibility: "complete" | "partial"; +} + +function phaseValue( + values: readonly Value[], + index: number, +): Value { + const value = values[index]; + if (value === undefined) { + throw new Error("Query binding normalization phase invariant failed"); + } + return value; +} + +function assignedSkeleton(ast: AstSelect): SelectSkeleton { + if (ast.skeleton === null) { + throw new Error("Query binding skeleton assignment invariant failed"); + } + return ast.skeleton; +} + +type OwnProperty = + | { readonly kind: "invalid" | "missing" } + | { readonly kind: "value"; readonly value: unknown }; + +const MAX_AST_ARRAY_LENGTH = 1_024; +const MAX_AST_PROPERTY_TEXT = 2_048; + +function ownProperty(value: object, key: PropertyKey): OwnProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { kind: "missing" }; + } + if (!("value" in descriptor)) { + return { kind: "invalid" }; + } + return { kind: "value", value: descriptor.value }; + } catch { + return { kind: "invalid" }; + } +} + +function objectProperty(value: object, key: PropertyKey): object | null { + const property = ownProperty(value, key); + return property.kind === "value" && + property.value !== null && + typeof property.value === "object" + ? property.value + : null; +} + +function stringProperty(value: object, key: PropertyKey): string | null { + const property = ownProperty(value, key); + return property.kind === "value" && + typeof property.value === "string" && + property.value.length > 0 && + property.value.length <= MAX_AST_PROPERTY_TEXT + ? property.value + : null; +} + +function arrayProperty( + value: object, + key: PropertyKey, +): readonly unknown[] | null { + const property = ownProperty(value, key); + if (property.kind === "missing") { + return []; + } + if (property.kind !== "value") { + return null; + } + if (property.value === null) { + return []; + } + try { + if (!Array.isArray(property.value)) { + return null; + } + } catch { + return null; + } + const length = ownProperty(property.value, "length"); + if ( + length.kind !== "value" || + typeof length.value !== "number" || + !Number.isSafeInteger(length.value) || + length.value < 0 || + length.value > MAX_AST_ARRAY_LENGTH + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const item = ownProperty(property.value, index); + if (item.kind !== "value") { + return null; + } + items.push(item.value); + } + return items; +} + +function word(token: Token | undefined, expected: string): boolean { + return ( + token?.kind === "word" && + token.text.toLowerCase() === expected + ); +} + +function tokenize( + text: string, + grammar: NodeSqlParserQueryBindingGrammar, +): readonly Token[] | null { + const source = createIdentitySqlSource(text); + const lexer = new BoundedSqlLexer( + source, + 0, + text.length, + grammar === "bigquery" + ? BIGQUERY_SQL_LEXICAL_PROFILE + : POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + const tokens: Token[] = []; + let depth = 0; + for (;;) { + const lexeme = lexer.next(); + if (lexeme === null) { + return lexer.resource === null ? tokens : null; + } + if ( + lexeme.kind === "comment" || + lexeme.kind === "line-comment" + ) { + continue; + } + const raw = text.slice(lexeme.from, lexeme.to); + if (raw === ")") { + depth -= 1; + if (depth < 0) { + return null; + } + } + tokens.push(Object.freeze({ + ...lexeme, + depth, + text: raw, + })); + if (raw === "(") { + depth += 1; + if (depth > MAX_QUERY_BINDING_BLOCKS) { + return null; + } + } + } +} + +function selectSkeletons( + tokens: readonly Token[], + statementLength: number, +): readonly SelectSkeleton[] { + const skeletons: SelectSkeleton[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token || !word(token, "select")) { + continue; + } + let to = statementLength; + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + const candidate = tokens[cursor]; + if ( + candidate?.text === ")" && + candidate.depth < token.depth + ) { + to = candidate.from; + break; + } + } + let from = 0; + if (token.depth > 0) { + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + const candidate = tokens[cursor]; + if ( + candidate?.text !== "(" || + candidate.depth !== token.depth - 1 + ) { + continue; + } + const close = matchingClose(tokens, cursor, candidate.depth); + if (close !== null && phaseValue(tokens, close).from < token.from) { + continue; + } + from = candidate.to; + break; + } + } + skeletons.push(Object.freeze({ + depth: token.depth, + from, + selectToken: index, + to, + })); + } + return skeletons; +} + +function astSelectNode(value: unknown): object | null { + if (value === null || typeof value !== "object") { + return null; + } + const type = stringProperty(value, "type"); + return type !== null && + (type.toLowerCase() === "select" || + type.toLowerCase() === "union" || + type.toLowerCase() === "intersect" || + type.toLowerCase() === "except") + ? value + : null; +} + +function astSelectKind(value: object): "compound" | "select" { + return stringProperty(value, "type")?.toLowerCase() === "select" + ? "select" + : "compound"; +} + +function derivedAst(value: object): object | null { + const expression = objectProperty(value, "expr"); + if (expression === null) { + return null; + } + return astSelectNode(objectProperty(expression, "ast")); +} + +function buildAstTree( + root: object, +): { readonly root: AstSelect; readonly textual: readonly AstSelect[] } | null { + const seen = new WeakSet(); + const textual: AstSelect[] = []; + let count = 0; + + function visit(node: object): AstSelect | null { + if (seen.has(node) || count >= MAX_QUERY_BINDING_BLOCKS) { + return null; + } + seen.add(node); + count += 1; + const result: AstSelect = { + cteChildren: [], + derivedChildren: [], + kind: astSelectKind(node), + node, + skeleton: null, + }; + const withItems = arrayProperty(node, "with"); + if (withItems === null) { + return null; + } + for (const item of withItems) { + if (item === null || typeof item !== "object") { + return null; + } + const statement = astSelectNode(objectProperty(item, "stmt")); + if (statement === null) { + return null; + } + const child = visit(statement); + if (child === null) { + return null; + } + result.cteChildren.push(child); + } + textual.push(result); + const fromItems = arrayProperty(node, "from"); + if (fromItems === null) { + return null; + } + for (const item of fromItems) { + if (item === null || typeof item !== "object") { + return null; + } + const statement = derivedAst(item); + if (statement !== null) { + const child = visit(statement); + if (child === null) { + return null; + } + result.derivedChildren.push(child); + } + } + return result; + } + + const tree = visit(root); + return tree === null ? null : { root: tree, textual }; +} + +function assignSkeletons( + tree: { readonly root: AstSelect; readonly textual: readonly AstSelect[] }, + skeletons: readonly SelectSkeleton[], +): boolean { + if (tree.textual.length > skeletons.length) { + return false; + } + for (let index = 0; index < tree.textual.length; index += 1) { + const ast = phaseValue(tree.textual, index); + const skeleton = phaseValue(skeletons, index); + ast.skeleton = skeleton; + } + return true; +} + +function emitBlocks( + root: AstSelect, + statementLength: number, +): { + readonly blocks: readonly BlockBuild[]; + readonly ids: ReadonlyMap; +} | null { + const blocks: BlockBuild[] = []; + const ids = new Map(); + function emit(ast: AstSelect, parent: number | null): void { + const skeleton = assignedSkeleton(ast); + const id = blocks.length; + ids.set(ast.node, id); + blocks.push({ + ast, + id, + parent, + range: Object.freeze( + parent === null + ? { from: 0, to: statementLength } + : { from: skeleton.from, to: skeleton.to }, + ), + skeleton, + }); + for (const child of [...ast.cteChildren, ...ast.derivedChildren]) { + emit(child, id); + } + } + try { + emit(root, null); + return { blocks, ids }; + } catch { + return null; + } +} + +function tokenIdentifier(token: Token): SqlIdentifierComponent | null { + if (token.kind === "word") { + return Object.freeze({ quoted: false, value: token.text }); + } + if (token.kind !== "quoted-identifier" || token.text.length < 2) { + return null; + } + const quote = token.text[0]; + const value = token.text + .slice(1, -1) + .split(`${quote}${quote}`) + .join(quote); + return value.length === 0 + ? null + : Object.freeze({ quoted: true, value }); +} + +function matchingClose( + tokens: readonly Token[], + openIndex: number, + openDepth: number, +): number | null { + for (let index = openIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token?.text === ")" && token.depth === openDepth) { + return index; + } + } + return null; +} + +function relationSites( + tokens: readonly Token[], + skeleton: SelectSkeleton, +): readonly RelationSite[] { + const sites: RelationSite[] = []; + let inFrom = false; + for ( + let index = skeleton.selectToken + 1; + index < tokens.length; + index += 1 + ) { + const token = tokens[index]; + if (!token || token.from >= skeleton.to) { + break; + } + if (token.depth !== skeleton.depth) { + continue; + } + const lower = token.kind === "word" + ? token.text.toLowerCase() + : ""; + if ( + lower === "where" || + lower === "group" || + lower === "having" || + lower === "qualify" || + lower === "order" || + lower === "limit" || + lower === "window" || + lower === "union" || + lower === "intersect" || + lower === "except" + ) { + inFrom = false; + } + const startsRelation = + lower === "from" || + lower === "join" || + (inFrom && token.text === ","); + if (!startsRelation) { + continue; + } + inFrom = true; + const startIndex = index + 1; + const start = tokens[startIndex]; + if (!start) { + continue; + } + let endIndex = startIndex; + let derived = false; + if (start.text === "(") { + const close = matchingClose(tokens, startIndex, start.depth); + if (close === null) { + continue; + } + endIndex = close; + derived = true; + } else { + while (endIndex + 2 < tokens.length) { + const dot = tokens[endIndex + 1]; + const component = tokens[endIndex + 2]; + if ( + dot?.text !== "." || + component === undefined || + tokenIdentifier(component) === null + ) { + break; + } + endIndex += 2; + } + } + const lexicalIdentifiers: SqlIdentifierComponent[] = []; + if (!derived) { + for ( + let componentIndex = startIndex; + componentIndex <= endIndex; + componentIndex += 1 + ) { + const componentToken = phaseValue(tokens, componentIndex); + const identifier = tokenIdentifier(componentToken); + if (identifier !== null) { + lexicalIdentifiers.push(identifier); + } + } + } + let aliasToken: Token | null = null; + let cursor = endIndex + 1; + const explicitAlias = word(tokens[cursor], "as"); + if (explicitAlias) { + cursor += 1; + } + const possibleAlias = tokens[cursor]; + if ( + possibleAlias !== undefined && + possibleAlias.depth === skeleton.depth && + tokenIdentifier(possibleAlias) !== null && + ![ + "cross", "full", "inner", "join", "left", "natural", + "on", "right", "using", "where", "group", "having", + "qualify", "order", "limit", "window", + ].includes(possibleAlias.text.toLowerCase()) + ) { + aliasToken = possibleAlias; + endIndex = cursor; + } + const end = phaseValue(tokens, endIndex); + sites.push(Object.freeze({ + aliasToken, + derived, + explicitAlias, + from: start.from, + lexicalIdentifiers: Object.freeze(lexicalIdentifiers), + to: end.to, + })); + } + return sites; +} + +function pathFromAst( + relation: object, + grammar: NodeSqlParserQueryBindingGrammar, + site: RelationSite, +): SqlIdentifierPath | null { + const table = stringProperty(relation, "table"); + if (table === null) { + return null; + } + const values: string[] = []; + const database = + stringProperty(relation, "db") ?? + stringProperty(relation, "schema"); + if (database !== null) { + values.push(database); + } + if (grammar === "bigquery") { + values.push(...table.split(".")); + } else { + values.push(table); + } + if ( + values.length === 0 || + values.length > 8 || + values.some((value) => value.length === 0 || value.length > 256) + ) { + return null; + } + const lexicalIdentifiers = site.lexicalIdentifiers; + const singleQuotedBigQueryPath = + grammar === "bigquery" && + lexicalIdentifiers.length === 1 && + lexicalIdentifiers[0]?.quoted === true; + if (singleQuotedBigQueryPath) { + if (lexicalIdentifiers[0]?.value !== values.join(".")) { + return null; + } + return Object.freeze(values.map((value) => + Object.freeze({ quoted: true, value }) + )); + } + if ( + values.length !== lexicalIdentifiers.length || + values.some((value, index) => { + const lexical = lexicalIdentifiers[index]; + return lexical === undefined || + !identifierMatchesAst(value, lexical); + }) + ) { + return null; + } + return Object.freeze([...lexicalIdentifiers]); +} + +interface CteDeclaration { + readonly name: SqlIdentifierComponent; + readonly range: SqlTextRange; +} + +interface CteClause { + readonly declarations: readonly CteDeclaration[]; + readonly recursion: "authenticated" | "none" | "uncertain"; +} + +interface CteEnvironment { + readonly uncertain: ReadonlySet; + readonly visible: ReadonlyMap; +} + +interface IdentifierToken { + readonly identifier: SqlIdentifierComponent; + readonly token: Token; +} + +function identifierMatchesAst( + astValue: string, + lexical: SqlIdentifierComponent, +): boolean { + return lexical.quoted + ? astValue === lexical.value + : astValue.toLowerCase() === lexical.value.toLowerCase(); +} + +function identifierKey(identifier: SqlIdentifierComponent): string { + return identifier.quoted + ? `quoted:${identifier.value}` + : `unquoted:${identifier.value.toLowerCase()}`; +} + +function findIdentifierToken( + tokens: readonly Token[], + from: number, + to: number, + depth: number, + astName: string, +): IdentifierToken | null { + for (const token of tokens) { + const identifier = tokenIdentifier(token); + if ( + token.from >= from && + token.from < to && + token.depth === depth && + identifier !== null && + identifierMatchesAst(astName, identifier) + ) { + return { identifier, token }; + } + } + return null; +} + +function astRecursiveMarker(item: object): boolean | null { + const property = ownProperty(item, "recursive"); + if (property.kind === "missing") { + return false; + } + return property.kind === "value" && + typeof property.value === "boolean" + ? property.value + : null; +} + +function ownCteDeclarations( + ast: AstSelect, + tokens: readonly Token[], +): CteClause | null { + const withItems = arrayProperty(ast.node, "with"); + if (withItems === null) { + return null; + } + if (withItems.length !== ast.cteChildren.length) { + return null; + } + const declarations: CteDeclaration[] = []; + let astRecursive = false; + let validMarkers = true; + let searchFrom = assignedSkeleton(ast).from; + for (let index = 0; index < withItems.length; index += 1) { + const item = withItems[index]; + const child = phaseValue(ast.cteChildren, index); + if (item === null || typeof item !== "object") { + return null; + } + const recursive = astRecursiveMarker(item); + if (recursive === null || (index > 0 && recursive)) { + validMarkers = false; + } else if (index === 0) { + astRecursive = recursive; + } + const nameObject = objectProperty(item, "name"); + const name = nameObject === null + ? stringProperty(item, "name") + : stringProperty(nameObject, "value"); + if (name === null) { + return null; + } + const declaration = findIdentifierToken( + tokens, + searchFrom, + assignedSkeleton(child).from, + assignedSkeleton(ast).depth, + name, + ); + if (declaration === null) { + return null; + } + declarations.push(Object.freeze({ + name: declaration.identifier, + range: Object.freeze({ + from: declaration.token.from, + to: declaration.token.to, + }), + })); + searchFrom = assignedSkeleton(child).to; + } + if (declarations.length === 0) { + return Object.freeze({ + declarations: Object.freeze(declarations), + recursion: "none", + }); + } + const first = phaseValue(declarations, 0); + const withIndex = tokens.findIndex((token) => + token.from >= assignedSkeleton(ast).from && + token.from < first.range.from && + token.depth === assignedSkeleton(ast).depth && + word(token, "with") + ); + if (withIndex < 0) { + return null; + } + const lexicalRecursive = word(tokens[withIndex + 1], "recursive"); + return Object.freeze({ + declarations: Object.freeze(declarations), + recursion: + validMarkers && lexicalRecursive === astRecursive + ? lexicalRecursive ? "authenticated" : "none" + : "uncertain", + }); +} + +function visibleCteDeclarations( + root: AstSelect, + tokens: readonly Token[], +): ReadonlyMap | null { + const result = new Map(); + function visit( + ast: AstSelect, + inherited: CteEnvironment, + ): boolean { + const clause = ownCteDeclarations(ast, tokens); + if (clause === null) { + return false; + } + const visible = new Map(inherited.visible); + const uncertain = new Set(inherited.uncertain); + for (let index = 0; index < ast.cteChildren.length; index += 1) { + const child = phaseValue(ast.cteChildren, index); + const declaration = phaseValue(clause.declarations, index); + const childVisible = new Map(visible); + const childUncertain = new Set(uncertain); + if (clause.recursion === "authenticated") { + childVisible.set(identifierKey(declaration.name), declaration); + for ( + let forward = index + 1; + forward < clause.declarations.length; + forward += 1 + ) { + const key = identifierKey( + phaseValue(clause.declarations, forward).name, + ); + childUncertain.add(key); + childVisible.delete(key); + } + } else if (clause.recursion === "uncertain") { + for (const candidate of clause.declarations) { + const key = identifierKey(candidate.name); + childUncertain.add(key); + childVisible.delete(key); + } + } + if (!visit(child, { + uncertain: childUncertain, + visible: childVisible, + })) { + return false; + } + visible.set(identifierKey(declaration.name), declaration); + uncertain.delete(identifierKey(declaration.name)); + } + const environment = { uncertain, visible }; + result.set(ast.node, environment); + for (const child of ast.derivedChildren) { + if (!visit(child, environment)) { + return false; + } + } + return true; + } + return visit(root, { + uncertain: new Set(), + visible: new Map(), + }) ? result : null; +} + +function addIssue( + issues: SqlQueryBindingIssue[], + code: SqlQueryBindingIssue["code"], + range: SqlTextRange, +): void { + if ( + issues.length < 256 && + !issues.some((issue) => + issue.code === code && + issue.range.from === range.from && + issue.range.to === range.to + ) + ) { + issues.push(Object.freeze({ code, range })); + } +} + +function clauseRegions( + tokens: readonly Token[], + block: BlockBuild, + fullScope: number, +): SqlVisibilityRegion[] { + const markers: { + readonly from: number; + readonly kind: SqlVisibilityRegion["kind"]; + readonly to: number; + }[] = []; + const start = block.skeleton.selectToken; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token || token.from >= block.skeleton.to) { + break; + } + if (token.depth !== block.skeleton.depth || token.kind !== "word") { + continue; + } + const value = token.text.toLowerCase(); + const kind = + value === "select" ? "select-list" + : value === "where" ? "where" + : value === "group" && word(tokens[index + 1], "by") ? "group-by" + : value === "having" ? "having" + : value === "qualify" ? "qualify" + : value === "order" && word(tokens[index + 1], "by") ? "order-by" + : value === "limit" ? "limit" + : null; + if (kind !== null) { + markers.push({ from: token.from, kind, to: token.to }); + } + } + const fromToken = tokens.find((token) => + token.depth === block.skeleton.depth && + token.from > block.skeleton.from && + token.from < block.skeleton.to && + word(token, "from") + ); + const result: SqlVisibilityRegion[] = []; + for (let index = 0; index < markers.length; index += 1) { + const marker = phaseValue(markers, index); + const next = markers[index + 1]; + const to = + marker.kind === "select-list" && fromToken !== undefined + ? fromToken.from + : next?.from ?? block.skeleton.to; + if (marker.to < to) { + result.push(Object.freeze({ + block: block.id, + kind: marker.kind, + range: Object.freeze({ from: marker.to, to }), + scope: fullScope, + })); + } + } + return result; +} + +function joinRegions( + tokens: readonly Token[], + block: BlockBuild, + sites: readonly RelationSite[], + scopesAfterRelations: readonly number[], +): SqlVisibilityRegion[] { + const result: SqlVisibilityRegion[] = []; + for (let relationIndex = 1; relationIndex < sites.length; relationIndex += 1) { + const site = phaseValue(sites, relationIndex); + const scope = scopesAfterRelations[relationIndex]; + if (scope === undefined) { + continue; + } + let conditionIndex = -1; + for (let index = 0; index < tokens.length; index += 1) { + const candidate = phaseValue(tokens, index); + if ( + candidate.from < site.to || + candidate.depth !== block.skeleton.depth + ) { + continue; + } + if (candidate.from >= block.skeleton.to) { + break; + } + if (word(candidate, "on") || word(candidate, "using")) { + conditionIndex = index; + break; + } + if ( + word(candidate, "join") || + candidate.text === "," || + word(candidate, "where") || + word(candidate, "group") || + word(candidate, "having") || + word(candidate, "qualify") || + word(candidate, "order") || + word(candidate, "limit") + ) { + break; + } + } + const token = tokens[conditionIndex]; + if (token === undefined) { + continue; + } + let to = block.skeleton.to; + for (let cursor = conditionIndex + 1; cursor < tokens.length; cursor += 1) { + const next = phaseValue(tokens, cursor); + if (next.from >= block.skeleton.to) { + break; + } + if ( + next.depth === block.skeleton.depth && + (word(next, "join") || + next.text === "," || + word(next, "where") || + word(next, "group") || + word(next, "having") || + word(next, "qualify") || + word(next, "order") || + word(next, "limit")) + ) { + to = next.from; + break; + } + } + if (token.to < to) { + result.push(Object.freeze({ + block: block.id, + kind: "join-condition", + range: Object.freeze({ from: token.to, to }), + scope, + })); + } + } + return result; +} + +export function normalizeNodeSqlParserQueryBindings( + root: unknown, + statementText: unknown, + authority: unknown, + options: NodeSqlParserQueryBindingOptions, +): NodeSqlParserQueryBindingResult { + if ( + typeof statementText !== "string" || + statementText.length === 0 || + statementText.length > 16 * 1024 || + authority === null || + typeof authority !== "object" + ) { + return Object.freeze({ reason: "resource-limit", status: "unavailable" }); + } + const astRoot = astSelectNode(root); + if (astRoot === null) { + return Object.freeze({ reason: "not-query", status: "unavailable" }); + } + const tokens = tokenize(statementText, options.grammar); + if (tokens === null) { + return Object.freeze({ reason: "resource-limit", status: "unavailable" }); + } + const skeletons = selectSkeletons(tokens, statementText.length); + const tree = buildAstTree(astRoot); + if (tree === null) { + return Object.freeze({ reason: "malformed-ast", status: "unavailable" }); + } + if (!assignSkeletons(tree, skeletons)) { + return Object.freeze({ reason: "unsupported-shape", status: "unavailable" }); + } + const emitted = emitBlocks(tree.root, statementText.length); + if (emitted === null) { + return Object.freeze({ reason: "unsupported-shape", status: "unavailable" }); + } + const declarations = visibleCteDeclarations(tree.root, tokens); + if (declarations === null) { + return Object.freeze({ reason: "malformed-ast", status: "unavailable" }); + } + + const coverage: MutableCoverage = { + queryBlocks: + skeletons.length === tree.textual.length ? "complete" : "partial", + relationBindings: "complete", + visibility: "complete", + }; + const issues: SqlQueryBindingIssue[] = []; + const statementRange = Object.freeze({ from: 0, to: statementText.length }); + if (options.compatibility) { + coverage.queryBlocks = "partial"; + coverage.relationBindings = "partial"; + coverage.visibility = "partial"; + addIssue(issues, "parser-compatibility", statementRange); + } + if (skeletons.length !== tree.textual.length) { + addIssue(issues, "unsupported-clause", statementRange); + } + + const bindings: SqlRelationBinding[] = []; + const scopes: SqlRelationScope[] = [ + Object.freeze({ addedBinding: null, parentScope: null }), + ]; + const regions: SqlVisibilityRegion[] = []; + const publicBlocks = emitted.blocks.map((block) => + Object.freeze({ + baseScope: 0, + kind: block.ast.kind, + parentBlock: block.parent, + range: block.range, + }), + ); + + for (const block of emitted.blocks) { + const fromItems = arrayProperty(block.ast.node, "from"); + if (fromItems === null) { + return Object.freeze({ reason: "malformed-ast", status: "unavailable" }); + } + const sites = relationSites(tokens, block.skeleton); + if (sites.length !== fromItems.length) { + coverage.relationBindings = "partial"; + coverage.visibility = "partial"; + addIssue(issues, "unsupported-relation-source", block.range); + } + let currentScope = 0; + const after: number[] = []; + const count = Math.min(sites.length, fromItems.length); + for (let index = 0; index < count; index += 1) { + if (bindings.length >= MAX_QUERY_BINDINGS) { + return Object.freeze({ reason: "resource-limit", status: "unavailable" }); + } + const rawRelation = fromItems[index]; + const site = phaseValue(sites, index); + if ( + rawRelation === null || + typeof rawRelation !== "object" + ) { + return Object.freeze({ reason: "malformed-ast", status: "unavailable" }); + } + const aliasText = stringProperty(rawRelation, "as"); + const aliasToken = site.aliasToken; + const aliasName = + aliasToken === null ? null : tokenIdentifier(aliasToken); + const aliasMatches = + aliasText !== null && + aliasName !== null && + identifierMatchesAst(aliasText, aliasName); + const alias = + aliasMatches && aliasName !== null && aliasToken !== null + ? Object.freeze({ + explicit: site.explicitAlias, + name: aliasName, + range: Object.freeze({ + from: aliasToken.from, + to: aliasToken.to, + }), + }) + : null; + if ( + (aliasText === null) !== (site.aliasToken === null) || + (aliasText !== null && aliasName !== null && !aliasMatches) + ) { + coverage.relationBindings = "partial"; + addIssue(issues, "unsupported-relation-source", { + from: site.from, + to: site.to, + }); + } + const nested = derivedAst(rawRelation); + let source: SqlRelationBinding["source"]; + if (nested !== null) { + const nestedId = emitted.ids.get(nested); + if (nestedId === undefined || !site.derived) { + coverage.relationBindings = "partial"; + source = Object.freeze({ + kind: "unknown", + reason: "unsupported-relation-source", + }); + } else { + source = Object.freeze({ block: nestedId, kind: "derived" }); + } + } else { + const path = pathFromAst( + rawRelation, + options.grammar, + site, + ); + if (path === null || site.derived) { + coverage.relationBindings = "partial"; + addIssue(issues, "unsupported-relation-source", { + from: site.from, + to: site.to, + }); + source = Object.freeze({ + kind: "unknown", + reason: "unsupported-relation-source", + }); + } else { + const last = phaseValue(path, path.length - 1); + const environment = declarations.get(block.ast.node); + const key = identifierKey(last); + const declaration = environment?.visible.get(key); + if (declaration !== undefined) { + source = Object.freeze({ + declarationRange: declaration.range, + kind: "cte", + name: declaration.name, + }); + } else if (environment?.uncertain.has(key)) { + coverage.relationBindings = "partial"; + addIssue(issues, "recursive-cte-uncertainty", { + from: site.from, + to: site.to, + }); + source = Object.freeze({ + kind: "unknown", + reason: "unknown-correlation", + }); + } else { + source = Object.freeze({ kind: "named", path }); + } + } + } + const bindingIndex = bindings.length; + bindings.push(Object.freeze({ + alias, + owner: block.id, + range: Object.freeze({ from: site.from, to: site.to }), + source, + })); + scopes.push(Object.freeze({ + addedBinding: bindingIndex, + parentScope: currentScope, + })); + if (!site.derived) { + regions.push(Object.freeze({ + block: block.id, + kind: "from-source", + range: Object.freeze({ from: site.from, to: site.to }), + scope: currentScope, + })); + } + currentScope = scopes.length - 1; + after.push(currentScope); + } + regions.push(...clauseRegions(tokens, block, currentScope)); + regions.push(...joinRegions(tokens, block, sites, after)); + } + + regions.sort((left, right) => + left.range.from - right.range.from || + left.range.to - right.range.to + ); + const nonOverlapping: SqlVisibilityRegion[] = []; + let end = 0; + for (const region of regions) { + if (region.range.from < end) { + coverage.visibility = "partial"; + addIssue(issues, "unsupported-clause", region.range); + continue; + } + nonOverlapping.push(region); + end = region.range.to; + } + + try { + return Object.freeze({ + model: createSqlQueryBindingModel( + statementText, + authority, + { + bindings, + blocks: publicBlocks, + coverage, + issues, + regions: nonOverlapping, + scopes, + statementRange, + }, + ), + status: "ready", + }); + } catch { + return Object.freeze({ reason: "unsupported-shape", status: "unavailable" }); + } +} diff --git a/src/vnext/node-sql-parser-wire.ts b/src/vnext/node-sql-parser-wire.ts index 93cc22d..40613c0 100644 --- a/src/vnext/node-sql-parser-wire.ts +++ b/src/vnext/node-sql-parser-wire.ts @@ -2,9 +2,15 @@ import { MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, type NodeSqlParserBackendOutcome, } from "./node-sql-parser-backend.js"; +import { + createSqlQueryBindingModel, + isSqlQueryBindingModel, + type SqlQueryBindingModel, + type SqlQueryBindingModelData, +} from "./query-binding-model.js"; import type { SqlStatementKind } from "./syntax.js"; -export const NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION = 1 as const; +export const NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION = 2 as const; // The parse request is the largest closed protocol shape. const MAX_NODE_SQL_PARSER_WIRE_RECORD_KEYS = 5; @@ -17,7 +23,7 @@ export type NodeSqlParserWireFailureCode = | "module-load"; export interface NodeSqlParserWireRequest { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "parse"; readonly requestId: number; readonly grammar: NodeSqlParserWireGrammar; @@ -26,34 +32,35 @@ export interface NodeSqlParserWireRequest { export type NodeSqlParserWireMessage = | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "ready"; } | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "parsed"; + readonly queryBindings: SqlQueryBindingModelData | null; readonly requestId: number; readonly statementKind: SqlStatementKind; } | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "syntax-rejected"; readonly requestId: number; } | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "unsupported"; readonly requestId: number; readonly reason: "multiple-statements" | "resource-limit"; } | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "failed"; readonly requestId: number; readonly code: NodeSqlParserWireFailureCode; } | { - readonly protocolVersion: 1; + readonly protocolVersion: 2; readonly kind: "protocol-error"; readonly code: "invalid-request"; }; @@ -174,6 +181,62 @@ function isRequestText(value: unknown): value is string { ); } +const wireBindingAuthority = Object.freeze({}); + +type DecodedWireBindings = + | { readonly valid: false } + | { + readonly model: SqlQueryBindingModel | null; + readonly valid: true; + }; + +function decodeWireBindings(value: unknown): DecodedWireBindings { + if (value === null) { + return { model: null, valid: true }; + } + if (value === null || typeof value !== "object") { + return { valid: false }; + } + try { + const statementRange = Object.getOwnPropertyDescriptor( + value, + "statementRange", + ); + if ( + statementRange === undefined || + !("value" in statementRange) || + statementRange.value === null || + typeof statementRange.value !== "object" + ) { + return { valid: false }; + } + const to = Object.getOwnPropertyDescriptor( + statementRange.value, + "to", + ); + if ( + to === undefined || + !("value" in to) || + typeof to.value !== "number" || + !Number.isSafeInteger(to.value) || + to.value <= 0 || + to.value > MAX_NODE_SQL_PARSER_STATEMENT_LENGTH + ) { + return { valid: false }; + } + return { + model: createSqlQueryBindingModel( + " ".repeat(to.value), + wireBindingAuthority, + value, + ), + valid: true, + }; + } catch { + return { valid: false }; + } +} + function requireRequestId(value: number): void { if (!isRequestId(value)) { throw new TypeError( @@ -299,6 +362,7 @@ export function encodeNodeSqlParserWireProtocolError(): Extract< export function encodeNodeSqlParserWireBackendOutcome( requestId: number, outcome: NodeSqlParserBackendOutcome, + queryBindings: SqlQueryBindingModel | null = null, ): Exclude< NodeSqlParserWireMessage, { readonly kind: "protocol-error" | "ready" } @@ -307,9 +371,18 @@ export function encodeNodeSqlParserWireBackendOutcome( switch (outcome.kind) { case "parsed": requireStatementKind(outcome.statementKind); + if ( + queryBindings !== null && + !isSqlQueryBindingModel(queryBindings) + ) { + throw new TypeError( + "node-sql-parser wire query bindings must be authenticated", + ); + } return Object.freeze({ kind: "parsed", protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings, requestId, statementKind: outcome.statementKind, }); @@ -362,21 +435,27 @@ export function decodeNodeSqlParserWireMessage( : null; case "parsed": { const statementKind = record.values.get("statementKind"); + const queryBindings = decodeWireBindings( + record.values.get("queryBindings"), + ); if ( !hasExactKeys(record, [ "protocolVersion", "kind", + "queryBindings", "requestId", "statementKind", ]) || !isRequestId(requestId) || - !isStatementKind(statementKind) + !isStatementKind(statementKind) || + !queryBindings.valid ) { return null; } return Object.freeze({ kind: "parsed", protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings: queryBindings.model, requestId, statementKind, }); diff --git a/src/vnext/query-binding-model.ts b/src/vnext/query-binding-model.ts new file mode 100644 index 0000000..8b3537b --- /dev/null +++ b/src/vnext/query-binding-model.ts @@ -0,0 +1,1171 @@ +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlTextRange, +} from "./types.js"; + +export const MAX_QUERY_BINDING_STATEMENT_LENGTH = 16 * 1024; +export const MAX_QUERY_BINDING_BLOCKS = 256; +export const MAX_QUERY_BINDINGS = 1_024; +export const MAX_QUERY_BINDING_SCOPES = 2_048; +export const MAX_QUERY_VISIBILITY_REGIONS = 2_048; +export const MAX_QUERY_BINDING_ISSUES = 256; +export const MAX_QUERY_BINDING_PATH_COMPONENTS = 8; +export const MAX_QUERY_BINDING_IDENTIFIER_LENGTH = 256; + +export type SqlQueryBindingCoverage = "complete" | "partial"; + +export interface SqlQueryBindingCoverageSet { + readonly queryBlocks: SqlQueryBindingCoverage; + readonly relationBindings: SqlQueryBindingCoverage; + readonly visibility: SqlQueryBindingCoverage; +} + +export type SqlQueryBindingIssueCode = + | "ambiguous-alias" + | "duplicate-alias" + | "opaque-template-context" + | "parser-compatibility" + | "recursive-cte-uncertainty" + | "resource-limit" + | "unknown-correlation" + | "unsupported-clause" + | "unsupported-relation-source"; + +export interface SqlQueryBindingIssue { + readonly code: SqlQueryBindingIssueCode; + readonly range: SqlTextRange; +} + +export type SqlQueryBlockKind = "compound" | "select"; + +export interface SqlQueryBlock { + readonly baseScope: number; + readonly kind: SqlQueryBlockKind; + readonly parentBlock: number | null; + readonly range: SqlTextRange; +} + +export interface SqlRelationScope { + readonly addedBinding: number | null; + readonly parentScope: number | null; +} + +export type SqlVisibilityRegionKind = + | "from-source" + | "group-by" + | "having" + | "join-condition" + | "limit" + | "order-by" + | "other" + | "qualify" + | "select-list" + | "where"; + +export interface SqlVisibilityRegion { + readonly block: number; + readonly kind: SqlVisibilityRegionKind; + readonly range: SqlTextRange; + readonly scope: number; +} + +export interface SqlNamedRelationSource { + readonly kind: "named"; + readonly path: SqlIdentifierPath; +} + +export interface SqlDerivedRelationSource { + readonly block: number; + readonly kind: "derived"; +} + +export interface SqlCteRelationSource { + readonly declarationRange: SqlTextRange; + readonly kind: "cte"; + readonly name: SqlIdentifierComponent; +} + +export interface SqlUnknownRelationSource { + readonly kind: "unknown"; + readonly reason: + | "opaque-template-context" + | "unknown-correlation" + | "unsupported-relation-source"; +} + +export type SqlRelationBindingSource = + | SqlCteRelationSource + | SqlDerivedRelationSource + | SqlNamedRelationSource + | SqlUnknownRelationSource; + +export interface SqlRelationBindingAlias { + readonly explicit: boolean; + readonly name: SqlIdentifierComponent; + readonly range: SqlTextRange; +} + +export interface SqlRelationBinding { + readonly alias: SqlRelationBindingAlias | null; + readonly owner: number; + readonly range: SqlTextRange; + readonly source: SqlRelationBindingSource; +} + +/** Validated plain-data shape used before exact source/authority authentication. */ +export interface SqlQueryBindingModelData { + readonly blocks: readonly SqlQueryBlock[]; + readonly bindings: readonly SqlRelationBinding[]; + readonly coverage: SqlQueryBindingCoverageSet; + readonly issues: readonly SqlQueryBindingIssue[]; + readonly regions: readonly SqlVisibilityRegion[]; + readonly scopes: readonly SqlRelationScope[]; + readonly statementRange: SqlTextRange; +} + +/** A model authenticated for one exact statement and parser authority. */ +export interface SqlQueryBindingModel extends SqlQueryBindingModelData {} + +export type SqlQueryBindingModelErrorCode = + | "invalid-authority" + | "invalid-model" + | "invalid-source" + | "resource-limit"; + +const modelErrors = new WeakSet(); +const models = new WeakSet(); +const modelOrigins = new WeakMap< + object, + { readonly authority: object; readonly statementText: string } +>(); + +export class SqlQueryBindingModelError extends Error { + readonly code: SqlQueryBindingModelErrorCode; + + constructor(code: SqlQueryBindingModelErrorCode, message: string) { + super(message); + this.name = "SqlQueryBindingModelError"; + this.code = code; + modelErrors.add(this); + } +} + +export function isSqlQueryBindingModelError( + candidate: unknown, +): candidate is SqlQueryBindingModelError { + return ( + candidate !== null && + typeof candidate === "object" && + modelErrors.has(candidate) + ); +} + +export function isSqlQueryBindingModel( + candidate: unknown, +): candidate is SqlQueryBindingModel { + return ( + candidate !== null && + typeof candidate === "object" && + models.has(candidate) + ); +} + +export function sqlQueryBindingModelMatches( + model: unknown, + statementText: string, + authority: unknown, +): boolean { + if (model === null || typeof model !== "object") { + return false; + } + const origin = modelOrigins.get(model); + return ( + origin !== undefined && + origin.authority === authority && + origin.statementText === statementText + ); +} + +interface FoundProperty { + readonly found: true; + readonly value: unknown; +} + +interface MissingProperty { + readonly found: false; +} + +type DataProperty = FoundProperty | MissingProperty; + +function ownDataProperty( + value: object, + key: PropertyKey, + subject: string, +): DataProperty { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { found: false }; + } + if (!("value" in descriptor)) { + throw invalid(`${subject}.${String(key)} cannot be an accessor`); + } + return { found: true, value: descriptor.value }; +} + +function requiredProperty( + value: object, + key: PropertyKey, + subject: string, +): unknown { + const property = ownDataProperty(value, key, subject); + if (!property.found) { + throw invalid(`${subject}.${String(key)} is required`); + } + return property.value; +} + +function objectValue(value: unknown, subject: string): object { + if (value === null || typeof value !== "object") { + throw invalid(`${subject} must be an object`); + } + return value; +} + +function invalid(message: string): SqlQueryBindingModelError { + return new SqlQueryBindingModelError("invalid-model", message); +} + +function resource(message: string): SqlQueryBindingModelError { + return new SqlQueryBindingModelError("resource-limit", message); +} + +function integer( + value: unknown, + subject: string, + maximum: number, +): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 || + value >= maximum + ) { + throw invalid(`${subject} must be an in-bounds non-negative integer`); + } + return value; +} + +function nullableInteger( + value: unknown, + subject: string, + maximum: number, +): number | null { + return value === null ? null : integer(value, subject, maximum); +} + +function booleanValue(value: unknown, subject: string): boolean { + if (typeof value !== "boolean") { + throw invalid(`${subject} must be a boolean`); + } + return value; +} + +function enumValue( + value: unknown, + subject: string, + allowed: readonly Value[], +): Value { + if (typeof value !== "string") { + throw invalid(`${subject} is not supported`); + } + const match = allowed.find((candidate) => candidate === value); + if (match === undefined) { + throw invalid(`${subject} is not supported`); + } + return match; +} + +function arrayValues( + value: unknown, + subject: string, + maximum: number, +): readonly unknown[] { + if (!Array.isArray(value)) { + throw invalid(`${subject} must be an array`); + } + const lengthProperty = ownDataProperty(value, "length", subject); + if ( + !lengthProperty.found || + typeof lengthProperty.value !== "number" || + !Number.isSafeInteger(lengthProperty.value) || + lengthProperty.value < 0 + ) { + throw invalid(`${subject} has an invalid length`); + } + if (lengthProperty.value > maximum) { + throw resource(`${subject} exceeds its ${maximum} item limit`); + } + const result: unknown[] = []; + for (let index = 0; index < lengthProperty.value; index += 1) { + const item = ownDataProperty(value, index, subject); + if (!item.found) { + throw invalid(`${subject} cannot contain holes`); + } + result.push(item.value); + } + return result; +} + +function rangeValue( + value: unknown, + statementLength: number, + subject: string, + allowEmpty = true, +): SqlTextRange { + const object = objectValue(value, subject); + const from = integer( + requiredProperty(object, "from", subject), + `${subject}.from`, + statementLength + 1, + ); + const to = integer( + requiredProperty(object, "to", subject), + `${subject}.to`, + statementLength + 1, + ); + if (from > to || (!allowEmpty && from === to)) { + throw invalid(`${subject} must be a valid half-open range`); + } + return Object.freeze({ from, to }); +} + +function rangeContains( + outer: SqlTextRange, + inner: SqlTextRange, +): boolean { + return outer.from <= inner.from && inner.to <= outer.to; +} + +function authenticatedItem( + values: readonly Value[], + index: number, +): Value { + const value = values[index]; + if (value === undefined) { + throw new Error("Authenticated query binding model invariant was violated"); + } + return value; +} + +interface RelationshipValidationIndex { + readonly ancestors: readonly Uint8Array[]; + readonly scopesIncludingLocal: readonly Uint8Array[]; + readonly scopesWithoutLocal: readonly Uint8Array[]; +} + +function relationshipValidationIndex( + model: SqlQueryBindingModel, +): RelationshipValidationIndex { + const ancestors = model.blocks.map((_block, blockIndex) => { + const result = new Uint8Array(model.blocks.length); + let cursor: number | null = blockIndex; + while (cursor !== null) { + result[cursor] = 1; + cursor = authenticatedItem(model.blocks, cursor).parentBlock; + } + return result; + }); + const scopesIncludingLocal: Uint8Array[] = []; + const scopesWithoutLocal: Uint8Array[] = []; + for (let blockIndex = 0; blockIndex < model.blocks.length; blockIndex += 1) { + const includingLocal = new Uint8Array(model.scopes.length); + const withoutLocal = new Uint8Array(model.scopes.length); + const blockAncestors = authenticatedItem(ancestors, blockIndex); + for (let scopeIndex = 0; scopeIndex < model.scopes.length; scopeIndex += 1) { + const scope = authenticatedItem(model.scopes, scopeIndex); + const parentIncluding = scope.parentScope === null || + includingLocal[scope.parentScope] === 1; + const parentWithout = scope.parentScope === null || + withoutLocal[scope.parentScope] === 1; + if (scope.addedBinding === null) { + includingLocal[scopeIndex] = parentIncluding ? 1 : 0; + withoutLocal[scopeIndex] = parentWithout ? 1 : 0; + continue; + } + const binding = authenticatedItem(model.bindings, scope.addedBinding); + const ownerVisible = blockAncestors[binding.owner] === 1; + includingLocal[scopeIndex] = + parentIncluding && ownerVisible ? 1 : 0; + withoutLocal[scopeIndex] = + parentWithout && ownerVisible && binding.owner !== blockIndex ? 1 : 0; + } + scopesIncludingLocal.push(includingLocal); + scopesWithoutLocal.push(withoutLocal); + } + return { + ancestors, + scopesIncludingLocal, + scopesWithoutLocal, + }; +} + +function wellFormedString(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const trailing = value.charCodeAt(index + 1); + if (!(trailing >= 0xdc00 && trailing <= 0xdfff)) { + return false; + } + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function identifierValue( + value: unknown, + subject: string, +): SqlIdentifierComponent { + const object = objectValue(value, subject); + const text = requiredProperty(object, "value", subject); + if ( + typeof text !== "string" || + text.length === 0 || + text.length > MAX_QUERY_BINDING_IDENTIFIER_LENGTH || + !wellFormedString(text) + ) { + throw invalid(`${subject}.value must be a bounded well-formed string`); + } + return Object.freeze({ + quoted: booleanValue( + requiredProperty(object, "quoted", subject), + `${subject}.quoted`, + ), + value: text, + }); +} + +function pathValue( + value: unknown, + subject: string, +): SqlIdentifierPath { + const input = arrayValues( + value, + subject, + MAX_QUERY_BINDING_PATH_COMPONENTS, + ); + if (input.length === 0) { + throw invalid(`${subject} cannot be empty`); + } + return Object.freeze( + input.map((component, index) => + identifierValue(component, `${subject}[${index}]`), + ), + ); +} + +const BLOCK_KINDS: readonly SqlQueryBlockKind[] = Object.freeze([ + "compound", + "select", +]); +const REGION_KINDS: readonly SqlVisibilityRegionKind[] = Object.freeze([ + "from-source", + "group-by", + "having", + "join-condition", + "limit", + "order-by", + "other", + "qualify", + "select-list", + "where", +]); +const ISSUE_CODES: readonly SqlQueryBindingIssueCode[] = Object.freeze([ + "ambiguous-alias", + "duplicate-alias", + "opaque-template-context", + "parser-compatibility", + "recursive-cte-uncertainty", + "resource-limit", + "unknown-correlation", + "unsupported-clause", + "unsupported-relation-source", +]); +const UNKNOWN_REASONS: readonly SqlUnknownRelationSource["reason"][] = + Object.freeze([ + "opaque-template-context", + "unknown-correlation", + "unsupported-relation-source", + ]); + +function coverageValue( + value: unknown, +): SqlQueryBindingCoverageSet { + const object = objectValue(value, "query binding coverage"); + const read = (key: string): SqlQueryBindingCoverage => { + const result = enumValue( + requiredProperty(object, key, "query binding coverage"), + `query binding coverage.${key}`, + ["complete", "partial"], + ); + return result === "complete" ? "complete" : "partial"; + }; + return Object.freeze({ + queryBlocks: read("queryBlocks"), + relationBindings: read("relationBindings"), + visibility: read("visibility"), + }); +} + +function blockValue( + value: unknown, + index: number, + count: number, + scopeCount: number, + statementLength: number, +): SqlQueryBlock { + const subject = `query block ${index}`; + const object = objectValue(value, subject); + const kind = enumValue( + requiredProperty(object, "kind", subject), + `${subject}.kind`, + BLOCK_KINDS, + ); + const parentBlock = nullableInteger( + requiredProperty(object, "parentBlock", subject), + `${subject}.parentBlock`, + count, + ); + if (parentBlock !== null && parentBlock >= index) { + throw invalid(`${subject} parent must precede its child`); + } + return Object.freeze({ + baseScope: integer( + requiredProperty(object, "baseScope", subject), + `${subject}.baseScope`, + scopeCount, + ), + kind: kind === "compound" ? "compound" : "select", + parentBlock, + range: rangeValue( + requiredProperty(object, "range", subject), + statementLength, + `${subject}.range`, + false, + ), + }); +} + +function scopeValue( + value: unknown, + index: number, + count: number, + bindingCount: number, +): SqlRelationScope { + const subject = `relation scope ${index}`; + const object = objectValue(value, subject); + const parentScope = nullableInteger( + requiredProperty(object, "parentScope", subject), + `${subject}.parentScope`, + count, + ); + if (parentScope !== null && parentScope >= index) { + throw invalid(`${subject} parent must precede its child`); + } + return Object.freeze({ + addedBinding: nullableInteger( + requiredProperty(object, "addedBinding", subject), + `${subject}.addedBinding`, + bindingCount, + ), + parentScope, + }); +} + +function aliasValue( + value: unknown, + statementLength: number, + subject: string, +): SqlRelationBindingAlias | null { + if (value === null) { + return null; + } + const object = objectValue(value, subject); + return Object.freeze({ + explicit: booleanValue( + requiredProperty(object, "explicit", subject), + `${subject}.explicit`, + ), + name: identifierValue( + requiredProperty(object, "name", subject), + `${subject}.name`, + ), + range: rangeValue( + requiredProperty(object, "range", subject), + statementLength, + `${subject}.range`, + false, + ), + }); +} + +function sourceValue( + value: unknown, + blockCount: number, + statementLength: number, + subject: string, +): SqlRelationBindingSource { + const object = objectValue(value, subject); + const kind = requiredProperty(object, "kind", subject); + if (kind === "named") { + return Object.freeze({ + kind, + path: pathValue(requiredProperty(object, "path", subject), `${subject}.path`), + }); + } + if (kind === "derived") { + return Object.freeze({ + block: integer( + requiredProperty(object, "block", subject), + `${subject}.block`, + blockCount, + ), + kind, + }); + } + if (kind === "cte") { + return Object.freeze({ + declarationRange: rangeValue( + requiredProperty(object, "declarationRange", subject), + statementLength, + `${subject}.declarationRange`, + false, + ), + kind, + name: identifierValue( + requiredProperty(object, "name", subject), + `${subject}.name`, + ), + }); + } + if (kind === "unknown") { + const reason = enumValue( + requiredProperty(object, "reason", subject), + `${subject}.reason`, + UNKNOWN_REASONS, + ); + if (reason === "opaque-template-context") { + return Object.freeze({ kind, reason }); + } + if (reason === "unknown-correlation") { + return Object.freeze({ kind, reason }); + } + return Object.freeze({ + kind, + reason: "unsupported-relation-source", + }); + } + throw invalid(`${subject}.kind is not supported`); +} + +function bindingValue( + value: unknown, + index: number, + blockCount: number, + statementLength: number, +): SqlRelationBinding { + const subject = `relation binding ${index}`; + const object = objectValue(value, subject); + const range = rangeValue( + requiredProperty(object, "range", subject), + statementLength, + `${subject}.range`, + false, + ); + const alias = aliasValue( + requiredProperty(object, "alias", subject), + statementLength, + `${subject}.alias`, + ); + if (alias !== null && !rangeContains(range, alias.range)) { + throw invalid(`${subject} must contain its alias range`); + } + return Object.freeze({ + alias, + owner: integer( + requiredProperty(object, "owner", subject), + `${subject}.owner`, + blockCount, + ), + range, + source: sourceValue( + requiredProperty(object, "source", subject), + blockCount, + statementLength, + `${subject}.source`, + ), + }); +} + +function regionValue( + value: unknown, + index: number, + blockCount: number, + scopeCount: number, + statementLength: number, +): SqlVisibilityRegion { + const subject = `visibility region ${index}`; + const object = objectValue(value, subject); + const kind = enumValue( + requiredProperty(object, "kind", subject), + `${subject}.kind`, + REGION_KINDS, + ); + return Object.freeze({ + block: integer( + requiredProperty(object, "block", subject), + `${subject}.block`, + blockCount, + ), + kind, + range: rangeValue( + requiredProperty(object, "range", subject), + statementLength, + `${subject}.range`, + false, + ), + scope: integer( + requiredProperty(object, "scope", subject), + `${subject}.scope`, + scopeCount, + ), + }); +} + +function issueValue( + value: unknown, + index: number, + statementLength: number, +): SqlQueryBindingIssue { + const subject = `query binding issue ${index}`; + const object = objectValue(value, subject); + const code = enumValue( + requiredProperty(object, "code", subject), + `${subject}.code`, + ISSUE_CODES, + ); + return Object.freeze({ + code, + range: rangeValue( + requiredProperty(object, "range", subject), + statementLength, + `${subject}.range`, + ), + }); +} + +function validateRelationships(model: SqlQueryBindingModel): void { + const validation = relationshipValidationIndex(model); + for (let index = 0; index < model.blocks.length; index += 1) { + const block = authenticatedItem(model.blocks, index); + if (block.parentBlock !== null) { + const parent = authenticatedItem(model.blocks, block.parentBlock); + if (!rangeContains(parent.range, block.range)) { + throw invalid(`query block ${index} is outside its parent`); + } + } + if ( + authenticatedItem(validation.scopesWithoutLocal, index)[block.baseScope] !== + 1 + ) { + throw invalid( + `query block ${index} base scope contains an unrelated relation binding`, + ); + } + for (let sibling = 0; sibling < index; sibling += 1) { + const other = authenticatedItem(model.blocks, sibling); + const overlaps = + block.range.from < other.range.to && + other.range.from < block.range.to; + if ( + overlaps && + authenticatedItem(validation.ancestors, index)[sibling] !== 1 && + authenticatedItem(validation.ancestors, sibling)[index] !== 1 + ) { + throw invalid(`query block ${index} overlaps an unrelated block`); + } + } + } + for (let index = 0; index < model.bindings.length; index += 1) { + const binding = authenticatedItem(model.bindings, index); + const owner = authenticatedItem(model.blocks, binding.owner); + if (!rangeContains(owner.range, binding.range)) { + throw invalid(`relation binding ${index} is outside its owner`); + } + if (binding.source.kind === "derived") { + const derived = authenticatedItem(model.blocks, binding.source.block); + if ( + derived.parentBlock !== binding.owner || + !rangeContains(binding.range, derived.range) + ) { + throw invalid(`derived relation binding ${index} has an unrelated block`); + } + } + if ( + model.coverage.relationBindings === "complete" && + binding.source.kind === "unknown" + ) { + throw invalid("complete relation coverage cannot contain unknown bindings"); + } + } + const introducedBindings = new Set(); + for (let index = 0; index < model.scopes.length; index += 1) { + const scope = authenticatedItem(model.scopes, index); + if (scope.addedBinding !== null) { + if (introducedBindings.has(scope.addedBinding)) { + throw invalid(`relation binding ${scope.addedBinding} is introduced twice`); + } + introducedBindings.add(scope.addedBinding); + } + } + if (introducedBindings.size !== model.bindings.length) { + throw invalid("every relation binding must be introduced by exactly one scope"); + } + let previousEnd = 0; + for (let index = 0; index < model.regions.length; index += 1) { + const region = authenticatedItem(model.regions, index); + if (region.range.from < previousEnd) { + throw invalid("visibility regions must be ordered and non-overlapping"); + } + const block = authenticatedItem(model.blocks, region.block); + if (!rangeContains(block.range, region.range)) { + throw invalid(`visibility region ${index} is outside its block`); + } + if ( + authenticatedItem( + validation.scopesIncludingLocal, + region.block, + )[region.scope] !== 1 + ) { + throw invalid( + `visibility region ${index} scope contains an unrelated relation binding`, + ); + } + previousEnd = region.range.to; + } +} + +export function createSqlQueryBindingModel( + statementText: unknown, + authority: unknown, + input: unknown, +): SqlQueryBindingModel { + if (typeof statementText !== "string") { + throw new SqlQueryBindingModelError( + "invalid-source", + "query binding statement text must be a string", + ); + } + if ( + statementText.length === 0 || + statementText.length > MAX_QUERY_BINDING_STATEMENT_LENGTH + ) { + throw new SqlQueryBindingModelError( + statementText.length > MAX_QUERY_BINDING_STATEMENT_LENGTH + ? "resource-limit" + : "invalid-source", + `query binding statement must contain 1-${MAX_QUERY_BINDING_STATEMENT_LENGTH} UTF-16 code units`, + ); + } + if (authority === null || typeof authority !== "object") { + throw new SqlQueryBindingModelError( + "invalid-authority", + "query binding parser authority must be an object identity", + ); + } + try { + const object = objectValue(input, "query binding model"); + const blockInputs = arrayValues( + requiredProperty(object, "blocks", "query binding model"), + "query blocks", + MAX_QUERY_BINDING_BLOCKS, + ); + const bindingInputs = arrayValues( + requiredProperty(object, "bindings", "query binding model"), + "relation bindings", + MAX_QUERY_BINDINGS, + ); + const scopeInputs = arrayValues( + requiredProperty(object, "scopes", "query binding model"), + "relation scopes", + MAX_QUERY_BINDING_SCOPES, + ); + const regionInputs = arrayValues( + requiredProperty(object, "regions", "query binding model"), + "visibility regions", + MAX_QUERY_VISIBILITY_REGIONS, + ); + const issueInputs = arrayValues( + requiredProperty(object, "issues", "query binding model"), + "query binding issues", + MAX_QUERY_BINDING_ISSUES, + ); + if (blockInputs.length === 0 || scopeInputs.length === 0) { + throw invalid("query binding model requires a block and a scope"); + } + const statementRange = rangeValue( + requiredProperty(object, "statementRange", "query binding model"), + statementText.length, + "query binding model.statementRange", + false, + ); + if (statementRange.from !== 0 || statementRange.to !== statementText.length) { + throw invalid("statementRange must cover the exact statement text"); + } + const blocks = Object.freeze( + blockInputs.map((block, index) => + blockValue( + block, + index, + blockInputs.length, + scopeInputs.length, + statementText.length, + ), + ), + ); + const bindings = Object.freeze( + bindingInputs.map((binding, index) => + bindingValue( + binding, + index, + blockInputs.length, + statementText.length, + ), + ), + ); + const scopes = Object.freeze( + scopeInputs.map((scope, index) => + scopeValue( + scope, + index, + scopeInputs.length, + bindingInputs.length, + ), + ), + ); + const regions = Object.freeze( + regionInputs.map((region, index) => + regionValue( + region, + index, + blockInputs.length, + scopeInputs.length, + statementText.length, + ), + ), + ); + const issues = Object.freeze( + issueInputs.map((issue, index) => + issueValue(issue, index, statementText.length), + ), + ); + const model: SqlQueryBindingModel = Object.freeze({ + blocks, + bindings, + coverage: coverageValue( + requiredProperty(object, "coverage", "query binding model"), + ), + issues, + regions, + scopes, + statementRange, + }); + validateRelationships(model); + models.add(model); + modelOrigins.set(model, Object.freeze({ authority, statementText })); + return model; + } catch (error) { + if (isSqlQueryBindingModelError(error)) { + throw error; + } + throw new SqlQueryBindingModelError( + "invalid-model", + "query binding model could not be inspected safely", + ); + } +} + +export interface SqlVisibleRelationBindings { + readonly bindings: readonly SqlRelationBinding[]; + readonly coverage: SqlQueryBindingCoverage; + readonly issues: readonly SqlQueryBindingIssue[]; + readonly region: SqlVisibilityRegion; + readonly status: "ready"; +} + +export interface SqlVisibleRelationBindingsUnavailable { + readonly reason: "invalid-model" | "outside-visibility-region"; + readonly status: "unavailable"; +} + +export type SqlVisibleRelationBindingsResult = + | SqlVisibleRelationBindings + | SqlVisibleRelationBindingsUnavailable; + +function regionAt( + regions: readonly SqlVisibilityRegion[], + position: number, +): SqlVisibilityRegion | null { + let low = 0; + let high = regions.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const region = regions[middle]; + if (!region || region.range.from > position) { + high = middle; + } else { + low = middle + 1; + } + } + const candidate = regions[low - 1]; + return candidate && position < candidate.range.to ? candidate : null; +} + +export function visibleSqlRelationBindingsAt( + model: unknown, + position: number, +): SqlVisibleRelationBindingsResult { + if ( + !isSqlQueryBindingModel(model) || + !Number.isSafeInteger(position) || + position < 0 || + position > model.statementRange.to + ) { + return Object.freeze({ reason: "invalid-model", status: "unavailable" }); + } + const region = regionAt(model.regions, position); + if (region === null) { + return Object.freeze({ + reason: "outside-visibility-region", + status: "unavailable", + }); + } + const reversed: SqlRelationBinding[] = []; + let cursor: number | null = region.scope; + while (cursor !== null) { + const scope: SqlRelationScope = + authenticatedItem(model.scopes, cursor); + if (scope.addedBinding !== null) { + const binding = authenticatedItem(model.bindings, scope.addedBinding); + reversed.push(binding); + } + cursor = scope.parentScope; + } + reversed.reverse(); + const coverage = + model.coverage.relationBindings === "complete" && + model.coverage.visibility === "complete" + ? "complete" + : "partial"; + return Object.freeze({ + bindings: Object.freeze(reversed), + coverage, + issues: model.issues, + region, + status: "ready", + }); +} + +export type SqlIdentifierComparator = ( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +) => boolean; + +export interface SqlQualifierResolutionFound { + readonly binding: SqlRelationBinding; + readonly coverage: SqlQueryBindingCoverage; + readonly status: "resolved"; +} + +export interface SqlQualifierResolutionAmbiguous { + readonly bindings: readonly SqlRelationBinding[]; + readonly coverage: SqlQueryBindingCoverage; + readonly status: "ambiguous"; +} + +export interface SqlQualifierResolutionMissing { + readonly status: "no-match"; +} + +export interface SqlQualifierResolutionUnavailable { + readonly reason: + | "invalid-model" + | "outside-visibility-region" + | "partial-coverage"; + readonly status: "unavailable"; +} + +export type SqlQualifierResolution = + | SqlQualifierResolutionAmbiguous + | SqlQualifierResolutionFound + | SqlQualifierResolutionMissing + | SqlQualifierResolutionUnavailable; + +function bindingVisibleName( + binding: SqlRelationBinding, +): SqlIdentifierComponent | null { + if (binding.alias !== null) { + return binding.alias.name; + } + if (binding.source.kind === "named") { + return binding.source.path[binding.source.path.length - 1] ?? null; + } + if (binding.source.kind === "cte") { + return binding.source.name; + } + return null; +} + +export function resolveSqlRelationQualifier( + model: unknown, + position: number, + qualifier: SqlIdentifierComponent, + equals: SqlIdentifierComparator, +): SqlQualifierResolution { + const visible = visibleSqlRelationBindingsAt(model, position); + if (visible.status === "unavailable") { + return visible; + } + const matches: SqlRelationBinding[] = []; + for (const binding of visible.bindings) { + const name = bindingVisibleName(binding); + if (name !== null && equals(name, qualifier)) { + matches.push(binding); + } + } + if (matches.length === 0) { + return visible.coverage === "complete" + ? Object.freeze({ status: "no-match" }) + : Object.freeze({ + reason: "partial-coverage", + status: "unavailable", + }); + } + if (matches.length === 1) { + const binding = authenticatedItem(matches, 0); + return Object.freeze({ + binding, + coverage: visible.coverage, + status: "resolved", + }); + } + return Object.freeze({ + bindings: Object.freeze(matches), + coverage: visible.coverage, + status: "ambiguous", + }); +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index ce950fd..c6057fe 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -249,6 +249,23 @@ export interface SqlCatalogCompletionProvenance { readonly entityId: string; } +export interface SqlColumnCompletionProvenance { + readonly kind: "column-catalog"; + readonly providerId: string; + readonly scope: string; + readonly epoch: SqlCatalogEpoch; + readonly relationEntityId: string; + readonly columnEntityId: string; +} + +export interface SqlNamespaceCompletionProvenance { + readonly containerEntityId: string; + readonly epoch: SqlCatalogEpoch; + readonly kind: "namespace-catalog"; + readonly providerId: string; + readonly scope: string; +} + interface SqlCompletionItemBase { readonly label: string; readonly edit: SqlTextChange; @@ -265,6 +282,17 @@ export type SqlCompletionItem = readonly kind: "relation"; readonly relationKind: SqlCatalogRelationKind; readonly provenance: SqlCatalogCompletionProvenance; + }) + | (SqlCompletionItemBase & { + readonly dataType?: string; + readonly kind: "column"; + readonly provenance: SqlColumnCompletionProvenance; + readonly relationRequestKey: string; + }) + | (SqlCompletionItemBase & { + readonly kind: "namespace"; + readonly provenance: SqlNamespaceCompletionProvenance; + readonly role: SqlCatalogContainerRole; }); export type SqlCompletionIssue = @@ -272,6 +300,12 @@ export type SqlCompletionIssue = readonly reason: "catalog-loading"; readonly remainingIntentLeaseMs: number; } + | { + readonly reason: + | "column-catalog-loading" + | "namespace-catalog-loading"; + readonly remainingIntentLeaseMs?: number; + } | { readonly reason: | "catalog-partial" @@ -281,7 +315,15 @@ export type SqlCompletionIssue = | "catalog-overloaded" | "catalog-queue-timeout" | "catalog-timeout" + | "column-catalog-failed" + | "column-catalog-malformed" + | "column-catalog-partial" | "cte-scope-uncertainty" + | "namespace-catalog-failed" + | "namespace-catalog-malformed" + | "namespace-catalog-partial" + | "namespace-prefix-uncertain" + | "query-binding-partial" | "query-site-recovery" | "opaque-template-context" | "recursive-cte-uncertainty" @@ -345,6 +387,81 @@ export type SqlCatalogProviderReport = readonly reason: SqlCatalogProviderUnavailableReason; }); +export type SqlColumnCatalogProviderReport = + | { + readonly feature: "column-catalog"; + readonly outcome: "ready"; + readonly providerId: string; + readonly coverage: "complete" | "partial"; + readonly failures: readonly SqlColumnCatalogFailure[]; + } + | { + readonly feature: "column-catalog"; + readonly outcome: "loading"; + readonly providerId: string; + readonly failures: readonly SqlColumnCatalogFailure[]; + } + | { + readonly feature: "column-catalog"; + readonly outcome: "failed"; + readonly providerId: string; + readonly failures: readonly [ + SqlColumnCatalogFailure, + ...SqlColumnCatalogFailure[], + ]; + } + | { + readonly feature: "column-catalog"; + readonly outcome: "unavailable"; + readonly providerId: string; + readonly reason: + | "disposed" + | "invalid-request" + | "malformed-response" + | "provider-failed"; + }; + +export interface SqlColumnCatalogFailure { + readonly code: SqlCatalogFailureCode; + readonly requestKey: string; + readonly retry: SqlCatalogRetryPolicy; +} + +export type SqlNamespaceCatalogProviderReport = + | { + readonly coverage: "complete" | "partial"; + readonly feature: "namespace-catalog"; + readonly outcome: "ready"; + readonly providerId: string; + } + | { + readonly feature: "namespace-catalog"; + readonly outcome: "loading"; + readonly providerId: string; + } + | { + readonly feature: "namespace-catalog"; + readonly outcome: "failed"; + readonly providerId: string; + readonly code: SqlCatalogFailureCode; + readonly retry: SqlCatalogRetryPolicy; + } + | { + readonly feature: "namespace-catalog"; + readonly outcome: "unavailable"; + readonly providerId: string; + readonly reason: + | "disposed" + | "invalid-request" + | "malformed-response" + | "provider-failed"; + }; + +export type SqlCompletionProviderReport = + | SqlCatalogProviderReport + | SqlColumnCatalogProviderReport + | SqlNamespaceCatalogProviderReport; + export interface SqlServiceFailure { readonly code: "internal"; readonly retryable: boolean; @@ -356,7 +473,7 @@ export type SqlCompletionResult = readonly revision: SqlRevision; readonly refreshToken: SqlCompletionRefreshToken | null; readonly value: SqlCompletionList; - readonly sources: readonly SqlCatalogProviderReport[]; + readonly sources: readonly SqlCompletionProviderReport[]; } | { readonly status: "unavailable"; diff --git a/src/vnext/relation-completion.ts b/src/vnext/relation-completion.ts index a0d2668..87ac3cb 100644 --- a/src/vnext/relation-completion.ts +++ b/src/vnext/relation-completion.ts @@ -72,7 +72,7 @@ interface RankedCteItem { interface RankedCatalogItem { readonly completionPathLength: number; readonly item: Exclude< - SqlCompletionItem, + Extract, { readonly relationKind: "cte" } >; readonly label: string; diff --git a/src/vnext/session.ts b/src/vnext/session.ts index bb5359d..628410a 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -10,6 +10,31 @@ import type { SqlTextChange, SqlTextRange, } from "./types.js"; +import { + createSqlColumnCatalogBatchCoordinator, + type SqlColumnCatalogBatchCoordinator, + type SqlColumnCatalogBatchOwner, + type SqlColumnCatalogBatchTicket, +} from "./column-catalog-batch-coordinator.js"; +import { + composeSqlColumnCompletion, + prepareSqlColumnCatalogRelations, +} from "./column-completion.js"; +import { + createSqlNamespaceCatalogCoordinator, + type SqlNamespaceCatalogCoordinator, + type SqlNamespaceCatalogOwner, + type SqlNamespaceCatalogSearchOutcome, + type SqlNamespaceCatalogSearchTicket, +} from "./namespace-catalog-coordinator.js"; +import { + composeSqlNamespaceCompletion, + prepareSqlNamespaceCatalogSearch, + type SqlNamespaceCompletionComposition, +} from "./namespace-completion.js"; +import { + MAX_NAMESPACE_RESULTS, +} from "./namespace-catalog-boundary.js"; import { buildSqlStatementIndex, findSqlStatementSlot, @@ -34,6 +59,7 @@ import { type SqlCatalogSearchWorkTicket, } from "./relation-catalog-search-work.js"; import { + analyzeSqlLocalColumnSite, analyzeSqlLocalRelationSite, prepareSqlLocalRelationStatement, type SqlLocalRelationStatementPreparation, @@ -48,6 +74,9 @@ import { createSqlCompletionRefreshToken, type SqlCompletionRequest, type SqlCompletionRefreshToken, + type SqlCompletionIssue, + type SqlCompletionItem, + type SqlCompletionList, type SqlDisposable, type SqlCompletionResult, type SqlCompletionTask, @@ -95,6 +124,7 @@ const MAX_DIALECTS = 1_000; const DEFAULT_CATALOG_RESPONSE_BUDGET_MS = 40; const MAX_CATALOG_RESPONSE_BUDGET_MS = 50; const TERMINAL_LOADING_INTENT_LEASE_MS = 1_000; +const AUXILIARY_LOADING_RETRY_DELAY_MS = 100; interface ResolvedCatalogContext { readonly scope: string; @@ -104,7 +134,11 @@ interface ResolvedCatalogContext { interface CompletionRequestState { cancelReason: "caller" | "disposed" | "superseded" | null; readonly revision: SqlRevision; - ticket: SqlCatalogSearchWorkTicket | null; + readonly tickets: Set< + | SqlCatalogSearchWorkTicket + | SqlColumnCatalogBatchTicket + | SqlNamespaceCatalogSearchTicket + >; readonly token: SqlCompletionRefreshToken; } @@ -132,6 +166,12 @@ interface TerminalRefreshIntent { readonly token: SqlCompletionRefreshToken; } +interface AuxiliaryLoadingRetry { + readonly context: Context; + readonly position: number; + readonly source: SqlSourceSnapshot; +} + interface CompletionConfiguration { readonly catalogResponseBudgetMs: number; } @@ -531,6 +571,130 @@ function completionCancellationReason( return request.cancelReason ?? "superseded"; } +function cancelCompletionTickets( + request: CompletionRequestState | null, +): void { + if (!request) return; + for (const ticket of request.tickets) ticket.cancel(); +} + +async function raceCatalogResponse( + result: Promise, + timeoutMs: number, +): Promise< + | { readonly kind: "outcome"; readonly outcome: Outcome } + | { readonly kind: "timeout" } +> { + let timer: ReturnType | undefined; + const raced = await Promise.race([ + result.then((outcome) => + Object.freeze({ kind: "outcome" as const, outcome }) + ), + new Promise<{ readonly kind: "timeout" }>((resolve) => { + timer = setTimeout( + () => resolve(Object.freeze({ kind: "timeout" })), + timeoutMs, + ); + }), + ]); + clearTimeout(timer); + return raced; +} + +function remainingCatalogBudget( + startedAt: number, + budgetMs: number, +): number { + return Math.max(0, budgetMs - Math.max(0, performance.now() - startedAt)); +} + +function namespaceCompletionList( + composition: SqlNamespaceCompletionComposition, +): SqlCompletionList { + const items: readonly SqlCompletionItem[] = + composition.value.items.map((item) => + Object.freeze({ + ...(item.detail === undefined ? {} : { detail: item.detail }), + edit: item.edit, + kind: "namespace" as const, + label: item.label, + provenance: item.provenance, + role: item.role, + }) + ); + const issues: readonly SqlCompletionIssue[] = + composition.value.issues.map((reason) => + Object.freeze({ reason }) + ); + const first = issues[0]; + if (first === undefined) { + return Object.freeze({ + isIncomplete: false, + issues: Object.freeze([] as const), + items: Object.freeze(items), + }); + } + const incompleteIssues: [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ] = [first, ...issues.slice(1)]; + return Object.freeze({ + isIncomplete: true, + issues: Object.freeze(incompleteIssues), + items: Object.freeze(items), + }); +} + +function mergeCompletionLists( + left: SqlCompletionList, + right: SqlCompletionList, +): SqlCompletionList { + const items = Object.freeze([...left.items, ...right.items]); + const issues = Object.freeze([...left.issues, ...right.issues]); + const first = issues[0]; + if (first === undefined) { + return Object.freeze({ + isIncomplete: false, + issues: Object.freeze([] as const), + items, + }); + } + const incompleteIssues: [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ] = [first, ...issues.slice(1)]; + return Object.freeze({ + isIncomplete: true, + issues: Object.freeze(incompleteIssues), + items, + }); +} + +function completionListWithLoadingLease( + value: SqlCompletionList, + reason: + | "column-catalog-loading" + | "namespace-catalog-loading", + remainingIntentLeaseMs: number, +): SqlCompletionList { + const issues = value.issues.map((issue) => + issue.reason === reason + ? Object.freeze({ reason, remainingIntentLeaseMs }) + : issue + ); + const first = issues[0]; + if (!first) return value; + const incompleteIssues: [ + SqlCompletionIssue, + ...SqlCompletionIssue[], + ] = [first, ...issues.slice(1)]; + return Object.freeze({ + isIncomplete: true, + issues: Object.freeze(incompleteIssues), + items: value.items, + }); +} + interface MissingDataProperty { readonly found: false; } @@ -937,13 +1101,23 @@ export class DefaultSqlDocumentSession { readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; readonly #catalogResponseBudgetMs: number; + readonly #columnCoordinator: SqlColumnCatalogBatchCoordinator | null; + readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #dialects: ReadonlyMap; readonly #onDispose: () => void; readonly #listeners = new Set(); #activeCompletion: CompletionRequestState | null = null; + #columnLoadingRetry: AuxiliaryLoadingRetry | null = null; #catalogOwner: SqlCatalogSearchWorkOwner | null = null; #catalogOwnerDialect: SqlRelationDialectRuntime | null = null; #catalogOwnerScope: string | null = null; + #columnOwner: SqlColumnCatalogBatchOwner | null = null; + #columnOwnerDialect: SqlRelationDialectRuntime | null = null; + #columnOwnerScope: string | null = null; + #namespaceOwner: SqlNamespaceCatalogOwner | null = null; + #namespaceOwnerDialect: SqlRelationDialectRuntime | null = null; + #namespaceOwnerScope: string | null = null; + #namespaceLoadingRetry: AuxiliaryLoadingRetry | null = null; #disposed = false; #localRelationStatementCache: | LocalRelationStatementCache @@ -960,10 +1134,14 @@ export class DefaultSqlDocumentSession context: Context, dialects: ReadonlyMap, catalogCoordinator: SqlCatalogSearchWorkCoordinator | null, + columnCoordinator: SqlColumnCatalogBatchCoordinator | null, + namespaceCoordinator: SqlNamespaceCatalogCoordinator | null, completion: CompletionConfiguration, onDispose: () => void, ) { this.#catalogCoordinator = catalogCoordinator; + this.#columnCoordinator = columnCoordinator; + this.#namespaceCoordinator = namespaceCoordinator; this.#catalogResponseBudgetMs = completion.catalogResponseBudgetMs; this.#dialects = dialects; @@ -984,6 +1162,8 @@ export class DefaultSqlDocumentSession sourceSequence, }); this.#replaceCatalogOwner(); + this.#replaceColumnOwner(); + this.#replaceNamespaceOwner(); } get revision(): SqlRevision { @@ -1100,6 +1280,77 @@ export class DefaultSqlDocumentSession clearTimeout(timer.handle); } + #retainAuxiliaryRefresh( + active: CompletionRequestState, + readiness: Promise, + ): number { + this.#clearSoftRefreshIntentTimer(); + this.#refreshIntent = active; + const cell: SessionTimerCell = { + active: true, + handle: undefined, + }; + this.#softRefreshIntentTimer = cell; + const expire = setTimeout(() => { + cell.active = false; + this.#softRefreshIntentTimer = null; + this.#refreshIntent = null; + cancelCompletionTickets(active); + }, TERMINAL_LOADING_INTENT_LEASE_MS); + cell.handle = expire; + void readiness.then( + () => { + if ( + !cell.active || + this.#softRefreshIntentTimer !== cell + ) { + return; + } + const commit = this.#prepareServiceChange({ + expected: active, + reason: "catalog-availability", + }); + commit?.(); + }, + () => { + const commit = this.#prepareServiceChange({ + expected: active, + reason: "catalog-availability", + }); + commit?.(); + }, + ); + return TERMINAL_LOADING_INTENT_LEASE_MS; + } + + #claimAuxiliaryLoadingRetry( + feature: "column" | "namespace", + snapshot: SessionSnapshot, + position: number, + ): boolean { + const previous = feature === "column" + ? this.#columnLoadingRetry + : this.#namespaceLoadingRetry; + if ( + previous?.context === snapshot.context && + previous.source === snapshot.source && + previous.position === position + ) { + return false; + } + const next = Object.freeze({ + context: snapshot.context, + position, + source: snapshot.source, + }); + if (feature === "column") { + this.#columnLoadingRetry = next; + } else { + this.#namespaceLoadingRetry = next; + } + return true; + } + #dispatchChange(event: SqlSessionChangeEvent): void { if ( this.#disposed || @@ -1192,8 +1443,22 @@ export class DefaultSqlDocumentSession } } return (): undefined => { - active?.ticket?.cancel(); - if (intent !== active) intent?.ticket?.cancel(); + cancelCompletionTickets(active); + if (intent !== active) cancelCompletionTickets(intent); + if (change.reason === "catalog") { + this.#columnLoadingRetry = null; + this.#namespaceLoadingRetry = null; + this.#columnOwner?.dispose(); + this.#columnOwner = null; + this.#columnOwnerDialect = null; + this.#columnOwnerScope = null; + this.#namespaceOwner?.dispose(); + this.#namespaceOwner = null; + this.#namespaceOwnerDialect = null; + this.#namespaceOwnerScope = null; + this.#replaceColumnOwner(); + this.#replaceNamespaceOwner(); + } this.#dispatchChange(event); return undefined; }; @@ -1240,6 +1505,62 @@ export class DefaultSqlDocumentSession } } + #replaceColumnOwner(): void { + const catalog = resolveCatalogContext(this.#snapshot.context); + const dialect = this.#snapshot.dialect.relationDialect; + if ( + this.#columnOwner && + catalog && + this.#columnOwnerScope === catalog.scope && + this.#columnOwnerDialect === dialect + ) { + return; + } + this.#columnOwner?.dispose(); + this.#columnOwner = null; + this.#columnOwnerDialect = null; + this.#columnOwnerScope = null; + if (!catalog || !this.#columnCoordinator || this.#disposed) { + return; + } + const prepared = this.#columnCoordinator.prepareOwner({ + dialectId: dialect.id, + scope: catalog.scope, + }); + if (prepared.status !== "prepared") return; + this.#columnOwner = prepared.owner; + this.#columnOwnerDialect = dialect; + this.#columnOwnerScope = catalog.scope; + } + + #replaceNamespaceOwner(): void { + const catalog = resolveCatalogContext(this.#snapshot.context); + const dialect = this.#snapshot.dialect.relationDialect; + if ( + this.#namespaceOwner && + catalog && + this.#namespaceOwnerScope === catalog.scope && + this.#namespaceOwnerDialect === dialect + ) { + return; + } + this.#namespaceOwner?.dispose(); + this.#namespaceOwner = null; + this.#namespaceOwnerDialect = null; + this.#namespaceOwnerScope = null; + if (!catalog || !this.#namespaceCoordinator || this.#disposed) { + return; + } + const prepared = this.#namespaceCoordinator.prepareOwner({ + dialectId: dialect.id, + scope: catalog.scope, + }); + if (prepared.status !== "prepared") return; + this.#namespaceOwner = prepared.owner; + this.#namespaceOwnerDialect = dialect; + this.#namespaceOwnerScope = catalog.scope; + } + readonly onDidChange = ( listener: (event: SqlSessionChangeEvent) => void, ): SqlDisposable => { @@ -1269,6 +1590,25 @@ export class DefaultSqlDocumentSession }); }; + readonly invalidateCatalog = (): SqlRevision => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const commit = this.#prepareServiceChange({ reason: "catalog" }); + if (!commit) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const revision = this.#snapshot.revision; + commit(); + return revision; + }; + readonly complete = ( request: SqlCompletionRequest, ): SqlCompletionTask => { @@ -1394,9 +1734,9 @@ export class DefaultSqlDocumentSession previousIntent.cancelReason = "superseded"; } const cancelPrevious = (): void => { - previousActive?.ticket?.cancel(); + cancelCompletionTickets(previousActive); if (previousIntent !== previousActive) { - previousIntent?.ticket?.cancel(); + cancelCompletionTickets(previousIntent); } if (this.#refreshIntent === previousIntent) { this.#refreshIntent = null; @@ -1409,19 +1749,17 @@ export class DefaultSqlDocumentSession const active: CompletionRequestState = { cancelReason: null, revision: snapshot.revision, - ticket: null, + tickets: new Set(), token: refreshToken, }; this.#activeCompletion = active; + const isCurrent = (): boolean => + this.#activeCompletion === active && + active.cancelReason === null && + snapshot.revision === this.#snapshot.revision; const cancellationIfNotCurrent = (): SqlCompletionResult | null => { - if ( - this.#activeCompletion === active && - active.cancelReason === null && - snapshot.revision === this.#snapshot.revision - ) { - return null; - } + if (isCurrent()) return null; cancelPrevious(); return completionCancellation( snapshot.revision, @@ -1437,7 +1775,7 @@ export class DefaultSqlDocumentSession active.cancelReason === null ) { active.cancelReason = "caller"; - active.ticket?.cancel(); + cancelCompletionTickets(active); } }; const makeInvocationInert = (): void => { @@ -1539,6 +1877,135 @@ export class DefaultSqlDocumentSession position, ); if (localSite.status !== "ready") { + const columnSite = analyzeSqlLocalColumnSite( + prepared.statement, + position, + ); + if ( + localSite.status === "inactive" && + columnSite.status === "ready" + ) { + const catalog = resolveCatalogContext(snapshot.context); + const columnOwner = this.#columnOwner; + const columnCoordinator = this.#columnCoordinator; + const preparedRelations = prepareSqlColumnCatalogRelations( + columnSite, + snapshot.dialect.relationDialect, + ); + if (preparedRelations.references.length === 0) { + cancelPrevious(); + return Object.freeze({ + reason: unavailableCompletionReason(localSite), + retryable: false, + revision: snapshot.revision, + status: "unavailable", + }); + } + if (!catalog || !columnOwner || !columnCoordinator) { + cancelPrevious(); + return Object.freeze({ + reason: "unsupported-query-site", + retryable: false, + revision: snapshot.revision, + status: "unavailable", + }); + } + if (!isCurrent()) { + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + const ticket = columnOwner.request({ + expectedEpoch: null, + relations: preparedRelations.references, + searchPaths: catalog.searchPaths, + }); + active.tickets.add(ticket); + const raced = await raceCatalogResponse( + ticket.result, + remainingCatalogBudget( + completionStartedAt, + this.#catalogResponseBudgetMs, + ), + ); + if (!isCurrent()) { + ticket.cancel(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + if (raced.kind === "timeout") { + const remainingIntentLeaseMs = + this.#retainAuxiliaryRefresh(active, ticket.result); + return Object.freeze({ + refreshToken: active.token, + revision: snapshot.revision, + sources: Object.freeze([Object.freeze({ + feature: "column-catalog" as const, + failures: Object.freeze([]), + outcome: "loading" as const, + providerId: columnCoordinator.providerId, + })]), + status: "ready", + value: Object.freeze({ + isIncomplete: true, + issues: Object.freeze([Object.freeze({ + reason: "column-catalog-loading" as const, + remainingIntentLeaseMs, + })] as const), + items: Object.freeze([]), + }), + }); + } + const composition = composeSqlColumnCompletion({ + dialect: snapshot.dialect.relationDialect, + outcome: raced.outcome, + prepared: preparedRelations, + providerId: columnCoordinator.providerId, + site: columnSite, + }); + if (!composition) { + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + const columnLoading = + composition.sources[0]?.outcome === "loading"; + const retryLoading = + columnLoading && + this.#claimAuxiliaryLoadingRetry( + "column", + snapshot, + request.position, + ); + const remainingIntentLeaseMs = retryLoading + ? this.#retainAuxiliaryRefresh( + active, + new Promise((resolve) => { + setTimeout( + resolve, + AUXILIARY_LOADING_RETRY_DELAY_MS, + ); + }), + ) + : 0; + return Object.freeze({ + refreshToken: retryLoading ? active.token : null, + revision: snapshot.revision, + sources: composition.sources, + status: "ready", + value: retryLoading + ? completionListWithLoadingLease( + composition.value, + "column-catalog-loading", + remainingIntentLeaseMs, + ) + : composition.value, + }); + } cancelPrevious(); return Object.freeze({ reason: unavailableCompletionReason(localSite), @@ -1547,11 +2014,7 @@ export class DefaultSqlDocumentSession status: "unavailable", }); } - if ( - this.#activeCompletion !== active || - active.cancelReason !== null || - snapshot.revision !== this.#snapshot.revision - ) { + if (!isCurrent()) { return completionCancellation( snapshot.revision, completionCancellationReason(active), @@ -1578,11 +2041,7 @@ export class DefaultSqlDocumentSession status: "unavailable", }); } else { - if ( - this.#activeCompletion !== active || - active.cancelReason !== null || - snapshot.revision !== this.#snapshot.revision - ) { + if (!isCurrent()) { cancelPrevious(); return completionCancellation( snapshot.revision, @@ -1599,38 +2058,15 @@ export class DefaultSqlDocumentSession if (this.#refreshIntent === previousIntent) { this.#refreshIntent = null; } - active.ticket = ticket; - const elapsed = Math.max( - 0, - performance.now() - completionStartedAt, - ); - const remaining = Math.max( - 0, - this.#catalogResponseBudgetMs - elapsed, - ); - let responseTimer: - | ReturnType - | undefined; - const raced = await Promise.race([ - ticket.result.then((outcome) => - Object.freeze({ - kind: "outcome" as const, - outcome, - }), + active.tickets.add(ticket); + const raced = await raceCatalogResponse( + ticket.result, + remainingCatalogBudget( + completionStartedAt, + this.#catalogResponseBudgetMs, ), - new Promise<{ readonly kind: "timeout" }>((resolve) => { - responseTimer = setTimeout( - () => resolve(Object.freeze({ kind: "timeout" })), - remaining, - ); - }), - ]); - clearTimeout(responseTimer); - if ( - this.#activeCompletion !== active || - active.cancelReason !== null || - snapshot.revision !== this.#snapshot.revision - ) { + ); + if (!isCurrent()) { ticket.cancel(); return completionCancellation( snapshot.revision, @@ -1774,6 +2210,95 @@ export class DefaultSqlDocumentSession cancelPrevious(); } + let namespaceComposition: SqlNamespaceCompletionComposition | null = + null; + let namespaceLoadingLeaseMs = 0; + const namespaceOwner = this.#namespaceOwner; + const namespaceCoordinator = this.#namespaceCoordinator; + if (catalog && namespaceOwner && namespaceCoordinator) { + const ticket = namespaceOwner.request( + prepareSqlNamespaceCatalogSearch( + { + prefix: querySite.prefix, + qualifier: querySite.qualifier, + replacementRange, + }, + null, + catalog.searchPaths, + MAX_NAMESPACE_RESULTS, + ), + ); + active.tickets.add(ticket); + const raced = await raceCatalogResponse( + ticket.result, + remainingCatalogBudget( + completionStartedAt, + this.#catalogResponseBudgetMs, + ), + ); + if (!isCurrent()) { + ticket.cancel(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + if (raced.kind === "timeout") { + namespaceLoadingLeaseMs = + this.#retainAuxiliaryRefresh(active, ticket.result); + namespaceComposition = Object.freeze({ + source: Object.freeze({ + feature: "namespace-catalog", + outcome: "loading", + providerId: namespaceCoordinator.providerId, + }), + value: Object.freeze({ + isIncomplete: true, + issues: Object.freeze([ + "namespace-catalog-loading" as const, + ] as const), + items: Object.freeze([]), + }), + }); + } else { + const outcome: SqlNamespaceCatalogSearchOutcome = + raced.outcome; + namespaceComposition = composeSqlNamespaceCompletion({ + matchPrefix: + snapshot.dialect.relationDialect.completion + .cteIdentifierMatchesPrefix, + outcome, + prefix: querySite.prefix, + providerId: namespaceCoordinator.providerId, + replacementRange, + }); + if (!namespaceComposition) { + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + if (namespaceComposition.source.outcome === "loading") { + namespaceLoadingLeaseMs = + this.#claimAuxiliaryLoadingRetry( + "namespace", + snapshot, + request.position, + ) + ? this.#retainAuxiliaryRefresh( + active, + new Promise((resolve) => { + setTimeout( + resolve, + AUXILIARY_LOADING_RETRY_DELAY_MS, + ); + }), + ) + : 0; + } + } + } + const composition = composeSqlRelationCompletion({ catalogOutcome, dialect: snapshot.dialect.relationDialect, @@ -1786,19 +2311,34 @@ export class DefaultSqlDocumentSession replacementRange, statementOffset, }); - if ( - this.#activeCompletion !== active || - active.cancelReason !== null || - snapshot.revision !== this.#snapshot.revision - ) { + if (!isCurrent()) { return completionCancellation( snapshot.revision, completionCancellationReason(active), ); } + const value = namespaceComposition + ? mergeCompletionLists( + composition.value, + namespaceLoadingLeaseMs > 0 + ? completionListWithLoadingLease( + namespaceCompletionList(namespaceComposition), + "namespace-catalog-loading", + namespaceLoadingLeaseMs, + ) + : namespaceCompletionList(namespaceComposition), + ) + : composition.value; + const sources = namespaceComposition + ? Object.freeze([ + ...composition.sources, + namespaceComposition.source, + ]) + : composition.sources; return Object.freeze({ refreshToken: - (catalogOutcome?.status === "loading" || + (namespaceLoadingLeaseMs > 0 || + catalogOutcome?.status === "loading" || (catalogOutcome?.status === "usable" && catalogOutcome.response.status === "loading")) && (this.#refreshIntent === active || @@ -1806,9 +2346,9 @@ export class DefaultSqlDocumentSession ? active.token : null, revision: snapshot.revision, - sources: composition.sources, + sources, status: "ready", - value: composition.value, + value, }); } finally { if (this.#activeCompletion === active) { @@ -2029,7 +2569,12 @@ export class DefaultSqlDocumentSession this.#dialects, ); const nextCatalog = resolveCatalogContext(nextContext); - if (nextCatalog && !this.#catalogCoordinator) { + if ( + nextCatalog && + !this.#catalogCoordinator && + !this.#columnCoordinator && + !this.#namespaceCoordinator + ) { throw new SqlSessionError( "invalid-context", "SQL catalog context requires a configured catalog provider", @@ -2100,6 +2645,8 @@ export class DefaultSqlDocumentSession this.#localRelationStatementCache = null; } this.#activeCompletion = null; + this.#columnLoadingRetry = null; + this.#namespaceLoadingRetry = null; this.#refreshIntent = null; this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); @@ -2116,11 +2663,13 @@ export class DefaultSqlDocumentSession ) { refreshIntent.cancelReason = "superseded"; } - activeCompletion?.ticket?.cancel(); + cancelCompletionTickets(activeCompletion); if (refreshIntent !== activeCompletion) { - refreshIntent?.ticket?.cancel(); + cancelCompletionTickets(refreshIntent); } this.#replaceCatalogOwner(); + this.#replaceColumnOwner(); + this.#replaceNamespaceOwner(); return this.#snapshot.revision; } @@ -2136,9 +2685,15 @@ export class DefaultSqlDocumentSession const activeCompletion = this.#activeCompletion; const refreshIntent = this.#refreshIntent; const catalogOwner = this.#catalogOwner; + const columnOwner = this.#columnOwner; + const namespaceOwner = this.#namespaceOwner; this.#activeCompletion = null; this.#refreshIntent = null; this.#catalogOwner = null; + this.#columnLoadingRetry = null; + this.#columnOwner = null; + this.#namespaceOwner = null; + this.#namespaceLoadingRetry = null; this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); this.#listeners.clear(); @@ -2157,11 +2712,13 @@ export class DefaultSqlDocumentSession ) { refreshIntent.cancelReason = "disposed"; } - activeCompletion?.ticket?.cancel(); + cancelCompletionTickets(activeCompletion); if (refreshIntent !== activeCompletion) { - refreshIntent?.ticket?.cancel(); + cancelCompletionTickets(refreshIntent); } catalogOwner?.dispose(); + columnOwner?.dispose(); + namespaceOwner?.dispose(); this.#onDispose(); }; } @@ -2170,6 +2727,8 @@ export class DefaultSqlLanguageService implements SqlLanguageService { readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; + readonly #columnCoordinator: SqlColumnCatalogBatchCoordinator | null; + readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #completion: CompletionConfiguration; readonly #dialects: ReadonlyMap; readonly #sessions = new Set>(); @@ -2303,6 +2862,48 @@ export class DefaultSqlLanguageService } this.#catalogCoordinator = coordinator.coordinator; } + + const columns = readOwnDataProperty( + options, + "columns", + "invalid-service-options", + "SQL language service options", + ); + if (!columns.found || columns.value === undefined) { + this.#columnCoordinator = null; + } else { + const coordinator = createSqlColumnCatalogBatchCoordinator({ + provider: columns.value, + }); + if (coordinator.status !== "created") { + throw new SqlSessionError( + "invalid-service-options", + "SQL column catalog provider is invalid", + ); + } + this.#columnCoordinator = coordinator.coordinator; + } + + const namespaces = readOwnDataProperty( + options, + "namespaces", + "invalid-service-options", + "SQL language service options", + ); + if (!namespaces.found || namespaces.value === undefined) { + this.#namespaceCoordinator = null; + } else { + const coordinator = createSqlNamespaceCatalogCoordinator({ + provider: namespaces.value, + }); + if (coordinator.status !== "created") { + throw new SqlSessionError( + "invalid-service-options", + "SQL namespace catalog provider is invalid", + ); + } + this.#namespaceCoordinator = coordinator.coordinator; + } } catch (error) { if (error instanceof SqlSessionError) { throw error; @@ -2365,7 +2966,12 @@ export class DefaultSqlLanguageService const context = cloneContext(candidateContext); resolveDialectRuntime(context, this.#dialects); const catalogContext = resolveCatalogContext(context); - if (catalogContext && !this.#catalogCoordinator) { + if ( + catalogContext && + !this.#catalogCoordinator && + !this.#columnCoordinator && + !this.#namespaceCoordinator + ) { throw new SqlSessionError( "invalid-context", "SQL catalog context requires a configured catalog provider", @@ -2378,6 +2984,8 @@ export class DefaultSqlLanguageService context, this.#dialects, this.#catalogCoordinator, + this.#columnCoordinator, + this.#namespaceCoordinator, this.#completion, () => { this.#sessions.delete(session); @@ -2419,6 +3027,8 @@ export class DefaultSqlLanguageService } this.#sessions.clear(); this.#catalogCoordinator?.dispose(); + this.#columnCoordinator?.dispose(); + this.#namespaceCoordinator?.dispose(); }; } diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 075c403..a0582b4 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -1,5 +1,15 @@ const revisionBrand: unique symbol = Symbol("SqlRevision"); +export function isDataArray( + value: unknown, +): value is readonly unknown[] { + try { + return Array.isArray(value); + } catch { + return false; + } +} + /** Immutable identity issued by a document session. */ export interface SqlRevision { readonly [revisionBrand]: "SqlRevision"; @@ -135,6 +145,8 @@ export interface OpenSqlDocument { /** Owns all mutable state for one open SQL document. */ export interface SqlDocumentSession { readonly revision: SqlRevision; + /** Invalidates relation, column, and namespace catalog observations. */ + readonly invalidateCatalog: () => SqlRevision; readonly statementBoundaryAt: ( request: SqlStatementBoundaryAtRequest, ) => SqlStatementBoundaryAtResult; @@ -162,10 +174,12 @@ export interface SqlLanguageService { export interface SqlLanguageServiceOptions { readonly catalog?: SqlRelationCatalogProvider | undefined; + readonly columns?: SqlColumnCatalogProvider | undefined; readonly completion?: { readonly catalogResponseBudgetMs?: number | undefined; } | undefined; readonly dialects: readonly SqlDialect[]; + readonly namespaces?: SqlNamespaceCatalogProvider | undefined; } export type SqlSessionErrorCode = @@ -192,6 +206,12 @@ export class SqlSessionError extends Error { this.code = code; } } +import type { + SqlColumnCatalogProvider, +} from "./column-catalog-types.js"; +import type { + SqlNamespaceCatalogProvider, +} from "./namespace-catalog-types.js"; import type { SqlCompletionRequest, SqlCompletionTask, diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts index 33938c2..95dab26 100644 --- a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts +++ b/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts @@ -3,6 +3,7 @@ import type { EditorView } from "@codemirror/view"; import type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, + SqlEditorStatementGutterOptions, } from "../../src/vnext/codemirror/index.js"; import type { SqlCompletionItem } from "../../src/vnext/relation-completion-types.js"; @@ -32,6 +33,14 @@ declare const item: SqlCompletionItem; const resolved = resolveInfo(item, { signal: new AbortController().signal, }); +const gutter: SqlEditorStatementGutterOptions = { + hideWhenNotFocused: true, + showInactive: true, +}; +const invalidGutter: SqlEditorStatementGutterOptions = { + // @ts-expect-error gutter flags are boolean + showInactive: "yes", +}; // @ts-expect-error resolver items are immutable item.label = "changed"; @@ -62,6 +71,8 @@ const resolverWithoutDestroy: SqlCompletionInfoResolver = () => ({ }); void resolved; +void gutter; +void invalidGutter; void resolverReturningNode; void resolverReturningNumber; void resolverReturningReactData; diff --git a/test/vnext-types/marimo-sql-migration.test-d.ts b/test/vnext-types/marimo-sql-migration.test-d.ts new file mode 100644 index 0000000..deebda8 --- /dev/null +++ b/test/vnext-types/marimo-sql-migration.test-d.ts @@ -0,0 +1,591 @@ +import type { + CompletionSource, +} from "@codemirror/autocomplete"; +import type { + ChangeSpec, + EditorState, + StateEffectType, + TransactionSpec, +} from "@codemirror/state"; + +import { + bigQueryDialect, + createSqlLanguageService, + dremioDialect, + duckdbDialect, + postgresDialect, + type SqlCanonicalRelationPath, + type SqlCatalogEpoch, + type SqlCatalogReadyCoverage, + type SqlCatalogRelation, + type SqlCatalogSearchRequest, + type SqlCatalogSearchResponse, + type SqlColumnCatalogBatchRequest, + type SqlColumnCatalogProvider, + type SqlContextInput, + type SqlDocumentContext, + type SqlEmbeddedRegion, + type SqlIdentifierComponent, + type SqlIdentifierPath, + type SqlNamespaceCatalogProvider, + type SqlRelationCatalogProvider, +} from "../../src/vnext/index.js"; +import { + sqlEditor, + type SqlCompletionInfoResolver, + type SqlEditorSupport, +} from "../../src/vnext/codemirror/index.js"; + +type VnextDialectId = + | "bigquery" + | "dremio" + | "duckdb" + | "postgres"; + +type MarimoBackendDialect = + | VnextDialectId + | "postgresql" + | "mysql" + | "oracle" + | "sqlite" + | "snowflake"; + +interface MarimoSqlContext extends SqlDocumentContext { + readonly engine: string; +} + +interface MarimoConnection { + readonly defaultDatabase: string | null; + readonly defaultSchema: string | null; + readonly dialect: MarimoBackendDialect; + readonly engine: string; + /** Changes whenever a same-named backend connection is replaced. */ + readonly incarnation: number; +} + +interface MarimoTableMetadata { + readonly detail: string; + readonly entityId: string; +} + +interface MarimoColumnMetadata { + readonly columnEntityId: string; + readonly detail: string; + readonly relationEntityId: string; +} + +interface MarimoCatalogSnapshot { + readonly epoch: SqlCatalogEpoch; + readonly relations: readonly SqlCatalogRelation[]; +} + +interface MarimoDataTableColumn { + readonly columnEntityId: string; + readonly dataType?: string; + readonly detail?: string; + readonly identifier: SqlIdentifierComponent; + readonly insertText: string; + readonly ordinal: number; +} + +type MarimoDataTableColumnResult = + | { + readonly columns: readonly MarimoDataTableColumn[]; + readonly coverage: "complete" | "partial"; + readonly relationEntityId: string; + readonly requestKey: string; + readonly status: "ready"; + } + | { + readonly requestKey: string; + readonly status: "loading"; + } + | { + readonly code: + | "authentication" + | "authorization" + | "invalid-configuration" + | "rate-limited" + | "unavailable" + | "unknown"; + readonly requestKey: string; + readonly retry: "after-invalidation" | "never" | "next-request"; + readonly status: "failed"; + }; + +interface MarimoDataTableColumnBatch { + readonly epoch: SqlCatalogEpoch; + readonly relations: readonly MarimoDataTableColumnResult[]; +} + +interface MarimoNamespaceProjection { + readonly entityId: string; + readonly kind: "catalog" | "dataset" | "project" | "schema"; + readonly path: SqlIdentifierPath; + readonly scope: string; +} + +declare const catalogByScope: + ReadonlyMap; +declare const tableMetadataById: + ReadonlyMap; +declare const columnMetadataByProvenance: + ReadonlyMap; +declare const namespaceProjectionByScope: + ReadonlyMap; +declare const subscribeToCatalogScope: ( + scope: string, + listener: (epoch: SqlCatalogEpoch) => void, +) => () => void; +declare const searchMarimoCatalogIndex: ( + snapshot: MarimoCatalogSnapshot, + request: SqlCatalogSearchRequest, +) => { + readonly coverage: SqlCatalogReadyCoverage; + readonly relations: readonly SqlCatalogRelation[]; +}; +declare const loadMarimoDataTableColumnBatch: ( + request: SqlColumnCatalogBatchRequest, + signal: AbortSignal, +) => Promise; +declare const variableCompletionSource: CompletionSource; +declare const keywordCompletionSource: CompletionSource; +declare const legacySchemaCompletionSource: CompletionSource; + +interface ReactRootLike { + readonly render: (value: unknown) => void; + readonly unmount: () => void; +} + +declare function createReactRoot(container: Element): ReactRootLike; + +function connectionScope( + connection: MarimoConnection, +): string { + return `${connection.engine}:${connection.incarnation}`; +} + +function identifier(value: string): SqlIdentifierPath[number] { + return { quoted: false, value }; +} + +function searchPaths( + connection: MarimoConnection, +): readonly SqlIdentifierPath[] { + const path: SqlIdentifierPath[number][] = []; + if (connection.defaultDatabase !== null) { + path.push(identifier(connection.defaultDatabase)); + } + if (connection.defaultSchema !== null) { + path.push(identifier(connection.defaultSchema)); + } + return path.length === 0 ? [] : [path]; +} + +function contextFor( + route: VnextCompletionRoute, +): SqlContextInput { + const { connection } = route; + return { + catalog: { + scope: connectionScope(connection), + searchPath: searchPaths(connection), + }, + dialect: route.dialect, + engine: connection.engine, + }; +} + +const marimoCatalogProvider: SqlRelationCatalogProvider = { + id: "marimo-datasets", + search: async ( + request, + signal, + ): Promise => { + signal.throwIfAborted(); + const snapshot = catalogByScope.get(request.scope); + if (snapshot === undefined) { + return { + code: "invalid-configuration", + epoch: { generation: 0, token: "missing-scope" }, + retry: "after-invalidation", + status: "failed", + }; + } + const result = searchMarimoCatalogIndex(snapshot, request); + return { + coverage: result.coverage, + epoch: snapshot.epoch, + relations: result.relations, + status: "ready", + }; + }, + subscribe: (scope, onInvalidation) => { + const unsubscribe = subscribeToCatalogScope(scope, (epoch) => { + onInvalidation({ epoch }); + }); + return (): undefined => { + unsubscribe(); + return undefined; + }; + }, +}; + +const marimoColumnProvider: SqlColumnCatalogProvider = { + id: "marimo-datatable-columns", + loadColumns: async (request, signal) => { + signal.throwIfAborted(); + const batch = await loadMarimoDataTableColumnBatch(request, signal); + signal.throwIfAborted(); + return { + epoch: batch.epoch, + relations: batch.relations.map((result) => { + if (result.status !== "ready") return result; + return { + columns: result.columns.map((column) => ({ + columnEntityId: column.columnEntityId, + identifier: column.identifier, + insertText: column.insertText, + ordinal: column.ordinal, + ...(column.dataType === undefined + ? {} + : { dataType: column.dataType }), + ...(column.detail === undefined + ? {} + : { detail: column.detail }), + })), + coverage: result.coverage, + relationEntityId: result.relationEntityId, + requestKey: result.requestKey, + status: result.status, + }; + }), + }; + }, +}; + +const marimoNamespaceProvider: SqlNamespaceCatalogProvider = { + id: "marimo-namespaces", + search: async (request, signal) => { + signal.throwIfAborted(); + const projections = + namespaceProjectionByScope.get(request.scope) ?? []; + return { + containers: projections.flatMap((projection) => { + const first = projection.path[0]; + if (first === undefined) return []; + const canonicalPath = [ + { + ...first, + role: projection.kind, + }, + ...projection.path.slice(1).map((component) => ({ + ...component, + role: projection.kind, + })), + ] as const; + return [{ + canonicalPath, + containerEntityId: projection.entityId, + insertText: + (canonicalPath[canonicalPath.length - 1] ?? first).value, + matchQuality: "exact" as const, + }]; + }), + coverage: "complete", + epoch: catalogByScope.get(request.scope)?.epoch ?? { + generation: 0, + token: "missing-scope", + }, + status: "ready", + }; + }, +}; + +// One caller-owned service is shared by every SQL editor support/view. +const sharedSqlService = + createSqlLanguageService({ + catalog: marimoCatalogProvider, + columns: marimoColumnProvider, + dialects: [ + bigQueryDialect(), + dremioDialect(), + duckdbDialect(), + postgresDialect(), + ], + namespaces: marimoNamespaceProvider, + }); + +const infoResolver: SqlCompletionInfoResolver = ( + item, + { signal }, +) => { + signal.throwIfAborted(); + const metadata = item.provenance.kind === "catalog" + ? tableMetadataById.get(item.provenance.entityId) + : item.provenance.kind === "column-catalog" + ? columnMetadataByProvenance.get( + `${item.provenance.scope}\0${item.provenance.relationEntityId}\0${item.provenance.columnEntityId}`, + ) + : undefined; + if (metadata === undefined) return null; + const dom = document.createElement("div"); + const root = createReactRoot(dom); + root.render(metadata.detail); + return { + destroy: () => root.unmount(), + dom, + }; +}; + +/** + * Returns the complete, ordered region set for the resulting document. + * Regions include both braces, matching marimo's current `{python}` syntax. + */ +function pythonTemplateRegions( + text: string, +): readonly SqlEmbeddedRegion[] { + const regions: SqlEmbeddedRegion[] = []; + for (let index = 0; index < text.length;) { + if (text[index] !== "{") { + index += 1; + continue; + } + if (text[index + 1] === "{") { + index += 2; + continue; + } + const close = text.indexOf("}", index + 1); + const to = close === -1 ? text.length : close + 1; + if (to > index) { + regions.push({ + from: index, + language: "python", + to, + }); + } + index = Math.max(index + 1, to); + } + return regions; +} + +/** + * Embedded regions are half-open, so an unmatched expression ending at EOF + * also needs an insertion-point gate at `doc.length`. + */ +function sqlCompletionPositionAllowed( + state: EditorState, + position: number, +): boolean { + const prefix = state.sliceDoc(0, position); + let inPython = false; + for (let index = 0; index < prefix.length; index += 1) { + if (inPython) { + if (prefix[index] === "}") inPython = false; + continue; + } + if ( + prefix[index] === "{" && + prefix[index + 1] !== "{" + ) { + inPython = true; + continue; + } + if ( + prefix[index] === "{" && + prefix[index + 1] === "{" + ) { + index += 1; + } + } + return !inPython; +} + +interface VnextCompletionRoute { + readonly connection: MarimoConnection; + readonly dialect: VnextDialectId; + readonly kind: "vnext"; +} + +type CompletionRoute = + | { + readonly kind: "legacy"; + readonly source: CompletionSource; + } + | VnextCompletionRoute; + +function completionRoute( + connection: MarimoConnection, +): CompletionRoute { + switch (connection.dialect) { + case "bigquery": + return { connection, dialect: "bigquery", kind: "vnext" }; + case "dremio": + return { connection, dialect: "dremio", kind: "vnext" }; + case "duckdb": + return { connection, dialect: "duckdb", kind: "vnext" }; + case "postgres": + case "postgresql": + return { connection, dialect: "postgres", kind: "vnext" }; + case "mysql": + case "oracle": + case "sqlite": + case "snowflake": + return { kind: "legacy", source: legacySchemaCompletionSource }; + } +} + +function createVnextSupport( + route: VnextCompletionRoute, + initialText: string, +): SqlEditorSupport { + return sqlEditor({ + autocomplete: { + defaultKeymap: false, + externalSources: [ + variableCompletionSource, + keywordCompletionSource, + ], + infoResolver, + isCompletionPositionAllowed: sqlCompletionPositionAllowed, + }, + initialContext: contextFor(route), + initialEmbeddedRegions: pythonTemplateRegions(initialText), + service: sharedSqlService, + statementGutter: { + hideWhenNotFocused: true, + showInactive: true, + }, + }); +} + +interface MarimoSqlMetadata { + readonly route: VnextCompletionRoute; +} + +declare const setMarimoSqlMetadata: + StateEffectType; +declare const reconfigureSqlDialect: + StateEffectType; + +/** + * Marimo owns this transaction seam. `resultingText` is the document after + * `changes`; all interpretation inputs are emitted in the same transaction. + */ +function atomicSqlInputTransaction( + support: SqlEditorSupport, + input: { + readonly changes?: ChangeSpec; + readonly metadata: MarimoSqlMetadata; + readonly resultingText: string; + }, +): TransactionSpec { + const { route } = input.metadata; + const transaction: TransactionSpec = { + effects: [ + setMarimoSqlMetadata.of(input.metadata), + support.contextEffect.of(contextFor(route)), + reconfigureSqlDialect.of(route.dialect), + support.embeddedRegionsEffect.of( + pythonTemplateRegions(input.resultingText), + ), + ], + }; + return input.changes === undefined + ? transaction + : { ...transaction, changes: input.changes }; +} + +declare const duckdbRoute: VnextCompletionRoute; +declare const secondDuckdbRoute: VnextCompletionRoute; +declare const mysqlConnection: MarimoConnection & { + readonly dialect: "mysql"; +}; + +const firstSupport = createVnextSupport( + duckdbRoute, + "SELECT * FROM {df}", +); +const secondSupport = createVnextSupport( + secondDuckdbRoute, + "SELECT * FROM users", +); +const atomicSwitch = atomicSqlInputTransaction(firstSupport, { + changes: { from: 14, insert: "orders", to: 18 }, + metadata: { route: secondDuckdbRoute }, + resultingText: "SELECT * FROM orders", +}); +const legacyRoute = completionRoute(mysqlConnection); +if (legacyRoute.kind === "legacy") { + const source: CompletionSource = legacyRoute.source; + void source; +} + +const relationPath = [ + { quoted: false, role: "catalog", value: "memory" }, + { quoted: false, role: "schema", value: "main" }, + { quoted: false, role: "relation", value: "users" }, +] satisfies SqlCanonicalRelationPath; + +const coldColumnBatch = { + dialectId: "duckdb", + expectedEpoch: null, + relations: [ + { + path: [ + { quoted: false, value: "main" }, + { quoted: false, value: "users" }, + ], + requestKey: "binding:users", + }, + { + path: [ + { quoted: false, value: "main" }, + { quoted: false, value: "orders" }, + ], + requestKey: "binding:orders", + }, + ], + searchPaths: [[{ quoted: false, value: "main" }]], + scope: "duckdb:42", +} satisfies SqlColumnCatalogBatchRequest; + +const columnProviderStateExamples = [ + { + columns: [{ + columnEntityId: "connection:users:customer-id", + dataType: "VARCHAR", + detail: "Customer identifier", + identifier: { quoted: true, value: "customer id" }, + insertText: '"customer id"', + ordinal: 0, + }], + coverage: "partial", + relationEntityId: "connection:users", + requestKey: "binding:users", + status: "ready", + }, + { + requestKey: "binding:orders", + status: "loading", + }, + { + code: "authorization", + requestKey: "binding:private-orders", + retry: "after-invalidation", + status: "failed", + }, +] satisfies readonly MarimoDataTableColumnResult[]; + +void atomicSwitch; +void coldColumnBatch; +void columnProviderStateExamples; +void firstSupport; +void marimoCatalogProvider; +void marimoColumnProvider; +void namespaceProjectionByScope; +void relationPath; +void secondSupport; + +// The application, not either editor support, owns the shared service. +sharedSqlService.dispose(); From 377b53a4559b6810587d87a2d70fff844a40b051 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 15:44:25 +0800 Subject: [PATCH 34/42] mainify --- .cursorrules | 3 +- .github/workflows/release.yml | 1 - .gitignore | 3 +- AGENTS.md | 5 +- README.md | 187 +- SQL_EDITOR_RESEARCH.md | 1685 ----------------- _typos.toml | 10 +- demo/custom-renderers.ts | 329 ---- demo/data.ts | 65 +- demo/index.html | 167 +- demo/index.ts | 454 ++--- demo/utils.ts | 19 - docs/adr/0001-language-service-and-session.md | 22 +- docs/adr/0002-normalized-syntax-contract.md | 8 +- docs/adr/0003-node-sql-parser-adapter.md | 6 +- docs/adr/0004-isolated-parser-execution.md | 6 +- ...-parser-independent-relation-completion.md | 2 +- docs/{vnext => }/capability-charter.md | 18 +- docs/{vnext => }/node-sql-parser-adapter.md | 8 +- docs/{vnext => }/session-primitives.md | 11 +- docs/{vnext => }/source-coordinates.md | 2 +- docs/{vnext => }/statement-index.md | 4 +- implementation.md | 861 --------- package.json | 53 +- pnpm-lock.yaml | 32 +- scripts/changed-coverage.mjs | 3 +- scripts/package-smoke.mjs | 100 +- scripts/worker-placement.mjs | 2 +- .../__tests__/bounded-sql-lexer.test.ts | 0 src/{vnext => }/__tests__/cte-layout.bench.ts | 0 src/{vnext => }/__tests__/cte-layout.test.ts | 0 .../incremental-statement-index.test.ts | 0 src/__tests__/index.test.ts | 76 - src/{vnext => }/__tests__/lexical.test.ts | 0 .../__tests__/local-relation-site.bench.ts | 0 .../__tests__/local-relation-site.test.ts | 0 .../node-sql-parser-adapter.bench.ts | 0 .../__tests__/node-sql-parser-adapter.test.ts | 0 .../__tests__/node-sql-parser-backend.test.ts | 0 .../node-sql-parser-browser-executor.test.ts | 0 ...sql-parser-browser-worker-endpoint.test.ts | 0 .../__tests__/node-sql-parser-wire.test.ts | 0 src/__tests__/package-exports.test.ts | 25 +- src/{vnext => }/__tests__/query-site.bench.ts | 0 src/{vnext => }/__tests__/query-site.test.ts | 0 .../relation-catalog-boundary.bench.ts | 0 .../relation-catalog-boundary.test.ts | 0 ...elation-catalog-epoch-coordinator.bench.ts | 0 ...relation-catalog-epoch-coordinator.test.ts | 0 .../relation-catalog-search-work.bench.ts | 0 .../relation-catalog-search-work.test.ts | 0 .../relation-dialect-boundaries.test.ts | 0 .../__tests__/relation-dialect.test.ts | 0 src/{vnext => }/__tests__/session.test.ts | 0 src/{vnext => }/__tests__/source.test.ts | 0 .../__tests__/statement-index.bench.ts | 0 .../__tests__/statement-index.test.ts | 0 src/{vnext => }/__tests__/syntax.test.ts | 0 src/{vnext => }/bounded-sql-lexer.ts | 0 .../fixtures/node-sql-parser-crash-worker.js | 0 .../fixtures/node-sql-parser-silent-worker.js | 0 .../node-sql-parser-adapter.test.ts | 0 .../node-sql-parser-browser-executor.test.ts | 0 .../node-sql-parser-browser-worker.test.ts | 0 src/browser_tests/sql-editor.test.ts | 87 - .../codemirror/relation-completion-types.ts | 0 src/{vnext => }/cte-layout.ts | 0 src/data/README.md | 39 - src/data/common-keywords.json | 399 ---- src/data/duckdb-keywords.json | 360 ---- src/debug.ts | 6 - src/dialects/bigquery.ts | 14 - src/dialects/dremio.ts | 21 - src/dialects/duckdb/README.md | 16 - src/dialects/duckdb/duckdb.ts | 23 - src/dialects/duckdb/spec_duckdb.py | 367 ---- src/dialects/index.ts | 3 - src/index.ts | 94 +- src/{vnext => }/lexical.ts | 0 src/{vnext => }/local-relation-site.ts | 0 src/{vnext => }/node-sql-parser-adapter.ts | 0 src/{vnext => }/node-sql-parser-backend.ts | 0 .../node-sql-parser-browser-executor.ts | 0 ...node-sql-parser-browser-worker-endpoint.ts | 0 .../node-sql-parser-browser-worker.ts | 0 src/{vnext => }/node-sql-parser-wire.ts | 0 src/{vnext => }/query-site.ts | 0 src/{vnext => }/relation-catalog-boundary.ts | 0 .../relation-catalog-epoch-coordinator.ts | 0 .../relation-catalog-search-work.ts | 0 src/{vnext => }/relation-completion-types.ts | 0 src/{vnext => }/relation-dialect.ts | 0 src/{vnext => }/relation-query-site.ts | 0 src/{vnext => }/relation-reserved-words.ts | 0 src/{vnext => }/relation-runtime-auth.ts | 0 src/{vnext => }/session.ts | 0 src/{vnext => }/source.ts | 0 .../__tests__/alias-completion-source.test.ts | 133 -- .../column-completion-source.test.ts | 220 --- .../__tests__/completion-extension.test.ts | 59 - src/sql/__tests__/completion-utils.test.ts | 170 -- .../__tests__/cte-completion-source.test.ts | 454 ----- src/sql/__tests__/demo-doc.test.ts | 26 - src/sql/__tests__/diagnostics.test.ts | 315 --- src/sql/__tests__/extension.test.ts | 107 -- src/sql/__tests__/hover-integration.test.ts | 1088 ----------- src/sql/__tests__/hover-render.test.ts | 627 ------ src/sql/__tests__/namespace-utils.test.ts | 924 --------- .../__tests__/navigation-extension.test.ts | 164 -- src/sql/__tests__/parser.test.ts | 540 ------ src/sql/__tests__/query-context.test.ts | 347 ---- src/sql/__tests__/references.test.ts | 297 --- .../__tests__/semantic-diagnostics.test.ts | 437 ----- src/sql/__tests__/structure-analyzer.test.ts | 347 ---- src/sql/__tests__/structure-extension.test.ts | 261 --- src/sql/__tests__/test-utils.ts | 85 - src/sql/alias-completion-source.ts | 106 -- src/sql/column-completion-source.ts | 158 -- src/sql/completion-extension.ts | 111 -- src/sql/completion-utils.ts | 96 - src/sql/cte-completion-source.ts | 182 -- src/sql/diagnostics.ts | 141 -- src/sql/extension.ts | 154 -- src/sql/hover.ts | 769 -------- src/sql/namespace-utils.ts | 681 ------- src/sql/navigation-extension.ts | 417 ---- src/sql/parser.ts | 384 ---- src/sql/query-context.ts | 635 ------- src/sql/references.ts | 448 ----- src/sql/schema-facet.ts | 44 - src/sql/semantic-diagnostics.ts | 705 ------- src/sql/structure-analyzer.ts | 322 ---- src/sql/structure-extension.ts | 295 --- src/sql/types.ts | 58 - src/{vnext => }/statement-index.ts | 0 src/{vnext => }/syntax.ts | 0 src/{vnext => }/types.ts | 0 src/utils.ts | 12 - src/vnext/index.ts | 30 - ...o-relation-completion-codemirror.test-d.ts | 4 +- .../marimo-relation-completion.test-d.ts | 6 +- .../relation-catalog-search-work.test-d.ts | 12 +- test/{vnext-types => types}/session.test-d.ts | 6 +- test/{vnext-types => types}/syntax.test-d.ts | 4 +- test/worker-placement/src/core.js | 2 +- test/worker-placement/src/workers.js | 2 +- tsconfig.json | 7 +- tsconfig.tests.json | 8 +- ...json => tsconfig.tests.loose-optional.json | 2 +- tsconfig.vnext-tests.json | 14 - vitest.config.ts | 3 +- 151 files changed, 535 insertions(+), 17480 deletions(-) delete mode 100644 SQL_EDITOR_RESEARCH.md delete mode 100644 demo/custom-renderers.ts delete mode 100644 demo/utils.ts rename docs/{vnext => }/capability-charter.md (95%) rename docs/{vnext => }/node-sql-parser-adapter.md (97%) rename docs/{vnext => }/session-primitives.md (94%) rename docs/{vnext => }/source-coordinates.md (99%) rename docs/{vnext => }/statement-index.md (98%) delete mode 100644 implementation.md rename src/{vnext => }/__tests__/bounded-sql-lexer.test.ts (100%) rename src/{vnext => }/__tests__/cte-layout.bench.ts (100%) rename src/{vnext => }/__tests__/cte-layout.test.ts (100%) rename src/{vnext => }/__tests__/incremental-statement-index.test.ts (100%) delete mode 100644 src/__tests__/index.test.ts rename src/{vnext => }/__tests__/lexical.test.ts (100%) rename src/{vnext => }/__tests__/local-relation-site.bench.ts (100%) rename src/{vnext => }/__tests__/local-relation-site.test.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-adapter.bench.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-adapter.test.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-backend.test.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-browser-executor.test.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-browser-worker-endpoint.test.ts (100%) rename src/{vnext => }/__tests__/node-sql-parser-wire.test.ts (100%) rename src/{vnext => }/__tests__/query-site.bench.ts (100%) rename src/{vnext => }/__tests__/query-site.test.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-boundary.bench.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-boundary.test.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-epoch-coordinator.bench.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-epoch-coordinator.test.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-search-work.bench.ts (100%) rename src/{vnext => }/__tests__/relation-catalog-search-work.test.ts (100%) rename src/{vnext => }/__tests__/relation-dialect-boundaries.test.ts (100%) rename src/{vnext => }/__tests__/relation-dialect.test.ts (100%) rename src/{vnext => }/__tests__/session.test.ts (100%) rename src/{vnext => }/__tests__/source.test.ts (100%) rename src/{vnext => }/__tests__/statement-index.bench.ts (100%) rename src/{vnext => }/__tests__/statement-index.test.ts (100%) rename src/{vnext => }/__tests__/syntax.test.ts (100%) rename src/{vnext => }/bounded-sql-lexer.ts (100%) rename src/{vnext => }/browser_tests/fixtures/node-sql-parser-crash-worker.js (100%) rename src/{vnext => }/browser_tests/fixtures/node-sql-parser-silent-worker.js (100%) rename src/{vnext => }/browser_tests/node-sql-parser-adapter.test.ts (100%) rename src/{vnext => }/browser_tests/node-sql-parser-browser-executor.test.ts (100%) rename src/{vnext => }/browser_tests/node-sql-parser-browser-worker.test.ts (100%) delete mode 100644 src/browser_tests/sql-editor.test.ts rename src/{vnext => }/codemirror/relation-completion-types.ts (100%) rename src/{vnext => }/cte-layout.ts (100%) delete mode 100644 src/data/README.md delete mode 100644 src/data/common-keywords.json delete mode 100644 src/data/duckdb-keywords.json delete mode 100644 src/debug.ts delete mode 100644 src/dialects/bigquery.ts delete mode 100644 src/dialects/dremio.ts delete mode 100644 src/dialects/duckdb/README.md delete mode 100644 src/dialects/duckdb/duckdb.ts delete mode 100644 src/dialects/duckdb/spec_duckdb.py delete mode 100644 src/dialects/index.ts rename src/{vnext => }/lexical.ts (100%) rename src/{vnext => }/local-relation-site.ts (100%) rename src/{vnext => }/node-sql-parser-adapter.ts (100%) rename src/{vnext => }/node-sql-parser-backend.ts (100%) rename src/{vnext => }/node-sql-parser-browser-executor.ts (100%) rename src/{vnext => }/node-sql-parser-browser-worker-endpoint.ts (100%) rename src/{vnext => }/node-sql-parser-browser-worker.ts (100%) rename src/{vnext => }/node-sql-parser-wire.ts (100%) rename src/{vnext => }/query-site.ts (100%) rename src/{vnext => }/relation-catalog-boundary.ts (100%) rename src/{vnext => }/relation-catalog-epoch-coordinator.ts (100%) rename src/{vnext => }/relation-catalog-search-work.ts (100%) rename src/{vnext => }/relation-completion-types.ts (100%) rename src/{vnext => }/relation-dialect.ts (100%) rename src/{vnext => }/relation-query-site.ts (100%) rename src/{vnext => }/relation-reserved-words.ts (100%) rename src/{vnext => }/relation-runtime-auth.ts (100%) rename src/{vnext => }/session.ts (100%) rename src/{vnext => }/source.ts (100%) delete mode 100644 src/sql/__tests__/alias-completion-source.test.ts delete mode 100644 src/sql/__tests__/column-completion-source.test.ts delete mode 100644 src/sql/__tests__/completion-extension.test.ts delete mode 100644 src/sql/__tests__/completion-utils.test.ts delete mode 100644 src/sql/__tests__/cte-completion-source.test.ts delete mode 100644 src/sql/__tests__/demo-doc.test.ts delete mode 100644 src/sql/__tests__/diagnostics.test.ts delete mode 100644 src/sql/__tests__/extension.test.ts delete mode 100644 src/sql/__tests__/hover-integration.test.ts delete mode 100644 src/sql/__tests__/hover-render.test.ts delete mode 100644 src/sql/__tests__/namespace-utils.test.ts delete mode 100644 src/sql/__tests__/navigation-extension.test.ts delete mode 100644 src/sql/__tests__/parser.test.ts delete mode 100644 src/sql/__tests__/query-context.test.ts delete mode 100644 src/sql/__tests__/references.test.ts delete mode 100644 src/sql/__tests__/semantic-diagnostics.test.ts delete mode 100644 src/sql/__tests__/structure-analyzer.test.ts delete mode 100644 src/sql/__tests__/structure-extension.test.ts delete mode 100644 src/sql/__tests__/test-utils.ts delete mode 100644 src/sql/alias-completion-source.ts delete mode 100644 src/sql/column-completion-source.ts delete mode 100644 src/sql/completion-extension.ts delete mode 100644 src/sql/completion-utils.ts delete mode 100644 src/sql/cte-completion-source.ts delete mode 100644 src/sql/diagnostics.ts delete mode 100644 src/sql/extension.ts delete mode 100644 src/sql/hover.ts delete mode 100644 src/sql/namespace-utils.ts delete mode 100644 src/sql/navigation-extension.ts delete mode 100644 src/sql/parser.ts delete mode 100644 src/sql/query-context.ts delete mode 100644 src/sql/references.ts delete mode 100644 src/sql/schema-facet.ts delete mode 100644 src/sql/semantic-diagnostics.ts delete mode 100644 src/sql/structure-analyzer.ts delete mode 100644 src/sql/structure-extension.ts delete mode 100644 src/sql/types.ts rename src/{vnext => }/statement-index.ts (100%) rename src/{vnext => }/syntax.ts (100%) rename src/{vnext => }/types.ts (100%) delete mode 100644 src/utils.ts delete mode 100644 src/vnext/index.ts rename test/{vnext-types => types}/marimo-relation-completion-codemirror.test-d.ts (92%) rename test/{vnext-types => types}/marimo-relation-completion.test-d.ts (98%) rename test/{vnext-types => types}/relation-catalog-search-work.test-d.ts (95%) rename test/{vnext-types => types}/session.test-d.ts (98%) rename test/{vnext-types => types}/syntax.test-d.ts (98%) rename tsconfig.vnext-tests.loose-optional.json => tsconfig.tests.loose-optional.json (61%) delete mode 100644 tsconfig.vnext-tests.json diff --git a/.cursorrules b/.cursorrules index a167dc3..4e01d09 100644 --- a/.cursorrules +++ b/.cursorrules @@ -5,7 +5,8 @@ - Avoid using `any` or `unknown` types unless absolutely necessary. - Do not use type assertions; instead, define or refine types as needed. - Create and use explicit types or interfaces for complex structures. -- Prefer using node-sql-parser's API and types for SQL parsing and analysis. +- Prefer using the package's public session and dialect APIs for consumers. +- Prefer the internal node-sql-parser adapter contracts for parser work. # Code Style diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95bf921..a121854 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,6 @@ jobs: name: package path: | dist/ - src/data/ package.json README.md LICENSE diff --git a/.gitignore b/.gitignore index e517eaf..71a858d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ scripts/**.js dist .DS_Store !.vscode -__marimo__ \ No newline at end of file +__marimo__ +.pnpm-store \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a9ea775..78332b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # codemirror-sql -CodeMirror 6 extension for SQL: real-time linting, gutter indicators, hover tooltips, and schema-aware autocomplete (DuckDB, BigQuery, Dremio dialects). Published to npm as `@marimo-team/codemirror-sql` and used by marimo's editor. +SQL language service for document sessions, dialects, and schema-aware analysis. +Published to npm as `@marimo-team/codemirror-sql` and used by marimo. ## Development @@ -12,6 +13,8 @@ pnpm run lint # oxlint --fix (autofix.ci runs this on PRs) pnpm exec oxlint # non-mutating lint CI enforces pnpm run typecheck # tsc --noEmit pnpm run demo # vite build of demo/ +pnpm run test:package # pack + consumer smoke +pnpm run test:worker-placement # browser worker placement budgets ``` - Browser tests need Playwright browsers installed first: `pnpm exec playwright install`. diff --git a/README.md b/README.md index cfbc1b9..0852a35 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ # codemirror-sql -A CodeMirror extension for SQL linting and visual gutter indicators. Built by and used in [marimo](https://github.com/marimo-team/marimo). +SQL language service for document sessions, dialects, and schema-aware analysis. +Built by and used in [marimo](https://github.com/marimo-team/marimo). + +Published as [`@marimo-team/codemirror-sql`](https://www.npmjs.com/package/@marimo-team/codemirror-sql). ## Features -- ⚡ **Real-time validation** - Per-statement SQL syntax checking as you type, with detailed error messages for every broken statement -- 🧠 **Schema-aware linting** - Warns about unknown tables, unknown columns, and ambiguous column references based on your schema -- 🎨 **Visual gutter** - Color-coded statement indicators and error highlighting -- 💡 **Hover tooltips** - Schema info, keywords, and column details on hover -- 🔮 **CTE autocomplete** - Statement-scoped completion of CTE names and their output columns -- 🏷️ **Alias resolution** - Hover and completion understand table aliases (`SELECT u.name FROM users u`) -- 📇 **FROM-aware column completion** - Unqualified prefixes complete the columns of the tables in the statement's FROM clause (`SELECT e` -> `email` with `FROM users`), even with many tables in the schema -- 🧭 **Navigation** - Go-to-definition, reference highlighting, and rename for CTEs and aliases -- 🎯 **Query-aware resolution** - Context-sensitive schema and column suggestions -- 🔍 **Additional dialects** - DuckDB, BigQuery, Dremio -- 🛠️ **Custom renderers** - Customizable tooltip rendering for tables, columns, and keywords +- **Document sessions** — open SQL documents, apply atomic text and context updates, and track opaque revisions +- **Built-in dialects** — PostgreSQL, DuckDB, BigQuery, and Dremio +- **Embedded regions** — mask non-SQL spans (for example notebook interpolations) in document coordinates +- **Isolated parsing** — optional browser-worker parser execution kept off the public API surface + +Editor integrations (completion, diagnostics, hover, navigation) build on this +session API and will ship as focused vertical slices. ## Installation @@ -26,142 +25,59 @@ pnpm add @marimo-team/codemirror-sql ## Usage -### Basic Setup - ```ts -import { sql, StandardSQL } from "@codemirror/lang-sql"; -import { basicSetup, EditorView } from "codemirror"; -import { sqlExtension, cteCompletionSource, aliasColumnCompletionSource, unqualifiedColumnCompletionSource } from "@marimo-team/codemirror-sql"; - -const schema = { - users: ["id", "name", "email", "active"], - posts: ["id", "title", "content", "user_id"], -}; - -const editor = new EditorView({ - doc: "SELECT * FROM users WHERE active = true", - extensions: [ - basicSetup, - sql({ - dialect: StandardSQL, - schema: schema, - upperCaseKeywords: true, - }), - StandardSQL.language.data.of({ - autocomplete: cteCompletionSource, - }), - StandardSQL.language.data.of({ - // Complete `u.` -> columns of `users` in `SELECT ... FROM users u` - autocomplete: aliasColumnCompletionSource({ schema }), - }), - StandardSQL.language.data.of({ - // Complete `SELECT e` -> `email` because `FROM users` is in the statement - autocomplete: unqualifiedColumnCompletionSource({ schema }), - }), - sqlExtension({ - // Shared by hover tooltips and semantic linting - schema: schema, - linterConfig: { - delay: 250, // Validation delay in ms - }, - gutterConfig: { - backgroundColor: "#3b82f6", // Current statement color - errorBackgroundColor: "#ef4444", // Error highlight color - hideWhenNotFocused: true, - }, - enableHover: true, - hoverConfig: { - hoverTime: 300, - enableKeywords: true, - enableTables: true, - enableColumns: true, - }, - }), - ], - parent: document.querySelector("#editor"), +import { + createSqlLanguageService, + duckdbDialect, + type SqlDocumentContext, +} from "@marimo-team/codemirror-sql"; + +interface AppSqlContext extends SqlDocumentContext { + readonly engine: string; +} + +const dialect = duckdbDialect(); +const service = createSqlLanguageService({ + dialects: [dialect], }); -``` - -### Schema-aware semantic linting - -When a schema is provided (via the top-level `schema` option, the -`sqlSchemaFacet`, or `semanticLinterConfig.schema`), queries are validated -against it: unknown tables, unknown columns, and ambiguous column references -are reported as warnings (configurable per check). Without a schema the -semantic linter is inert. -```ts -import { EditorView } from "codemirror"; -import { sqlSemanticLinter } from "@marimo-team/codemirror-sql"; - -const editor = new EditorView({ - extensions: [ - sqlSemanticLinter({ - schema: { users: ["id", "name"], posts: ["id", "user_id"] }, - severity: { - unknownTable: "error", // "error" | "warning" | "off" (default: "warning") - unknownColumn: "warning", - ambiguousColumn: "warning", - }, - }), - ], - parent: document.querySelector("#editor"), +const session = service.openDocument({ + text: "SELECT * FROM users", + context: { dialect: dialect.id, engine: "local" }, }); -``` - -Checks only run on statements that parse cleanly, and skip anything that -can't be confidently resolved (CTE outputs, subquery results, aliases from -outer scopes), preferring under-reporting over false positives. Semantic -diagnostics carry `source: "sql-schema"`; syntax diagnostics use -`source: "sql-parser"`. If the schema is provided as a function, it is called -on every lint pass and should be cheap/memoized. -### Navigation: go-to-definition, highlights, rename - -`sqlExtension` includes navigation for statement-local identifiers (CTE names, -table aliases, select aliases) by default: the references of the identifier -under the cursor are highlighted, and Mod-click (Cmd/Ctrl-click) on a -resolvable identifier jumps to its definition. Keybindings (`F12`/`Mod-b` for -go-to-definition, `F2` for rename) are opt-in: - -```ts -import { renameSqlIdentifier, sqlExtension } from "@marimo-team/codemirror-sql"; - -sqlExtension({ - enableNavigation: true, // default - navigationConfig: { - keymap: true, // enable F12 / Mod-b / F2 - // Supply your own rename UI (defaults to window.prompt) - prompt: (currentName) => window.prompt(`Rename '${currentName}' to:`, currentName), +const revision = session.update({ + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 14, to: 19, insert: "customers" }], }, + embeddedRegions: [], }); -// Rename programmatically: rewrites the definition and all references -// in a single undo step -await renameSqlIdentifier(view, { prompt: () => "new_name" }); -``` - -The pieces are also exported individually: `sqlHighlightReferences`, -`sqlGotoDefinition`, `sqlNavigationKeymap`, `gotoSqlDefinition`, and -`findReferences`. Rename returns `false` when the identifier can't be -confidently resolved — it never falls back to text search-and-replace. - -## Additional Dialects +if (session.isCurrent(revision)) { + // Results produced for this revision may still be applied. +} -This extension adds support for additional dialects: - -- **DuckDB** -- **BigQuery** -- **Dremio** +session.dispose(); +service.dispose(); +``` -## Keyword Completion +Use `{ kind: "replace", text }` for full replacement and +`{ baseRevision, context }` for a context-only update. Document mutations also +supply the complete embedded-region set for the resulting text. -The extension includes keyword documentation for common **SQL keywords** including used in hover and completion, -which can be found in the `src/data` directory. +See [session primitives](./docs/session-primitives.md) for the full contract. ## Demo -See the [demo](https://marimo-team.github.io/codemirror-sql/) for a full example. +```bash +pnpm install +pnpm dev +``` + +The demo wires a CodeMirror editor to `SqlDocumentSession.update()` so edits, +replacements, and dialect switches exercise the public session API. ## Development @@ -172,6 +88,9 @@ pnpm install # Run tests pnpm test +# Typecheck +pnpm run typecheck + # Run demo pnpm dev ``` diff --git a/SQL_EDITOR_RESEARCH.md b/SQL_EDITOR_RESEARCH.md deleted file mode 100644 index e04725c..0000000 --- a/SQL_EDITOR_RESEARCH.md +++ /dev/null @@ -1,1685 +0,0 @@ -# SQL Editor Research and Gap Analysis - -Date: 2026-07-24 - -## Executive summary - -The repository has a strong foundation, especially after v0.3.0, but the -investigation found several confirmed correctness bugs and one potential -security issue. - -The biggest strategic limitation is that most semantic features are built -around a flat, mostly location-free `node-sql-parser` AST. This makes nested -scopes, accurately positioned diagnostics, dialect fidelity, and multi-catalog -completion harder than they need to be. - -The recommended sequence is: - -1. Ship a correctness and security release. -2. Consolidate parsing and semantic analysis around a shared, location-aware - document model. -3. Build richer completion, schema, formatting, and language-server features on - that model. - -## Highest-priority findings - -### P0: Escape all default hover content - -Default tooltips assemble schema and keyword metadata as HTML and assign it -through `innerHTML`. Table names, column names, completion `detail` and `info`, -keyword descriptions, examples, and metadata are not escaped. - -Relevant code: - -- `src/sql/hover.ts`, around the tooltip DOM construction and `innerHTML` - assignment. -- `src/sql/hover.ts`, in `createNamespaceTooltip`, - `createKeywordTooltip`, `createTableTooltip`, and `createColumnTooltip`. - -If schema descriptions or completion metadata originate from a database or -service, crafted values could inject markup or script-capable elements into the -host application. - -Recommended fix: - -- Build default tooltips with DOM nodes and `textContent`, or escape every value - used by the default renderers. -- Document whether custom renderers return trusted HTML or plain text. -- Add hostile table, column, description, and metadata tests. - -### P1: BigQuery dialect construction is incorrect - -`src/dialects/bigquery.ts` spreads the `PostgreSQL` dialect object rather than -`PostgreSQL.spec`: - -```ts -const BigQuery: SQLDialectSpec = { - ...PostgreSQL, - caseInsensitiveIdentifiers: true, - identifierQuotes: "`", -}; -``` - -Runtime inspection confirmed: - -- PostgreSQL has 831 registered words. -- `BigQueryDialect` has only 295, corresponding to the standard defaults. -- BigQuery-relevant items such as `QUALIFY` are absent. - -At minimum, this should spread `PostgreSQL.spec`. Preferably, the project should -generate and maintain a real BigQuery dialect spec rather than describing it as -a PostgreSQL wrapper. - -The earlier marimo -[BigQuery quoting report](https://github.com/marimo-team/marimo/issues/5419) -also illustrates why dialect-specific quoting and completion need to be -accurate. - -### P1: The statement splitter corrupts valid SQL - -The custom scanner in `src/sql/structure-analyzer.ts` understands single, -double, and backtick quotes plus comments, but not: - -- PostgreSQL dollar-quoted strings -- MSSQL bracket-quoted identifiers -- Backslash escape modes -- Procedural `BEGIN ... END` bodies -- Custom delimiters -- Some dialect-specific multiline strings - -The following was reproduced: - -```sql -SELECT $$a;b$$; SELECT [x;y] FROM t; SELECT 3 -``` - -It was split into: - -```text -SELECT $$a -b$$ -SELECT [x -y] FROM t -SELECT 3 -``` - -This affects linting, gutter state, hover scope, completion, and navigation -simultaneously. - -Recommended fix: - -- Obtain statement boundaries from a tokenizer, parser, or CodeMirror syntax - tree. -- Keep the scanner only as a documented fallback. -- Add a dialect-specific statement-boundary corpus. - -### P1: Semantic diagnostics can point to the wrong text or disappear - -Semantic diagnostic locations are reconstructed by finding the first matching -identifier text in the statement. This can underline an occurrence inside a -comment or string rather than the real table or column reference. - -Confirmed examples: - -```sql -SELECT 'usres' AS note FROM usres -``` - -```sql --- usres -SELECT * FROM usres -``` - -In both cases, the unknown-table diagnostic points to the first `usres`. - -There is a second issue. `SqlStatement.content` has comments removed before -semantic re-parsing, but comment removal does not preserve whitespace. These -queries silently lose an expected unknown-column warning: - -```sql -SELECT/*x*/bad FROM users -SELECT bad/*x*/FROM users -SELECT bad FROM/*x*/users -``` - -The original statement parses, but semantic analysis reparses concatenated text -such as `SELECTbad`. - -Recommended fix: - -- Store the original statement source and parsed AST on `SqlStatement`. -- Preserve source locations from the parser. -- Never reparse comment-stripped display content. -- Mask comments with spaces when offsets must be preserved. - -### P1: Nested query scopes leak into completion - -`walkAst` flattens tables from every nested query into one `QueryContext`. -Unqualified completion then iterates the entire flattened table list. - -This outer-query completion was reproduced: - -```sql -SELECT | FROM users -WHERE EXISTS (SELECT 1 FROM orders) -``` - -With a schema containing `user_col` and `order_col`, the outer completion -offered both. Only the columns of `users` are visible at that cursor. - -The query model should become a scope tree containing: - -- Parent and child scopes -- Exact source ranges -- Visible CTEs and aliases -- Derived-table output columns -- Correlation rules -- The innermost scope at a cursor position - -This also provides a principled fix for alias shadowing and CTE visibility. - -### P1: State-sensitive parser results are cached only by SQL text - -Both `SqlStructureAnalyzer` and `QueryContextAnalyzer` cache by SQL text alone. -However, `NodeSqlParser.getParserOptions(state)` explicitly allows dialect and -parser options to depend on editor state. - -A targeted reproduction analyzed identical SQL under two states with different -dialect facets. The parser was only called for the first state, and the second -state received the cached result. - -Recommended fix: - -- Add a parser/dialect/configuration identity to cache keys, or -- Invalidate analyzers when relevant facets change. - -### P1: Async gutter results can arrive out of order - -The gutter starts untracked asynchronous analysis after document, selection, or -focus changes and dispatches each result unconditionally. A slower result for an -older state can therefore overwrite the result for newer text. - -Navigation already uses generation checks. The gutter should follow the same -pattern and verify that `view.state` still matches the captured state before -dispatching. - -## Smaller confirmed issues - -### A zero-millisecond lint delay is ignored - -Both linters use: - -```ts -delay: config.delay || DEFAULT_DELAY -``` - -As a result, `delay: 0` becomes the default delay. Use: - -```ts -delay: config.delay ?? DEFAULT_DELAY -``` - -### Hover fuzzy-search documentation and behavior disagree - -The `SqlHoverConfig` documentation says fuzzy matching defaults to `false`, but -`createHoverSource` defaults it to `true`. - -One side should be changed and a default-behavior test added. - -### Missing `@codemirror/lang-sql` peer dependency - -The exported `./dialects` entry requires `@codemirror/lang-sql` at runtime, but -that package is only listed as a development dependency. - -It should be a peer dependency, potentially optional if consumers who never -import `./dialects` should not be required to install it. - -### DuckDB syntax can be declared valid without an AST - -DuckDB statements beginning with `FROM` or containing `macro` are accepted -without an AST. This prevents false syntax errors, but it also silently disables -semantic linting, reliable hover, CTE analysis, and navigation for those -statements. - -Validity and analyzability should be represented separately. - -### The same document is parsed repeatedly - -Linting, semantic linting, gutter analysis, completion, hover, and navigation -often own separate analyzers. Semantic linting additionally parses a valid -statement once in the structure analyzer and again for semantic checks. - -A shared document-analysis service should provide: - -- Statement boundaries -- Original source -- AST and tokens -- Source locations -- Scope information -- Parse diagnostics -- A state-sensitive cache identity - -### Parser bundle size - -The full local `node-sql-parser` installation occupied approximately 88 MB. -Its documentation says that the browser build containing all database parsers -is about 750 KB, versus about 150 KB for a specific database parser. - -Per-dialect imports and parser adapters are worth investigating: - -- [node-sql-parser documentation](https://www.npmjs.com/package/node-sql-parser) - -## Features worth adding - -### 1. Real cursor-scoped completion - -Support: - -- Nested and correlated subqueries -- Derived tables -- CTE `SELECT *` propagation -- `UNION` output schemas -- Correct catalog and schema search paths -- Temporary tables and views created in preceding statements - -The open marimo report about completion with multiple DuckDB databases is -direct product evidence: - -- [marimo #7499](https://github.com/marimo-team/marimo/issues/7499) - -### 2. A richer schema model - -The current `SQLNamespace` representation is useful for CodeMirror completion, -but advanced semantic features need more information: - -- Catalog, schema, table, view, and function distinctions -- Column type, nullability, description, and completion metadata -- Primary and foreign keys -- Relationships -- Default catalog and schema -- Search path -- Asynchronous schema loading with caching and cancellation - -An optional migration helper can convert `SQLNamespace` into this richer model, -but the new major should expose the richer model directly. - -### 3. Grammar-aware completion - -Add context-specific suggestions for: - -- Expected keywords and entity kinds at the cursor -- Functions in expression positions -- `INSERT` target columns -- `UPDATE SET` columns -- `GROUP BY` and `ORDER BY` aliases and ordinals -- Dialect-correct quoting and casing - -### 4. Higher-value editor actions - -Potential features: - -- Join-condition suggestions based on foreign keys -- Expand `*` -- Qualify ambiguous columns -- Generate table aliases -- Function signature help -- Document symbols and statement/CTE folding -- Go-to-definition for physical tables through a host callback - -### 5. Formatting and lint code actions - -Support: - -- Format document -- Format selection -- Apply safe lint fixes -- Configurable keyword case and indentation -- Templating-aware regions for `{...}`, Jinja/dbt, and prepared placeholders - -### 6. Parameter awareness - -Recognize dialect-specific parameters: - -- `?` -- `$1` -- `:name` -- `@name` - -The extension should avoid treating parameters as identifiers and could expose -host callbacks for parameter metadata. - -This complements marimo's prepared-statement discussion: - -- [marimo #9445](https://github.com/marimo-team/marimo/issues/9445) - -## Repositories and projects to study - -### DTStack/dt-sql-parser and monaco-sql-languages - -- [DTStack/dt-sql-parser](https://github.com/DTStack/dt-sql-parser) -- [DTStack/monaco-sql-languages](https://github.com/DTStack/monaco-sql-languages) - -These are the strongest direct inspiration for grammar-aware completion. -`dt-sql-parser` returns expected keywords and entity kinds at the caret and -associates tables with statement contexts. - -Its documentation also acknowledges limitations in nested subquery scenarios, -which is useful design guidance for a scope-tree implementation. - -Limitations for this project's needs: - -- Its documented dialects focus on MySQL, Flink, Spark, Hive, PostgreSQL, - Trino, Impala, and generic SQL. -- It does not directly solve DuckDB or BigQuery support. - -### SQLGlot - -- [tobymao/sqlglot](https://github.com/tobymao/sqlglot) - -SQLGlot is an excellent semantic reference implementation. It provides broad -dialect coverage, qualification, type annotation, star expansion, AST -transformations, and lineage. - -Because it is Python, it is best considered as: - -- A server-side `SqlParser` adapter for hosts such as marimo -- A correctness oracle -- A source of dialect and semantic test cases - -It is not a direct browser dependency. - -### DuckDB native autocomplete and DuckDB UI - -- [DuckDB autocomplete extension](https://duckdb.org/docs/stable/core_extensions/autocomplete.html) -- [duckdb/duckdb-ui](https://github.com/duckdb/duckdb-ui) - -For DuckDB, the strongest completion source is the actual engine through -`sql_auto_complete`. It knows attached catalogs and live schema state, which a -static browser parser cannot infer. - -DuckDB UI is also useful architectural inspiration because its server API -supports SQL tokenization and catalog-update events. - -A host integration could merge native DuckDB suggestions with local CodeMirror -suggestions. - -### SQLFluff - -- [sqlfluff/sqlfluff](https://github.com/sqlfluff/sqlfluff) - -Study: - -- Dialect and templating architecture -- Configurable lint rules -- Automatic fixes -- Behavior for incomplete and templated SQL -- Broad dialect test coverage - -### sqruff - -- [quarylabs/sqruff](https://github.com/quarylabs/sqruff) - -sqruff is a fast Rust formatter and linter with a browser playground. It is -worth evaluating for either a browser/WASM adapter or a host-side lint and -formatting service. - -### sql-formatter - -- [sql-formatter-org/sql-formatter](https://github.com/sql-formatter-org/sql-formatter) - -This is a practical TypeScript option for document and range formatting. It -supports many relevant dialects, configurable casing, and placeholders. - -Its documented limitations include stored procedures and alternate delimiter -types, so integrations should retain escape hatches and avoid presenting it as -a full parser. - -### Bytebase - -- [bytebase/bytebase](https://github.com/bytebase/bytebase) -- [Bytebase SQL Editor documentation](https://docs.bytebase.com/sql-editor/run-queries) - -Bytebase is primarily product-level inspiration: - -- Inline schema details -- SQL review rules -- Visual `EXPLAIN` -- Statement access modes -- History and sharing -- AI-assisted explanation and problem finding - -Not all of these belong in a CodeMirror package, but the package can provide the -editor primitives and host callbacks needed to build them. - -### CodeMirror LSP client - -- [CodeMirror LSP client reference](https://codemirror.net/docs/ref/#lsp-client) - -Rather than implementing every IDE feature locally, the project should provide -a documented composition path for SQL language servers. This gives hosts a -route to: - -- Formatting -- Document symbols -- Signature help -- Code actions -- Server-backed completion and diagnostics - -## Recommended execution order - -### Batch 1: Correctness and security release - -1. Escape default hover content. -2. Fix BigQuery dialect construction. -3. Fix `delay: 0`. -4. Resolve the fuzzy-search default mismatch. -5. Add the missing peer dependency. -6. Add gutter generation and stale-state checks. -7. Add regression tests for each issue. - -### Batch 2: Analysis-core refactor - -Create one shared analysis result containing: - -- Original source -- Statement boundaries -- AST -- Tokens and locations -- Query scopes -- Parse diagnostics -- Parser and dialect cache identity - -Then: - -1. Replace the manual statement splitter where possible. -2. Build a cursor-addressable scope tree. -3. Share analysis across all editor features. -4. Eliminate comment-stripped re-parsing and duplicate parsing. - -### Batch 3: Feature release - -1. Derived-table and CTE output propagation. -2. Multi-catalog and search-path completion. -3. Async rich schema model. -4. Formatting hooks. -5. LSP integration hooks. -6. Native-engine completion adapters, beginning with DuckDB. - -## Verification performed - -The repository was verified with: - -```bash -pnpm test --run -pnpm run typecheck -pnpm exec oxlint -``` - -Results: - -- 21 test files passed. -- 588 tests passed. -- 1 expected failure remained. -- Statement coverage was 93.54%. -- TypeScript typechecking passed. -- oxlint reported no warnings or errors. - -The test suite is broad, but it should add adversarial coverage for: - -- Dialect changes with unchanged document text -- Asynchronous analysis races -- Nested query scopes -- Hostile tooltip metadata -- Dollar-quoted and bracket-quoted semicolons -- Procedural statement bodies -- Comment-adjacent tokens -- Diagnostic locations when names also appear in strings and comments - -## Architectural direction - -The repository should make one major architectural shift: stop letting each -editor feature independently parse and interpret SQL. Instead, it should build -a shared SQL analysis platform and make linting, completion, hover, navigation, -and gutter rendering thin consumers of it. - -The project is currently a collection of capable CodeMirror extensions. To -become an excellent SQL editor foundation, it should become a small language -service. - -### Target architecture - -```mermaid -flowchart TD - CM["CodeMirror document + cursor"] --> COORD["Analysis coordinator"] - DIALECT["Dialect definition"] --> COORD - SCHEMA["Async schema provider"] --> COORD - - COORD --> SNAP["Immutable AnalysisSnapshot"] - - PARSER["Parser backend"] --> SNAP - SNAP --> TOKENS["Statements + tokens + locations"] - SNAP --> SCOPES["Scope graph"] - SNAP --> SYMBOLS["Symbols + references"] - SNAP --> TYPES["Resolved tables + columns + types"] - - SNAP --> LINT["Diagnostics"] - SNAP --> COMPLETE["Completion"] - SNAP --> HOVER["Hover"] - SNAP --> NAV["Definition / references / rename"] - SNAP --> STRUCTURE["Gutter / folding / symbols"] - SNAP --> ACTIONS["Formatting / code actions"] - - NATIVE["Native engine or LSP provider"] --> COMPLETE - NATIVE --> LINT - NATIVE --> ACTIONS -``` - -### 1. Introduce a single immutable analysis snapshot - -This is the most important refactor. - -Every document version should produce one `AnalysisSnapshot`: - -```ts -interface AnalysisSnapshot { - documentVersion: number; - dialectId: string; - schemaVersion?: string; - - statements: SqlStatementNode[]; - tokens: SqlToken[]; - diagnostics: SqlDiagnostic[]; - scopeGraph: SqlScopeGraph; - symbols: SqlSymbolTable; - - ast?: unknown; - capabilities: AnalysisCapabilities; -} -``` - -Each analyzed statement should retain: - -- Original source text -- Absolute document range -- Tokens and exact source locations -- Parsed AST -- Parse errors -- Root scope -- Statement type -- Whether the result is complete, recovered, or opaque - -This would eliminate: - -- Duplicate parsing -- Comment-stripped re-parsing -- Text-search diagnostic positioning -- Inconsistent interpretations between features -- Caches keyed only by SQL text - -The current `SqlStructureAnalyzer`, `QueryContextAnalyzer`, and semantic AST -traversal should gradually collapse into this service. - -### 2. Build a real scope graph - -The semantic model should represent SQL lexical and relational scopes -explicitly rather than flattening them into one query context: - -```ts -interface SqlScope { - id: string; - kind: "statement" | "select" | "cte" | "subquery" | "dml"; - range: SqlRange; - parent?: string; - - sources: SqlRelation[]; - columns: SqlColumnBinding[]; - aliases: SqlSymbol[]; - ctes: SqlSymbol[]; - children: string[]; -} -``` - -A scope graph unlocks: - -- Correct completion inside nested subqueries -- Correlated-reference handling -- Alias shadowing -- Derived-table columns -- CTE output propagation -- Reliable rename and references -- Proper ambiguity diagnostics -- `UNION` output schemas -- Completion based on the cursor's exact scope - -This semantic intermediate representation should be independent of -`node-sql-parser` AST shapes. Parser adapters should translate their ASTs into -the common model. This prevents a parser replacement from requiring every -editor feature to be rewritten. - -### 3. Make parser backends capability-based - -The current `SqlParser` interface implies that every parser can provide roughly -the same behavior. In practice, some DuckDB fallbacks can only establish -validity, while other dialects produce usable ASTs. - -Capabilities should be explicit: - -```ts -interface SqlParserBackend { - readonly id: string; - readonly dialects: readonly string[]; - - analyze( - document: string, - options: AnalyzeOptions, - signal: AbortSignal, - ): Promise; -} - -interface ParserAnalysis { - statements?: ParsedStatement[]; - tokens?: SqlToken[]; - diagnostics: SqlDiagnostic[]; - ast?: unknown; - - capabilities: { - exactLocations: boolean; - recovery: boolean; - scopes: boolean; - formatting: boolean; - }; -} -``` - -The architecture could then support: - -- `node-sql-parser` as the initial browser fallback -- Dialect-specific ANTLR parsers where valuable -- Native DuckDB completion and tokenization through a host -- SQLGlot through a server-side marimo adapter -- LSP-backed analysis -- Future WASM parsers - -Features could degrade honestly based on capabilities instead of treating -"valid but no AST" as fully analyzed. - -### 4. Make dialects first-class configurations - -A dialect should be more than CodeMirror highlighting and a -`node-sql-parser` database name: - -The stable API should expose a package-owned opaque dialect handle; the -following is the kind of internal bundle that handle resolves to, not a -caller-constructed public object: - -```ts -interface InternalSqlDialectBundle { - id: string; - codeMirrorDialect: SQLDialect; - parserDialect: string; - - identifier: { - quote: string; - caseSensitivity: "sensitive" | "insensitive"; - normalization: (name: string, quoted: boolean) => string; - }; - - parameters: ParameterStyle[]; - statementRules: StatementBoundaryRules; - keywords: KeywordCatalog; - functions: FunctionCatalog; - formatterDialect?: string; -} -``` - -A dialect registry should centralize: - -- Quoting -- Identifier normalization -- Keywords, types, and functions -- Parameter syntax -- Parser selection -- Formatter selection -- Statement-boundary behavior -- Feature capability tests - -Every dialect should have a conformance corpus containing valid, invalid, -incomplete, and multi-statement examples. BigQuery and DuckDB particularly need -this. - -### 5. Replace `SQLNamespace` as the schema model - -For the next major version, replace `SQLNamespace` at the public service -boundary rather than making it the permanent compatibility shape. Provide a -small, optional conversion helper for migrations, but make the richer catalog -graph the canonical API: - -```ts -interface SqlCatalog { - version: string; - catalogs: SqlCatalogNode[]; -} - -interface SqlRelation { - id: string; - path: IdentifierPath; - kind: "table" | "view" | "cte" | "derived" | "function"; - columns?: SqlColumn[]; -} - -interface SqlColumn { - name: string; - type?: SqlType; - nullable?: boolean; - description?: string; - primaryKey?: boolean; - references?: SqlColumnReference; -} -``` - -This enables: - -- Join-condition suggestions -- Type-aware function completion -- Better hover content -- `*` expansion -- Type mismatch diagnostics -- Search paths -- Multiple catalogs containing identically named tables - -Schema acquisition should be asynchronous and versioned: - -```ts -interface SqlSchemaProvider { - getCatalog( - request: CatalogRequest, - signal: AbortSignal, - ): Promise; - - search?( - query: SchemaSearchRequest, - signal: AbortSignal, - ): Promise; -} -``` - -Large databases should not require loading the entire catalog before the editor -works. - -### 6. Add an analysis scheduler - -Parsing should not run directly from every CodeMirror plugin. A scheduler -should provide: - -- Document-version tracking -- Cancellation through `AbortSignal` -- In-flight request deduplication -- Stale-result rejection -- Debouncing by workload -- LRU analysis caching -- Optional Web Worker execution -- Performance instrumentation - -Suggested behavior: - -- Tokenization and statement boundaries: immediate -- Local completion scope: low latency -- Syntax diagnostics: debounced -- Semantic diagnostics: more heavily debounced -- Remote schema and native completion: cancellable -- Formatting: explicit only - -For large SQL documents, worker execution will matter more than -micro-optimizing individual traversals. - -### 7. Separate language intelligence from CodeMirror - -The core should accept plain text, positions, dialect, and schema. It should not -require `EditorState`: - -```ts -const snapshot = await languageService.analyze({ - text, - version, - dialect, - schema, - signal, -}); - -const completions = languageService.complete(snapshot, position); -const hover = languageService.hover(snapshot, position); -``` - -`src/codemirror/*` should adapt these results into CodeMirror extensions. - -Benefits: - -- Core behavior can be tested without DOM or CodeMirror fixtures. -- Monaco, textarea, CLI, and backend use become possible. -- Parser behavior no longer depends implicitly on arbitrary `EditorState`. -- Browser tests can focus on integration instead of semantic correctness. - -### 8. Compose local, native-engine, and LSP providers - -The package should not try to outperform a live database at understanding its -own dialect and catalog. - -Support layered providers: - -1. Return local snapshot results immediately. -2. Request native engine or LSP results asynchronously. -3. Merge and deduplicate results. -4. Prefer authoritative results when available. - -For DuckDB: - -- Local completion provides immediate keywords and obvious aliases. -- `sql_auto_complete` provides authoritative catalog-aware suggestions. -- Catalog-change events invalidate schema caches. - -For marimo: - -- A SQLGlot-backed service could provide broader dialect parsing and semantic - analysis. -- The browser library should remain functional without a backend. - -### 9. Modularize packaging after the core refactor - -Avoid one mandatory package that ships every parser, dialect catalog, formatter, -and integration. - -A possible package or subpath-export structure is: - -```text -@marimo-team/codemirror-sql - /core - /codemirror - /dialects - /parsers/node-sql-parser - /providers/lsp - /providers/duckdb - /formatters/sql-formatter -``` - -Goals: - -- Core completion users do not pay for semantic linting. -- DuckDB users do not bundle every `node-sql-parser` dialect. -- Formatter and LSP dependencies remain optional. -- Tree-shaking does not depend on dynamic-import heuristics. - -### What not to do - -Avoid: - -- Replacing `node-sql-parser` before defining the common semantic model. That - changes the dependency without fixing the architecture. -- Adding more regular expressions to repair nested scopes and statement - boundaries. -- Expanding `QueryContext` into an ever-larger flat structure. -- Making `SQLNamespace` carry every future semantic concern. -- Letting each feature accept independently configured parsers and analyzers - indefinitely. -- Building formatting, lint rules, and LSP behavior directly into CodeMirror - plugins. - -### Practical overhaul plan - -#### Phase 1: Stabilize - -Fix the security and correctness findings earlier in this document. - -#### Phase 2: Build the new core behind a new-major entry point - -Add: - -```text -src/core/analysis-snapshot.ts -src/core/language-service.ts -src/core/scope-graph.ts -src/core/schema-model.ts -src/core/dialect.ts -src/core/scheduler.ts -src/parsers/node-sql-parser-adapter.ts -``` - -Reuse existing implementation details only when they satisfy the new contracts. -Do not expose the old analyzer types through the new entry point. - -#### Phase 3: Replace features with vertical slices - -Recommended order: - -1. Gutter and statement selection -2. Syntax diagnostics -3. Completion -4. Hover -5. Navigation -6. Semantic diagnostics - -Remove `SqlStructureAnalyzer`, `QueryContextAnalyzer`, and the old feature -configuration surface before the new major release. The new implementation -does not need to coexist with them in the published API. - -#### Phase 4: Add richer backends and features - -- DuckDB native provider -- SQLGlot server adapter -- LSP provider -- Formatter provider -- Rich schema and relationship completion - -The architectural north star is: - -> One document version, one semantic interpretation, many editor features. - -## Independent design review and consolidated direction - -This section records a second-pass review of the findings and architecture -above. Three independent reviews were performed with different priorities: - -1. Language-tooling architecture, correctness, concurrency, and performance -2. Source-level comparison with the upstream projects named in this report -3. The way marimo currently consumes this package in a real application - -The original architectural diagnosis survives review: this project should grow -from a collection of CodeMirror features into a shared SQL language service. -However, the reviewers agreed that the proposed single, eager -`AnalysisSnapshot` is the wrong implementation boundary. Data at different -levels has different dependencies, latency, authority, and invalidation rules. - -The revised north star is: - -> One versioned document and context model, reusable statement-level analysis, -> explicit semantic scopes, and independently cancellable consumers and -> authoritative providers. - -### Major-version assumption - -The remainder of this recommendation assumes the overhaul can ship as the next -major version, with breaking API and behavior changes. Backward compatibility -is not an architectural goal. - -That means the project should: - -- Replace the existing parser/analyzer/configuration API instead of wrapping it - indefinitely. -- Remove obsolete public implementation classes from the new entry point. -- Make rich catalog, dialect, validation, templating, and provider contracts - canonical from day one. -- Choose secure renderers and conservative feature defaults even when this - changes existing behavior. -- Change lint granularity and scheduling only through explicit new contracts. -- Update marimo as the reference migration while the new API is still fluid. - -It should still ship a migration guide, codemod or conversion helpers where -cheap, and integration tests for important consumers. Those protect users from -undocumented or silent changes; they do not constrain the new architecture. - -### Consensus - -All three reviews converged on the following: - -- Reuse analysis across completion, hover, navigation, diagnostics, and gutter - features. -- Keep parser-specific ASTs behind adapters. Public features should consume a - normalized syntax and semantic model. -- Make ranges, source mapping, cancellation, stale-result rejection, and - configuration revisions foundational rather than later refinements. -- Analyze the active statement first and reuse unchanged statement results. -- Treat parsing, engine validation, semantic diagnostics, formatting, and - completion as distinct capabilities. -- Use a richer, partially loaded catalog model as the public service contract. - An optional `SQLNamespace` conversion helper can ease migration without - shaping the new core. -- Keep a high-level CodeMirror extension for ordinary consumers even if the - intelligence core is editor-independent. -- Prove the new design against marimo before freezing it. Marimo already - exercises dynamic dialects, remote validation, interpolation, partial - catalogs, custom completion rendering, and many editors on one page. -- Add correctness, latency, memory, bundle-size, and adversarial-input budgets. - Architecture without measurable budgets will not reliably produce a - performant editor. - -### Important corrections to the earlier research - -The source-level ecosystem review found several places where the earlier -summary should be more precise. - -#### `node-sql-parser` has partial location support - -Current `node-sql-parser` supports `parseOptions.includeLocations`, and a number -of AST productions expose `loc`. This repository does not currently enable that -option. It should be the first experiment for improving diagnostic and -definition ranges before replacing the parser. - -The support is not universal, so a single `exactLocations: boolean` capability -would still be misleading. Track capabilities such as statement, token, and -identifier locations separately, and record the quality of each result. - -Sources: - -- [node-sql-parser parser options and types](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/types.d.ts) -- [location helper](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/pegjs/common/initializer/functions.pegjs) -- [BigQuery grammar location usage](https://github.com/taozhi8833998/node-sql-parser/blob/c1be96494794abadb26f3d5d99ddccb942de45a4/pegjs/bigquery.pegjs) - -#### DuckDB native completion is useful, but not scope-authoritative - -DuckDB's autocomplete extension is aware of the live catalog and produces typed -candidates. Its column suggestion path currently scans columns across tables -and views in all schemas; it does not itself constitute a complete model of -which relations are visible in the current query block. - -Use DuckDB as a strong native candidate provider, then filter and rank its -results with the local scope model. Do not assume all native suggestions are -semantically visible at the cursor. - -Sources: - -- [DuckDB autocomplete core](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/src/parser/peg/autocomplete_core.cpp) -- [DuckDB catalog provider](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/src/include/duckdb/parser/peg/autocomplete_catalog_provider.hpp) -- [DuckDB catalog scans](https://github.com/duckdb/duckdb/blob/b8939cdd48940384cc08e16078738cc6b8834e09/extension/autocomplete/autocomplete_extension.cpp) - -#### The official CodeMirror LSP feature set is narrower than stated - -The official client currently includes completion, diagnostics, hover, -signature help, definitions/declarations/implementations, references, rename, -and formatting. It does not currently ship turnkey document-symbol or -code-action modules. - -Use the official client instead of building a parallel protocol client, but -describe unsupported features as custom extensions rather than built-ins. Its -`WorkspaceMapping` and version-gating behavior are particularly relevant: -asynchronous server results can be mapped through local changes or rejected -when they overlap unsafe edits. - -Sources: - -- [CodeMirror LSP source modules](https://github.com/codemirror/lsp-client/tree/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src) -- [CodeMirror LSP client and workspace mappings](https://github.com/codemirror/lsp-client/blob/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src/client.ts) -- [Safe formatting application](https://github.com/codemirror/lsp-client/blob/97bb453eb772a6a1dbd70ccd3b68fd2b89de28cd/src/formatting.ts) - -LSP Markdown/HTML must also be sanitized. A remote language server is another -untrusted rendering boundary. - -#### `sqruff` deserves a real backend evaluation - -`sqruff` now has a direct WASM package, LSP crate, semantic tokens, formatting, -diagnostics, lineage, and SQL inference. Its LSP still uses full-document sync -and does not provide completion or hover, so it is not a full language-service -replacement. It is nevertheless a credible browser formatter, linter, and -tokenizer backend to benchmark. - -Sources: - -- [sqruff WASM API](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lib-wasm/src/lib.rs) -- [sqruff LSP](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lsp/src/lib.rs) -- [sqruff lineage scopes](https://github.com/quarylabs/sqruff/blob/5a129c3486217195bb865c4789c6cbc1f35b6979/crates/lineage/src/scope.rs) - -### What to borrow from the researched repositories - -These recommendations come from source-level inspection, not only feature -lists or READMEs. - -#### SQLGlot: the semantic reference model - -SQLGlot is the strongest reference for query semantics: - -- Explicit root, subquery, derived-table, CTE, union, and UDTF scopes -- Physical tables and child scopes represented as distinct source types -- Separate available sources from sources actually selected by a query -- Sequential CTE construction, so a later CTE can see an earlier one -- Explicit correlated-subquery and external-column handling -- Scope-local traversal that does not flatten nested queries -- Ordered treatment of `FROM`, joins, and lateral sources -- Lazy derived caches with explicit invalidation - -Sources: - -- [SQLGlot scope model](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/optimizer/scope.py) -- [Qualification pipeline](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/optimizer/qualify.py) -- [Schema resolution](https://github.com/tobymao/sqlglot/blob/85e0b7c89f3009fe4fbde381df30a1931a975fb4/sqlglot/schema.py) - -SQLGlot's qualification and type annotation can mutate and canonicalize the AST -and carry non-trivial overhead. It is an excellent correctness oracle or server -backend, but should not automatically become the per-keystroke browser hot -path. - -#### DTStack: minimize completion work to the active statement - -DTStack parses enough of the document to locate a small region around the -caret, then performs the expensive `antlr4-c3` completion analysis on that -fragment. It also separates grammar candidates such as “table” or “column” -from the host's concrete catalog candidates. - -Source: - -- [DTStack completion flow](https://github.com/DTStack/dt-sql-parser/blob/1e853da4eac6ba8afd31da2243f3bf4802d0b9a5/src/parser/common/basicSQL.ts) - -Do not copy its mutable “last input” cache, repeat parses, scope-depth -accessibility heuristic, simple fallback statement splitter, or generated-code -package size without careful measurement. - -#### SQLFluff: dual coordinates for templated SQL - -SQLFluff models the original source and rendered SQL separately, maps them with -explicit slices, and carries immutable position markers in both coordinate -systems. That is the right conceptual foundation for marimo `{...}` -expressions, Jinja/dbt templates, and safe formatting or quick fixes. - -Sources: - -- [SQLFluff templated file model](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/templaters/base.py) -- [SQLFluff position markers](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/parser/markers.py) -- [SQLFluff parse context and resource budgets](https://github.com/sqlfluff/sqlfluff/blob/dcb198ce3d69ddb501a727f10f58a983336fd4ca/src/sqlfluff/core/parser/context.py) - -Also borrow its hard parse-depth and node budgets. SQLFluff itself is too -heavyweight for the browser hot path. - -#### DuckDB: typed completion needs and catalog-provider boundaries - -DuckDB's grammar produces typed needs such as keyword, relation, column, -function, and type. A replaceable catalog provider supplies the actual objects. -Results preserve replacement positions, semantic types, scores, and insertion -suffixes. - -The normalized completion model in this project should similarly retain: - -- Exact replacement edit -- Snippet or insertion suffix -- Candidate kind -- Provider provenance -- Provider and local scores -- Confidence/completeness -- Quoting decision - -Reducing provider output to a label discards information required for powerful -and deterministic composition. - -### Marimo consumer findings - -The local marimo checkout is currently pinned to `^0.2.8`. Its integration -shows what a serious consumer already needs from this library. - -Marimo currently combines three intelligence paths: - -1. `@codemirror/lang-sql` highlighting, schema completion, and keyword - completion -2. This package's lint, gutter, and hover extensions -3. Backend parsing and `EXPLAIN` validation through the marimo kernel - -It also supplies a separate completion source for Python expressions inside SQL -`{...}` regions, dynamically changes dialect and connection context, mixes -notebook-local tables with external catalogs, and can mount many SQL editors in -one notebook. - -This is direct evidence for a coordinator/provider design: marimo has already -built an informal one around the current package. - -#### Critical `0.2.8` to `0.3.0` migration hazard - -Marimo's `CustomSqlParser` overrides: - -- `validateSql()` to call backend validation -- `parse()` to return unconditional success with no AST for its internal DuckDB - engine - -Version `0.2.8` linting calls `validateSql()` on the document. Version `0.3.0` -per-statement linting goes through `SqlStructureAnalyzer`, which calls -`parse()`. Upgrading marimo as-is can therefore silently disable its backend -DuckDB syntax diagnostics: every DuckDB statement is reported as valid by the -custom `parse()` implementation. - -This does not require preserving the old API, but it must become a marimo -migration test and an explicit item in the breaking-change guide. -Whole-document and per-statement validation are different provider -capabilities; the new API should make the choice impossible to change -implicitly. - -```ts -interface SqlValidationProvider { - readonly granularity: "document" | "statement"; - validate( - request: SqlValidationRequest, - signal: AbortSignal, - ): Promise; -} -``` - -Marimo's debounce also clears the preceding timer without resolving the Promise -created for the preceding validation. Rapid calls can remain pending forever. -Per-statement parallelism would make this worse. Cancellation should be a -library contract, every request must settle, and debounce state must belong to -an editor session rather than a shared parser. - -#### First-class embedded and templated regions - -Marimo commonly edits SQL such as: - -```sql -SELECT * FROM {df} -WHERE price < {price_threshold.value} -``` - -`NodeSqlParser.ignoreBrackets` currently rewrites braces for parsing while a -separate completion source handles Python variables. The language service -should instead understand embedded regions: - -```ts -interface SqlEmbeddedRegion { - range: SqlRange; - language?: string; - kind: "expression" | "parameter" | "template"; -} -``` - -Adapters can receive a length- and newline-preserving masked document, together -with an explicit source map. Diagnostics, scopes, statements, formatting edits, -and navigation must map back to the original document exactly. - -#### Partial catalogs and dynamic connection context - -Marimo distinguishes unloaded, loading, complete, and failed schema branches. -It also has nested child schemas, local ephemeral relations, tables, views, -columns, primary keys, indexes, types, samples, and host-specific metadata. - -A missing catalog child cannot mean both “empty” and “not fetched.” The -normalized catalog layer should support: - -- Stable entity identities -- Explicit load states -- Lazy path resolution and search -- Pagination -- Push invalidation/subscriptions -- Local and remote precedence -- Connection, session, search-path, and catalog revision keys -- Opaque host metadata for custom UI rendering - -The same document text can be reinterpreted when marimo switches its engine, -dialect, connection, schema, formatter, or validation backend. Every relevant -context change must invalidate the layers that depend on it, even when there is -no CodeMirror `docChanged` transaction. - -#### Preserve host extensibility - -Marimo does not use the package's new completion source wholesale; it combines -schema, keyword, and Python-expression completion. A future high-level -extension must support composition with external completion sources and custom -renderers. - -Marimo also reads `BigQueryDialect.spec.identifierQuotes` outside the editor to -format datasource queries. Stable dialect APIs should expose helpers such as: - -```ts -dialect.quoteIdentifier(name); -dialect.quotePath(parts); -dialect.normalizeIdentifier(name, quoted); -``` - -Consumers should not need to inspect CodeMirror's internal dialect spec. - -Finally, marimo assigns the package's string tooltip renderer to `innerHTML`. -The new major should replace this with a safe DOM-returning renderer. If a -trusted HTML escape hatch exists, it should be separately named, opt-in, and -documented as unsafe for untrusted metadata. - -### Revised architecture - -#### Replace the eager snapshot with layered artifacts - -Use a convenient snapshot facade if it improves the public API, but keep the -internal cache and scheduling model layered: - -```text -Document revision - └─ Embedded-region map - └─ Lexical tokens and statement index - └─ Parse artifacts per statement - └─ Query blocks, relations, and visibility graph - └─ Catalog resolution at a catalog/session epoch - └─ Feature results per cursor, request, and provider -``` - -Examples of independent invalidation: - -- Moving the cursor should not rebuild scopes. -- Refreshing a catalog should not reparse SQL. -- Changing a renderer should not invalidate semantic analysis. -- Editing one statement should reuse unaffected statement artifacts. -- A remote diagnostic should not block local completion. -- Changing dialect lexical rules may invalidate statement boundaries, while a - schema update should invalidate only resolution and dependent results. - -The public slogan “one semantic interpretation” should not imply that a local -partial parser, native engine, LSP, and loading schema always agree. The harder -and more useful invariant is: - -> Every displayed result identifies the exact document/context revision, -> provider, completeness, and interpretation that produced it; conflicts are -> resolved by an explicit feature-specific policy. - -#### Make statement-level reuse the first performance milestone - -Do not promise true incremental parsing while the parser backend reparses whole -strings. The practical and honest first target is incremental document analysis -through statement reuse: - -- Maintain a dialect-aware statement index. -- Map unchanged ranges through CodeMirror change sets. -- Parse only changed statements. -- Prioritize the active statement, then visible statements. -- Run full-document background analysis only when a feature requires it. -- Cache in-flight Promises so simultaneous features share the same work. -- Bound caches by item count and estimated retained size. -- Deprioritize or pause expensive work for hidden and unfocused editors. - -A `StatementBoundaryProvider` and conformance corpus are required. Neither a -generic CodeMirror tree nor a failing full parser is authoritative for all -dialects and procedural blocks. - -#### Model visibility, not only lexical parent scopes - -A parent/child scope tree is insufficient for SQL. The semantic IR should model: - -- Query blocks and set operations -- Input relations and derived output relations -- CTE declaration order and recursive visibility -- Correlation and `LATERAL` edges -- Clause-specific alias visibility -- Qualified and unqualified references -- Output column shapes, including wildcard and unknown shapes -- Definition/reference ranges and provenance -- Explicit partial, recovered, opaque, and failed states - -For example, select-list aliases may be visible in `ORDER BY` but not `WHERE`, -depending on dialect. A typed visibility graph can express this; a simple -lexical parent link cannot. - -Prototype the IR against at least two materially different parsers or -conformance corpora before freezing it, so `node-sql-parser` limitations do not -become permanent neutral interfaces. - -#### Separate providers by concern - -At minimum, define separate contracts for: - -- Statement boundaries -- Parsing -- Validation -- Catalog resolution -- Completion -- Hover and documentation -- Navigation -- Formatting -- LSP/native augmentation - -Capabilities and result quality should be granular and attached to artifacts, -not only static booleans on a backend. “Parse success,” “valid SQL,” “semantic -model available,” and “engine accepted the query” are different states. - -Provider arbitration must be feature-specific: - -- Completion can merge and deterministically rerank candidates. -- Hover can choose one source or display attributed sections. -- Diagnostics require authority and deduplication policies; blind unions create - contradictory errors. -- Formatting should normally have one selected provider. -- Definitions need precedence, confidence, and provenance. - -Remote and native providers should augment a fast local baseline and never -silently overwrite unrelated evidence. - -#### Make source coordinates and request identity non-negotiable - -Every range should be: - -- Absolute in the original document -- Measured in UTF-16 code units, matching CodeMirror -- Half-open: `[from, to)` -- Preserved through comments, interpolation, and other preprocessing - -Every asynchronous request and result should identify: - -- Editor/session identity -- Monotonic document revision -- Dialect and parser configuration identity -- Template/source-map revision -- Connection and session identity when relevant -- Catalog/search-path revision when relevant -- Provider identity and authority - -No result applies unless its complete dependency key is current. - -#### Keep mutable state out of shared providers - -`NodeSqlParser` currently stores `offsetRecord` on the parser instance. Two -concurrent calls can overwrite one another's transformation mapping. Move all -transformation and source-map state into the individual parse request. - -Focus, visibility, debounce timers, request generations, and cancellation -controllers belong to a per-editor analysis session, not a parser or provider -that might be shared across many editors. - -All sessions need `dispose()`: - -- Abort remote requests. -- Prevent dispatch into destroyed views. -- Release bounded caches and workers. -- Unsubscribe from catalog changes. -- Settle or reject pending requests. - -### Proposed stable API shape - -The library should provide both a composable core and a convenient CodeMirror -adapter: - -```ts -const service = createSqlLanguageService({ - dialects: [duckdbDialect(), postgresDialect(), bigQueryDialect()], - parser: nodeSqlParserProvider(), - catalog: catalogProvider, - validators: [engineValidationProvider], - template: braceExpressionTemplate(), -}); - -const extensions = sqlEditor({ - service, - context: (state) => getSqlDocumentContext(state), - features: { - completion: true, - hover: true, - diagnostics: true, - gutter: true, - navigation: true, - }, - externalCompletionSources: [pythonExpressionCompletion], - scheduling: { - remoteDiagnosticsDelay: 300, - analyzeWhenUnfocused: false, - }, - renderers: { - completionInfo: customCompletionRenderer, - hover: customHoverRenderer, - }, -}); -``` - -The CodeMirror adapter should expose facets or state effects for context, -catalog, dialect, scheduling priority, and theme changes. Parser providers -should not receive arbitrary `EditorState`; the adapter should extract explicit -context values. - -The new major should not expose legacy `SqlParser`, `NodeSqlParser`, -`SqlStructureAnalyzer`, or `QueryContextAnalyzer` classes. Public subclassing of -stateful implementation classes is one reason marimo's validation behavior can -diverge between `parse()` and `validateSql()`. - -Replace subclassing with narrow provider interfaces. Keep raw parser adapters -internal or explicitly unstable. A development-only comparison harness remains -useful for validating the rewrite, but it does not need to become a public -compatibility layer. - -### Non-negotiable invariants - -1. Every range is an absolute UTF-16 half-open range in original-document - coordinates. -2. No asynchronous result applies unless its complete revision key is current. -3. Every request settles, rejects, or is observably aborted. -4. Parser transformation state is request-local. -5. Analysis failure is distinct from SQL invalidity. -6. Schema absence or incompleteness cannot produce a definite - unknown-object diagnostic. -7. Untrusted metadata is never interpreted as HTML by default. -8. A statement is parsed at most once for a given content, parser/configuration - identity, template mapping, and environment fingerprint; concurrent - consumers share in-flight work. -9. Every resolved symbol has a range, role, provenance, and completeness state. -10. Provider conflicts are resolved by a documented policy for each feature. - -### Revised execution plan - -#### Phase 0: stabilize and measure - -- Fix the confirmed security and correctness bugs earlier in this report. -- Enable and assess `node-sql-parser` location support. -- Make parser preprocessing and offset state request-local. -- Reject stale results in every asynchronous feature. -- Ensure cancellation settles all requests. -- Add dialect/configuration revisions to current caches. -- Add a marimo migration test proving the new document-level validation - provider executes and stale requests are cancelled. -- Measure current latency, memory, cold-load time, and bundle size. - -#### Phase 1: analysis session and statement index - -- Add one disposable analysis session per editor/context. -- Define a dialect-aware statement-boundary provider. -- Map unchanged statements through CodeMirror changes. -- Cache both in-flight and completed parse artifacts. -- Prioritize active and visible statements. -- Add bounded caches and degradation policies. -- Reuse the current structure analyzer only as temporary scaffolding if useful; - do not retain its API in the new core. -- Compare old and new lint/gutter results in development to discover - regressions, without requiring behavioral parity when the old behavior is - wrong. - -#### Phase 2: ranges, source maps, and parser artifacts - -- Define normalized parse artifacts with granular completeness and location - capabilities. -- Model embedded regions and original/rendered source coordinates. -- Implement the existing parser adapter and at least one alternate/test - adapter. -- Remove downstream text searches for locations. -- Fully migrate statement metadata and syntax diagnostics. - -#### Phase 3: a minimal semantic vertical slice - -Start with well-tested `SELECT` behavior: - -- Query blocks -- CTEs -- Base and derived relations -- Aliases -- Column references -- Output shapes -- Typed visibility edges -- Partial and unknown states - -Migrate completion and hover before aggressive semantic diagnostics. These -features benefit from partial knowledge; diagnostics should only report an -error when the model can prove it with complete relevant scope and schema -information. - -#### Phase 4: catalogs and provider composition - -- Add lazy catalog resolution, stable identities, load states, pagination, and - invalidation. -- Define per-feature provider authority and merge policies. -- Integrate marimo backend validation through the explicit validation contract. -- Prototype DuckDB-native candidate augmentation. -- Integrate the official CodeMirror LSP client where a server is available. -- Benchmark `sqruff` WASM for formatting, diagnostics, and tokenization. - -#### Phase 5: advanced semantics and optional distribution changes - -Only after correctness and profiling: - -- Types and function signatures -- `UNION`, recursive CTEs, `LATERAL`, and DML outputs -- Cross-statement state such as temporary tables and search-path changes -- Worker execution where benchmarks justify it -- Formatter and additional LSP adapters -- Optional subpath or package splits after bundle analysis and API stabilization - -Workers deserve an explicit prototype because a synchronous PEG or ANTLR parse -cannot be interrupted by `AbortSignal` on the main thread. They are not an -automatic win: startup, cloning, duplicate caches, and bundling can make small -documents slower. Apply hard input-size, time, parse-depth, and node-count -budgets regardless. - -### Performance and correctness gates - -Track at least: - -- Keystroke-to-local-completion latency -- Active-statement parse latency -- Large-document edit latency -- Time to first useful result after cold parser load -- Memory after long edit sequences and many editor instances -- Catalog search over large and partially loaded schemas -- Main bundle and parser/dialect chunk sizes -- Stale-result and cancellation behavior under rapid edits - -Add: - -- Property and fuzz tests for statement boundaries and preprocessing -- Nested, correlated, CTE, lateral, and set-operation scope goldens -- Differential tests against SQLGlot and native DuckDB where appropriate -- Adversarial inputs that must not hang, exhaust memory, or crash a worker -- Range invariants across Unicode, comments, multiline strings, and templates -- A dialect conformance matrix instead of a blanket “supported” label - -These gates should decide whether workers, parser replacements, and packaging -changes ship. They should not be retrofitted after those decisions. - -### Consolidated recommendation - -Do not replace `node-sql-parser` with DTStack or another parser as the first -move. First: - -1. Fix the existing security, range, cache, race, and dialect bugs. -2. Enable and measure available parser locations. -3. Build a disposable, revisioned analysis session with statement-level reuse. -4. Define source-mapped templating and a SQLGlot-inspired visibility model. -5. Separate document/statement validation from parsing. -6. Add lazy catalogs and explicit provider authority. -7. Migrate marimo as the reference consumer, using integration tests and - development comparisons to validate the new contracts. -8. Evaluate DuckDB, LSP, SQLGlot, and `sqruff` as composable providers rather - than a single replacement stack. - -This sequence uses the major-version boundary to remove accidental APIs and -incorrect behavioral coupling. It creates the architecture needed for a -powerful SQL editor without committing too early to one parser, worker model, -or package layout. - -### Proposed new-major release strategy - -Treat the next major as a replacement product surface rather than a gradual -deprecation release: - -1. Freeze the current line to critical fixes only. -2. Develop the new language service and CodeMirror adapter behind a separate - entry point or prerelease tag. -3. Migrate marimo early and use it to exercise multi-editor performance, - dynamic connection context, templates, remote validation, and rich catalogs. -4. Publish prereleases with a small number of design partners. -5. Remove legacy exports before the release candidate. -6. Publish benchmarks, a dialect capability matrix, and the migration guide - with the stable major. - -The migration guide should map old concepts to new ones, but the implementation -should not contain permanent adapters unless they are trivial, isolated, and -have no effect on the core design. diff --git a/_typos.toml b/_typos.toml index 42336d8..47238a2 100644 --- a/_typos.toml +++ b/_typos.toml @@ -2,16 +2,10 @@ # Use typos:ignore-next-line to ignore the next line in the codebase extend-ignore-re = [ "(#|//)\\s*typos:ignore-next-line\\n.*", - # Deliberately malformed SQL and identifiers in research reproductions + # Deliberately malformed SQL and identifiers in fixtures "\\busres\\b", "\\bSELECTbad\\b", ] [files] -extend-exclude = [ - "src/data/duckdb-keywords.json", - # Contains intentionally misspelled SQL (e.g. SELCT) to trigger parse errors - "src/sql/__tests__/diagnostics.test.ts", - # Contains intentionally misspelled identifiers (e.g. usres) to trigger schema diagnostics - "src/sql/__tests__/semantic-diagnostics.test.ts", -] +extend-exclude = [] diff --git a/demo/custom-renderers.ts b/demo/custom-renderers.ts deleted file mode 100644 index 3c41331..0000000 --- a/demo/custom-renderers.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { NamespaceTooltipData } from "../src/sql/hover.js"; -import { isArrayNamespace } from "../src/sql/namespace-utils.js"; - -interface ColumnMetadata { - type: string; - primaryKey?: boolean; - foreignKey?: boolean; - unique?: boolean; - default?: string; - notNull?: boolean; - comment?: string; -} - -interface ForeignKeyMetadata { - column: string; - referencedTable: string; - referencedColumn: string; -} - -interface IndexMetadata { - name: string; - columns: string[]; - unique: boolean; -} - -interface TableMetadata { - description: string; - rowCount: string; - columns: Record; - foreignKeys: ForeignKeyMetadata[]; - indexes: IndexMetadata[]; -} - -export type Schema = "users" | "posts" | "orders" | "customers" | "categories" | "Users_Posts"; - -export const tableTooltipRenderer = (data: NamespaceTooltipData) => { - // Show table name, columns, description, primary key, foreign key, index, unique, check, default, comment - const table = data.item.path.join("."); - const namespace = data.item.namespace; - const columns = - namespace && isArrayNamespace(namespace) - ? namespace.map((column) => (typeof column === "string" ? column : column.label)) - : []; - - // Enhanced table metadata (simulated for demo purposes) - const tableMetadata = getTableMetadata(table); - - let tooltip = `
`; - - // Table header - tooltip += `
📋 Table: ${table}
`; - - // Description - if (tableMetadata.description) { - tooltip += `
${tableMetadata.description}
`; - } - - // Column details in a table - if (columns.length > 0) { - tooltip += `
`; - tooltip += `
📊 Columns
`; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - - columns.forEach((column) => { - const columnInfo = tableMetadata.columns[column]; - const constraints: string[] = []; - - if (columnInfo) { - if (columnInfo.primaryKey) constraints.push("🔑 PK"); - if (columnInfo.foreignKey) constraints.push("🔗 FK"); - if (columnInfo.unique) constraints.push("✨ UNIQUE"); - if (columnInfo.notNull) constraints.push("❌ NOT NULL"); - if (columnInfo.default) constraints.push(`💡 DEFAULT ${columnInfo.default}`); - - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - } else { - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - } - }); - - tooltip += `
ColumnTypeConstraintsDescription
${column}${columnInfo.type}${constraints.join(" ")}${columnInfo.comment || ""}
${column}---
`; - } - - // Foreign key relationships - if (tableMetadata.foreignKeys.length > 0) { - tooltip += `
`; - tooltip += `
🔗 Foreign Keys
`; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - - tableMetadata.foreignKeys.forEach((fk) => { - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - }); - - tooltip += `
ColumnReferences
${fk.column}${fk.referencedTable}.${fk.referencedColumn}
`; - } - - // Indexes - if (tableMetadata.indexes.length > 0) { - tooltip += `
`; - tooltip += `
📈 Indexes
`; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - - tableMetadata.indexes.forEach((index) => { - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - tooltip += ``; - }); - - tooltip += `
NameColumnsType
${index.name}${index.columns.join(", ")}${index.unique ? "UNIQUE" : "NORMAL"}
`; - } - - // Table statistics - tooltip += `
`; - tooltip += `📊 ${tableMetadata.rowCount} rows`; - tooltip += `
`; - - tooltip += `
`; - - return tooltip; -}; - -// Helper function to get enhanced table metadata -function getTableMetadata(tableName: string): TableMetadata { - const metadata: Record = { - users: { - description: "User accounts and profile information", - rowCount: "1,234", - columns: { - id: { type: "INT", primaryKey: true, notNull: true, comment: "Unique user identifier" }, - name: { type: "VARCHAR(255)", notNull: true, comment: "User's full name" }, - email: { - type: "VARCHAR(255)", - unique: true, - notNull: true, - comment: "User's email address", - }, - active: { type: "BOOLEAN", default: "true", comment: "Whether the user account is active" }, - status: { - type: "ENUM('active','inactive','suspended')", - default: "'active'", - comment: "User account status", - }, - created_at: { - type: "TIMESTAMP", - default: "CURRENT_TIMESTAMP", - comment: "Account creation date", - }, - updated_at: { - type: "TIMESTAMP", - default: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", - comment: "Last update timestamp", - }, - profile_id: { type: "INT", foreignKey: true, comment: "Reference to user profile" }, - }, - foreignKeys: [{ column: "profile_id", referencedTable: "profiles", referencedColumn: "id" }], - indexes: [ - { name: "idx_users_email", columns: ["email"], unique: true }, - { name: "idx_users_status", columns: ["status"], unique: false }, - { name: "idx_users_created", columns: ["created_at"], unique: false }, - ], - }, - posts: { - description: "Blog posts and articles", - rowCount: "5,678", - columns: { - id: { type: "INT", primaryKey: true, notNull: true, comment: "Unique post identifier" }, - title: { type: "VARCHAR(255)", notNull: true, comment: "Post title" }, - content: { type: "TEXT", comment: "Post content" }, - user_id: { type: "INT", notNull: true, foreignKey: true, comment: "Author of the post" }, - published: { type: "BOOLEAN", default: "false", comment: "Publication status" }, - created_at: { - type: "TIMESTAMP", - default: "CURRENT_TIMESTAMP", - comment: "Post creation date", - }, - updated_at: { - type: "TIMESTAMP", - default: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", - comment: "Last update timestamp", - }, - category_id: { type: "INT", foreignKey: true, comment: "Post category" }, - }, - foreignKeys: [ - { column: "user_id", referencedTable: "users", referencedColumn: "id" }, - { column: "category_id", referencedTable: "categories", referencedColumn: "id" }, - ], - indexes: [ - { name: "idx_posts_user", columns: ["user_id"], unique: false }, - { name: "idx_posts_published", columns: ["published"], unique: false }, - { name: "idx_posts_created", columns: ["created_at"], unique: false }, - ], - }, - orders: { - description: "Customer orders and transactions", - rowCount: "12,345", - columns: { - id: { type: "INT", primaryKey: true, notNull: true, comment: "Unique order identifier" }, - customer_id: { - type: "INT", - notNull: true, - foreignKey: true, - comment: "Customer who placed the order", - }, - order_date: { type: "DATE", notNull: true, comment: "Date when order was placed" }, - total_amount: { type: "DECIMAL(10,2)", notNull: true, comment: "Total order amount" }, - status: { - type: "ENUM('pending','processing','shipped','delivered','cancelled')", - default: "'pending'", - comment: "Order status", - }, - shipping_address: { type: "TEXT", comment: "Shipping address" }, - created_at: { - type: "TIMESTAMP", - default: "CURRENT_TIMESTAMP", - comment: "Order creation timestamp", - }, - }, - foreignKeys: [ - { column: "customer_id", referencedTable: "customers", referencedColumn: "id" }, - ], - indexes: [ - { name: "idx_orders_customer", columns: ["customer_id"], unique: false }, - { name: "idx_orders_date", columns: ["order_date"], unique: false }, - { name: "idx_orders_status", columns: ["status"], unique: false }, - ], - }, - customers: { - description: "Customer information and profiles", - rowCount: "2,345", - columns: { - id: { type: "INT", primaryKey: true, notNull: true, comment: "Unique customer identifier" }, - first_name: { type: "VARCHAR(100)", notNull: true, comment: "Customer's first name" }, - last_name: { type: "VARCHAR(100)", notNull: true, comment: "Customer's last name" }, - email: { - type: "VARCHAR(255)", - unique: true, - notNull: true, - comment: "Customer's email address", - }, - phone: { type: "VARCHAR(20)", comment: "Customer's phone number" }, - address: { type: "TEXT", comment: "Customer's address" }, - city: { type: "VARCHAR(100)", comment: "Customer's city" }, - country: { type: "VARCHAR(100)", comment: "Customer's country" }, - }, - foreignKeys: [], - indexes: [ - { name: "idx_customers_email", columns: ["email"], unique: true }, - { name: "idx_customers_name", columns: ["last_name", "first_name"], unique: false }, - ], - }, - categories: { - description: "Product and post categories", - rowCount: "89", - columns: { - id: { type: "INT", primaryKey: true, notNull: true, comment: "Unique category identifier" }, - name: { type: "VARCHAR(100)", notNull: true, comment: "Category name" }, - description: { type: "TEXT", comment: "Category description" }, - parent_id: { - type: "INT", - foreignKey: true, - comment: "Parent category for hierarchical structure", - }, - }, - foreignKeys: [{ column: "parent_id", referencedTable: "categories", referencedColumn: "id" }], - indexes: [ - { name: "idx_categories_name", columns: ["name"], unique: false }, - { name: "idx_categories_parent", columns: ["parent_id"], unique: false }, - ], - }, - Users_Posts: { - description: "User-Post relationships", - rowCount: "1,234", - columns: { - user_id: { type: "INT", notNull: true, foreignKey: true, comment: "User who posted" }, - post_id: { - type: "INT", - notNull: true, - foreignKey: true, - comment: "Post being commented on", - }, - }, - foreignKeys: [ - { column: "user_id", referencedTable: "users", referencedColumn: "id" }, - { column: "post_id", referencedTable: "posts", referencedColumn: "id" }, - ], - indexes: [{ name: "idx_users_posts_user", columns: ["user_id"], unique: false }], - }, - }; - - return ( - metadata[tableName] || { - description: "Table information", - rowCount: "Unknown", - columns: {}, - foreignKeys: [], - indexes: [], - } - ); -} diff --git a/demo/data.ts b/demo/data.ts index 79b3694..de87505 100644 --- a/demo/data.ts +++ b/demo/data.ts @@ -1,11 +1,6 @@ -import type { Schema } from "./custom-renderers.js"; +export const defaultSqlDoc = `-- codemirror-sql session demo +-- Edits in the editor are forwarded to SqlDocumentSession.update() -// Default SQL content for the demo -export const defaultSqlDoc = `-- Welcome to the SQL Editor Demo! --- Try editing the queries below to see real-time validation - --- Put the cursor on a CTE name or alias to highlight its references; --- Cmd/Ctrl-click (or F12) jumps to the definition, F2 renames. WITH recent_orders AS ( SELECT customer_id, total_amount FROM orders WHERE order_date >= '2024-01-01' ), @@ -19,7 +14,6 @@ FROM customers c JOIN top_customers t ON t.customer_id = c.id ORDER BY amount DESC; --- Valid queries (no errors): SELECT id, name, email FROM users WHERE active = true @@ -34,59 +28,4 @@ JOIN posts p ON u.id = p.user_id WHERE u.status = 'active' AND p.published = true LIMIT 10; - --- Try editing these to create syntax errors: --- Uncomment the lines below to see error highlighting - --- SELECT * FROM; -- Missing table name --- SELECT * FORM users; -- Typo in FROM keyword --- INSERT INTO VALUES (1, 2); -- Missing table name --- UPDATE SET name = 'test'; -- Missing table name - --- Complex example with subquery: -SELECT - customer_id, - order_date, - total_amount, - (SELECT AVG(total_amount) FROM orders) as avg_order_value -FROM orders -WHERE order_date >= '2024-01-01' - AND total_amount > ( - SELECT AVG(total_amount) * 0.8 - FROM orders - WHERE YEAR(order_date) = 2024 - ) -ORDER BY total_amount DESC; `; - -export const schema: Record = { - // Users table - users: ["id", "name", "email", "active", "status", "created_at", "updated_at", "profile_id"], - // Posts table - posts: [ - "id", - "title", - "content", - "user_id", - "published", - "created_at", - "updated_at", - "category_id", - ], - // Orders table - orders: [ - "id", - "customer_id", - "order_date", - "total_amount", - "status", - "shipping_address", - "created_at", - ], - // Customers table (additional example) - customers: ["id", "first_name", "last_name", "email", "phone", "address", "city", "country"], - // Categories table - categories: ["id", "name", "description", "parent_id"], - // Users_Posts table - Users_Posts: ["user_id", "post_id"], -}; diff --git a/demo/index.html b/demo/index.html index 339f5b0..4d7b791 100644 --- a/demo/index.html +++ b/demo/index.html @@ -4,129 +4,162 @@ codemirror-sql demo - + - +

- codemirror-sql

-

by marimo

+

+ by + marimo +

- A CodeMirror extension for SQL with real-time syntax validation and error diagnostics using - node-sql-parser. + Demo of + @marimo-team/codemirror-sql. + Editor edits are forwarded to + SqlDocumentSession.update() + with revision checks, dialect context, and embedded-region bookkeeping.

-

SQL Editor with Diagnostics

-

- Try typing invalid SQL syntax to see real-time error highlighting and messages. - Valid tables are: users, posts, orders, customers, categories -

+

SQL Editor

- Put the cursor on a CTE name or alias to highlight its references, - Cmd/Ctrl-click (or F12) to jump to its - definition, and press F2 to rename it everywhere in the statement. + Syntax highlighting comes from CodeMirror. Language-service state is owned by the session + primitives.

- + + + +
+ +
+
+
Dialect
+
PostgreSQL
+
+
+
Accepted updates
+
1
+
+
+
Revision
+
current
+
+
+

Example Queries

-

Click any example to load it into the editor:

+

Click any example to replace the document through the session API:

-

Valid Queries

- - - - -
-

Invalid Queries (will show errors)

- - -
-

Features

+

What this demo exercises

-

Real-time Validation

-

SQL syntax is validated as you type with a configurable delay (750ms default).

-
-
-

Error Highlighting

-

Syntax errors are highlighted with red underlines and detailed error messages.

-
-
-

Multiple SQL Dialects

-

Supports MySQL, PostgreSQL, MariaDB, SQLite, Snowflake, and DuckDB syntax validation.

-
-
-

DuckDB Support

-

Special support for DuckDB-specific syntax like "from table select * limit 100".

+

Session ownership

+

+ One SqlLanguageService opens a document session and accepts atomic + updates. +

-

CTE & Alias Navigation

+

Revision identity

- Go-to-definition (Cmd/Ctrl-click or F12), reference highlighting, and rename (F2) for CTE names, - table aliases, and select aliases. + Each accepted edit advances an opaque revision token checked with + session.isCurrent().

-

CTE Autocomplete

+

Dialect context

- Statement-scoped completion of CTE names and their output columns, including after - my_cte.. + Built-in dialect handles for PostgreSQL, DuckDB, BigQuery, and Dremio can be switched + without reloading the editor.

-

TypeScript Support

-

Full TypeScript support with comprehensive type definitions.

+

Walking skeleton

+

+ Completion, diagnostics, hover, and navigation are not wired up yet. They will arrive as + vertical slices on top of this session API. +

diff --git a/demo/index.ts b/demo/index.ts index 025fd77..25419d7 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -1,257 +1,207 @@ import { acceptCompletion } from "@codemirror/autocomplete"; -import { PostgreSQL, type SQLDialect, sql } from "@codemirror/lang-sql"; -import { Compartment, type EditorState, StateEffect, StateField } from "@codemirror/state"; -import { keymap } from "@codemirror/view"; -import { basicSetup, EditorView } from "codemirror"; +import { PostgreSQL, sql } from "@codemirror/lang-sql"; +import { EditorView, keymap } from "@codemirror/view"; +import { basicSetup } from "codemirror"; import { - DefaultSqlTooltipRenders, - defaultSqlHoverTheme, - NodeSqlParser, - QueryContextAnalyzer, - type SqlKeywordInfo, - type SupportedDialects, - sqlCompletion, - sqlExtension, + bigQueryDialect, + createSqlLanguageService, + dremioDialect, + duckdbDialect, + postgresDialect, + SqlSessionError, + type SqlDialect, + type SqlDocumentContext, + type SqlDocumentSession, + type SqlLanguageService, + type SqlTextChange, } from "../src/index.js"; -import { tableTooltipRenderer } from "./custom-renderers.js"; -import { defaultSqlDoc, schema } from "./data.js"; -import { guessSqlDialect } from "./utils.js"; +import { defaultSqlDoc } from "./data.js"; + +interface DemoSqlContext extends SqlDocumentContext { + readonly engine: "demo"; +} + +const dialectOptions = [ + postgresDialect(), + duckdbDialect(), + bigQueryDialect(), + dremioDialect(), +] as const; + +const dialectById = new Map( + dialectOptions.map((dialect) => [dialect.id, dialect]), +); + +let currentDialect: SqlDialect = postgresDialect(); + +const service: SqlLanguageService = + createSqlLanguageService({ + dialects: [...dialectOptions], + }); + +const session: SqlDocumentSession = service.openDocument({ + text: defaultSqlDoc, + context: { dialect: currentDialect.id, engine: "demo" }, +}); let editor: EditorView; +let updateCount = 1; -const completionKindStyles = { - borderRadius: "4px", - padding: "2px 4px", - marginRight: "4px", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: "12px", - height: "12px", +const statusElements = { + dialect: document.querySelector("#status-dialect"), + updates: document.querySelector("#status-updates"), + revision: document.querySelector("#status-revision"), + message: document.querySelector("#status-message"), }; -const defaultDialect = PostgreSQL; +function setStatus(message: string, isError = false): void { + if (!statusElements.message) { + return; + } + statusElements.message.textContent = message; + statusElements.message.classList.toggle("text-red-700", isError); + statusElements.message.classList.toggle("bg-red-50", isError); + statusElements.message.classList.toggle("border-red-200", isError); + statusElements.message.classList.toggle("text-gray-700", !isError); + statusElements.message.classList.toggle("bg-gray-50", !isError); + statusElements.message.classList.toggle("border-gray-200", !isError); +} -const defaultKeymap = [ - { - key: "Tab", - run: (view: EditorView) => { - // Try to accept completion first - if (acceptCompletion(view)) { - return true; - } - // In production, you can use @codemirror/commands.indentWithTab instead of custom logic - // If no completion to accept, insert a tab character - const { state } = view; - const { selection } = state; - if (selection.main.empty) { - // Insert tab at cursor position - view.dispatch({ - changes: { - from: selection.main.from, - insert: "\t", - }, - selection: { - anchor: selection.main.from + 1, - head: selection.main.from + 1, - }, - }); - return true; - } - return false; - }, - }, -]; +function refreshStatusPanel(): void { + if (statusElements.dialect) { + statusElements.dialect.textContent = currentDialect.displayName; + } + if (statusElements.updates) { + statusElements.updates.textContent = String(updateCount); + } + if (statusElements.revision) { + statusElements.revision.textContent = session.isCurrent(session.revision) + ? "current" + : "stale"; + } +} -// e.g. lazily load keyword docs -const getKeywordDocs = async (): Promise> => { - const keywords = await import("@marimo-team/codemirror-sql/data/common-keywords.json"); - const duckdbKeywords = await import("@marimo-team/codemirror-sql/data/duckdb-keywords.json"); - return { - ...keywords.default.keywords, - ...duckdbKeywords.default.keywords, - }; -}; +function collectTextChanges(changes: { + iterChanges: ( + callback: ( + fromA: number, + toA: number, + fromB: number, + toB: number, + inserted: { toString: () => string }, + ) => void, + ) => void; +}): SqlTextChange[] { + const textChanges: SqlTextChange[] = []; + changes.iterChanges((from, to, _fromB, _toB, inserted) => { + textChanges.push({ from, insert: inserted.toString(), to }); + }); + return textChanges; +} -const setDatabase = StateEffect.define(); -const databaseField = StateField.define({ - create: () => "PostgreSQL", - update: (prevValue, transaction) => { - for (const effect of transaction.effects) { - if (effect.is(setDatabase)) { - return effect.value; +function applySessionUpdate( + update: + | { + document: { kind: "changes"; changes: readonly SqlTextChange[] }; + embeddedRegions: []; + } + | { + document: { kind: "replace"; text: string }; + embeddedRegions: []; } + | { + context: DemoSqlContext; + }, +): void { + try { + const revision = session.update({ + baseRevision: session.revision, + ...update, + }); + updateCount += 1; + refreshStatusPanel(); + setStatus( + session.isCurrent(revision) + ? "Session update accepted." + : "Session update accepted but revision is no longer current.", + ); + } catch (error) { + refreshStatusPanel(); + if (error instanceof SqlSessionError) { + setStatus(`${error.code}: ${error.message}`, true); + return; } - return prevValue; - }, -}); - -// Allows us to reconfigure the base sql extension without reloading the editor -const baseSqlCompartment = new Compartment(); + throw error; + } +} -const baseSqlExtension = (dialect: SQLDialect) => { - return sql({ - dialect: dialect, - // Example schema for autocomplete - schema: schema, - // Enable uppercase keywords for more traditional SQL style - upperCaseKeywords: true, - keywordCompletion: (label, _type) => { - return { - label, - keyword: label, - info: async () => { - const dom = document.createElement("div"); - const keywordDocs = await getKeywordDocs(); - const description = keywordDocs[label.toLocaleLowerCase()]; - if (!description) { - return null; - } - dom.innerHTML = DefaultSqlTooltipRenders.keyword({ - keyword: label, - info: description, - }); - return dom; - }, - }; - }, +function replaceDocument(text: string): void { + applySessionUpdate({ + document: { kind: "replace", text }, + embeddedRegions: [], }); -}; - -// Initialize the SQL editor -function initializeEditor() { - // Use the same parser - const parser = new NodeSqlParser({ - getParserOptions: (state: EditorState) => { - return { - database: getDatabase(state), - }; - }, + editor.dispatch({ + changes: { from: 0, insert: text, to: editor.state.doc.length }, }); - // Shared between the completion sources so each edit is analyzed once - const contextAnalyzer = new QueryContextAnalyzer(parser); +} +function initializeEditor(): EditorView { const extensions = [ basicSetup, EditorView.lineWrapping, - keymap.of(defaultKeymap), - databaseField, - baseSqlCompartment.of(baseSqlExtension(defaultDialect)), - sqlExtension({ - // Shared schema for hover tooltips and schema-aware linting - schema: schema, - // Linter extension configuration - linterConfig: { - delay: 250, // Delay before running validation - parser, - }, - // Schema-aware linting (unknown tables/columns, ambiguous columns) - semanticLinterConfig: { - delay: 250, - parser, - }, - - // Gutter extension configuration - gutterConfig: { - backgroundColor: "#3b82f6", // Blue for current statement - errorBackgroundColor: "#ef4444", // Red for invalid statements - hideWhenNotFocused: true, // Hide gutter when editor loses focus - parser, - }, - // Hover extension configuration - enableHover: true, // Enable hover tooltips - hoverConfig: { - schema: schema, // Use the same schema as autocomplete - hoverTime: 300, // 300ms hover delay - enableKeywords: true, // Show keyword information - enableTables: true, // Show table information - enableColumns: true, // Show column information - keywords: async () => { - const keywords = await getKeywordDocs(); - return keywords; - }, - tooltipRenderers: { - // Custom renderer for tables - table: tableTooltipRenderer, + keymap.of([ + { + key: "Tab", + run: (view) => { + if (acceptCompletion(view)) { + return true; + } + const { selection } = view.state; + if (selection.main.empty) { + view.dispatch({ + changes: { from: selection.main.from, insert: "\t" }, + selection: { + anchor: selection.main.from + 1, + head: selection.main.from + 1, + }, + }); + return true; + } + return false; }, - theme: defaultSqlHoverTheme("light"), - parser, - }, - // Reference highlighting and go-to-definition for CTEs/aliases - enableNavigation: true, - navigationConfig: { - keymap: true, // F12/Mod-b go-to-definition, F2 rename - parser, }, + ]), + sql({ dialect: PostgreSQL, upperCaseKeywords: true }), + EditorView.updateListener.of((update) => { + if (!update.docChanged) { + return; + } + const changes = collectTextChanges(update.changes); + if (changes.length === 0) { + return; + } + applySessionUpdate({ + document: { changes, kind: "changes" }, + embeddedRegions: [], + }); }), - // Register all schema-aware completion sources at once: - // - CTE names and their output columns - // - `u.` -> columns of `users` in `SELECT ... FROM users u` - // - `SELECT e` -> `email` because `FROM users` is in the statement - sqlCompletion({ dialect: defaultDialect, schema, parser, contextAnalyzer }), - // Custom theme for better SQL editing EditorView.theme({ "&": { - fontSize: "14px", fontFamily: '"JetBrains Mono", monospace', + fontSize: "14px", }, ".cm-content": { minHeight: "400px", }, - ".cm-focused": { - outline: "none", - }, ".cm-editor": { borderRadius: "8px", }, + ".cm-focused": { + outline: "none", + }, ".cm-scroller": { fontFamily: "inherit", }, - // Style for diagnostic errors - ".cm-diagnostic-error": { - borderBottom: "2px wavy #dc2626", - }, - ".cm-diagnostic": { - padding: "4px 8px", - borderRadius: "4px", - backgroundColor: "#fef2f2", - border: "1px solid #fecaca", - color: "#dc2626", - fontSize: "13px", - }, - // Completion kind backgrounds - ".cm-completionIcon-keyword": { - backgroundColor: "#e0e7ff", // indigo-100 - ...completionKindStyles, - }, - ".cm-completionIcon-variable": { - backgroundColor: "#fef9c3", // yellow-100 - ...completionKindStyles, - }, - ".cm-completionIcon-property": { - backgroundColor: "#bbf7d0", // green-100 - ...completionKindStyles, - }, - ".cm-completionIcon-function": { - backgroundColor: "#bae6fd", // sky-100 - ...completionKindStyles, - }, - ".cm-completionIcon-class": { - backgroundColor: "#fbcfe8", // pink-100 - ...completionKindStyles, - }, - ".cm-completionIcon-constant": { - backgroundColor: "#fde68a", // amber-200 - ...completionKindStyles, - }, - ".cm-completionIcon-type": { - backgroundColor: "#ddd6fe", // violet-200 - ...completionKindStyles, - }, - ".cm-completionIcon-text": { - backgroundColor: "#f3f4f6", // gray-100 - ...completionKindStyles, - }, }), ]; @@ -264,63 +214,43 @@ function initializeEditor() { return editor; } -// Handle example button clicks -function setupExampleButtons() { - const buttons = document.querySelectorAll(".example-btn"); - - buttons.forEach((button) => { +function setupExampleButtons(): void { + document.querySelectorAll(".example-btn").forEach((button) => { button.addEventListener("click", () => { const code = button.querySelector("code"); - if (code && editor) { - const sql = code.textContent || ""; - // Replace editor content with the example - editor.dispatch({ - changes: { - from: 0, - to: editor.state.doc.length, - insert: sql, - }, - }); - // Focus the editor - editor.focus(); + if (!code) { + return; } + replaceDocument(code.textContent ?? ""); + editor.focus(); }); }); } -function getDatabase(state: EditorState): SupportedDialects { - return state.field(databaseField); -} - -function setupDatabaseSelect() { - const select = document.querySelector("#database-select"); - if (select) { - select.addEventListener("change", (e) => { - const value = (e.target as HTMLSelectElement).value as SupportedDialects; - updateSqlDialect(editor, guessSqlDialect(value)); - editor.dispatch({ - effects: [setDatabase.of(value)], - }); - }); +function setupDialectSelect(): void { + const select = document.querySelector("#dialect-select"); + if (!select) { + return; } -} -function updateSqlDialect(view: EditorView, dialect: SQLDialect) { - view.dispatch({ - effects: [baseSqlCompartment.reconfigure(baseSqlExtension(dialect))], + select.value = currentDialect.id; + select.addEventListener("change", () => { + const nextDialect = dialectById.get(select.value); + if (!nextDialect) { + return; + } + currentDialect = nextDialect; + applySessionUpdate({ + context: { dialect: nextDialect.id, engine: "demo" }, + }); + refreshStatusPanel(); }); } -// Initialize everything when the page loads document.addEventListener("DOMContentLoaded", () => { initializeEditor(); setupExampleButtons(); - setupDatabaseSelect(); - - console.log("SQL Editor Demo initialized!"); - console.log("Features:"); - console.log("- Real-time SQL syntax validation"); - console.log("- Error highlighting with detailed messages"); - console.log("- Support for multiple SQL dialects"); - console.log("- TypeScript support"); + setupDialectSelect(); + refreshStatusPanel(); + setStatus("Session opened. Type to send incremental updates."); }); diff --git a/demo/utils.ts b/demo/utils.ts deleted file mode 100644 index be0d14a..0000000 --- a/demo/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MySQL, PostgreSQL, type SQLDialect, SQLite } from "@codemirror/lang-sql"; -import { DuckDBDialect } from "../src/dialects/duckdb/duckdb"; -import type { SupportedDialects } from "../src/sql/parser"; - -export function guessSqlDialect(dialect: SupportedDialects): SQLDialect { - // Other supported dialects: Cassandra, MSSQL, MariaSQL, MySQL, PLSQL, PostgreSQL - switch (dialect) { - case "PostgreSQL": - return PostgreSQL; - case "DuckDB": - return DuckDBDialect; - case "MySQL": - return MySQL; - case "Sqlite": - return SQLite; - default: - return PostgreSQL; - } -} diff --git a/docs/adr/0001-language-service-and-session.md b/docs/adr/0001-language-service-and-session.md index c42390b..cc93480 100644 --- a/docs/adr/0001-language-service-and-session.md +++ b/docs/adr/0001-language-service-and-session.md @@ -5,28 +5,27 @@ Date: 2026-07-24 ## Context -The v0.x public API exposes mutable parser and analyzer implementations. -Parsers receive arbitrary CodeMirror `EditorState`, features can use different -parser and schema configurations, and asynchronous work has no common revision -or cancellation contract. +Host applications need shared SQL intelligence across many editors without +mutable parser instances, divergent feature configuration, or missing revision +and cancellation contracts. Marimo demonstrates the resulting pressure: -- It subclasses `NodeSqlParser` to mix local parsing with remote validation. -- The subclass stores focus, timer, and validation state on the parser. +- Local parsing is mixed with remote validation on one stateful object. +- Focus, timers, and validation state live on the parser. - Replacing a debounce timer can leave the earlier promise unsettled. -- DuckDB `parse()` and `validateSql()` deliberately report different evidence. +- DuckDB parse and validate deliberately report different evidence. - Connection, dialect, schema, and Python-template completion are wired through separate paths. - Many editor instances can share configuration while requiring independent mutable state. -The next major may break compatibility. The API should therefore model the -actual lifecycle instead of preserving these implementation classes. +The public API therefore models the actual lifecycle with a shared service and +per-document sessions. ## Decision -vNext has one shareable, framework-independent `SqlLanguageService` and one +The language service has one shareable, framework-independent `SqlLanguageService` and one disposable `SqlDocumentSession` per open document/editor. The service owns providers, workers, shared bounded caches, and immutable @@ -683,6 +682,3 @@ Marimo can delete `CustomSqlParser`: remote validation becomes a document-diagnostics provider, focus becomes scheduling policy, engine/dialect changes become context updates, braces become a source transformer, and Python completion remains an external CodeMirror completion source. - -Legacy `SqlParser`, `NodeSqlParser`, `SqlStructureAnalyzer`, and -`QueryContextAnalyzer` are not part of the next-major stable API. diff --git a/docs/adr/0002-normalized-syntax-contract.md b/docs/adr/0002-normalized-syntax-contract.md index 4791b0b..2d9d7d7 100644 --- a/docs/adr/0002-normalized-syntax-contract.md +++ b/docs/adr/0002-normalized-syntax-contract.md @@ -5,20 +5,20 @@ Date: 2026-07-24 ## Context -The legacy parser API accepts a complete CodeMirror `EditorState`, mutates -offset bookkeeping, exposes backend-specific AST shapes, and can report success +Earlier APIs mixed editor state, mutable offset bookkeeping, and +backend-specific AST shapes into one parse result, and could report success without a usable tree. Dialect support and location quality are also easy to overstate: a compatibility grammar rejecting input is not evidence that the target dialect is invalid. -The vNext statement index already separates exact, incomplete, and opaque +The statement index already separates exact, incomplete, and opaque lexical boundaries. The next layer needs a narrow parser boundary that can support multiple backends without making the first backend's AST public or mixing lexical eligibility, parser evidence, and request cancellation into one ambiguous result. This contract is internal. It must be exercised by a real adapter and semantic -consumer before any part is considered for the stable `/vnext` API. +consumer before any part is considered for the stable public API. ## Decision diff --git a/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md index 3e60f1f..7ca0705 100644 --- a/docs/adr/0003-node-sql-parser-adapter.md +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -11,7 +11,7 @@ does not decide how a concrete parser earns authority. The first adapter uses useful local AST in Node or an isolated worker. It must not turn the dependency's incomplete grammar, partial locations, synchronous execution, or packaging behavior into broader -vNext guarantees. +Language-service guarantees. The installed `node-sql-parser` 5.4.0 package provides separate CommonJS bundles for PostgreSQL and BigQuery. The complete bundle is approximately @@ -32,7 +32,7 @@ interrupt `astify` while JavaScript is blocked. ## Decision -The adapter remains internal and is not exported from `/vnext`. This change +The adapter remains internal and is not exported from the package. This change does not connect it to document sessions, caches, diagnostics, completion, or any other feature. Session wiring requires a separate decision about worker isolation and cancellation. @@ -174,7 +174,7 @@ normalization pass. The current production loader supports pure Node only. A future browser integration must invoke parsing from a dedicated worker whose global object is -not shared with application code, the legacy parser, or another installed copy. +not shared with application code or another installed package copy. The unsupported-realm rejection remains until that isolated execution path exists. diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index c5556cb..ec45ec5 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -65,8 +65,8 @@ The private browser executor is single-lane: - No worker pool or idle shutdown is introduced without profile evidence. - Executor disposal terminates the worker and settles every pending consumer. -The executor and its worker factory remain package-private. No package export, -`/vnext` export, language-service module, session ownership, or public +The executor and its worker factory remain package-private. No public export, +language-service module, session ownership, or public configuration surface is introduced by this slice. Ordinary caller cancellation and supersession settle the consumer promptly @@ -200,7 +200,7 @@ invalidation, and release semantics. ### Packaging boundary -Core and `/vnext` imports must remain SSR-safe and contain no parser grammar or +Core and public package imports must remain SSR-safe and contain no parser grammar or worker asset. A future optional integration entry may create the worker lazily, but it will expose an opaque language-service module factory rather than the protocol, worker URL, transport, pool, or backend AST. diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 13d6ae7..3cbe83f 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -39,7 +39,7 @@ A flat value named `dependencies`, `sources`, or `relations` would therefore encode stronger semantics than the adapter can prove. Parser acceptance is already compatibility-only evidence under ADR 0003. -The current vNext core has the required parser-independent foundations: +The current core has the required parser-independent foundations: - immutable original and masked analysis source; - absolute UTF-16 half-open coordinate mapping; diff --git a/docs/vnext/capability-charter.md b/docs/capability-charter.md similarity index 95% rename from docs/vnext/capability-charter.md rename to docs/capability-charter.md index d6353aa..ac40ab8 100644 --- a/docs/vnext/capability-charter.md +++ b/docs/capability-charter.md @@ -1,11 +1,11 @@ -# vNext Capability Charter +# Capability Charter Status: accepted Date: 2026-07-24 ## Product boundary -vNext is a framework-independent SQL language service with a first-class +This package is a framework-independent SQL language service with a first-class CodeMirror 6 adapter. It provides local editor intelligence and composes optional catalog, database-native, formatter, and remote validation providers. It does not execute queries. @@ -260,7 +260,7 @@ Provisional compressed bundle budgets: ## Explicit non-goals -vNext is not: +The language service is not: - A SQL execution engine - A query optimizer @@ -269,22 +269,20 @@ vNext is not: - An LSP transport implementation - A mandatory full-catalog loader - A generic universal SQL AST -- A compatibility wrapper around mutable v0.x parser/analyzer classes +- A wrapper around mutable parser or analyzer classes -Legacy behavior can be retained in migration helpers, but it does not constrain -the next-major architecture. +Migration helpers may exist for host apps, but they do not constrain the +architecture. ## Release evidence -Before vNext is stable: +Before the language service is stable: - Every conformance target has an explicit feature matrix and corpus. -- Repository coverage meets the thresholds in `implementation.md`. - Critical revision, range, mapping, cancellation, and arbitration logic has mutation evidence. - Browser, package, SSR, marimo, fuzz, leak, and benchmark gates pass. -- Bundle and latency budgets compare against both `main` and the previous - `dev-refactor` checkpoint. +- Bundle and latency budgets compare against `main` and prior checkpoints. - The marimo fixture proves remote DuckDB validation, connection changes, partial catalogs, Python-expression completion, rapid edits, and disposal. - Unsupported and partial states are documented and visible to consumers. diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/node-sql-parser-adapter.md similarity index 97% rename from docs/vnext/node-sql-parser-adapter.md rename to docs/node-sql-parser-adapter.md index 17995ea..f1d9921 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/node-sql-parser-adapter.md @@ -1,4 +1,4 @@ -# vNext node-sql-parser Adapter +# node-sql-parser Adapter Status: internal and not wired to sessions @@ -6,7 +6,7 @@ The first concrete syntax adapter exercises the normalized contract from [ADR 0002](../adr/0002-normalized-syntax-contract.md) under the evidence and execution policy in [ADR 0003](../adr/0003-node-sql-parser-adapter.md). It is not exported from -`/vnext` and does not currently power completion, diagnostics, hover, +the package and does not currently power completion, diagnostics, hover, navigation, or any other editor feature. ## Capability matrix @@ -67,7 +67,7 @@ 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`. +It is not exported from the package and is not reachable through the public API. There is no public worker constructor, executor, queue, language-service module, or session integration. @@ -99,7 +99,7 @@ replayed. Every worker-reported failure retires the generation because a never-posted queued work retains its original deadline on the replacement. The executor remains implementation infrastructure only. It is not exported -from the root package or `/vnext`, is not owned by `SqlLanguageService`, and +from the package, is not owned by `SqlLanguageService`, and does not yet create authenticated syntax analyses or relation facts for a session. diff --git a/docs/vnext/session-primitives.md b/docs/session-primitives.md similarity index 94% rename from docs/vnext/session-primitives.md rename to docs/session-primitives.md index 6c200d3..dc3c9fd 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/session-primitives.md @@ -1,7 +1,7 @@ -# vNext Session Primitives +# Session Primitives -Status: experimental walking skeleton -Import: `@marimo-team/codemirror-sql/vnext` +Status: walking skeleton +Import: `@marimo-team/codemirror-sql` This entry point currently provides document ownership, atomic text/context updates, opaque revisions, dialect registration, and lifecycle management. It @@ -18,7 +18,7 @@ import { createSqlLanguageService, duckdbDialect, type SqlDocumentContext, -} from "@marimo-team/codemirror-sql/vnext"; +} from "@marimo-team/codemirror-sql"; interface AppSqlContext extends SqlDocumentContext { readonly engine: string; @@ -107,6 +107,3 @@ The safety envelope currently permits at most 10,000 changes in one update, a 16 Mi-code-unit document, 1,000 registered dialects, and bounded context graph depth, size, properties, and string data. These are defensive walking-skeleton limits, not release performance claims. - -The `/vnext` name is provisional until the packaging ADR fixes the next-major -subpath layout. diff --git a/docs/vnext/source-coordinates.md b/docs/source-coordinates.md similarity index 99% rename from docs/vnext/source-coordinates.md rename to docs/source-coordinates.md index 92bda6b..2a28854 100644 --- a/docs/vnext/source-coordinates.md +++ b/docs/source-coordinates.md @@ -1,4 +1,4 @@ -# vNext Source Coordinates +# Source Coordinates Status: experimental core primitive diff --git a/docs/vnext/statement-index.md b/docs/statement-index.md similarity index 98% rename from docs/vnext/statement-index.md rename to docs/statement-index.md index 7a7b393..fe0a36b 100644 --- a/docs/vnext/statement-index.md +++ b/docs/statement-index.md @@ -1,10 +1,10 @@ -# vNext Statement Index +# Statement Index Status: internal full-scan oracle with incremental session reuse The statement index is a synchronous, parser-free partition of `analysisText`. It does not classify, parse, validate, or copy statements, and -it has no public `/vnext` export yet. +it has no public export yet. Each exact slot contains: diff --git a/implementation.md b/implementation.md deleted file mode 100644 index 47493ab..0000000 --- a/implementation.md +++ /dev/null @@ -1,861 +0,0 @@ -# SQL Language Service Overhaul: Implementation Plan - -Date: 2026-07-24 - -## Objective - -Build the next major version of `@marimo-team/codemirror-sql` as a performant, -robust, correct, and powerful SQL language service with a first-class -CodeMirror adapter. - -This is a breaking overhaul. Compatibility with current implementation classes -is not a design constraint. We will still publish a migration guide and use -marimo as the reference consumer. - -Success means: - -- Correct, explicitly bounded dialect support -- Predictable latency and bounded memory -- Honest partial and unsupported results -- Safety under malformed input, rapid edits, cancellation, and hostile metadata -- A small, understandable public API -- High confidence from independent forms of testing - -Before implementation, publish a vNext capability charter defining: - -- Initial dialects and constructs -- Explicit non-goals and unsupported syntax -- Browser, Node, bundler, and peer-dependency support -- Latency, memory, and bundle envelopes -- Which features require local, native, or remote providers - -## Engineering principles - -### Correctness before breadth - -Do not claim support until a construct passes its conformance corpus. Explicit -partial results are better than confident incorrect results. - -### Simple before clever - -Prefer small modules, explicit data flow, pure transformations, and narrow -interfaces. Optimize from profiles. Every abstraction must enforce an -invariant, isolate a dependency, remove duplication, or improve testability. - -### Comments explain why - -Names, types, and structure should explain normal behavior. Comments should -explain an invariant, external limitation, security boundary, performance -tradeoff, or surprising dialect rule. If ordinary flow needs a long comment, -first simplify the design. Public APIs and non-obvious algorithms still need -useful documentation. - -### Types represent the state machine - -The goal is not just to compile. Revision identity, source coordinates, -analysis completeness, catalog loading, cancellation, and provider provenance -should be difficult to omit or combine incorrectly. - -### Tests provide different evidence - -Coverage, contracts, examples, goldens, browser tests, fuzzing, mutation -testing, differential testing, and benchmarks find different failures. No one -metric substitutes for the others. - -## Branch and delivery strategy - -Create `dev-refactor` as the next-major integration branch. Do not implement -everything directly on it. Use short-lived, focused PR branches: - -```text -main - └─ dev-refactor - ├─ refactor/analysis-session - ├─ refactor/source-mapping - ├─ refactor/statement-index - ├─ refactor/catalog-provider - └─ refactor/codemirror-adapter -``` - -Required practices: - -- Every PR into `dev-refactor` leaves it green and runnable. -- Use a merge queue or serialize merges. -- Open a continuous draft PR from `dev-refactor` to `main`. -- Give the branch an owner, a target merge date, and explicit merge/delete - criteria. -- Allow no direct commits. -- Forward-merge `main` on a fixed cadence and document hotfix propagation. -- Keep a continuously runnable demo and marimo integration fixture. -- Build a canary artifact for every accepted integration commit; publish - prereleases only at selected checkpoints after canary gates pass. -- Compare API, coverage, bundle, and performance against both `main` and the - previous `dev-refactor` commit. -- Prefer a sequence of vertical slices to a large rewrite. - -The branch is an integration boundary, not permission to accumulate an -unreviewable diff. If its lifetime or divergence becomes excessive, move vNext -development onto `main` behind an unpublished entry point. - -Before branch protection is enabled, update CI triggers to include pushes and -pull requests targeting `dev-refactor`; the current workflow targets only -`main`. - -## Change sizing and review requirements - -Classify every PR before implementation. - -### Small - -Documentation, test data, mechanical renames, or a local fix with no public, -semantic, concurrency, or performance effect. One normal review is sufficient. - -### Medium - -Any change to: - -- A public/provider interface -- Parsing, source mapping, or semantics -- Scheduling, caching, cancellation, or concurrency -- Catalog resolution -- Completion, diagnostics, hover, navigation, formatting, or gutter behavior -- A dialect rule -- More than one architectural layer - -Two independent adversarial reviews are required. - -Classification is fail-closed. Sensitive paths, public API diffs, dependency -changes, concurrency modules, and cross-layer changes automatically make a PR -medium or large. A maintainer must approve any downgrade to small. - -### Large - -A cross-cutting architecture change, new semantic subsystem, or new parser or -remote/native provider. Split it into an ADR and multiple medium PRs. A large -PR is a planning failure unless the change is generated or indivisible. - -## Phase 0: establish baselines - -Do this before changing production architecture. - -### Behavioral baseline - -- Capture public exports and generated declarations. -- Record representative completion, hover, navigation, lint, and gutter output. -- Add characterization tests for critical migration-risk behavior. -- Put every confirmed issue from `SQL_EDITOR_RESEARCH.md` in an owned - traceability backlog. An unfixed case may use a commit-bound known-failure - fixture with an owner and expiry until its feature slice; the baseline suite - must not be permanently red. -- Add a marimo fixture for document-level DuckDB validation, connection - changes, schema completion, many editors, and `{...}` expressions. -- Record demo workflows in browser tests. - -Label characterization cases: - -```text -preserve intentional behavior -replace behavior intentionally changed in the new major -bug confirmed defect with the desired result documented -unknown needs a semantic or product decision -``` - -Characterization tests document behavior; they do not preserve known bugs. - -### Performance baseline - -Measure fixed document and catalog sizes: - -- Cold load and first parse -- Active-statement edit/reparse -- Completion and hover latency -- Full-document diagnostics -- Rapid-edit and stale-result behavior -- Memory after long edit sequences -- 1, 10, and 50 mounted editors -- Bundle and parser chunk sizes - -Store JSON results and raw samples as CI artifacts. Establish budgets from -measured baselines and product goals, not arbitrary percentages. - -### Quality baseline - -Record: - -- Line, statement, function, and branch coverage -- Unsafe assertions and suppression directives -- Public API surface -- Bundle size and dependency inventory -- Test runtime and flake rate - -Introduce ratchets from this baseline. Do not hide existing problems simply to -make the first gate green. - -## Test strategy - -### Coverage policy - -Targets: - -| Metric | Repository | New or changed core code | -| --- | ---: | ---: | -| Lines | At least 95% | At least 95% per changed file | -| Statements | At least 95% | At least 95% per changed file | -| Functions | At least 95% | At least 95% per changed file | -| Branches | 90%, ratcheting to 95% | At least 95% diff coverage | - -Branch coverage matters for failure, cancellation, partial-result, and dialect -paths. Coverage exclusions are limited to generated data, type-only code, -debug instrumentation, and true exhaustiveness guards. Each exclusion requires -a reviewed reason. - -Coverage is a floor, not an objective. Do not add tests that execute lines -without asserting semantics. Mutation testing must confirm that important -assertions detect meaningful faults. - -Enforcement must define: - -- All production files as the denominator, including files not imported by - tests -- Explicit checked-in include/exclude rules -- Repository thresholds for lines, statements, functions, and branches -- Changed executable lines and branches relative to the PR merge base, - including renamed files -- Diff coverage for lines and branches; statements and functions are enforced - at the changed-file and risk-tier module level rather than inferred - unreliably from a textual diff -- Per-module floors for revisions, ranges, source maps, cancellation, and - provider policy - -Add separate failing `test:coverage` and `coverage:diff` commands. Protect -coverage configuration, exclusions, baselines, and generated-code rules with -CODEOWNERS. Baseline reductions or exclusion growth require explicit approval. - -Apply strict changed-code gates immediately to vNext modules. The repository -target applies to retained/new-major production code by release candidate; do -not manufacture low-value tests for legacy modules scheduled for deletion. -Named invariant tests and mutation evidence are required for critical logic. -Defensive or platform-specific uncovered code needs a narrow reviewed waiver -with an owner and expiry. - -### Unit tests - -Use focused, table-driven tests for: - -- Range and position primitives -- Identifier normalization and quoting -- Statement boundaries -- Original/rendered source mapping -- Cache identity and invalidation -- Catalog load states and lookup -- Scope and visibility rules -- Provider authority, merge, and ranking policies -- Completion replacement edits -- Cancellation, disposal, and error normalization - -### Provider contract tests - -Every provider implementation must pass reusable contracts: - -- Ranges are absolute UTF-16 half-open ranges inside the document. -- Every request settles, rejects, or is observably aborted. -- Aborted or stale work cannot publish results. -- Results identify revision, provider, authority, and completeness. -- Incomplete catalogs cannot create definite unknown-object diagnostics. -- Document validators are never invoked once per statement. -- `dispose()` prevents callbacks and editor dispatch. -- Provider errors degrade according to documented policy. - -### Golden semantic corpus - -Use structured, reviewed fixtures for: - -- Nested and correlated subqueries -- CTE ordering, recursion, and shadowing -- Derived relations and `LATERAL` -- Set operations -- Clause-specific alias visibility -- Qualified, unqualified, and ambiguous references -- Search paths and multiple catalogs -- Quoted identifiers and case folding -- Incomplete input at likely cursor positions -- DML and templates/interpolation - -Each fixture names its dialect and expected capability. Prefer typed structured -outputs to large parser-AST or DOM snapshots. - -### Snapshot tests - -Use snapshots only for stable, high-dimensional normalized results such as a -scope graph or complete feature response. Prefer direct assertions for simple -behavior. Never snapshot parser-specific ASTs, unstable ordering, incidental -error details, or entire DOM trees. CI must not auto-update snapshots. - -### Property and fuzz tests - -Test invariants: - -- Statement ranges remain ordered, non-overlapping, and in bounds. -- Template masking preserves length and newline positions. -- Source-map conversion always yields valid ranges. -- Arbitrary edit sequences never publish stale results. -- Completion edits remain inside the document. -- Identifier quote/normalize behavior round-trips where defined. -- Caches stay bounded. -- Arbitrary input terminates within resource budgets. - -Run a deterministic short suite on every PR and longer randomized campaigns -nightly. Commit a minimal regression fixture and seed for every failure. - -Execute parser fuzz cases in disposable workers or processes. A normal Vitest -timeout cannot interrupt synchronous code that blocks the event loop. The -parent enforces wall-clock and memory limits and records the seed, input, -dialect, dependency versions, and operation sequence. Distinguish crash, -timeout, OOM, invalid range, unhandled rejection, and stale publication. - -### Differential tests - -Compare supported constructs with: - -- SQLGlot for scope and qualification -- DuckDB for DuckDB parsing and selected catalog behavior -- `node-sql-parser` location output while it is a backend -- `sqruff` if used for formatting or linting - -Classify differences as `ours-bug`, `upstream-difference`, -`dialect-ambiguity`, `unsupported`, or `intentional-policy`. Another tool is -evidence, not automatically the oracle. - -### Integration tests - -Use deterministic fake providers and schedulers to test: - -- Edit → invalidate affected layers → publish current feature result -- Dialect/connection changes without text edits -- Catalog refresh without reparsing -- Remote validation cancellation during rapid typing -- Local/native completion composition -- Disposal with work in flight -- Provider timeout and worker crash recovery -- Partial catalogs - -Use fake clocks; do not base correctness assertions on real time. - -Inject failures including malformed payloads, repeated callbacks, callback -after abort/dispose, synchronous throws before Promise creation, rejection -after abort, worker restart, catalog refresh storms, duplicate IDs, -out-of-order results, and exceptions from consumer renderers. - -### Browser and end-to-end tests - -Vitest currently includes a Playwright browser project through the aggregate -test configuration, but the documented `test:browser` script references the -nonexistent `vitest.browser.config.ts`. Repair that script and give the browser -project a named required CI step so configuration changes cannot silently stop -running it. - -Cover: - -- Completion opening, filtering, refreshing, and applying the exact edit -- Safe, attributed hover rendering -- Diagnostic movement and removal after edits -- Current-statement gutter and navigation -- Dialect/connection changes without text edits -- Stale slow-provider rejection -- Keyboard and accessibility behavior - -Require Chromium on PRs. Run Firefox and WebKit nightly until sufficiently -stable and fast. Capture traces and screenshots on failure, not as a broad -pixel-snapshot suite. - -### Consumer and packaging tests - -Maintain a small marimo fixture here and a pinned cross-repository test in -marimo. Cover dynamic context, document validation, many SQL cells, `{...}` -regions, external completion composition, partial/nested catalogs, and custom -renderers. - -Test a packed tarball rather than workspace source. For release candidates: - -- Install it in a minimal CodeMirror consumer. -- Import every documented entry point. -- Build with supported bundlers. -- Run in a real browser and in the marimo fixture. -- Verify ESM, declarations, source maps, peer dependencies, and optional - providers. -- Verify core-only consumers do not bundle optional integrations. - -### Security tests - -Test hostile catalog metadata, LSP Markdown/HTML, deeply nested SQL, extremely -long identifiers/statements, invalid/cyclic provider data, worker crashes, and -pathological regular-expression input. CodeQL and dependency scanning remain -required but are not substitutes for behavior tests. - -### Mutation testing - -Run mutation tests on ranges, semantics, scheduling, caching, and provider -policy. Start nightly. Establish a baseline mutation score, narrowly classify -equivalent mutations, and ratchet upward. High coverage with a weak mutation -score blocks further work in that subsystem. - -Track mutation results per critical module and mutation class. Designated -revision, range, source-map, cancellation, and security modules may have no -surviving high-risk mutants. Equivalent-mutant exclusions require a reviewed -reason and expiry. - -### Invariant traceability and suite integrity - -Maintain a checked-in invariant registry with stable ID, owner, failure mode, -affected modules, required test layers, test references, and applicable -performance/security budget. CI fails if an invariant loses all mapped tests. - -Fail CI on: - -- Zero discovered tests in an expected suite -- Unexpected `.only`, skipped, or todo tests -- Stale snapshots -- Unhandled rejections -- Leaked timers, handles, workers, listeners, or DOM nodes -- Unexpected browser console errors -- Missing fixture provenance - -Print test counts, duration, retry count, and first-attempt failures by category. -Retry success must not silently erase a flake; quarantines require an owner and -deadline. - -### Performance tests - -Report median, p95, worst observed time, and memory where measurable. Separate: - -- Pure core microbenchmarks -- Edit-to-result scenario benchmarks -- Browser/CodeMirror benchmarks -- Bundle-size checks - -Exercise 1/10/100/1,000 statements, small/large/partial catalogs, cold/warm -completion, 1/10/50 editors, rapid edits with delayed providers, and -template-heavy documents. - -PR CI runs stable smoke benchmarks and fails only on meaningful regressions -beyond agreed budgets. Full performance and leak tests run nightly on -controlled runners. Store history to catch gradual degradation. - -For every blocking benchmark, define runner class, warmups, sample count, -interleaved base/head execution, outlier policy, confidence method, minimum -effect size, absolute ceiling, and an inconclusive result for excessive noise. -Record hardware and runtime metadata. Use controlled runners for blocking -latency and memory gates; hosted runners enforce only gross smoke ceilings and -bundle size. Gate tail latency under load and event-loop blocking. Do not use -“worst observed” as a statistical gate; use a defined percentile plus a hard -timeout. - -Memory tests run in isolated processes with fixed edit/mount cycles and explicit -GC where supported. Track heap, DOM/listener retention, worker lifecycle, cache -size, process RSS, and retained-memory slope separately. - -## Type-safety standard - -The repo already enables `strict`, `noUncheckedIndexedAccess`, and -`noUnusedLocals`. Keep these and evaluate: - -- `exactOptionalPropertyTypes` -- `noImplicitOverride` -- `noPropertyAccessFromIndexSignature` -- `verbatimModuleSyntax` - -New-major rules: - -- No explicit `any`. -- No double assertion such as `as unknown as T`. -- No unchecked casts of parser, provider, JSON, or network data. -- No `@ts-ignore`. -- `@ts-expect-error` only in type tests, with the expected reason. -- Use `unknown` at boundaries and narrow or decode it. -- Use discriminated unions for partial, recovered, failed, and cancelled states. -- Use opaque types where document/revision/range identity can be confused. -- Use exhaustive checks for closed unions. -- Prefer `satisfies`; allow useful safe constructs such as `as const`. - -External ASTs are untrusted boundary data. Decode them in one adapter. Feature -code must not scatter parser-specific assertions. - -Automate this with type-aware lint rules, a forbidden-pattern CI check, type -tests, declaration generation, and a public API diff. If a third-party type -forces a narrow assertion, isolate and runtime-check it in an adapter and -require adversarial review; do not weaken global settings. - -The current production `tsconfig.json` excludes tests and the demo. Add separate -typecheck projects for production, unit tests, browser tests, demo, fixtures, -and public type tests. Validate emitted declarations in a clean consumer with -`skipLibCheck: false`. - -Forbidden assertions apply strictly to production. Tests use typed builders -rather than double assertions, but may have a separately audited boundary -registry for unavoidable third-party mocks. Decide each proposed compiler -option in an early ADR; do not leave “evaluate” as a permanent gate. - -## Code-quality standard - -Enforce the dependency direction: - -```text -core primitives - ← document/source mapping - ← syntax/statement analysis - ← semantic model - ← catalog and feature policies - ← providers - ← CodeMirror adapter -``` - -Core cannot import CodeMirror, DOM, remote providers, or parser-specific ASTs. -Automate import-boundary and dependency-cycle checks. - -Set ratcheted review thresholds for complexity, function/file size, nesting, -and parameter count. These are review triggers, not an invitation to split one -bad function into many bad functions. - -Public API rules: - -- Keep the stable surface small. -- Do not export implementation classes for tests. -- Prefer interfaces and factories over subclassing. -- Mark experimental surfaces explicitly. -- Generate and review an API report. -- Require examples and release notes for public behavior changes. - -Every runtime dependency needs bundle, license, security, maintenance, and -alternatives review, and must remain optional where appropriate. - -## Two-reviewer adversarial loop - -Every medium change receives two independent reviews before dependent work -continues. - -### Reviewer A: correctness and safety - -Review SQL semantics, Unicode/ranges, partial-result confidence, concurrency, -cancellation, disposal, security boundaries, and missing negative/fuzz tests. - -### Reviewer B: performance and design - -Review parsing/allocation cost, cache identity, bounded memory, main-thread -work, layer coupling, public API leakage, excessive abstraction, and benchmark -evidence. - -Both receive the same diff, acceptance criteria, invariants, test results, API -diff, and benchmark/bundle deltas. They do not see each other's initial -conclusions. - -Loop: - -1. Implement the scoped change and evidence. -2. Run automated gates. -3. Obtain both reviews. -4. Classify findings as blocking correctness, architecture, performance, test - gap, documentation gap, justified follow-up, or rejected with evidence. -5. Resolve all blocking findings. -6. Rerun gates. Each reviewer rechecks unresolved findings and materially - changed areas against the new commit. -7. Require two full reviews again only when the revision changes public - contracts, architecture, concurrency, or benchmark behavior materially. -8. After two automated resolution cycles, escalate disputes to a human - maintainer rather than looping indefinitely. -9. Store reports and the resolution ledger in the PR. - -Reviews must be tied to the exact commit; a push invalidates prior attestations. -Review agents supplement human maintainers and never authorize a merge. -Prefer reviewers with different prompts or models; two identical agents are -correlated evidence. Independent downstream work may target an accepted -interface contract, but cannot merge until its dependency is accepted. - -### Review automation - -Generate one review packet containing: - -- Base/head commit and diff -- Changed files and risk classification -- Affected invariants and ADRs -- Test/coverage results -- API, benchmark, and bundle deltas - -Add a PR template for change size, risks, evidence, two reports, finding ledger, -and rollback/disable strategy. CI should reject a medium PR missing commit-bound -review attestations or containing unresolved blockers. - -Use independent bot/App identities to create required GitHub checks containing -reviewer identity, model/version, packet digest, head SHA, findings, and -disposition. Validate them through a protected reusable workflow from the base -branch so a PR cannot approve itself. Maintainers approve classification -overrides and rejected blocking findings. - -Request GitHub Copilot review exactly once on every medium or large PR, after -the PR has a coherent head that is ready for review: - -```bash -gh pr edit --add-reviewer @copilot -``` - -Copilot is an additional advisory review, not one of the two independent -commit-bound adversarial attestations and not a substitute for human approval. -Resolve or explicitly disposition its actionable comments in the finding -ledger. Do not re-request Copilot after later pushes or material rewrites; the -commit-bound adversarial reviewers and required CI provide exact-head -revalidation. - -## CI architecture - -### Required fast PR lane - -Set measured per-job budgets and run independent jobs in parallel. Aim for a -useful result within ten minutes without making that aspirational number a -correctness constraint: - -- Frozen install -- Non-mutating lint -- Strict typecheck and forbidden-practice scan -- Unit, contract, and deterministic short fuzz tests -- Coverage and changed-code coverage -- Build, declarations, and public API diff -- Packed-package validation -- Bundle-size budget -- Required Chromium integration -- Import boundaries and dependency cycles - -Cancel superseded runs. - -Add a CI self-test before branch protection: deliberately failing fixture PRs -must prove every required check appears and blocks merging. - -### Required affected integration lane - -For relevant paths, run full browser tests, marimo fixture, packed consumers, -performance smoke tests, and security fixtures. Core, package, TypeScript, -lockfile, or CI changes conservatively run everything. - -### Nightly lane - -Run randomized fuzzing, mutation tests, the full performance matrix, -memory/leak scenarios, Firefox/WebKit, dependency auditing, differential -testing, and the dialect conformance matrix. - -Nightly failures should open or update a tracking issue with seeds and -artifacts. Avoid duplicate issue spam. Maintain a scheduled-health check that -records suite, source SHA, configuration digest, completion time, and expiry. -Release candidates are blocked by red or stale scheduled health. Define an -owner, triage SLA, and maximum quarantine duration. - -Run reduced mutation, differential, and leak sentinels in PR CI when critical -modules change; nightly evidence alone is too delayed. - -### Release-candidate lane - -Run all scheduled-class suites against the exact tagged SHA. - -Build one `npm pack` artifact from that SHA in a reusable verification workflow -and record its SHA-256 and provenance. Split release evidence: - -- Source/configuration gates against the exact tagged SHA: unit, coverage, - mutation, fuzz, static analysis, API, source-level benchmarks, and dependency - inventory -- Packaging/runtime gates against the exact tarball: minimal consumers, - supported bundlers/peers, browser smoke tests, marimo, declarations, source - maps, bundle composition, and migration examples - -Publishing downloads that immutable artifact and requires successful release -checks and scheduled-class evidence for the tagged SHA. Protect the npm -environment and reject tags not reachable from the protected release branch. -Never rebuild different bits in the publish job. - -Checkpoint prereleases publish the verified canary artifact under a `next` -dist-tag with provenance after their defined subset of source and runtime gates. -Document their versioning and retention policy; ordinary integration commits -remain downloadable CI artifacts rather than permanent npm versions. - -### Supported-runtime matrix - -Choose explicit supported Node, browser, peer-dependency, bundler, and SSR -versions. Test minimum and latest Node and peer sets against the tarball. Narrow -`engines` and compatibility claims to what CI continuously validates. - -## Ratchets - -- Coverage cannot decrease for retained/new-major code and reaches the - repository target by release candidate; changed-code and invariant floors - apply immediately. -- No new unsafe type practice is allowed from the first overhaul commit. -- Existing unsafe practices decrease by milestone. -- Bundle/performance budgets cannot regress without explicit approval. -- Mutation score cannot decrease. -- Flaky tests need an owner and expiry; quarantine is temporary. -- Public API growth requires explicit approval. - -Ratchets prevent new debt immediately while allowing deliberate removal of -existing debt. - -## Definition of done for a medium change - -- Acceptance criteria and affected invariants are satisfied. -- Relevant unit, contract, integration, browser, fuzz, and failure tests pass. -- Changed core code meets coverage targets. -- No forbidden type practice is introduced. -- Lint, typecheck, build, package, and API checks pass. -- Performance and bundle budgets pass or their change is approved. -- Fuzz failures have permanent regression fixtures. -- Both independent reviews have no unresolved blocking findings. -- Docs, ADRs, examples, and capability matrices are current. -- The result is simpler or demonstrably more capable than what it replaces. - -## Implementation sequence - -### 1. Governance and baseline - -Deliver branch protection, PR/ADR templates, review automation, behavior/API/ -coverage/bundle/performance baselines, confirmed-bug tests, and the marimo -fixture. - -Exit when every baseline is reproducible in CI. - -### 2. Quality harness and walking skeleton - -Deliver enforced coverage, contract/golden organization, fuzz/property harness, -mutation pilot, explicit browser CI, package smoke tests, type/import/API -checks, and benchmark/bundle budgets. - -In parallel, build a deliberately thin walking skeleton: - -```text -document revision - → parser adapter - → minimal normalized result - → one completion result - → CodeMirror presentation -``` - -Apply final changed-code gates to this new code immediately. Do not delay -architectural feedback while exhaustively testing legacy code scheduled for -deletion. - -Exit when critical characterization exists, harnesses are reproducible, and CI -catches deliberately introduced stale-result, unsafe-cast, range, bundle, and -API defects. Repository-wide coverage of retained/new-major code reaches the -target by release candidate, not as a waterfall prerequisite. - -### 3. Core primitives and minimal provider contracts - -Deliver revisions, UTF-16 half-open ranges, original/rendered source maps, -embedded regions, statement boundaries, disposable sessions, in-flight -deduplication, and bounded caches. - -Also define the minimum parser, validator, catalog, completion, and provider -result contracts now, including capability, authority, load state, -document-versus-statement granularity, timeout, cancellation, and provenance. -Run early DuckDB, LSP, formatter, and worker feasibility spikes and record -go/no-go ADRs. These contracts cannot be deferred until after features that -depend on them. - -Exit when arbitrary edit properties pass, stale results cannot apply, and -unchanged statements are reused. - -### 4. Parser integration and completion slices - -Deliver the parser contract, measured `node-sql-parser` adapter, normalized -artifacts, isolated execution, bounded coordination, and authenticated syntax -evidence. - -Deliver relation completion independently through a bounded partial-`SELECT` -query-site recognizer and CTE-visibility recognizer plus one asynchronous, -coverage-aware catalog provider. The host may implement that provider as a -composite; multi-provider arbitration remains a later explicit design. -This path must remain useful for incomplete SQL and must not construct or wait -for the parser worker. - -Keep the public provider and completion types provisional until the vertical -slice, two provider shapes, packed marimo integration, hostile decoding and -lifecycle suites, and declaration snapshots pass. - -Then design query blocks, typed visibility, CTE/relation/alias/column bindings, -and explicit partial semantic states against materially different dialect -corpora before an additional parser-derived scope feature consumes them. The -bounded CTE recognizer remains the explicit narrow exception. Flat parser -relation lists are not a semantic model. - -Exit when relation completion works through the framework-independent session, -catalog and parser failures remain independent, semantic goldens pass, parser -ASTs do not leak, and dialect differences are classified. - -### 5. Feature vertical slices - -After completion proves the end-to-end model, migrate: - -1. Statement selection and gutter -2. Syntax diagnostics -3. Hover -4. Navigation -5. Semantic diagnostics - -Each slice includes CodeMirror integration, browser tests, performance evidence, -and the two-reviewer loop. Delete the old slice after acceptance. - -### 6. Catalog and provider expansion - -Expand the minimal contracts into lazy catalogs, stable -identities/invalidation, production feature-specific authority, native/remote -providers, and the recorded DuckDB/LSP/`sqruff` decisions. - -Exit when optional remote work never delays the local baseline beyond the -checked product response budget, incomplete local results remain useful, and -all race/failure/partial-catalog contracts pass. - -### 7. Marimo migration and release - -Migrate marimo, publish performance results and a dialect capability matrix, -resolve prerelease feedback, remove legacy exports, and finalize migration/API -documentation. - -Exit when the exact release tarball passes marimo and minimal-consumer smoke -tests plus all correctness, quality, security, performance, and review gates. - -## Initial automation backlog - -Before semantic implementation: - -1. Add `dev-refactor` CI triggers and prove required checks fail closed. -2. Enforce explicit all-file Vitest coverage and per-file changed-code coverage. -3. Add explicit Chromium browser script and required CI step. -4. Add production/test/demo/fixture/public-consumer typecheck projects. -5. Add package tarball, peer-version, bundler, and minimal-consumer tests. -6. Add API declaration diffing. -7. Add bundle reporting and budgets. -8. Add isolated deterministic property/fuzz infrastructure. -9. Add JSON benchmarks, base/head comparison, and noise policy. -10. Add the hermetic marimo fixture and pinned scheduled canary. -11. Add forbidden type-practice, import-boundary, and cycle checks. -12. Add the invariant registry and test-suite integrity checks. -13. Add PR risk template and review-packet generation. -14. Add protected, commit-bound two-reviewer checks. -15. Automate one GitHub Copilot review request per medium or large PR. -16. Add verified-artifact provenance to release CI. -17. Add nightly mutation, fuzz, cross-browser, leak, and performance workflows. -18. Add expiring scheduled-health and deduplicated failure issues. -19. Define the supported runtime and dependency matrix. - -## Final recommendation - -The proposed emphasis on tests, >95% coverage, type safety, simplicity, and -adversarial review is right. The important refinements are: - -- Use coverage as a ratcheted floor, backed by branch coverage, mutation tests, - and semantic corpora so the metric cannot be gamed. -- Establish behavior and performance baselines before replacing code. -- Keep `dev-refactor` green with small vertical PRs. -- Test revisions, provider contracts, partial results, cancellation, and - disposal as aggressively as happy-path SQL. -- Make marimo a continuous reference consumer. -- Tie reviews to exact commits and repeat them after changes. -- Gate latency, memory, bundle size, API growth, and correctness with measurable - budgets. - -Automation should make the demanding path the easiest path while keeping every -change small enough for humans and review agents to understand completely. diff --git a/package.json b/package.json index 401581f..4cae231 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "publishConfig": { "access": "public" }, - "description": "CodeMirror plugin for SQL", + "description": "SQL language service for document sessions, dialects, and schema-aware analysis", "sideEffects": false, "repository": { "type": "git", @@ -13,11 +13,10 @@ }, "scripts": { "dev": "vite", - "typecheck": "pnpm run typecheck:src && pnpm run typecheck:tests && pnpm run typecheck:vnext-tests && pnpm run typecheck:vnext-tests:loose-optional && pnpm run typecheck:demo", + "typecheck": "pnpm run typecheck:src && pnpm run typecheck:tests && pnpm run typecheck:tests:loose-optional && pnpm run typecheck:demo", "typecheck:src": "tsc --project tsconfig.json --noEmit", "typecheck:tests": "tsc --project tsconfig.tests.json", - "typecheck:vnext-tests": "tsc --project tsconfig.vnext-tests.json", - "typecheck:vnext-tests:loose-optional": "tsc --project tsconfig.vnext-tests.loose-optional.json", + "typecheck:tests:loose-optional": "tsc --project tsconfig.tests.loose-optional.json", "typecheck:demo": "tsc --project tsconfig.demo.json", "lint": "oxlint --fix", "test": "vitest run --config vitest.config.ts", @@ -26,12 +25,12 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", - "bench:catalog-boundary": "vitest bench --run src/vnext/__tests__/relation-catalog-boundary.bench.ts", - "bench:catalog-coordinator": "vitest bench --run src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts", - "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", - "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", - "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", - "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", + "bench:catalog-boundary": "vitest bench --run src/__tests__/relation-catalog-boundary.bench.ts", + "bench:catalog-coordinator": "vitest bench --run src/__tests__/relation-catalog-epoch-coordinator.bench.ts", + "bench:local-relation-site": "vitest bench --run src/__tests__/local-relation-site.bench.ts", + "bench:parser-adapter": "vitest bench --run src/__tests__/node-sql-parser-adapter.bench.ts", + "bench:query-site": "vitest bench --run src/__tests__/query-site.bench.ts", + "bench:statement-index": "vitest bench --run src/__tests__/statement-index.bench.ts", "test:package": "node ./scripts/clean.mjs && tsc && node ./scripts/package-smoke.mjs", "demo": "vite build", "build": "node ./scripts/clean.mjs && tsc", @@ -40,21 +39,16 @@ "release": "pnpm version" }, "keywords": [ - "codemirror", - "codemirror-plugin", - "sql" + "sql", + "language-service", + "codemirror" ], "license": "Apache-2.0", "packageManager": "pnpm@11.4.0", - "peerDependencies": { - "@codemirror/autocomplete": "^6", - "@codemirror/lang-sql": "^6", - "@codemirror/lint": "^6", - "@codemirror/state": "^6", - "@codemirror/view": "^6" - }, "devDependencies": { + "@codemirror/autocomplete": "6.20.0", "@codemirror/lang-sql": "6.10.0", + "@codemirror/state": "6.5.4", "@codemirror/view": "6.43.0", "@testing-library/dom": "10.4.1", "@types/node": "24.10.1", @@ -69,8 +63,7 @@ "vitest": "4.1.6" }, "files": [ - "dist", - "src/data/*.json" + "dist" ], "exports": { "./package.json": "./package.json", @@ -79,21 +72,7 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } - }, - "./dialects": { - "import": { - "types": "./dist/dialects/index.d.ts", - "default": "./dist/dialects/index.js" - } - }, - "./vnext": { - "import": { - "types": "./dist/vnext/index.d.ts", - "default": "./dist/vnext/index.js" - } - }, - "./data/common-keywords.json": "./src/data/common-keywords.json", - "./data/duckdb-keywords.json": "./src/data/duckdb-keywords.json" + } }, "types": "./dist/index.d.ts", "type": "module", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56ec913..fbd16c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,22 +8,19 @@ importers: .: dependencies: - '@codemirror/autocomplete': - specifier: ^6 - version: 6.20.3 - '@codemirror/lint': - specifier: ^6 - version: 6.9.7 - '@codemirror/state': - specifier: ^6 - version: 6.7.1 node-sql-parser: specifier: 5.4.0 version: 5.4.0 devDependencies: + '@codemirror/autocomplete': + specifier: 6.20.0 + version: 6.20.0 '@codemirror/lang-sql': specifier: 6.10.0 version: 6.10.0 + '@codemirror/state': + specifier: 6.5.4 + version: 6.5.4 '@codemirror/view': specifier: 6.43.0 version: 6.43.0 @@ -114,6 +111,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -132,6 +132,9 @@ packages: '@codemirror/search@6.7.1': resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} @@ -1213,6 +1216,13 @@ snapshots: dependencies: css-tree: 3.2.1 + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 @@ -1257,6 +1267,10 @@ snapshots: '@codemirror/view': 6.43.0 crelt: 1.0.7 + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.3 diff --git a/scripts/changed-coverage.mjs b/scripts/changed-coverage.mjs index 2bf104d..961badb 100644 --- a/scripts/changed-coverage.mjs +++ b/scripts/changed-coverage.mjs @@ -34,8 +34,7 @@ const changedProductionFiles = execFileSync( !path.endsWith(".test.ts") && !path.includes("/__tests__/") && !path.includes("/browser_tests/") && - path !== "src/debug.ts" && - path !== "src/vnext/node-sql-parser-browser-worker.ts", + path !== "src/node-sql-parser-browser-worker.ts", ); const changedRuntimeFiles = ( await Promise.all( diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index fec61df..5db05bc 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -170,14 +170,14 @@ try { throw new Error("Packed manifest does not declare node-sql-parser"); } const privateWorkerArtifacts = [ - "dist/vnext/node-sql-parser-browser-executor.d.ts", - "dist/vnext/node-sql-parser-browser-executor.js", - "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", + "dist/node-sql-parser-browser-executor.d.ts", + "dist/node-sql-parser-browser-executor.js", + "dist/node-sql-parser-browser-worker.d.ts", + "dist/node-sql-parser-browser-worker.js", + "dist/node-sql-parser-browser-worker-endpoint.d.ts", + "dist/node-sql-parser-browser-worker-endpoint.js", + "dist/node-sql-parser-wire.d.ts", + "dist/node-sql-parser-wire.js", ]; for (const artifact of privateWorkerArtifacts) { if (!existsSync(join(packageDirectory, artifact))) { @@ -188,11 +188,11 @@ try { } writeFileSync( - join(temporaryDirectory, "vnext-consumer.mjs"), + join(temporaryDirectory, "session-consumer.mjs"), `import { createSqlLanguageService, duckdbDialect, -} from "@marimo-team/codemirror-sql/vnext"; +} from "@marimo-team/codemirror-sql"; const service = createSqlLanguageService({ dialects: [duckdbDialect()], @@ -213,36 +213,18 @@ service.dispose(); writeFileSync( join(temporaryDirectory, "consumer.mts"), - `import type { Extension } from "@codemirror/state"; -import { - NodeSqlParser, - sqlCompletion, - sqlExtension, -} from "@marimo-team/codemirror-sql"; -import { - BigQueryDialect, - DremioDialect, - DuckDBDialect, -} from "@marimo-team/codemirror-sql/dialects"; -import { + `import { createSqlLanguageService, duckdbDialect, type SqlDocumentContext, type SqlEmbeddedRegion, type SqlTextRange, -} from "@marimo-team/codemirror-sql/vnext"; -import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; -import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; +} from "@marimo-team/codemirror-sql"; interface HostContext extends SqlDocumentContext { readonly engine: string; } -const extensions: Extension[] = [ - sqlCompletion({ dialect: DuckDBDialect }), - sqlExtension(), -]; -const parser = new NodeSqlParser(); const service = createSqlLanguageService({ dialects: [duckdbDialect()], }); @@ -261,46 +243,29 @@ session.update({ document: { kind: "changes", changes: [] }, }); -void extensions; -void parser; void range; void session; -void BigQueryDialect; -void DremioDialect; -void commonKeywords; -void duckdbKeywords; `, ); writeFileSync( join(temporaryDirectory, "consumer.mjs"), - `import { EditorState } from "@codemirror/state"; -import * as api from "@marimo-team/codemirror-sql"; -import * as dialects from "@marimo-team/codemirror-sql/dialects"; -import * as vnext from "@marimo-team/codemirror-sql/vnext"; -import commonKeywords from "@marimo-team/codemirror-sql/data/common-keywords.json" with { type: "json" }; -import duckdbKeywords from "@marimo-team/codemirror-sql/data/duckdb-keywords.json" with { type: "json" }; + `import * as api from "@marimo-team/codemirror-sql"; -if (typeof api.sqlExtension !== "function" || typeof api.NodeSqlParser !== "function") { - throw new Error("Root package exports are incomplete"); -} -if (!dialects.BigQueryDialect || !dialects.DremioDialect || !dialects.DuckDBDialect) { - throw new Error("Dialect package exports are incomplete"); -} if ( - typeof vnext.createSqlLanguageService !== "function" || - typeof vnext.bigQueryDialect !== "function" || - typeof vnext.dremioDialect !== "function" || - typeof vnext.duckdbDialect !== "function" || - typeof vnext.postgresDialect !== "function" + typeof api.createSqlLanguageService !== "function" || + typeof api.bigQueryDialect !== "function" || + typeof api.dremioDialect !== "function" || + typeof api.duckdbDialect !== "function" || + typeof api.postgresDialect !== "function" ) { - throw new Error("vNext package exports are incomplete"); + throw new Error("Package exports are incomplete"); } for (const dialect of [ - vnext.bigQueryDialect(), - vnext.dremioDialect(), - vnext.duckdbDialect(), - vnext.postgresDialect(), + api.bigQueryDialect(), + api.dremioDialect(), + api.duckdbDialect(), + api.postgresDialect(), ]) { if ( Object.keys(dialect).join(",") !== "displayName,id" || @@ -309,21 +274,12 @@ for (const dialect of [ "relationDialect" in dialect || "renderRelationPath" in dialect ) { - throw new Error("vNext dialect implementation policy leaked publicly"); + throw new Error("Dialect implementation policy leaked publicly"); } } -if (!commonKeywords.keywords || !duckdbKeywords.keywords) { - throw new Error("Keyword data exports are incomplete"); -} - -const state = EditorState.create({ doc: "SELECT 1" }); -const parseResult = await new api.NodeSqlParser().parse("SELECT 1", { state }); -if (!parseResult.success || !parseResult.ast) { - throw new Error("The packaged parser could not load its runtime dependency"); -} -const service = vnext.createSqlLanguageService({ - dialects: [vnext.duckdbDialect()], +const service = api.createSqlLanguageService({ + dialects: [api.duckdbDialect()], }); const session = service.openDocument({ context: { dialect: "duckdb" }, @@ -337,7 +293,7 @@ const updatedRevision = session.update({ document: { kind: "replace", text: "SELECT * FROM {next_df}" }, }); if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { - throw new Error("The packaged vNext session violated revision identity"); + throw new Error("The packaged session violated revision identity"); } service.dispose(); `, @@ -368,7 +324,7 @@ service.dispose(); join(temporaryDirectory, "node_modules", "@codemirror"), ]; withRenamedPaths(isolatedDependencies, () => { - run(process.execPath, ["vnext-consumer.mjs"], temporaryDirectory); + run(process.execPath, ["session-consumer.mjs"], temporaryDirectory); }); run(process.execPath, ["consumer.mjs"], temporaryDirectory); } finally { diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 351e535..4ca5648 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -522,7 +522,7 @@ Object.defineProperty(globalThis, "Worker", { throw new Error("SSR import read Worker"); }, }); -const api = await import("@marimo-team/codemirror-sql/vnext"); +const api = await import("@marimo-team/codemirror-sql"); if ( typeof api.createSqlLanguageService !== "function" || typeof api.duckdbDialect !== "function" diff --git a/src/vnext/__tests__/bounded-sql-lexer.test.ts b/src/__tests__/bounded-sql-lexer.test.ts similarity index 100% rename from src/vnext/__tests__/bounded-sql-lexer.test.ts rename to src/__tests__/bounded-sql-lexer.test.ts diff --git a/src/vnext/__tests__/cte-layout.bench.ts b/src/__tests__/cte-layout.bench.ts similarity index 100% rename from src/vnext/__tests__/cte-layout.bench.ts rename to src/__tests__/cte-layout.bench.ts diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/__tests__/cte-layout.test.ts similarity index 100% rename from src/vnext/__tests__/cte-layout.test.ts rename to src/__tests__/cte-layout.test.ts diff --git a/src/vnext/__tests__/incremental-statement-index.test.ts b/src/__tests__/incremental-statement-index.test.ts similarity index 100% rename from src/vnext/__tests__/incremental-statement-index.test.ts rename to src/__tests__/incremental-statement-index.test.ts diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index a319e71..0000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import * as dialects from "../dialects"; -import * as exports from "../index"; - -describe("index.ts exports", () => { - it("should not change unexpectedly", () => { - const sortedExports = Object.keys(exports).sort(); - expect(sortedExports).toMatchInlineSnapshot(` - [ - "DefaultSqlTooltipRenders", - "NodeSqlParser", - "QueryContextAnalyzer", - "SqlReferenceResolver", - "SqlStructureAnalyzer", - "aliasColumnCompletionSource", - "analyzeQueryContext", - "createCteCompletionSource", - "cteCompletionSource", - "defaultSqlHoverTheme", - "findReferences", - "gotoSqlDefinition", - "renameSqlIdentifier", - "resolveSqlSchema", - "sqlCompletion", - "sqlExtension", - "sqlGotoDefinition", - "sqlHighlightReferences", - "sqlHover", - "sqlLinter", - "sqlNavigation", - "sqlNavigationKeymap", - "sqlSchemaFacet", - "sqlSemanticLinter", - "sqlStructureGutter", - "unqualifiedColumnCompletionSource", - ] - `); - }); -}); - -describe("keywords", () => { - it("should have the correct structure, keywords.keywords should be an object", () => { - const dataDirectory = join(__dirname, "../data"); - const keywordFiles = readdirSync(dataDirectory).filter((file) => - file.endsWith("-keywords.json"), - ); - - expect(keywordFiles.length).toBeGreaterThan(0); - for (const file of keywordFiles) { - const data = JSON.parse(readFileSync(join(dataDirectory, file), "utf8")); - expect(typeof data.keywords).toBe("object"); - } - }); -}); - -describe("dialects.ts exports", () => { - it("should not change unexpectedly", () => { - const sortedExports = Object.keys(dialects).sort(); - expect(sortedExports).toMatchInlineSnapshot(` - [ - "BigQueryDialect", - "DremioDialect", - "DuckDBDialect", - ] - `); - }); - - it("should expose a Dremio dialect with Dremio SQL keywords", () => { - expect(dialects.DremioDialect.spec.identifierQuotes).toBe('"'); - expect(dialects.DremioDialect.spec.keywords?.split(" ")).toContain("reflection"); - expect(dialects.DremioDialect.spec.keywords?.split(" ")).toContain("qualify"); - expect(dialects.DremioDialect.spec.types?.split(" ")).toContain("struct"); - }); -}); diff --git a/src/vnext/__tests__/lexical.test.ts b/src/__tests__/lexical.test.ts similarity index 100% rename from src/vnext/__tests__/lexical.test.ts rename to src/__tests__/lexical.test.ts diff --git a/src/vnext/__tests__/local-relation-site.bench.ts b/src/__tests__/local-relation-site.bench.ts similarity index 100% rename from src/vnext/__tests__/local-relation-site.bench.ts rename to src/__tests__/local-relation-site.bench.ts diff --git a/src/vnext/__tests__/local-relation-site.test.ts b/src/__tests__/local-relation-site.test.ts similarity index 100% rename from src/vnext/__tests__/local-relation-site.test.ts rename to src/__tests__/local-relation-site.test.ts diff --git a/src/vnext/__tests__/node-sql-parser-adapter.bench.ts b/src/__tests__/node-sql-parser-adapter.bench.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-adapter.bench.ts rename to src/__tests__/node-sql-parser-adapter.bench.ts diff --git a/src/vnext/__tests__/node-sql-parser-adapter.test.ts b/src/__tests__/node-sql-parser-adapter.test.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-adapter.test.ts rename to src/__tests__/node-sql-parser-adapter.test.ts diff --git a/src/vnext/__tests__/node-sql-parser-backend.test.ts b/src/__tests__/node-sql-parser-backend.test.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-backend.test.ts rename to src/__tests__/node-sql-parser-backend.test.ts diff --git a/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts b/src/__tests__/node-sql-parser-browser-executor.test.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-browser-executor.test.ts rename to src/__tests__/node-sql-parser-browser-executor.test.ts diff --git a/src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts b/src/__tests__/node-sql-parser-browser-worker-endpoint.test.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-browser-worker-endpoint.test.ts rename to src/__tests__/node-sql-parser-browser-worker-endpoint.test.ts diff --git a/src/vnext/__tests__/node-sql-parser-wire.test.ts b/src/__tests__/node-sql-parser-wire.test.ts similarity index 100% rename from src/vnext/__tests__/node-sql-parser-wire.test.ts rename to src/__tests__/node-sql-parser-wire.test.ts diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index 901d5f9..cc88f0a 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -30,9 +30,6 @@ function collectExportTargets(exportsField: unknown, out: string[] = []): string } describe("published package", () => { - // Run the real `npm pack` so we assert against the actual tarball contents, - // not the source tree. Regression guard for the `./data/*` exports that - // shipped dead in 0.2.5–0.2.7 because `src/data/` was missing at publish time. const packOutput: PackedManifest | PackedManifest[] = JSON.parse( execSync("pnpm pack --dry-run --json", { cwd: repoRoot, @@ -48,17 +45,19 @@ describe("published package", () => { const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); - // Only assert source-committed targets (e.g. src/data/*.json). `dist/*` - // targets are intentionally skipped: in CI `pnpm test` runs before - // `pnpm build`, so dist does not exist yet when this test executes. - const sourceTargets = collectExportTargets(pkg.exports) - .map((p) => p.replace(/^\.\//, "")) - .filter((p) => p.startsWith("src/")); + it("publishes package.json and declares a dist-only export map", () => { + expect(packedPaths).toContain("package.json"); + expect(pkg.files).toEqual(["dist"]); - it("includes every src/ export target in the tarball", () => { - expect(sourceTargets.length).toBeGreaterThan(0); - for (const target of sourceTargets) { - expect(packedPaths, `${target} is referenced by exports but missing from the npm tarball`).toContain(target); + const exportTargets = collectExportTargets(pkg.exports) + .map((path) => path.replace(/^\.\//, "")) + .filter((path) => path !== "package.json"); + + expect(exportTargets.length).toBeGreaterThan(0); + for (const target of exportTargets) { + expect(target.startsWith("dist/"), `${target} must live under dist/`).toBe( + true, + ); } }); }); diff --git a/src/vnext/__tests__/query-site.bench.ts b/src/__tests__/query-site.bench.ts similarity index 100% rename from src/vnext/__tests__/query-site.bench.ts rename to src/__tests__/query-site.bench.ts diff --git a/src/vnext/__tests__/query-site.test.ts b/src/__tests__/query-site.test.ts similarity index 100% rename from src/vnext/__tests__/query-site.test.ts rename to src/__tests__/query-site.test.ts diff --git a/src/vnext/__tests__/relation-catalog-boundary.bench.ts b/src/__tests__/relation-catalog-boundary.bench.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-boundary.bench.ts rename to src/__tests__/relation-catalog-boundary.bench.ts diff --git a/src/vnext/__tests__/relation-catalog-boundary.test.ts b/src/__tests__/relation-catalog-boundary.test.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-boundary.test.ts rename to src/__tests__/relation-catalog-boundary.test.ts diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts b/src/__tests__/relation-catalog-epoch-coordinator.bench.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts rename to src/__tests__/relation-catalog-epoch-coordinator.bench.ts diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/__tests__/relation-catalog-epoch-coordinator.test.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts rename to src/__tests__/relation-catalog-epoch-coordinator.test.ts diff --git a/src/vnext/__tests__/relation-catalog-search-work.bench.ts b/src/__tests__/relation-catalog-search-work.bench.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-search-work.bench.ts rename to src/__tests__/relation-catalog-search-work.bench.ts diff --git a/src/vnext/__tests__/relation-catalog-search-work.test.ts b/src/__tests__/relation-catalog-search-work.test.ts similarity index 100% rename from src/vnext/__tests__/relation-catalog-search-work.test.ts rename to src/__tests__/relation-catalog-search-work.test.ts diff --git a/src/vnext/__tests__/relation-dialect-boundaries.test.ts b/src/__tests__/relation-dialect-boundaries.test.ts similarity index 100% rename from src/vnext/__tests__/relation-dialect-boundaries.test.ts rename to src/__tests__/relation-dialect-boundaries.test.ts diff --git a/src/vnext/__tests__/relation-dialect.test.ts b/src/__tests__/relation-dialect.test.ts similarity index 100% rename from src/vnext/__tests__/relation-dialect.test.ts rename to src/__tests__/relation-dialect.test.ts diff --git a/src/vnext/__tests__/session.test.ts b/src/__tests__/session.test.ts similarity index 100% rename from src/vnext/__tests__/session.test.ts rename to src/__tests__/session.test.ts diff --git a/src/vnext/__tests__/source.test.ts b/src/__tests__/source.test.ts similarity index 100% rename from src/vnext/__tests__/source.test.ts rename to src/__tests__/source.test.ts diff --git a/src/vnext/__tests__/statement-index.bench.ts b/src/__tests__/statement-index.bench.ts similarity index 100% rename from src/vnext/__tests__/statement-index.bench.ts rename to src/__tests__/statement-index.bench.ts diff --git a/src/vnext/__tests__/statement-index.test.ts b/src/__tests__/statement-index.test.ts similarity index 100% rename from src/vnext/__tests__/statement-index.test.ts rename to src/__tests__/statement-index.test.ts diff --git a/src/vnext/__tests__/syntax.test.ts b/src/__tests__/syntax.test.ts similarity index 100% rename from src/vnext/__tests__/syntax.test.ts rename to src/__tests__/syntax.test.ts diff --git a/src/vnext/bounded-sql-lexer.ts b/src/bounded-sql-lexer.ts similarity index 100% rename from src/vnext/bounded-sql-lexer.ts rename to src/bounded-sql-lexer.ts diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js b/src/browser_tests/fixtures/node-sql-parser-crash-worker.js similarity index 100% rename from src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js rename to src/browser_tests/fixtures/node-sql-parser-crash-worker.js diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js b/src/browser_tests/fixtures/node-sql-parser-silent-worker.js similarity index 100% rename from src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js rename to src/browser_tests/fixtures/node-sql-parser-silent-worker.js diff --git a/src/vnext/browser_tests/node-sql-parser-adapter.test.ts b/src/browser_tests/node-sql-parser-adapter.test.ts similarity index 100% rename from src/vnext/browser_tests/node-sql-parser-adapter.test.ts rename to src/browser_tests/node-sql-parser-adapter.test.ts diff --git a/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts b/src/browser_tests/node-sql-parser-browser-executor.test.ts similarity index 100% rename from src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts rename to src/browser_tests/node-sql-parser-browser-executor.test.ts diff --git a/src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts b/src/browser_tests/node-sql-parser-browser-worker.test.ts similarity index 100% rename from src/vnext/browser_tests/node-sql-parser-browser-worker.test.ts rename to src/browser_tests/node-sql-parser-browser-worker.test.ts diff --git a/src/browser_tests/sql-editor.test.ts b/src/browser_tests/sql-editor.test.ts deleted file mode 100644 index 40744cc..0000000 --- a/src/browser_tests/sql-editor.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { PostgreSQL, sql } from "@codemirror/lang-sql"; -import { basicSetup, EditorView } from "codemirror"; -import { expect, test } from "vitest"; -import { NodeSqlParser, sqlExtension } from "../index.js"; - -const schema: Record = { - users: ["id", "name", "email", "active", "status", "created_at"], - posts: ["id", "title", "content", "user_id", "published", "created_at"], - orders: ["id", "customer_id", "order_date", "total_amount", "status"], - customers: ["id", "first_name", "last_name", "email", "phone"], - categories: ["id", "name", "description", "parent_id"], - tbl: ["id", "name"], -}; - -function initializeEditor(container: HTMLElement) { - const parser = new NodeSqlParser({ - getParserOptions: () => { - return { - database: "PostgreSQL", - }; - }, - }); - - const extensions = [ - basicSetup, - EditorView.lineWrapping, - sql({ - dialect: PostgreSQL, - schema: schema, - upperCaseKeywords: true, - }), - sqlExtension({ - linterConfig: { - delay: 250, - parser, - }, - gutterConfig: { - backgroundColor: "#3b82f6", - errorBackgroundColor: "#ef4444", - hideWhenNotFocused: true, - parser, - }, - enableHover: false, - }), - ]; - - const editor = new EditorView({ - doc: "", - extensions, - parent: container, - }); - - return editor; -} - -test("SQL editor with input", { timeout: 5000 }, async () => { - const container = document.createElement("div"); - container.id = "sql-editor-test"; - container.style.width = "800px"; - container.style.height = "400px"; - document.body.appendChild(container); - - const editor = initializeEditor(container); - - const sqlText = "select * from tbl\n inner join"; - - editor.dispatch({ - changes: { - from: 0, - to: editor.state.doc.length, - insert: sqlText, - }, - selection: { - anchor: sqlText.length, - head: sqlText.length, - }, - }); - - editor.focus(); - - // Wait a moment for potential browser freeze - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // If we get here, test should complete quickly - const content = editor.state.doc.toString(); - expect(content).toBe(sqlText); -}); diff --git a/src/vnext/codemirror/relation-completion-types.ts b/src/codemirror/relation-completion-types.ts similarity index 100% rename from src/vnext/codemirror/relation-completion-types.ts rename to src/codemirror/relation-completion-types.ts diff --git a/src/vnext/cte-layout.ts b/src/cte-layout.ts similarity index 100% rename from src/vnext/cte-layout.ts rename to src/cte-layout.ts diff --git a/src/data/README.md b/src/data/README.md deleted file mode 100644 index 6033a8d..0000000 --- a/src/data/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# SQL Keywords - -This directory contains the SQL keywords for the different dialects. Keywords are stored here so they can be lazily loaded. - -## Structure - -By default, we use the `SqlKeywordInfo` type to define the keywords. - -```json -{ - "keywords": { - "keyword1": { - "description": "Description of the keyword", - "syntax": "Syntax of the keyword", - "example": "Example of the keyword", - "metadata": { - "tag1": "value1", - "tag2": "value2" - } - }, - "keyword2": { - "description": "Description of the keyword", - "syntax": "Syntax of the keyword", - "example": "Example of the keyword", - "metadata": { - "tag1": "value1", - "tag2": "value2" - } - } - } -} -``` - -## Adding a new dialect - -For compatibility with Vite and other bundlers, `import` returns a JS module and not a JSON object - -So we need to nest the keywords under a json key to access them, -otherwise a keyword can conflict with a JS reserved keyword (e.g. `default` or `with`) diff --git a/src/data/common-keywords.json b/src/data/common-keywords.json deleted file mode 100644 index ec45170..0000000 --- a/src/data/common-keywords.json +++ /dev/null @@ -1,399 +0,0 @@ -{ - "keywords": { - "select": { - "description": "Retrieves data from one or more tables", - "syntax": "SELECT column1, column2, ... FROM table_name", - "example": "SELECT name, email FROM users WHERE active = true" - }, - "from": { - "description": "Specifies which table to select data from", - "syntax": "FROM table_name", - "example": "FROM users u JOIN orders o ON u.id = o.user_id" - }, - "where": { - "description": "Filters records based on specified conditions", - "syntax": "WHERE condition", - "example": "WHERE age > 18 AND status = 'active'" - }, - "join": { - "description": "Combines rows from two or more tables based on a related column", - "syntax": "JOIN table_name ON condition", - "example": "JOIN orders ON users.id = orders.user_id" - }, - "inner": { - "description": "Returns records that have matching values in both tables", - "syntax": "INNER JOIN table_name ON condition", - "example": "INNER JOIN orders ON users.id = orders.user_id" - }, - "left": { - "description": "Returns all records from the left table and matching records from the right", - "syntax": "LEFT JOIN table_name ON condition", - "example": "LEFT JOIN orders ON users.id = orders.user_id" - }, - "right": { - "description": "Returns all records from the right table and matching records from the left", - "syntax": "RIGHT JOIN table_name ON condition", - "example": "RIGHT JOIN users ON users.id = orders.user_id" - }, - "full": { - "description": "Returns all records when there is a match in either left or right table", - "syntax": "FULL OUTER JOIN table_name ON condition", - "example": "FULL OUTER JOIN orders ON users.id = orders.user_id" - }, - "outer": { - "description": "Used with FULL to return all records from both tables", - "syntax": "FULL OUTER JOIN table_name ON condition", - "example": "FULL OUTER JOIN orders ON users.id = orders.user_id" - }, - "cross": { - "description": "Returns the Cartesian product of both tables", - "syntax": "CROSS JOIN table_name", - "example": "CROSS JOIN colors" - }, - "order": { - "description": "Sorts the result set in ascending or descending order", - "syntax": "ORDER BY column_name [ASC|DESC]", - "example": "ORDER BY created_at DESC, name ASC" - }, - "by": { - "description": "Used with ORDER BY and GROUP BY clauses", - "syntax": "ORDER BY column_name or GROUP BY column_name", - "example": "ORDER BY name ASC or GROUP BY category" - }, - "group": { - "description": "Groups rows that have the same values into summary rows", - "syntax": "GROUP BY column_name", - "example": "GROUP BY category HAVING COUNT(*) > 5" - }, - "having": { - "description": "Filters groups based on specified conditions (used with GROUP BY)", - "syntax": "HAVING condition", - "example": "GROUP BY category HAVING COUNT(*) > 5" - }, - "insert": { - "description": "Adds new records to a table", - "syntax": "INSERT INTO table_name (columns) VALUES (values)", - "example": "INSERT INTO users (name, email) VALUES ('John', 'john@example.com')" - }, - "into": { - "description": "Specifies the target table for INSERT statements", - "syntax": "INSERT INTO table_name", - "example": "INSERT INTO users (name, email) VALUES ('John', 'john@example.com')" - }, - "values": { - "description": "Specifies the values to insert into a table", - "syntax": "VALUES (value1, value2, ...)", - "example": "VALUES ('John', 'john@example.com', true)" - }, - "update": { - "description": "Modifies existing records in a table", - "syntax": "UPDATE table_name SET column = value WHERE condition", - "example": "UPDATE users SET email = 'new@example.com' WHERE id = 1" - }, - "set": { - "description": "Specifies which columns to update and their new values", - "syntax": "SET column1 = value1, column2 = value2", - "example": "SET name = 'John', email = 'john@example.com'" - }, - "delete": { - "description": "Removes records from a table", - "syntax": "DELETE FROM table_name WHERE condition", - "example": "DELETE FROM users WHERE active = false" - }, - "create": { - "description": "Creates a new table, database, or other database object", - "syntax": "CREATE TABLE table_name (column definitions)", - "example": "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))" - }, - "table": { - "description": "Specifies a table in CREATE, ALTER, or DROP statements", - "syntax": "CREATE TABLE table_name or ALTER TABLE table_name", - "example": "CREATE TABLE users (id INT, name VARCHAR(100))" - }, - "drop": { - "description": "Deletes a table, database, or other database object", - "syntax": "DROP TABLE table_name", - "example": "DROP TABLE old_users" - }, - "alter": { - "description": "Modifies an existing database object", - "syntax": "ALTER TABLE table_name ADD/DROP/MODIFY column", - "example": "ALTER TABLE users ADD COLUMN phone VARCHAR(20)" - }, - "add": { - "description": "Adds a new column or constraint to a table", - "syntax": "ALTER TABLE table_name ADD column_name data_type", - "example": "ALTER TABLE users ADD phone VARCHAR(20)" - }, - "column": { - "description": "Specifies a column in table operations", - "syntax": "ADD COLUMN column_name or DROP COLUMN column_name", - "example": "ADD COLUMN created_at TIMESTAMP DEFAULT NOW()" - }, - "primary": { - "description": "Defines a primary key constraint", - "syntax": "PRIMARY KEY (column_name)", - "example": "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))" - }, - "key": { - "description": "Used with PRIMARY or FOREIGN to define constraints", - "syntax": "PRIMARY KEY or FOREIGN KEY", - "example": "PRIMARY KEY (id) or FOREIGN KEY (user_id) REFERENCES users(id)" - }, - "foreign": { - "description": "Defines a foreign key constraint", - "syntax": "FOREIGN KEY (column_name) REFERENCES table_name(column_name)", - "example": "FOREIGN KEY (user_id) REFERENCES users(id)" - }, - "references": { - "description": "Specifies the referenced table and column for foreign keys", - "syntax": "REFERENCES table_name(column_name)", - "example": "FOREIGN KEY (user_id) REFERENCES users(id)" - }, - "unique": { - "description": "Ensures all values in a column are unique", - "syntax": "UNIQUE (column_name)", - "example": "CREATE TABLE users (email VARCHAR(255) UNIQUE)" - }, - "constraint": { - "description": "Names a constraint for easier management", - "syntax": "CONSTRAINT constraint_name constraint_type", - "example": "CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)" - }, - "check": { - "description": "Defines a condition that must be true for all rows", - "syntax": "CHECK (condition)", - "example": "CHECK (age >= 18)" - }, - "default": { - "description": "Specifies a default value for a column", - "syntax": "column_name data_type DEFAULT value", - "example": "created_at TIMESTAMP DEFAULT NOW()" - }, - "index": { - "description": "Creates an index to improve query performance", - "syntax": "CREATE INDEX index_name ON table_name (column_name)", - "example": "CREATE INDEX idx_user_email ON users (email)" - }, - "view": { - "description": "Creates a virtual table based on a SELECT statement", - "syntax": "CREATE VIEW view_name AS SELECT ...", - "example": "CREATE VIEW active_users AS SELECT * FROM users WHERE active = true" - }, - "limit": { - "description": "Restricts the number of records returned", - "syntax": "LIMIT number", - "example": "SELECT * FROM users LIMIT 10" - }, - "offset": { - "description": "Skips a specified number of rows before returning results", - "syntax": "OFFSET number", - "example": "SELECT * FROM users LIMIT 10 OFFSET 20" - }, - "top": { - "description": "Limits the number of records returned (SQL Server syntax)", - "syntax": "SELECT TOP number columns FROM table", - "example": "SELECT TOP 10 * FROM users" - }, - "fetch": { - "description": "Retrieves a specific number of rows (modern SQL standard)", - "syntax": "OFFSET number ROWS FETCH NEXT number ROWS ONLY", - "example": "OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY" - }, - "with": { - "description": "Defines a Common Table Expression (CTE)", - "syntax": "WITH cte_name AS (SELECT ...) SELECT ... FROM cte_name", - "example": "WITH user_stats AS (SELECT user_id, COUNT(*) FROM orders GROUP BY user_id) SELECT * FROM user_stats" - }, - "recursive": { - "description": "Creates a recursive CTE that can reference itself", - "syntax": "WITH RECURSIVE cte_name AS (...) SELECT ...", - "example": "WITH RECURSIVE tree AS (SELECT id, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree" - }, - "distinct": { - "description": "Returns only unique values", - "syntax": "SELECT DISTINCT column_name FROM table_name", - "example": "SELECT DISTINCT category FROM products" - }, - "count": { - "description": "Returns the number of rows that match a condition", - "syntax": "COUNT(*) or COUNT(column_name)", - "example": "SELECT COUNT(*) FROM users WHERE active = true" - }, - "sum": { - "description": "Returns the sum of numeric values", - "syntax": "SUM(column_name)", - "example": "SELECT SUM(price) FROM orders WHERE status = 'completed'" - }, - "avg": { - "description": "Returns the average value of numeric values", - "syntax": "AVG(column_name)", - "example": "SELECT AVG(age) FROM users" - }, - "max": { - "description": "Returns the maximum value", - "syntax": "MAX(column_name)", - "example": "SELECT MAX(price) FROM products" - }, - "min": { - "description": "Returns the minimum value", - "syntax": "MIN(column_name)", - "example": "SELECT MIN(price) FROM products" - }, - "as": { - "description": "Creates an alias for a column or table", - "syntax": "column_name AS alias_name or table_name AS alias_name", - "example": "SELECT name AS customer_name FROM users AS u" - }, - "on": { - "description": "Specifies the join condition between tables", - "syntax": "JOIN table_name ON condition", - "example": "JOIN orders ON users.id = orders.user_id" - }, - "and": { - "description": "Combines multiple conditions with logical AND", - "syntax": "WHERE condition1 AND condition2", - "example": "WHERE age > 18 AND status = 'active'" - }, - "or": { - "description": "Combines multiple conditions with logical OR", - "syntax": "WHERE condition1 OR condition2", - "example": "WHERE category = 'electronics' OR category = 'books'" - }, - "not": { - "description": "Negates a condition", - "syntax": "WHERE NOT condition", - "example": "WHERE NOT status = 'inactive'" - }, - "null": { - "description": "Represents a missing or unknown value", - "syntax": "column_name IS NULL or column_name IS NOT NULL", - "example": "WHERE email IS NOT NULL" - }, - "is": { - "description": "Used to test for NULL values or boolean conditions", - "syntax": "column_name IS NULL or column_name IS NOT NULL", - "example": "WHERE deleted_at IS NULL" - }, - "in": { - "description": "Checks if a value matches any value in a list", - "syntax": "column_name IN (value1, value2, ...)", - "example": "WHERE status IN ('active', 'pending', 'approved')" - }, - "between": { - "description": "Selects values within a range", - "syntax": "column_name BETWEEN value1 AND value2", - "example": "WHERE age BETWEEN 18 AND 65" - }, - "like": { - "description": "Searches for a pattern in a column", - "syntax": "column_name LIKE pattern", - "example": "WHERE name LIKE 'John%' (starts with 'John')" - }, - "exists": { - "description": "Tests whether a subquery returns any rows", - "syntax": "WHERE EXISTS (subquery)", - "example": "WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)" - }, - "any": { - "description": "Compares a value to any value returned by a subquery", - "syntax": "column_name operator ANY (subquery)", - "example": "WHERE price > ANY (SELECT price FROM products WHERE category = 'electronics')" - }, - "all": { - "description": "Compares a value to all values returned by a subquery", - "syntax": "column_name operator ALL (subquery)", - "example": "WHERE price > ALL (SELECT price FROM products WHERE category = 'books')" - }, - "some": { - "description": "Synonym for ANY - compares a value to some values in a subquery", - "syntax": "column_name operator SOME (subquery)", - "example": "WHERE price > SOME (SELECT price FROM products WHERE category = 'electronics')" - }, - "union": { - "description": "Combines the result sets of two or more SELECT statements", - "syntax": "SELECT ... UNION SELECT ...", - "example": "SELECT name FROM customers UNION SELECT name FROM suppliers" - }, - "intersect": { - "description": "Returns rows that are in both result sets", - "syntax": "SELECT ... INTERSECT SELECT ...", - "example": "SELECT customer_id FROM orders INTERSECT SELECT customer_id FROM returns" - }, - "except": { - "description": "Returns rows from the first query that are not in the second", - "syntax": "SELECT ... EXCEPT SELECT ...", - "example": "SELECT customer_id FROM customers EXCEPT SELECT customer_id FROM blacklist" - }, - "case": { - "description": "Provides conditional logic in SQL queries", - "syntax": "CASE WHEN condition THEN result ELSE result END", - "example": "CASE WHEN age < 18 THEN 'Minor' ELSE 'Adult' END" - }, - "when": { - "description": "Specifies conditions in CASE statements", - "syntax": "CASE WHEN condition THEN result", - "example": "CASE WHEN score >= 90 THEN 'A' WHEN score >= 80 THEN 'B' END" - }, - "then": { - "description": "Specifies the result for a WHEN condition", - "syntax": "WHEN condition THEN result", - "example": "WHEN age < 18 THEN 'Minor'" - }, - "else": { - "description": "Specifies the default result in CASE statements", - "syntax": "CASE WHEN condition THEN result ELSE default_result END", - "example": "CASE WHEN score >= 60 THEN 'Pass' ELSE 'Fail' END" - }, - "end": { - "description": "Terminates a CASE statement", - "syntax": "CASE WHEN condition THEN result END", - "example": "CASE WHEN age < 18 THEN 'Minor' ELSE 'Adult' END" - }, - "over": { - "description": "Defines a window for window functions", - "syntax": "window_function() OVER (PARTITION BY ... ORDER BY ...)", - "example": "ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC)" - }, - "partition": { - "description": "Divides the result set into groups for window functions", - "syntax": "OVER (PARTITION BY column_name)", - "example": "SUM(salary) OVER (PARTITION BY department)" - }, - "row_number": { - "description": "Assigns a unique sequential integer to each row", - "syntax": "ROW_NUMBER() OVER (ORDER BY column_name)", - "example": "ROW_NUMBER() OVER (ORDER BY created_at DESC)" - }, - "rank": { - "description": "Assigns a rank to each row with gaps for ties", - "syntax": "RANK() OVER (ORDER BY column_name)", - "example": "RANK() OVER (ORDER BY score DESC)" - }, - "dense_rank": { - "description": "Assigns a rank to each row without gaps for ties", - "syntax": "DENSE_RANK() OVER (ORDER BY column_name)", - "example": "DENSE_RANK() OVER (ORDER BY score DESC)" - }, - "begin": { - "description": "Starts a transaction block", - "syntax": "BEGIN [TRANSACTION]", - "example": "BEGIN; UPDATE accounts SET balance = balance - 100; COMMIT;" - }, - "commit": { - "description": "Permanently saves all changes made in the current transaction", - "syntax": "COMMIT [TRANSACTION]", - "example": "BEGIN; INSERT INTO users VALUES (...); COMMIT;" - }, - "rollback": { - "description": "Undoes all changes made in the current transaction", - "syntax": "ROLLBACK [TRANSACTION]", - "example": "BEGIN; DELETE FROM users WHERE id = 1; ROLLBACK;" - }, - "transaction": { - "description": "Groups multiple SQL statements into a single unit of work", - "syntax": "BEGIN TRANSACTION; ... COMMIT/ROLLBACK;", - "example": "BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;" - } - } -} diff --git a/src/data/duckdb-keywords.json b/src/data/duckdb-keywords.json deleted file mode 100644 index 630f50f..0000000 --- a/src/data/duckdb-keywords.json +++ /dev/null @@ -1,360 +0,0 @@ -{ - "keywords": { - "array_agg": { - "description": "Returns a LIST containing all the values of a column.", - "example": "list(A)" - }, - "array_aggr": { - "description": "Executes the aggregate function name on the elements of list", - "example": "list_aggregate([1, 2, NULL], 'min')" - }, - "array_aggregate": { - "description": "Executes the aggregate function name on the elements of list", - "example": "list_aggregate([1, 2, NULL], 'min')" - }, - "array_apply": { - "description": "Returns a list that is the result of applying the lambda function to each element of the input list. See the Lambda Functions section for more details", - "example": "list_transform([1, 2, 3], x -> x + 1)" - }, - "array_cat": { - "description": "Concatenates two lists.", - "example": "list_concat([2, 3], [4, 5, 6])" - }, - "array_concat": { - "description": "Concatenates two lists.", - "example": "list_concat([2, 3], [4, 5, 6])" - }, - "array_contains": { - "description": "Returns true if the list contains the element.", - "example": "list_contains([1, 2, NULL], 1)" - }, - "array_cosine_distance": { - "description": "Compute the cosine distance between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_cosine_distance([1, 2, 3], [1, 2, 3])" - }, - "array_cosine_similarity": { - "description": "Compute the cosine similarity between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_cosine_similarity([1, 2, 3], [1, 2, 3])" - }, - "array_cross_product": { - "description": "Compute the cross product of two arrays of size 3. The array elements can not be NULL.", - "example": "array_cross_product([1, 2, 3], [1, 2, 3])" - }, - "array_distance": { - "description": "Compute the distance between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_distance([1, 2, 3], [1, 2, 3])" - }, - "array_distinct": { - "description": "Removes all duplicates and NULLs from a list. Does not preserve the original order", - "example": "list_distinct([1, 1, NULL, -3, 1, 5])" - }, - "array_dot_product": { - "description": "Compute the inner product between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_inner_product([1, 2, 3], [1, 2, 3])" - }, - "array_extract": { - "description": "Extract the indexth (1-based) value from the array.", - "example": "array_extract('DuckDB', 2)" - }, - "array_filter": { - "description": "Constructs a list from those elements of the input list for which the lambda function returns true", - "example": "list_filter([3, 4, 5], x -> x > 4)" - }, - "array_grade_up": { - "description": "Returns the index of their sorted position.", - "example": "list_grade_up([3, 6, 1, 2])" - }, - "array_has": { - "description": "Returns true if the list contains the element.", - "example": "list_contains([1, 2, NULL], 1)" - }, - "array_has_all": { - "description": "Returns true if all elements of l2 are in l1. NULLs are ignored.", - "example": "list_has_all([1, 2, 3], [2, 3])" - }, - "array_has_any": { - "description": "Returns true if the lists have any element in common. NULLs are ignored.", - "example": "list_has_any([1, 2, 3], [2, 3, 4])" - }, - "array_indexof": { - "description": "Returns the index of the element if the list contains the element. If the element is not found, it returns NULL.", - "example": "list_position([1, 2, NULL], 2)" - }, - "array_inner_product": { - "description": "Compute the inner product between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_inner_product([1, 2, 3], [1, 2, 3])" - }, - "array_length": { - "description": "Returns the length of the `list`.", - "example": "array_length([1,2,3])" - }, - "array_negative_dot_product": { - "description": "Compute the negative inner product between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_negative_inner_product([1, 2, 3], [1, 2, 3])" - }, - "array_negative_inner_product": { - "description": "Compute the negative inner product between two arrays of the same size. The array elements can not be NULL. The arrays can have any size as long as the size is the same for both arguments.", - "example": "array_negative_inner_product([1, 2, 3], [1, 2, 3])" - }, - "array_position": { - "description": "Returns the index of the element if the list contains the element. If the element is not found, it returns NULL.", - "example": "list_position([1, 2, NULL], 2)" - }, - "array_reduce": { - "description": "Returns a single value that is the result of applying the lambda function to each element of the input list, starting with the first element and then repeatedly applying the lambda function to the result of the previous application and the next element of the list. When an initial value is provided, it is used as the first argument to the lambda function", - "example": "list_reduce([1, 2, 3], (x, y) -> x + y)" - }, - "array_resize": { - "description": "Resizes the list to contain size elements. Initializes new elements with value or NULL if value is not set.", - "example": "list_resize([1, 2, 3], 5, 0)" - }, - "array_reverse_sort": { - "description": "Sorts the elements of the list in reverse order", - "example": "list_reverse_sort([3, 6, 1, 2])" - }, - "array_select": { - "description": "Returns a list based on the elements selected by the index_list.", - "example": "list_select([10, 20, 30, 40], [1, 4])" - }, - "array_slice": { - "description": "list_slice with added step feature.", - "example": "list_slice([4, 5, 6], 2, 3)" - }, - "array_sort": { - "description": "Sorts the elements of the list", - "example": "list_sort([3, 6, 1, 2])" - }, - "array_transform": { - "description": "Returns a list that is the result of applying the lambda function to each element of the input list. See the Lambda Functions section for more details", - "example": "list_transform([1, 2, 3], x -> x + 1)" - }, - "array_unique": { - "description": "Counts the unique elements of a list", - "example": "list_unique([1, 1, NULL, -3, 1, 5])" - }, - "array_value": { - "description": "Create an ARRAY containing the argument values.", - "example": "array_value(4, 5, 6)" - }, - "array_where": { - "description": "Returns a list with the BOOLEANs in mask_list applied as a mask to the value_list.", - "example": "list_where([10, 20, 30, 40], [true, false, false, true])" - }, - "array_zip": { - "description": "Zips k LISTs to a new LIST whose length will be that of the longest list. Its elements are structs of k elements from each list list_1, …, list_k, missing elements are replaced with NULL. If truncate is set, all lists are truncated to the smallest list length.", - "example": "list_zip([1, 2], [3, 4], [5, 6])" - }, - "cast_to_type": { - "description": "Casts the first argument to the type of the second argument", - "example": "cast_to_type('42', NULL::INTEGER)" - }, - "concat": { - "description": "Concatenates many strings together.", - "example": "concat('Hello', ' ', 'World')" - }, - "concat_ws": { - "description": "Concatenates strings together separated by the specified separator.", - "example": "concat_ws(', ', 'Banana', 'Apple', 'Melon')" - }, - "contains": { - "description": "Returns true if the `list` contains the `element`.", - "example": "contains([1, 2, NULL], 1)" - }, - "count": { - "description": "Returns the number of non-null values in arg.", - "example": "count(A)" - }, - "count_if": { - "description": "Counts the total number of TRUE values for a boolean column", - "example": "count_if(A)" - }, - "countif": { - "description": "Counts the total number of TRUE values for a boolean column", - "example": "count_if(A)" - }, - "date_diff": { - "description": "The number of partition boundaries between the timestamps", - "example": "date_diff('hour', TIMESTAMPTZ '1992-09-30 23:59:59', TIMESTAMPTZ '1992-10-01 01:58:00')" - }, - "date_part": { - "description": "Get subfield (equivalent to extract)", - "example": "date_part('minute', TIMESTAMP '1992-09-20 20:38:40')" - }, - "date_sub": { - "description": "The number of complete partitions between the timestamps", - "example": "date_sub('hour', TIMESTAMPTZ '1992-09-30 23:59:59', TIMESTAMPTZ '1992-10-01 01:58:00')" - }, - "date_trunc": { - "description": "Truncate to specified precision", - "example": "date_trunc('hour', TIMESTAMPTZ '1992-09-20 20:38:40')" - }, - "datediff": { - "description": "The number of partition boundaries between the timestamps", - "example": "date_diff('hour', TIMESTAMPTZ '1992-09-30 23:59:59', TIMESTAMPTZ '1992-10-01 01:58:00')" - }, - "datepart": { - "description": "Get subfield (equivalent to extract)", - "example": "date_part('minute', TIMESTAMP '1992-09-20 20:38:40')" - }, - "datesub": { - "description": "The number of complete partitions between the timestamps", - "example": "date_sub('hour', TIMESTAMPTZ '1992-09-30 23:59:59', TIMESTAMPTZ '1992-10-01 01:58:00')" - }, - "datetrunc": { - "description": "Truncate to specified precision", - "example": "date_trunc('hour', TIMESTAMPTZ '1992-09-20 20:38:40')" - }, - "day": { - "description": "Extract the day component from a date or timestamp", - "example": "day(timestamp '2021-08-03 11:59:44.123456')" - }, - "dayname": { - "description": "The (English) name of the weekday", - "example": "dayname(TIMESTAMP '1992-03-22')" - }, - "dayofmonth": { - "description": "Extract the dayofmonth component from a date or timestamp", - "example": "dayofmonth(timestamp '2021-08-03 11:59:44.123456')" - }, - "dayofweek": { - "description": "Extract the dayofweek component from a date or timestamp", - "example": "dayofweek(timestamp '2021-08-03 11:59:44.123456')" - }, - "dayofyear": { - "description": "Extract the dayofyear component from a date or timestamp", - "example": "dayofyear(timestamp '2021-08-03 11:59:44.123456')" - }, - "generate_series": { - "description": "Create a list of values between start and stop - the stop parameter is inclusive", - "example": "generate_series(2, 5, 3)" - }, - "histogram": { - "description": "Returns a LIST of STRUCTs with the fields bucket and count.", - "example": "histogram(A)" - }, - "histogram_exact": { - "description": "Returns a LIST of STRUCTs with the fields bucket and count matching the buckets exactly.", - "example": "histogram_exact(A, [0, 1, 2])" - }, - "string_agg": { - "description": "Concatenates the column string values with an optional separator.", - "example": "string_agg(A, '-')" - }, - "string_split": { - "description": "Splits the `string` along the `separator`", - "example": "string_split('hello-world', '-')" - }, - "string_split_regex": { - "description": "Splits the `string` along the `regex`", - "example": "string_split_regex('hello world; 42', ';? ')" - }, - "string_to_array": { - "description": "Splits the `string` along the `separator`", - "example": "string_split('hello-world', '-')" - }, - "struct_concat": { - "description": "Merge the multiple STRUCTs into a single STRUCT.", - "example": "struct_concat(struct_pack(i := 4), struct_pack(s := 'string'))" - }, - "struct_extract": { - "description": "Extract the named entry from the STRUCT.", - "example": "struct_extract({'i': 3, 'v2': 3, 'v3': 0}, 'i')" - }, - "struct_extract_at": { - "description": "Extract the entry from the STRUCT by position (starts at 1!).", - "example": "struct_extract_at({'i': 3, 'v2': 3, 'v3': 0}, 2)" - }, - "struct_insert": { - "description": "Adds field(s)/value(s) to an existing STRUCT with the argument values. The entry name(s) will be the bound variable name(s)", - "example": "struct_insert({'a': 1}, b := 2)" - }, - "struct_pack": { - "description": "Create a STRUCT containing the argument values. The entry name will be the bound variable name.", - "example": "struct_pack(i := 4, s := 'string')" - }, - "substring": { - "description": "Extract substring of `length` characters starting from character `start`. Note that a start value of 1 refers to the first character of the `string`.", - "example": "substring('Hello', 2, 2)" - }, - "substring_grapheme": { - "description": "Extract substring of `length` grapheme clusters starting from character `start`. Note that a start value of 1 refers to the first character of the `string`.", - "example": "substring_grapheme('🦆🤦🏼‍♂️🤦🏽‍♀️🦆', 3, 2)" - }, - "to_base": { - "description": "Converts a value to a string in the given base radix, optionally padding with leading zeros to the minimum length", - "example": "to_base(42, 16)" - }, - "to_base64": { - "description": "Converts a `blob` to a base64 encoded `string`.", - "example": "base64('A'::BLOB)" - }, - "to_binary": { - "description": "Converts the value to binary representation", - "example": "bin(42)" - }, - "to_centuries": { - "description": "Construct a century interval", - "example": "to_centuries(5)" - }, - "to_days": { - "description": "Construct a day interval", - "example": "to_days(5)" - }, - "to_decades": { - "description": "Construct a decade interval", - "example": "to_decades(5)" - }, - "to_hex": { - "description": "Converts the value to hexadecimal representation.", - "example": "hex(42)" - }, - "to_hours": { - "description": "Construct a hour interval", - "example": "to_hours(5)" - }, - "to_microseconds": { - "description": "Construct a microsecond interval", - "example": "to_microseconds(5)" - }, - "to_millennia": { - "description": "Construct a millennium interval", - "example": "to_millennia(1)" - }, - "to_milliseconds": { - "description": "Construct a millisecond interval", - "example": "to_milliseconds(5.5)" - }, - "to_minutes": { - "description": "Construct a minute interval", - "example": "to_minutes(5)" - }, - "to_months": { - "description": "Construct a month interval", - "example": "to_months(5)" - }, - "to_quarters": { - "description": "Construct a quarter interval", - "example": "to_quarters(5)" - }, - "to_seconds": { - "description": "Construct a second interval", - "example": "to_seconds(5.5)" - }, - "to_timestamp": { - "description": "Converts secs since epoch to a timestamp with time zone", - "example": "to_timestamp(1284352323.5)" - }, - "to_weeks": { - "description": "Construct a week interval", - "example": "to_weeks(5)" - }, - "to_years": { - "description": "Construct a year interval", - "example": "to_years(5)" - }, - "trim": { - "description": "Removes any spaces from either side of the string.", - "example": "trim('>>>>test<<', '><')" - } - } -} diff --git a/src/debug.ts b/src/debug.ts deleted file mode 100644 index e1df213..0000000 --- a/src/debug.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function debug(message: string, ...args: unknown[]) { - // @ts-expect-error - import.meta.env is not typed - if (import.meta.env.DEV) { - console.log(`[codemirror-sql]`, message, ...args); - } -} diff --git a/src/dialects/bigquery.ts b/src/dialects/bigquery.ts deleted file mode 100644 index f1209c4..0000000 --- a/src/dialects/bigquery.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PostgreSQL, SQLDialect, type SQLDialectSpec } from "@codemirror/lang-sql"; - -/** - * There is no custom BigQuery dialect at the moment, - * but we want to provide a light wrapper around PostgreSQL - */ - -const BigQuery: SQLDialectSpec = { - ...PostgreSQL, - caseInsensitiveIdentifiers: true, // Case-insensitive completes - identifierQuotes: "`", // BigQuery uses backticks for identifiers -}; - -export const BigQueryDialect = SQLDialect.define(BigQuery); diff --git a/src/dialects/dremio.ts b/src/dialects/dremio.ts deleted file mode 100644 index bca209f..0000000 --- a/src/dialects/dremio.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SQLDialect, type SQLDialectSpec } from "@codemirror/lang-sql"; - -// Source: https://docs.dremio.com/current/reference/sql/reserved-keywords/ -const dremioKeywords = - "abs access acos aes_decrypt aggregate all allocate allow alter analyze and any approx_count_distinct approx_percentile are array array_avg array_cat array_compact array_contains array_generate_range array_max array_max_cardinality array_min array_position array_remove array_remove_at array_size array_sum array_to_string arrow as ascii asensitive asin assign asymmetric at atan atan2 atomic authorization auto avg avoid base64 batch begin begin_frame begin_partition between bigint bin bin_pack binary binary_string bit bit_and bit_length bit_or bitwise_and bitwise_not bitwise_or bitwise_xor blob bool_and bool_or boolean both branch bround btrim by cache call called cardinality cascaded case cast catalog cbrt ceil ceiling change char char_length character character_length check chr classifier clob close cloud coalesce col_like collate collect column columns commit commits_older_than compute concat concat_ws condition connect constraint contains convert convert_from convert_replaceutf8 convert_timezone convert_to copy corr corresponding cos cosh cot count covar_pop covar_samp crc32 create cross cube cume_dist current current_catalog current_date current_date_utc current_default_transform_group current_path current_role current_row current_schema current_time current_timestamp current_transform_group_for_type current_user cursor cycle data databases datasets date date_add date_diff date_format date_part date_sub date_trunc datediff datetype day dayofmonth dayofweek dayofyear deallocate dec decimal declare dedupe_lookback_period default define degrees delete dense_rank deref describe deterministic dimensions disallow disconnect display distinct double drop dynamic e each element else empty empty_as_null encode end end-exec end_frame end_partition ends_with engine equals escape escape_char every except exec execute exists exp expire explain extend external extract factorial false fetch field field_delimiter file_format files filter first_value flatten float floor folder for foreign frame_row free from from_hex full function fusion geo_beyond geo_distance geo_nearby get global grant grants greatest group grouping groups hash hash64 having hex history hold hour identity if ilike imindir import in include indicator initcap initial inner inout insensitive insert instr int integer intersect intersection interval into is is_bigint is_int is_member is_substr is_utf8 is_varchar isdate isnumeric job join json_array json_arrayagg json_exists json_object json_objectagg json_query json_value lag language large last_day last_query_id last_value lateral lazy lcase lead leading least left length levenshtein like like_regex limit listagg ln local localsort localtime localtimestamp locate log log10 logs lower lpad lshift ltrim manifests map_keys map_values mask mask_first_n mask_hash mask_last_n mask_show_first_n mask_show_last_n masking match match_number match_recognize matches max max_file_size_mb maxdir md5 measures median member merge metadata method min min_file_size_mb min_input_files mindir minus minute missing mod modifies module monitor month months_between more multiset national natural nchar nclob ndv new next next_day no none normalize normalize_string not notification_provider notification_queue_reference now nth_value ntile null null_if nullif numeric nvl occurrences_regex octet_length of offset old older_than omit on one only open operate optimize or order orphan out outer over overlaps overlay ownership parameter parse_url partition partitions pattern per percent percent_rank percentile_cont percentile_disc period permute pi pivot pmod policy portion position position_regex pow power precedes precision prepare prev primary procedure project promotion qualify quarter query query_user quote quote_char radians random range rank raw reads real record_delimiter recursive ref reference references referencing reflection reflections refresh regex regexp_col_like regexp_extract regexp_like regexp_matches regexp_replace regexp_split regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxy regr_syy release remove rename repeat repeatstr replace reset result retain_last retain_last_commits retain_last_snapshots return returns reverse revoke rewrite right role rollback rollup round route row row_number rows rpad rshift rtrim running savepoint schemas scope scroll search second seek select sensitive session_user set sha sha1 sha256 sha512 show sign similar similar_to sin sinh size skip smallint snapshot snapshots snapshots_older_than some soundex specific specifictype split_part sql sqlexception sqlstate sqlwarning sqrt st_fromgeohash st_geohash start starts_with static statistics stddev stddev_pop stddev_samp stream string_binary strpos submultiset subset substr substring substring_index substring_regex succeeds sum symmetric system system_time system_user table tables tablesample tag tan tanh target_file_size_mb tblproperties then time time_format timestamp timestamp_format timestampadd timestampdiff timestamptype timezone_hour timezone_minute tinyint to to_char to_date to_hex to_number to_time to_timestamp toascii trailing transaction_timestamp translate translate_regex translation treat trigger trim trim_array trim_space true truncate typeof ucase uescape unbase64 unhex union unique unix_timestamp unknown unnest unpivot unset update upper upsert usage use user using vacuum value value_of values var_pop var_samp varbinary varchar varying versioning view views week weekofyear when whenever where width_bucket window with within without write xor year"; - -// Source: https://docs.dremio.com/dremio-cloud/sql/data-types/ -const dremioTypes = - "array bigint binary boolean char character date dec decimal double float int integer interval map numeric struct time timestamp uuid varbinary varchar variant"; - -const DremioDialectSpec: SQLDialectSpec = { - doubleQuotedStrings: false, - hashComments: false, - spaceAfterDashes: false, - identifierQuotes: '"', - caseInsensitiveIdentifiers: true, - keywords: dremioKeywords, - types: dremioTypes, -}; - -export const DremioDialect = SQLDialect.define(DremioDialectSpec); diff --git a/src/dialects/duckdb/README.md b/src/dialects/duckdb/README.md deleted file mode 100644 index a59d462..0000000 --- a/src/dialects/duckdb/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Generating SQL Dialects and Keywords - -Dialects enable CodeMirror to provide SQL syntax highlighting. - -Keywords allow CodeMirror to offer SQL autocompletion and display hover tooltips. - -| Database | How to Run Spec Script | -| -------- | --------------------------------------- | -| DuckDB | `python src/data/duckdb/spec_duckdb.py` | - -> 💡 **Tip:** Update the script path to match your target SQL dialect. -> Running the script will automatically generate the keywords and types for the corresponding `*.ts` dialect file. - -> 🚀 **Quick Start:** -> To open and run the script interactively in marimo, use: -> `uvx marimo edit ` diff --git a/src/dialects/duckdb/duckdb.ts b/src/dialects/duckdb/duckdb.ts deleted file mode 100644 index e9814aa..0000000 --- a/src/dialects/duckdb/duckdb.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* Copyright 2025 Marimo. All rights reserved. */ -// Credit to https://github.com/sekuel/codemirror-sql-duckdb/blob/main/DuckDBDialect.js for the dialect spec - -import { SQLDialect, type SQLDialectSpec } from "@codemirror/lang-sql"; - -const otherFunctions = - "percentile_cont row_number rank dense_rank rank_dense percent_rank cume_dist ntile lag lead first_value last_value nth_value"; - -const DuckDBDialectSpec: SQLDialectSpec = { - charSetCasts: true, - doubleQuotedStrings: false, - unquotedBitLiterals: true, - hashComments: false, - spaceAfterDashes: false, - specialVar: "@?", - identifierQuotes: '"', - caseInsensitiveIdentifiers: true, - keywords: `${otherFunctions} !__postfix !~~ !~~* % & && * ** + - ->> / // <-> << <=> <@ >> @ @> Calendar JSON TimeZone ^ ^@ abort abs absolute access access_mode acos acosh action add add_parquet_key admin after age aggregate alias all all_profiling_output allow_community_extensions allow_extensions_metadata_mismatch allow_persistent_secrets allow_unredacted_secrets allow_unsigned_extensions allowed_directories allowed_paths also alter always analyse analyze and anti any any_value apply approx_count_distinct approx_quantile approx_top_k arbitrary arg_max arg_max_null arg_min arg_min_null argmax argmin array array_agg array_aggr array_aggregate array_append array_apply array_cat array_concat array_contains array_cosine_distance array_cosine_similarity array_cross_product array_distance array_distinct array_dot_product array_extract array_filter array_grade_up array_has array_has_all array_has_any array_indexof array_inner_product array_intersect array_length array_negative_dot_product array_negative_inner_product array_pop_back array_pop_front array_position array_prepend array_push_back array_push_front array_reduce array_resize array_reverse array_reverse_sort array_select array_slice array_sort array_to_json array_to_string array_to_string_comma_default array_transform array_unique array_value array_where array_zip arrow_large_buffer_size arrow_lossless_conversion arrow_output_list_view arrow_output_version arrow_scan arrow_scan_dumb as asc ascii asin asinh asof asof_loop_join_threshold assertion assignment asymmetric at atan atan2 atanh attach attribute authorization autoinstall_extension_repository autoinstall_known_extensions autoload_known_extensions avg backward bar base64 before begin between bigint bin binary binary_as_string bit bit_and bit_count bit_length bit_or bit_position bit_xor bitstring bitstring_agg blob bool bool_and bool_or boolean both bpchar by bytea cache call called can_cast_implicitly cardinality cascade cascaded case cast cast_to_type catalog catalog_error_max_schemas cbrt ceil ceiling centuries century chain char char_length character character_length characteristics check checkpoint checkpoint_threshold chr class close cluster coalesce col_description collate collation collations column columns combine comment comments commit committed compression concat concat_ws concurrently configuration conflict connection constant_or_null constraint constraints contains content continue conversion copy copy_database corr cos cosh cost cot count count_if count_star countif covar_pop covar_samp create create_sort_key cross csv cube current current_catalog current_connection_id current_database current_date current_localtime current_localtimestamp current_query current_query_id current_role current_schema current_schemas current_setting current_transaction_id current_user currval cursor custom_extension_repository custom_profiling_settings custom_user_agent cycle damerau_levenshtein data database database_list database_size date date_add date_diff date_part date_sub date_trunc datediff datepart datesub datetime datetrunc day dayname dayofmonth dayofweek dayofyear days deallocate debug_asof_iejoin debug_checkpoint_abort debug_force_external debug_force_no_cross_product debug_skip_checkpoint_on_commit debug_verify_vector debug_window_mode dec decade decades decimal declare decode default default_block_size default_collation default_null_order default_order default_secret_storage defaults deferrable deferred definer degrees delete delimiter delimiters depends desc describe detach dictionary disable disable_checkpoint_on_shutdown disable_logging disable_object_cache disable_optimizer disable_parquet_prefetching disable_print_progress_bar disable_profile disable_profiling disable_progress_bar disable_timestamptz_casts disable_verification disable_verify_external disable_verify_fetch_row disable_verify_parallelism disable_verify_serializer disabled_compression_methods disabled_filesystems disabled_log_types disabled_optimizers discard distinct divide do document domain double drop duckdb_api duckdb_columns duckdb_constraints duckdb_databases duckdb_dependencies duckdb_extensions duckdb_external_file_cache duckdb_functions duckdb_indexes duckdb_keywords duckdb_log_contexts duckdb_logs duckdb_logs_parsed duckdb_memory duckdb_optimizers duckdb_prepared_statements duckdb_schemas duckdb_secret_types duckdb_secrets duckdb_sequences duckdb_settings duckdb_table_sample duckdb_tables duckdb_temporary_files duckdb_types duckdb_variables duckdb_views dynamic_or_filter_threshold each editdist3 element_at else enable enable_checkpoint_on_shutdown enable_external_access enable_external_file_cache enable_fsst_vectors enable_geoparquet_conversion enable_http_logging enable_http_metadata_cache enable_logging enable_macro_dependencies enable_object_cache enable_optimizer enable_print_progress_bar enable_profile enable_profiling enable_progress_bar enable_progress_bar_print enable_verification enable_view_dependencies enabled_log_types encode encoding encrypted end ends_with entropy enum enum_code enum_first enum_last enum_range enum_range_boundary epoch epoch_ms epoch_ns epoch_us equi_width_bins era error errors_as_json escape even event except exclude excluding exclusive execute exists exp explain explain_output export export_state extension extension_directory extension_versions extensions external external_threads extract factorial false family favg fdiv fetch file_search_path filter finalize first flatten float float4 float8 floor fmod following for force force_bitpacking_mode force_checkpoint force_compression foreign format formatReadableDecimalSize formatReadableSize format_bytes format_pg_type format_type forward freeze from from_base64 from_binary from_hex from_json from_json_strict fsum full function functions gamma gcd gen_random_uuid generate_series generate_subscripts generated geomean geometric_mean get_bit get_block_size get_current_time get_current_timestamp getvariable glob global grade_up grant granted greatest greatest_common_divisor group group_concat grouping grouping_id groups guid hamming handler having header hex histogram histogram_exact histogram_values hold home_directory hour hours http_logging_output http_proxy http_proxy_password http_proxy_username hugeint identity ieee_floating_point_ops if ignore ilike ilike_escape immediate immediate_transaction_mode immutable implicit import import_database in in_search_path include including increment index index_scan_max_count index_scan_percentage indexes inet_client_addr inet_client_port inet_server_addr inet_server_port inherit inherits initially inline inner inout input insensitive insert install instead instr int int1 int128 int16 int2 int32 int4 int64 int8 integer integer_division integral intersect interval into invoker is is_histogram_other_bin isfinite isinf isnan isnull isodow isolation isoyear jaccard jaro_similarity jaro_winkler_similarity join json json_array json_array_length json_contains json_deserialize_sql json_each json_execute_serialized_sql json_exists json_extract json_extract_path json_extract_path_text json_extract_string json_group_array json_group_object json_group_structure json_keys json_merge_patch json_object json_pretty json_quote json_serialize_plan json_serialize_sql json_structure json_transform json_transform_strict json_tree json_type json_valid json_value julian kahan_sum key kurtosis kurtosis_pop label lambda lambda_syntax language large last last_day late_materialization_max_rows lateral lcase lcm leading leakproof least least_common_multiple left left_grapheme len length length_grapheme level levenshtein lgamma like like_escape limit list list_aggr list_aggregate list_any_value list_append list_apply list_approx_count_distinct list_avg list_bit_and list_bit_or list_bit_xor list_bool_and list_bool_or list_cat list_concat list_contains list_cosine_distance list_cosine_similarity list_count list_distance list_distinct list_dot_product list_element list_entropy list_extract list_filter list_first list_grade_up list_has list_has_all list_has_any list_histogram list_indexof list_inner_product list_intersect list_kurtosis list_kurtosis_pop list_last list_mad list_max list_median list_min list_mode list_negative_dot_product list_negative_inner_product list_pack list_position list_prepend list_product list_reduce list_resize list_reverse list_reverse_sort list_select list_sem list_skewness list_slice list_sort list_stddev_pop list_stddev_samp list_string_agg list_sum list_transform list_unique list_value list_var_pop list_var_samp list_where list_zip listagg listen ln load local location lock lock_configuration locked log log10 log2 log_query_path logged logging_level logging_mode logging_storage logical long lower lpad ltrim macro mad make_date make_time make_timestamp make_timestamp_ns make_timestamptz map map_concat map_contains map_contains_entry map_contains_value map_entries map_extract map_extract_value map_from_entries map_keys map_to_pg_oid map_values mapping match materialized max max_by max_expression_depth max_memory max_temp_directory_size max_vacuum_tasks maxvalue md5 md5_number md5_number_lower md5_number_upper mean median memory_limit merge_join_threshold metadata_info method microsecond microseconds millennia millennium millisecond milliseconds min min_by minute minutes minvalue mismatches mod mode month monthname months move multiply name names nanosecond national natural nchar nested_loop_join_threshold new next nextafter nextval nfc_normalize no none normalized_interval not not_ilike_escape not_like_escape nothing notify notnull now nowait null null_order nullif nulls numeric nvarchar obj_description object octet_length of off offset oid oids old old_implicit_casting on only operator option options or ord order order_by_non_integer_literal ordered_aggregate_threshold ordinality others out outer over overlaps overlay overriding owned owner pandas_analyze_sample pandas_scan parallel parquet_bloom_probe parquet_file_metadata parquet_kv_metadata parquet_metadata parquet_metadata_cache parquet_scan parquet_schema parse_dirname parse_dirpath parse_duckdb_log_message parse_filename parse_path parser partial partition partitioned partitioned_write_flush_threshold partitioned_write_max_open_files passing password percent perfect_ht_threshold persistent pi pivot pivot_filter_threshold pivot_limit pivot_longer pivot_wider placing plans platform policy position positional pow power pragma pragma_collations pragma_database_size pragma_metadata_info pragma_platform pragma_show pragma_storage_info pragma_table_info pragma_user_agent pragma_version preceding precision prefer_range_joins prefetch_all_parquet_files prefix prepare prepared preserve preserve_identifier_case preserve_insertion_order primary printf prior privileges procedural procedure produce_arrow_string_view product profile_output profiling_mode profiling_output program progress_bar_time publication python_enable_replacements python_map_function python_scan_all_frames qualify quantile quantile_cont quantile_disc quarter quarters query query_table quote radians random range read read_blob read_csv read_csv_auto read_json read_json_auto read_json_objects read_json_objects_auto read_ndjson read_ndjson_auto read_ndjson_objects read_parquet read_text real reassign recheck recursive reduce ref references referencing refresh regexp_escape regexp_extract regexp_extract_all regexp_full_match regexp_matches regexp_replace regexp_split_to_array regexp_split_to_table regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy regr_syy reindex relative release remap_struct rename repeat repeat_row repeatable replace replica reservoir_quantile reset respect restart restrict returning returns reverse revoke right right_grapheme role rollback rollup round round_even roundbankers row row_to_json rows rpad rtrim rule sample savepoint scalar_subquery_error_on_multiple_rows scheduler_process_partial schema schemas scope scroll search search_path second seconds secret secret_directory security select sem semi seq_scan sequence sequences serializable server session session_user set set_bit setof sets setseed sha1 sha256 share shobj_description short show show_databases show_tables show_tables_expanded sign signbit signed similar simple sin sinh skewness skip smallint snapshot sniff_csv some sorted split split_part sql sqrt stable standalone start starts_with statement statistics stats stddev stddev_pop stddev_samp stdin stdout storage storage_compatibility_version storage_info stored str_split str_split_regex streaming_buffer_size strftime strict string string_agg string_split string_split_regex string_to_array strip strip_accents strlen strpos strptime struct struct_concat struct_extract struct_extract_at struct_insert struct_pack subscription substr substring substring_grapheme subtract suffix sum sum_no_overflow sumkahan summarize summary symmetric sysid system table table_info tables tablesample tablespace tan tanh temp temp_directory template temporary test_all_types test_vector_types text then threads ties time time_bucket timestamp timestamp_ms timestamp_ns timestamp_s timestamp_us timestamptz timetz timetz_byte_comparable timezone timezone_hour timezone_minute tinyint to to_base to_base64 to_binary to_centuries to_days to_decades to_hex to_hours to_json to_microseconds to_millennia to_milliseconds to_minutes to_months to_quarters to_seconds to_timestamp to_weeks to_years today trailing transaction transaction_timestamp transform translate treat trigger trim true trunc truncate truncate_duckdb_logs trusted try_cast try_strptime txid_current type typeof types ubigint ucase uhugeint uint128 uint16 uint32 uint64 uint8 uinteger unbin unbounded uncommitted unencrypted unhex unicode union union_extract union_tag union_value unique unknown unlisten unlogged unnest unpack unpivot unpivot_list until update upper url_decode url_encode use user user_agent username using usmallint utinyint uuid uuid_extract_timestamp uuid_extract_version uuidv4 uuidv7 vacuum valid validate validator value values var_pop var_samp varbinary varchar variable variadic variance varint varying vector_type verbose verify_external verify_fetch_row verify_parallelism verify_serializer version view views virtual volatile wal_autocheckpoint wavg week weekday weekofyear weeks weighted_avg when where which_secret whitespace window with within without work worker_threads wrapper write write_log xml xmlattributes xmlconcat xmlelement xmlexists xmlforest xmlnamespaces xmlparse xmlpi xmlroot xmlserialize xmltable xor year years yearweek yes zone zstd_min_string_length | || ~ ~~ ~~* ~~~`, - types: - "JSON bigint binary bit bitstring blob bool boolean bpchar bytea char date datetime dec decimal double enum float float4 float8 guid hugeint int int1 int128 int16 int2 int32 int4 int64 int8 integer integral interval list logical long map null numeric nvarchar oid real row short signed smallint string struct text time timestamp timestamp_ms timestamp_ns timestamp_s timestamp_us timestamptz timetz tinyint ubigint uhugeint uint128 uint16 uint32 uint64 uint8 uinteger union usmallint utinyint uuid varbinary varchar varint", -}; - -export const DuckDBDialect = SQLDialect.define(DuckDBDialectSpec); diff --git a/src/dialects/duckdb/spec_duckdb.py b/src/dialects/duckdb/spec_duckdb.py deleted file mode 100644 index c714631..0000000 --- a/src/dialects/duckdb/spec_duckdb.py +++ /dev/null @@ -1,367 +0,0 @@ -# Copyright 2025 Marimo. All rights reserved. -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "duckdb==1.3.2", -# "marimo", -# "polars==1.31.0", -# "pyarrow==21.0.0", -# "sqlglot==27.4.1", -# ] -# /// - -# Ignores lack of return type for functions -# ruff: noqa: ANN202 -# mypy: disable-error-code="no-untyped-def" - -# Ignore SQL Types -# ruff: noqa: F541 - -import marimo - -__generated_with = "0.14.16" -app = marimo.App(width="medium") - -with app.setup: - import argparse - from datetime import datetime - import os - - import duckdb - import polars as pl - import json - - import marimo as mo - from marimo import _loggers - - LOGGER = _loggers.marimo_logger() - - -@app.cell -def _(): - EXCLUDED_KEYWORDS = ["__internal", "icu", "has_", "pg_", "allocator"] - return (EXCLUDED_KEYWORDS,) - - -@app.cell(hide_code=True) -def _(EXCLUDED_KEYWORDS, form, num_keywords, num_types): - mo.md( - rf""" - ## DuckDB Schema - - #### Number of keywords: **{num_keywords}** - - #### Number of types: **{num_types}** - - To update the duckdb codemirror spec, you can either - - - Run this python script to generate the outputs or - - Submit the form below - - {form} - - ❗ And then update the duckdb.ts file - - *Excluded keywords: {EXCLUDED_KEYWORDS} - - _{mo.md(f"Ran on DuckDB {duckdb.__version__}").right()}_ - _{mo.md(f"Last updated: {datetime.today().date().strftime('%B %d %Y')}").right()}_ - """ - ) - return - - -@app.cell -def _(df, fn_dict, form, save_to_json, write_to_file): - parser = argparse.ArgumentParser( - prog="DuckDB Spec", - description="Runs SQL to save types and keywords for DuckDB dialect in Codemirror", - ) - - parser.add_argument("-s", "--savepath", default="temp.json") - args = parser.parse_args() - - if mo.app_meta().mode == "script": - savepath = args.savepath - LOGGER.info(f"Saving JSON files to {savepath}") - write_to_file(df, savepath) - save_to_json(fn_dict, f"{savepath}/functions.json") - LOGGER.info(f"Saved files to {savepath}") - else: - savepath = form.value - if savepath is not None and savepath.strip() != "": - if not os.path.exists(savepath): - os.makedirs(savepath) - - write_to_file(df, f"{savepath}/keywords.json") - save_to_json(fn_dict, f"{savepath}/functions.json") - - mo.output.replace(mo.md(f"## Saved JSON files to {savepath}!")) - return - - -@app.cell -def _(): - def write_to_file(df: pl.DataFrame, filepath: str) -> None: - df.select(pl.exclude("builtin")).write_json(filepath) - - - def save_to_json(obj, filepath: str) -> None: - with open(filepath, "w") as f: - json.dump(obj, f, indent=4) - return save_to_json, write_to_file - - -@app.cell -def _(EXCLUDED_KEYWORDS): - functions = mo.sql( - f""" - -- Find functions and their descriptions - -- Functions tend to have overloading properties, so we capture them in a single row. - - WITH - function_details AS ( - SELECT - function_name, - parameters AS duckdb_params, - return_type, - parameter_types, - description, - alias_of, - examples - FROM - duckdb_functions() - ) - SELECT - function_name, - -- For properties that are consistent across overloads, you can use MIN/MAX or just ARRAY_AGG and pick the first element if appropriate - ARRAY_AGG(duckdb_params) AS parameter_overloads, - ARRAY_AGG(return_type) AS return_type_overloads, - ARRAY_AGG(parameter_types) AS parameter_types_overloads, - MAX(description) AS description, -- Assuming description is the same for all overloads - MAX(alias_of) AS alias_of, -- Assuming alias_of is the same for all overloads - MAX(examples)[1] AS example -- Examples tend to be consistent - FROM - function_details - WHERE {filter_keywords_query("function_name", EXCLUDED_KEYWORDS)} - GROUP BY - function_name - ORDER BY - function_name; - """, - output=False - ) - return (functions,) - - -@app.cell(hide_code=True) -def _(): - _df = mo.sql( - f""" - WITH - duckdb_types_cte AS ( - SELECT - ROW_NUMBER() OVER () AS rn, - type_name AS duckdb_types - FROM - ( - SELECT DISTINCT - type_name - FROM - duckdb_types() - ) - ), - duckdb_settings_cte AS ( - SELECT - ROW_NUMBER() OVER () AS rn, - name AS duckdb_settings - FROM - ( - SELECT DISTINCT - name - FROM - duckdb_settings() - ) - ), - duckdb_functions_cte AS ( - SELECT - ROW_NUMBER() OVER () AS rn, - function_name AS duckdb_functions - FROM - ( - SELECT DISTINCT - function_name - FROM - duckdb_functions() - ) - ), - duckdb_keywords_cte AS ( - SELECT - ROW_NUMBER() OVER () AS rn, - keyword_name AS duckdb_keywords - FROM - ( - SELECT DISTINCT - keyword_name - FROM - duckdb_keywords() - ) - ) - SELECT - t.duckdb_types, - s.duckdb_settings, - f.duckdb_functions, - k.duckdb_keywords - FROM - duckdb_types_cte AS t - FULL OUTER JOIN duckdb_settings_cte AS s ON t.rn = s.rn - FULL OUTER JOIN duckdb_functions_cte AS f ON COALESCE(t.rn, s.rn) = f.rn - FULL OUTER JOIN duckdb_keywords_cte AS k ON COALESCE(t.rn, s.rn, f.rn) = k.rn - ORDER BY - COALESCE(t.rn, s.rn, f.rn, k.rn); - """ - ) - return - - -@app.cell(hide_code=True) -def _(EXCLUDED_KEYWORDS): - df = mo.sql( - f""" - WITH - stg_keywords AS ( - SELECT - 'duckdb_keywords' AS keyword_group, - keyword_name AS keyword - FROM - duckdb_keywords() - UNION ALL - SELECT - 'duckdb_settings' AS keyword_group, - name AS keyword - FROM - duckdb_settings() - UNION ALL - SELECT - 'duckdb_functions' AS keyword_group, - function_name AS keyword - FROM - duckdb_functions() - UNION ALL - SELECT - 'duckdb_types' AS keyword_group, - type_name AS keyword - FROM - duckdb_types() - ), - all_keywords AS ( - SELECT - STRING_AGG(DISTINCT keyword, ' ' ORDER BY keyword) AS keywords_str - FROM - stg_keywords - WHERE {filter_keywords_query("keyword", EXCLUDED_KEYWORDS)} - ), - builtin_keywords AS ( - SELECT - STRING_AGG(DISTINCT keyword, ' ' ORDER BY keyword) AS builtin_str - FROM - stg_keywords - WHERE - keyword_group IN ('duckdb_keywords', 'duckdb_settings') - ), - duckdb_types_str AS ( - SELECT - STRING_AGG(DISTINCT type_name, ' ' ORDER BY type_name) AS types_str - FROM - duckdb_types() - ) - SELECT - version() as duckdb_version, - today() as last_updated, - ak.keywords_str AS keywords, - bk.builtin_str AS builtin, - dts.types_str AS types - FROM - all_keywords AS ak, - builtin_keywords AS bk, - duckdb_types_str AS dts; - """ - ) - return (df,) - - -@app.cell -def _(df): - num_keywords = len(df["keywords"][0].split(" ")) - num_types = len(df["types"][0].split(" ")) - - form = mo.ui.text(placeholder="duckdb", label="Folder").form( - submit_button_label="Save" - ) - return form, num_keywords, num_types - - -@app.function -def filter_keywords_query(column: str, EXCLUDED_KEYWORDS: list[str]) -> str: - like_conditions = [ - f"{column} NOT LIKE '{kw}%'" for kw in EXCLUDED_KEYWORDS - ] - where_clause = " AND ".join(like_conditions) - return where_clause - - -@app.cell(hide_code=True) -def _(functions): - mo.md( - rf""" - ## DuckDB Functions - - {mo.ui.table(functions)} - """ - ) - return - - -@app.cell -def _(functions): - # We only want certain functions - included_functions = [ - "array", - "count", - "struct", - "concat", - "cast", - "combine", - "contains", - "date", - "day", - "histogram", - "generate_series", - "json", - "string", - "substring", - "to_", - "trim", - ] - - filtered_fn = functions.select(["function_name", "description", "example"]) - - prefix_filter = pl.any_horizontal( - pl.col("function_name").str.starts_with(prefix) - for prefix in included_functions - ) - - filtered_fn = filtered_fn.filter(prefix_filter, pl.col("description") != "") - - # transform to {keyword: {description, example }} - fn_dict = {} - for row in filtered_fn.iter_rows(named=True): - fn_dict[row["function_name"]] = { - "description": row["description"], - "example": row["example"], - } - return (fn_dict,) - - -if __name__ == "__main__": - app.run() diff --git a/src/dialects/index.ts b/src/dialects/index.ts deleted file mode 100644 index 42380ea..0000000 --- a/src/dialects/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { BigQueryDialect } from "./bigquery.js"; -export { DremioDialect } from "./dremio.js"; -export { DuckDBDialect } from "./duckdb/duckdb.js"; diff --git a/src/index.ts b/src/index.ts index e9fb836..d850c9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,68 +1,30 @@ export { - type AliasCompletionConfig, - aliasColumnCompletionSource, -} from "./sql/alias-completion-source.js"; -export { - type ColumnCompletionConfig, - unqualifiedColumnCompletionSource, -} from "./sql/column-completion-source.js"; -export { - type SqlCompletionConfig, - sqlCompletion, -} from "./sql/completion-extension.js"; -export { - createCteCompletionSource, - type CteCompletionConfig, - cteCompletionSource, -} from "./sql/cte-completion-source.js"; -export { type SqlLinterConfig, sqlLinter } from "./sql/diagnostics.js"; -export { type SqlExtensionConfig, sqlExtension } from "./sql/extension.js"; + bigQueryDialect, + createSqlLanguageService, + dremioDialect, + duckdbDialect, + postgresDialect, +} from "./session.js"; export type { - KeywordTooltipData, - NamespaceTooltipData, - SqlHoverConfig, - SqlKeywordInfo, -} from "./sql/hover.js"; -export { DefaultSqlTooltipRenders, defaultSqlHoverTheme, sqlHover } from "./sql/hover.js"; -export { - gotoSqlDefinition, - renameSqlIdentifier, - type SqlNavigationConfig, - sqlGotoDefinition, - sqlHighlightReferences, - sqlNavigation, - sqlNavigationKeymap, -} from "./sql/navigation-extension.js"; -export { - NodeSqlParser, - type NodeSqlParserOptions, - type NodeSqlParserResult, - type ParserOption, - type SupportedDialects, -} from "./sql/parser.js"; -export { - analyzeQueryContext, - type QueryContext, - QueryContextAnalyzer, - type QueryContextCte, - type QueryContextTable, -} from "./sql/query-context.js"; -export { - findReferences, - type SqlIdentifierKind, - type SqlRange, - type SqlReferenceConfig, - SqlReferenceResolver, - type SqlReferenceResult, -} from "./sql/references.js"; -export { resolveSqlSchema, type SqlSchemaSource, sqlSchemaFacet } from "./sql/schema-facet.js"; -export { - type SemanticSeverity, - type SqlSemanticLinterConfig, - sqlSemanticLinter, -} from "./sql/semantic-diagnostics.js"; -export type { SqlStatement } from "./sql/structure-analyzer.js"; -export { SqlStructureAnalyzer } from "./sql/structure-analyzer.js"; -export type { SqlGutterConfig } from "./sql/structure-extension.js"; -export { sqlStructureGutter } from "./sql/structure-extension.js"; -export type { SqlParseError, SqlParseResult, SqlParser } from "./sql/types.js"; + OpenSqlDocument, + SqlCatalogContext, + SqlContextInput, + SqlDialect, + SqlDocumentChanges, + SqlDocumentContext, + SqlDocumentEdit, + SqlEmbeddedRegion, + SqlDocumentReplacement, + SqlDocumentSession, + SqlDocumentUpdate, + SqlIdentifierComponent, + SqlIdentifierPath, + SqlLanguageService, + SqlLanguageServiceOptions, + SqlPlainData, + SqlRevision, + SqlSessionErrorCode, + SqlTextChange, + SqlTextRange, +} from "./types.js"; +export { SqlSessionError } from "./types.js"; diff --git a/src/vnext/lexical.ts b/src/lexical.ts similarity index 100% rename from src/vnext/lexical.ts rename to src/lexical.ts diff --git a/src/vnext/local-relation-site.ts b/src/local-relation-site.ts similarity index 100% rename from src/vnext/local-relation-site.ts rename to src/local-relation-site.ts diff --git a/src/vnext/node-sql-parser-adapter.ts b/src/node-sql-parser-adapter.ts similarity index 100% rename from src/vnext/node-sql-parser-adapter.ts rename to src/node-sql-parser-adapter.ts diff --git a/src/vnext/node-sql-parser-backend.ts b/src/node-sql-parser-backend.ts similarity index 100% rename from src/vnext/node-sql-parser-backend.ts rename to src/node-sql-parser-backend.ts diff --git a/src/vnext/node-sql-parser-browser-executor.ts b/src/node-sql-parser-browser-executor.ts similarity index 100% rename from src/vnext/node-sql-parser-browser-executor.ts rename to src/node-sql-parser-browser-executor.ts diff --git a/src/vnext/node-sql-parser-browser-worker-endpoint.ts b/src/node-sql-parser-browser-worker-endpoint.ts similarity index 100% rename from src/vnext/node-sql-parser-browser-worker-endpoint.ts rename to src/node-sql-parser-browser-worker-endpoint.ts diff --git a/src/vnext/node-sql-parser-browser-worker.ts b/src/node-sql-parser-browser-worker.ts similarity index 100% rename from src/vnext/node-sql-parser-browser-worker.ts rename to src/node-sql-parser-browser-worker.ts diff --git a/src/vnext/node-sql-parser-wire.ts b/src/node-sql-parser-wire.ts similarity index 100% rename from src/vnext/node-sql-parser-wire.ts rename to src/node-sql-parser-wire.ts diff --git a/src/vnext/query-site.ts b/src/query-site.ts similarity index 100% rename from src/vnext/query-site.ts rename to src/query-site.ts diff --git a/src/vnext/relation-catalog-boundary.ts b/src/relation-catalog-boundary.ts similarity index 100% rename from src/vnext/relation-catalog-boundary.ts rename to src/relation-catalog-boundary.ts diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/relation-catalog-epoch-coordinator.ts similarity index 100% rename from src/vnext/relation-catalog-epoch-coordinator.ts rename to src/relation-catalog-epoch-coordinator.ts diff --git a/src/vnext/relation-catalog-search-work.ts b/src/relation-catalog-search-work.ts similarity index 100% rename from src/vnext/relation-catalog-search-work.ts rename to src/relation-catalog-search-work.ts diff --git a/src/vnext/relation-completion-types.ts b/src/relation-completion-types.ts similarity index 100% rename from src/vnext/relation-completion-types.ts rename to src/relation-completion-types.ts diff --git a/src/vnext/relation-dialect.ts b/src/relation-dialect.ts similarity index 100% rename from src/vnext/relation-dialect.ts rename to src/relation-dialect.ts diff --git a/src/vnext/relation-query-site.ts b/src/relation-query-site.ts similarity index 100% rename from src/vnext/relation-query-site.ts rename to src/relation-query-site.ts diff --git a/src/vnext/relation-reserved-words.ts b/src/relation-reserved-words.ts similarity index 100% rename from src/vnext/relation-reserved-words.ts rename to src/relation-reserved-words.ts diff --git a/src/vnext/relation-runtime-auth.ts b/src/relation-runtime-auth.ts similarity index 100% rename from src/vnext/relation-runtime-auth.ts rename to src/relation-runtime-auth.ts diff --git a/src/vnext/session.ts b/src/session.ts similarity index 100% rename from src/vnext/session.ts rename to src/session.ts diff --git a/src/vnext/source.ts b/src/source.ts similarity index 100% rename from src/vnext/source.ts rename to src/source.ts diff --git a/src/sql/__tests__/alias-completion-source.test.ts b/src/sql/__tests__/alias-completion-source.test.ts deleted file mode 100644 index 837aa68..0000000 --- a/src/sql/__tests__/alias-completion-source.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { describe, expect, it } from "vitest"; -import { aliasColumnCompletionSource } from "../alias-completion-source.js"; -import { completeWith, labels, NESTED_SCHEMA, TEST_SCHEMA } from "./test-utils.js"; - -const schema = TEST_SCHEMA; -const nestedSchema = NESTED_SCHEMA; - -const complete = completeWith(aliasColumnCompletionSource); - -describe("aliasColumnCompletionSource", () => { - it("offers the aliased table's columns after `alias.`", async () => { - const result = await complete("SELECT u. FROM users u", { - schema, - pos: "SELECT u.".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - expect(result?.from).toBe("SELECT u.".length); - expect(result?.options.every((option) => option.type === "property")).toBe(true); - }); - - it("completes with a typed prefix and keeps `from` after the dot", async () => { - const doc = "SELECT u.user FROM users u"; - const result = await complete(doc, { schema, pos: "SELECT u.user".length }); - expect(labels(result)).toEqual(["id", "username", "email"]); - expect(result?.from).toBe("SELECT u.".length); - expect(result?.validFor).toBeTruthy(); - }); - - it("works on explicit completion requests", async () => { - const result = await complete("SELECT u. FROM users u", { - schema, - pos: "SELECT u.".length, - explicit: true, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("resolves each alias in a join to its own table", async () => { - const doc = "SELECT o. FROM users u JOIN orders o ON u.id = o.user_id"; - const result = await complete(doc, { schema, pos: "SELECT o.".length }); - expect(labels(result)).toEqual(["order_id", "total"]); - // Completion objects from the schema are preserved - expect(result?.options.find((option) => option.label === "order_id")?.detail).toBe( - "Order ID", - ); - }); - - it("resolves aliases of tables nested in the schema", async () => { - const result = await complete("SELECT u. FROM mydb.users u", { - schema: nestedSchema, - pos: "SELECT u.".length, - }); - expect(labels(result)).toEqual(["id", "username"]); - }); - - it("offers CTE columns for a CTE alias", async () => { - const doc = "WITH recent AS (SELECT x, y FROM logs) SELECT r. FROM recent r"; - const result = await complete(doc, { schema, pos: doc.indexOf("r. FROM") + 2 }); - expect(labels(result)).toEqual(["x", "y"]); - }); - - it("falls back to the sqlSchemaFacet when no schema is configured", async () => { - const result = await complete("SELECT u. FROM users u", { - facetSchema: schema, - pos: "SELECT u.".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("returns null when the qualifier is not an alias", async () => { - const result = await complete("SELECT x. FROM users u", { - schema, - pos: "SELECT x.".length, - }); - expect(result).toBeNull(); - }); - - it("returns null without a dot before the cursor", async () => { - const result = await complete("SELECT u FROM users u", { - schema, - pos: "SELECT u".length, - }); - expect(result).toBeNull(); - }); - - it("ignores multi-segment qualifiers like `db.table.`", async () => { - const doc = "SELECT mydb.users. FROM mydb.users u"; - const result = await complete(doc, { schema: nestedSchema, pos: "SELECT mydb.users.".length }); - expect(result).toBeNull(); - }); - - it("scopes aliases to the statement containing the cursor", async () => { - const doc = "SELECT id FROM users u; SELECT u. FROM orders o"; - const result = await complete(doc, { schema, pos: doc.indexOf("u. FROM orders") + 2 }); - // `u` is aliased in statement 1, not statement 2 - expect(result).toBeNull(); - }); - - it("still completes while the statement is mid-edit (unparsable)", async () => { - const doc = "SELECT u. FROM users u WHERE"; - const result = await complete(doc, { schema, pos: "SELECT u.".length }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("completes after a quoted alias qualifier", async () => { - const quotedSchema: SQLNamespace = { "User Table": ["id", "full_name"] }; - const doc = 'SELECT "ut". FROM "User Table" ut'; - const result = await complete(doc, { schema: quotedSchema, pos: 'SELECT "ut".'.length }); - expect(labels(result)).toEqual(["id", "full_name"]); - }); - - it("forces the property type but keeps schema-provided details", async () => { - const typedSchema: SQLNamespace = { - users: [{ label: "id", type: "keyword", detail: "Primary key" }], - }; - const result = await complete("SELECT u. FROM users u", { - schema: typedSchema, - pos: "SELECT u.".length, - }); - expect(result?.options).toEqual([ - { label: "id", type: "property", detail: "Primary key" }, - ]); - }); - - it("returns null when the aliased table is not in the schema", async () => { - const result = await complete("SELECT m. FROM missing m", { - schema, - pos: "SELECT m.".length, - }); - expect(result).toBeNull(); - }); -}); diff --git a/src/sql/__tests__/column-completion-source.test.ts b/src/sql/__tests__/column-completion-source.test.ts deleted file mode 100644 index f9c6732..0000000 --- a/src/sql/__tests__/column-completion-source.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { describe, expect, it } from "vitest"; -import { unqualifiedColumnCompletionSource } from "../column-completion-source.js"; -import { completeWith, labels, NESTED_SCHEMA, TEST_SCHEMA } from "./test-utils.js"; - -const schema = TEST_SCHEMA; -const nestedSchema = NESTED_SCHEMA; - -const complete = completeWith(unqualifiedColumnCompletionSource); - -describe("unqualifiedColumnCompletionSource", () => { - it("offers the FROM table's columns for a bare prefix", async () => { - const result = await complete("SELECT e FROM users", { - schema, - pos: "SELECT e".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - expect(result?.from).toBe("SELECT ".length); - expect(result?.options.every((option) => option.type === "property")).toBe(true); - }); - - it("completes in ORDER BY position", async () => { - const doc = "SELECT id FROM users ORDER BY user"; - const result = await complete(doc, { schema, pos: doc.length }); - expect(labels(result)).toContain("username"); - expect(result?.from).toBe(doc.indexOf("user", doc.indexOf("ORDER BY"))); - }); - - it("only offers columns of the referenced table when the schema has many", async () => { - const result = await complete("SELECT e FROM users", { - schema, - pos: "SELECT e".length, - }); - expect(labels(result)).not.toContain("total"); - expect(labels(result)).not.toContain("order_id"); - }); - - it("offers the union of all joined tables' columns with per-table details", async () => { - const doc = "SELECT FROM users u JOIN orders o ON u.id = o.user_id"; - const result = await complete(doc, { - schema, - pos: "SELECT ".length, - explicit: true, - }); - expect(labels(result)).toEqual(["id", "username", "email", "order_id", "total"]); - expect(result?.options.find((option) => option.label === "email")?.detail).toBe( - "column of users", - ); - expect(result?.options.find((option) => option.label === "total")?.detail).toBe( - "column of orders", - ); - // Schema-provided details are preserved - expect(result?.options.find((option) => option.label === "order_id")?.detail).toBe( - "Order ID", - ); - }); - - it("offers a duplicate column name once, from the first table", async () => { - const dupSchema: SQLNamespace = { a: ["id", "x"], b: ["id", "y"] }; - const doc = "SELECT FROM a JOIN b ON a.id = b.id"; - const result = await complete(doc, { schema: dupSchema, pos: "SELECT ".length, explicit: true }); - expect(labels(result)).toEqual(["id", "x", "y"]); - expect(result?.options.find((option) => option.label === "id")?.detail).toBe("column of a"); - }); - - it("returns null after a dot", async () => { - const result = await complete("SELECT u. FROM users u", { - schema, - pos: "SELECT u.".length, - explicit: true, - }); - expect(result).toBeNull(); - }); - - it("returns null in table-name position", async () => { - expect(await complete("SELECT id FROM use", { schema, pos: "SELECT id FROM use".length })).toBeNull(); - const doc = "SELECT id FROM users JOIN ord"; - expect(await complete(doc, { schema, pos: doc.length })).toBeNull(); - }); - - it("returns null in a comma-continued FROM table list", async () => { - expect(await complete("SELECT id FROM users, ord", { schema })).toBeNull(); - expect(await complete("SELECT id FROM users u, ord", { schema })).toBeNull(); - expect(await complete("SELECT id FROM users AS u, orders o, ord", { schema })).toBeNull(); - }); - - it("still completes after commas in non-FROM clauses", async () => { - const groupBy = "SELECT id FROM users GROUP BY id, user"; - expect(labels(await complete(groupBy, { schema }))).toContain("username"); - const selectList = "SELECT id, e FROM users"; - expect(labels(await complete(selectList, { schema, pos: "SELECT id, e".length }))).toContain( - "email", - ); - }); - - it("returns null inside string literals and comments", async () => { - const inString = "SELECT id FROM users WHERE name = 'em"; - expect(await complete(inString, { schema })).toBeNull(); - const inLineComment = "SELECT id FROM users -- em"; - expect(await complete(inLineComment, { schema })).toBeNull(); - const inBlockComment = "SELECT id FROM users /* em"; - expect(await complete(inBlockComment, { schema })).toBeNull(); - }); - - it("requires a prefix unless the request is explicit", async () => { - const doc = "SELECT FROM users"; - expect(await complete(doc, { schema, pos: "SELECT ".length })).toBeNull(); - expect(labels(await complete(doc, { schema, pos: "SELECT ".length, explicit: true }))).toEqual([ - "id", - "username", - "email", - ]); - }); - - it("returns null when the statement has no FROM clause yet", async () => { - const result = await complete("SELECT em", { schema, pos: "SELECT em".length }); - expect(result).toBeNull(); - }); - - it("still completes while the statement is mid-edit (unparsable)", async () => { - const result = await complete("SELECT em FROM users WHERE", { - schema, - pos: "SELECT em".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("scopes tables to the statement containing the cursor", async () => { - const doc = "SELECT id FROM users; SELECT t FROM orders"; - const result = await complete(doc, { schema, pos: doc.indexOf("t FROM orders") + 1 }); - expect(labels(result)).toEqual(["order_id", "total"]); - }); - - it("completes columns of an aliased table", async () => { - const result = await complete("SELECT e FROM users u", { - schema, - pos: "SELECT e".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("resolves tables nested in the schema", async () => { - const qualified = await complete("SELECT user FROM mydb.users", { - schema: nestedSchema, - pos: "SELECT user".length, - }); - expect(labels(qualified)).toEqual(["id", "username"]); - - const underQualified = await complete("SELECT user FROM users", { - schema: nestedSchema, - pos: "SELECT user".length, - }); - expect(labels(underQualified)).toEqual(["id", "username"]); - }); - - it("returns null when an under-qualified table is ambiguous across schemas", async () => { - const ambiguousSchema: SQLNamespace = { - db1: { users: ["id"] }, - db2: { users: ["email"] }, - }; - const result = await complete("SELECT i FROM users", { - schema: ambiguousSchema, - pos: "SELECT i".length, - }); - expect(result).toBeNull(); - }); - - it("offers CTE columns when the FROM table is a CTE", async () => { - const doc = "WITH recent AS (SELECT x, y FROM logs) SELECT x FROM recent"; - const result = await complete(doc, { schema, pos: doc.indexOf("x FROM recent") + 1 }); - const resultLabels = labels(result); - expect(resultLabels).toContain("x"); - expect(resultLabels).toContain("y"); - expect(result?.options.find((option) => option.label === "x")?.detail).toBe( - "column of recent", - ); - }); - - it("completes columns of a quoted table name", async () => { - const quotedSchema: SQLNamespace = { "User Table": ["id", "full_name"] }; - const doc = 'SELECT full FROM "User Table"'; - const result = await complete(doc, { schema: quotedSchema, pos: "SELECT full".length }); - expect(labels(result)).toEqual(["id", "full_name"]); - }); - - it("completes columns of a quoted table name containing a dot", async () => { - const dottedSchema: SQLNamespace = { "my.table": ["id", "email"] }; - const doc = 'SELECT e FROM "my.table"'; - const result = await complete(doc, { schema: dottedSchema, pos: "SELECT e".length }); - expect(labels(result)).toEqual(["id", "email"]); - }); - - it("falls back to the sqlSchemaFacet when no schema is configured", async () => { - const result = await complete("SELECT e FROM users", { - facetSchema: schema, - pos: "SELECT e".length, - }); - expect(labels(result)).toEqual(["id", "username", "email"]); - }); - - it("returns null when the table is not in the schema", async () => { - const result = await complete("SELECT x FROM missing", { - schema, - pos: "SELECT x".length, - }); - expect(result).toBeNull(); - }); - - it("boosts columns while preserving schema-provided boosts", async () => { - const boostedSchema: SQLNamespace = { - users: ["id", { label: "email", boost: 5 }], - }; - const result = await complete("SELECT e FROM users", { - schema: boostedSchema, - pos: "SELECT e".length, - }); - expect(result?.options.find((option) => option.label === "id")?.boost).toBe(1); - expect(result?.options.find((option) => option.label === "email")?.boost).toBe(5); - }); -}); diff --git a/src/sql/__tests__/completion-extension.test.ts b/src/sql/__tests__/completion-extension.test.ts deleted file mode 100644 index ec0af68..0000000 --- a/src/sql/__tests__/completion-extension.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { CompletionSource } from "@codemirror/autocomplete"; -import { PostgreSQL, sql } from "@codemirror/lang-sql"; -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { sqlCompletion } from "../completion-extension.js"; - -const schema = { users: ["id", "name", "email"] }; - -/** Collect the `autocomplete` completion sources registered on the language. */ -type EditorStateConfig = NonNullable[0]>; -type EditorExtension = NonNullable; - -function autocompleteSources(...extensions: EditorExtension[]) { - const state = EditorState.create({ - doc: "SELECT ", - extensions: [sql({ dialect: PostgreSQL, schema }), ...extensions], - }); - return state - .languageDataAt("autocomplete", state.doc.length) - .filter((value) => typeof value === "function"); -} - -describe("sqlCompletion", () => { - it("registers all three completion sources by default", () => { - // Baseline: lang-sql registers its own schema-based autocomplete source - const baseline = autocompleteSources().length; - const withHelper = autocompleteSources( - sqlCompletion({ dialect: PostgreSQL, schema }), - ).length; - expect(withHelper - baseline).toBe(3); - }); - - it("omits sources that are disabled", () => { - const baseline = autocompleteSources().length; - const withHelper = autocompleteSources( - sqlCompletion({ - dialect: PostgreSQL, - schema, - enableAliasCompletion: false, - enableColumnCompletion: false, - }), - ).length; - expect(withHelper - baseline).toBe(1); - }); - - it("returns no extra sources when everything is disabled", () => { - const baseline = autocompleteSources().length; - const withHelper = autocompleteSources( - sqlCompletion({ - dialect: PostgreSQL, - schema, - enableCteCompletion: false, - enableAliasCompletion: false, - enableColumnCompletion: false, - }), - ).length; - expect(withHelper - baseline).toBe(0); - }); -}); diff --git a/src/sql/__tests__/completion-utils.test.ts b/src/sql/__tests__/completion-utils.test.ts deleted file mode 100644 index e23623f..0000000 --- a/src/sql/__tests__/completion-utils.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { Completion, CompletionContext } from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; -import type { EditorView } from "@codemirror/view"; -import { describe, expect, it, vi } from "vitest"; -import { columnsOf, findTableColumns, resolveSchema, toCompletion } from "../completion-utils.js"; -import { sqlSchemaFacet } from "../schema-facet.js"; -import { createCompletionContext, createState } from "./test-utils.js"; - -describe("resolveSchema", () => { - it("returns an explicitly provided namespace as-is", () => { - const schema: SQLNamespace = { users: ["id"] }; - const context = createCompletionContext("SELECT "); - expect(resolveSchema(schema, context)).toBe(schema); - }); - - it("falls back to the schema facet when no explicit source is given", () => { - const schema: SQLNamespace = { users: ["id"] }; - const context = createCompletionContext("SELECT ", { - extensions: [sqlSchemaFacet.of(schema)], - }); - expect(resolveSchema(undefined, context)).toBe(schema); - }); - - it("returns null when neither an explicit source nor a facet is present", () => { - const context = createCompletionContext("SELECT "); - expect(resolveSchema(undefined, context)).toBeNull(); - }); - - it("returns null for a function source when the context has no view", () => { - // Contexts created without an editor (as in unit tests) get no schema - const source = vi.fn(() => ({ users: ["id"] }) as SQLNamespace); - const context = createCompletionContext("SELECT "); - expect(resolveSchema(source, context)).toBeNull(); - expect(source).not.toHaveBeenCalled(); - }); - - it("invokes a function source with the view when one is present", () => { - const schema: SQLNamespace = { users: ["id"] }; - const view = {} as EditorView; - const source = vi.fn(() => schema); - // Minimal context carrying a view — resolveSchema only reads `state`/`view` - const context = { state: createState("SELECT "), view } as unknown as CompletionContext; - - expect(resolveSchema(source, context)).toBe(schema); - expect(source).toHaveBeenCalledWith(view); - }); -}); - -describe("columnsOf", () => { - it("returns null for a nullish namespace", () => { - expect(columnsOf(undefined)).toBeNull(); - }); - - it("returns the array itself for an array namespace", () => { - const columns = ["id", "name"]; - expect(columnsOf(columns)).toBe(columns); - }); - - it("unwraps the children of a self/children namespace", () => { - const children = ["id", "name"]; - const namespace = { self: { label: "users" }, children } as unknown as SQLNamespace; - expect(columnsOf(namespace)).toBe(children); - }); - - it("returns null for an object (non-column) namespace", () => { - expect(columnsOf({ users: ["id"] } as SQLNamespace)).toBeNull(); - }); - - it("returns null when a self/children node wraps a non-array child", () => { - const namespace = { - self: { label: "db" }, - children: { users: ["id"] }, - } as unknown as SQLNamespace; - expect(columnsOf(namespace)).toBeNull(); - }); -}); - -describe("findTableColumns", () => { - const schema: SQLNamespace = { - users: ["id", "username", "email"], - orders: ["order_id", "total"], - }; - - it("resolves a simple table name (case-insensitively)", () => { - expect(findTableColumns(schema, "users")).toEqual(["id", "username", "email"]); - expect(findTableColumns(schema, "USERS")).toEqual(["id", "username", "email"]); - }); - - it("resolves a fully-qualified path", () => { - const nested: SQLNamespace = { mydb: { users: ["id", "name"] } }; - expect(findTableColumns(nested, "mydb.users")).toEqual(["id", "name"]); - }); - - it("resolves an under-qualified name to a table nested deeper", () => { - const nested: SQLNamespace = { mydb: { users: ["id", "name"] } }; - expect(findTableColumns(nested, "users")).toEqual(["id", "name"]); - }); - - it("resolves via pre-split segments (preserving dotted identifiers)", () => { - const nested: SQLNamespace = { "my.db": { users: ["id"] } }; - expect(findTableColumns(nested, ["my.db", "users"])).toEqual(["id"]); - }); - - it("returns null for an unknown table", () => { - expect(findTableColumns(schema, "missing")).toBeNull(); - }); - - it("returns null when the last segment is empty", () => { - // A path that reduces to no final segment cannot resolve - expect(findTableColumns(schema, [])).toBeNull(); - expect(findTableColumns(schema, "")).toBeNull(); - }); - - it("returns null when an under-qualified name is ambiguous", () => { - const ambiguous: SQLNamespace = { - db1: { users: ["id", "a"] }, - db2: { users: ["id", "b"] }, - }; - // `users` exists under two catalogs — refuse to guess - expect(findTableColumns(ambiguous, "users")).toBeNull(); - }); - - it("returns null when the qualified path is longer than any match", () => { - const nested: SQLNamespace = { mydb: { users: ["id"] } }; - // `other.users` — the suffix does not match the `mydb.users` path - expect(findTableColumns(nested, "other.users")).toBeNull(); - }); -}); - -describe("toCompletion", () => { - it("wraps a string column with the property type and detail", () => { - expect(toCompletion("id", "column")).toEqual({ - label: "id", - type: "property", - detail: "column", - }); - }); - - it("adds a boost to a string column when provided", () => { - expect(toCompletion("id", "column", 5)).toEqual({ - label: "id", - type: "property", - detail: "column", - boost: 5, - }); - }); - - it("preserves an existing Completion detail and forces the property type", () => { - const column: Completion = { label: "id", type: "keyword", detail: "Primary key" }; - expect(toCompletion(column, "fallback")).toEqual({ - label: "id", - type: "property", - detail: "Primary key", - }); - }); - - it("uses the fallback detail when the Completion has none", () => { - const column: Completion = { label: "id" }; - expect(toCompletion(column, "fallback")).toMatchObject({ - label: "id", - type: "property", - detail: "fallback", - }); - }); - - it("applies a boost to a Completion only when it has none of its own", () => { - expect(toCompletion({ label: "id" }, "d", 3).boost).toBe(3); - expect(toCompletion({ label: "id", boost: 9 }, "d", 3).boost).toBe(9); - }); -}); diff --git a/src/sql/__tests__/cte-completion-source.test.ts b/src/sql/__tests__/cte-completion-source.test.ts deleted file mode 100644 index 9a8c8b2..0000000 --- a/src/sql/__tests__/cte-completion-source.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -import type { CompletionContext } from "@codemirror/autocomplete"; -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { cteCompletionSource } from "../cte-completion-source.js"; - -// Helper function to handle both sync and async completion results -async function getCompletionResult(context: CompletionContext) { - const result = cteCompletionSource(context); - if (result instanceof Promise) { - return await result; - } - return result; -} - -// Helper function to create a mock completion context -function createMockContext(doc: string, pos: number, explicit = false): CompletionContext { - const state = EditorState.create({ doc }); - - return { - state, - pos, - explicit, - matchBefore: (pattern: RegExp) => { - const before = doc.slice(0, pos); - - // For word patterns, find the word at the end - if (pattern.source === "\\w*" || pattern.source === "[\\w$]*") { - const wordMatch = before.match(/([\w$]*)$/); - if (!wordMatch) return null; - - const text = wordMatch[1] || ""; - const from = pos - text.length; - return { - from, - to: pos, - text, - }; - } - - // For other patterns, use the original logic - const match = before.match(pattern); - if (!match) return null; - - const from = pos - match[0].length; - return { - from, - to: pos, - text: match[0], - }; - }, - aborted: false, - } as CompletionContext; -} - -describe("cteCompletionSource", () => { - describe("basic CTE detection", () => { - it("should detect single CTE", async () => { - const sql = `WITH user_stats AS ( - SELECT id, name FROM users - ) - SELECT * FROM user_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options).toHaveLength(1); - expect(result?.options[0].label).toBe("user_stats"); - expect(result?.options[0].type).toBe("variable"); - expect(result?.options[0].info).toBe("Common Table Expression: user_stats"); - }); - - it("should detect multiple CTEs", async () => { - const sql = `WITH - user_stats AS (SELECT id, name FROM users), - post_counts AS (SELECT user_id, COUNT(*) as count FROM posts GROUP BY user_id) - SELECT * FROM `; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options).toHaveLength(2); - - const labels = result?.options.map((opt) => opt.label).sort(); - expect(labels).toEqual(["post_counts", "user_stats"]); - }); - - it("should detect RECURSIVE CTEs", async () => { - const sql = `WITH RECURSIVE category_tree AS ( - SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL - UNION ALL - SELECT c.id, c.name, c.parent_id - FROM categories c - JOIN category_tree ct ON c.parent_id = ct.id - ) - SELECT * FROM category_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options).toHaveLength(1); - expect(result?.options[0].label).toBe("category_tree"); - }); - }); - - describe("CTE name patterns", () => { - it("should handle CTEs with underscores", async () => { - const sql = `WITH user_activity_stats AS ( - SELECT * FROM users - ) - SELECT * FROM user_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options[0].label).toBe("user_activity_stats"); - }); - - it("should handle CTEs with numbers", async () => { - const sql = `WITH stats2024 AS ( - SELECT * FROM users - ) - SELECT * FROM stats`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options[0].label).toBe("stats2024"); - }); - - it("should handle case-insensitive WITH keyword", async () => { - const sql = `with user_data as ( - select * from users - ) - select * from user_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options[0].label).toBe("user_data"); - }); - }); - - describe("complex SQL scenarios", () => { - it("should detect CTEs in nested queries", async () => { - const sql = `WITH outer_cte AS ( - WITH inner_cte AS (SELECT id FROM users) - SELECT * FROM inner_cte - ) - SELECT * FROM `; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options).toHaveLength(2); - - const labels = result?.options.map((opt) => opt.label).sort(); - expect(labels).toEqual(["inner_cte", "outer_cte"]); - }); - - it("should handle CTEs with complex subqueries", async () => { - const sql = `WITH filtered_users AS ( - SELECT u.id, u.name - FROM users u - WHERE u.active = true - AND u.created_at > ( - SELECT DATE_SUB(NOW(), INTERVAL 30 DAY) - ) - ) - SELECT * FROM filtered_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options[0].label).toBe("filtered_users"); - }); - - it("scopes CTEs to the statement containing the cursor", async () => { - const sql = `WITH first_cte AS (SELECT 1) - SELECT * FROM first_cte; - - WITH second_cte AS (SELECT 2) - SELECT * FROM `; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - // first_cte belongs to the previous statement and must not leak in - expect(result?.options.map((opt) => opt.label)).toEqual(["second_cte"]); - }); - - it("offers the first statement's CTE when the cursor is in it", async () => { - const sql = `WITH first_cte AS (SELECT 1) - SELECT * FROM first_; - - WITH second_cte AS (SELECT 2) - SELECT * FROM second_cte`; - - const pos = sql.indexOf("first_;") + "first_".length; - const context = createMockContext(sql, pos, true); - const result = await getCompletionResult(context); - - expect(result?.options.map((opt) => opt.label)).toEqual(["first_cte"]); - }); - }); - - describe("edge cases", () => { - it("should return null when no CTEs are present", async () => { - const sql = "SELECT * FROM users WHERE id = 1"; - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("should return null when not in explicit mode and no word being typed", async () => { - const sql = `WITH user_stats AS (SELECT * FROM users) - SELECT * FROM `; - - const context = createMockContext(sql, sql.length, false); // explicit = false - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("should handle incomplete CTEs gracefully", async () => { - const sql = "WITH incomplete_cte AS SELECT * FROM users"; - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("should handle empty document", async () => { - const sql = ""; - const context = createMockContext(sql, 0, true); - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("should deduplicate CTE names", async () => { - const sql = `WITH user_stats AS (SELECT * FROM users) - SELECT * FROM user_stats - UNION ALL - WITH user_stats AS (SELECT * FROM users) - SELECT * FROM `; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.options).toHaveLength(1); - expect(result?.options[0].label).toBe("user_stats"); - }); - }); - - describe("completion context", () => { - it("should respect word boundaries", async () => { - const sql = `WITH user_stats AS (SELECT * FROM users) - SELECT * FROM user_st`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.from).toBe(sql.lastIndexOf("user_st")); - expect(result?.options[0].label).toBe("user_stats"); - }); - - it("should provide correct completion metadata", async () => { - const sql = `WITH my_cte AS (SELECT * FROM users) - SELECT * FROM my_`; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - const completion = result?.options[0]; - - expect(completion?.label).toBe("my_cte"); - expect(completion?.type).toBe("variable"); - expect(completion?.info).toBe("Common Table Expression: my_cte"); - expect(completion?.boost).toBe(10); - }); - }); - - describe("CTE bodies containing parentheses", () => { - it("detects CTEs after a body with a function call", async () => { - const sql = "WITH a AS (SELECT max(x) FROM t), b AS (SELECT 1) SELECT * FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - const labels = result?.options.map((option) => option.label) ?? []; - expect(labels).toContain("a"); - expect(labels).toContain("b"); - }); - - it("detects CTEs after a body with a nested subquery", async () => { - const sql = - "WITH a AS (SELECT * FROM (SELECT 1) sub), b AS (SELECT 2), c AS (SELECT 3) SELECT * FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - const labels = result?.options.map((option) => option.label) ?? []; - expect(labels).toContain("a"); - expect(labels).toContain("b"); - expect(labels).toContain("c"); - }); - - it("detects CTEs declared with a column list", async () => { - const sql = "WITH a(x, y) AS (SELECT 1, 2), b AS (SELECT 3) SELECT * FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - const labels = result?.options.map((option) => option.label) ?? []; - expect(labels).toContain("a"); - expect(labels).toContain("b"); - }); - - it("detects 3+ comma-separated CTEs with nested parens mid-edit", async () => { - const sql = - "WITH a AS (SELECT max(x) FROM (SELECT 1) s), b AS (SELECT count(*) FROM t), c AS (SELECT 3) SELECT * FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - const labels = result?.options.map((option) => option.label) ?? []; - expect(labels).toEqual(["a", "b", "c"]); - }); - }); - - describe("quoted CTE names", () => { - it("detects quoted CTE names", async () => { - const sql = `WITH "My Stats" AS (SELECT 1) SELECT * FROM `; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result?.options.map((opt) => opt.label)).toEqual(["My Stats"]); - expect(result?.options[0].apply).toBe('"My Stats"'); - }); - - it("does not add an apply override for bare names", async () => { - const sql = "WITH stats AS (SELECT 1) SELECT * FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result?.options[0].apply).toBeUndefined(); - }); - - it("completes names containing $ as one token", async () => { - const sql = "WITH t$1 AS (SELECT 1) SELECT * FROM t$"; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result?.from).toBe(sql.lastIndexOf("t$")); - expect(result?.options[0].label).toBe("t$1"); - }); - - it("quotes non-bare column labels on apply", async () => { - const sql = 'WITH t("My Col", b) AS (SELECT 1, 2) SELECT t. FROM t'; - const pos = sql.indexOf("t. FROM") + 2; - - const context = createMockContext(sql, pos, false); - const result = await getCompletionResult(context); - - const myCol = result?.options.find((opt) => opt.label === "My Col"); - expect(myCol?.apply).toBe('"My Col"'); - expect(result?.options.find((opt) => opt.label === "b")?.apply).toBeUndefined(); - }); - }); - - describe("CTE column completion", () => { - it("completes columns after . from a declared column list", async () => { - const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT t. FROM t"; - const pos = sql.indexOf("t. FROM") + 2; - - const context = createMockContext(sql, pos, false); - const result = await getCompletionResult(context); - - expect(result).toBeTruthy(); - expect(result?.from).toBe(pos); - expect(result?.options.map((opt) => opt.label)).toEqual(["a", "b"]); - expect(result?.options[0].type).toBe("property"); - expect(result?.options[0].detail).toBe("column of t"); - }); - - it("completes columns after . inferred from the select list", async () => { - const sql = "WITH t AS (SELECT id, name AS n FROM users) SELECT t. FROM t"; - const pos = sql.indexOf("t. FROM") + 2; - - const context = createMockContext(sql, pos, false); - const result = await getCompletionResult(context); - - expect(result?.options.map((opt) => opt.label)).toEqual(["id", "n"]); - }); - - it("offers no columns for a SELECT * CTE", async () => { - const sql = "WITH t AS (SELECT * FROM users) SELECT t. FROM t"; - const pos = sql.indexOf("t. FROM") + 2; - - const context = createMockContext(sql, pos, false); - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("does not complete columns for a multi-segment qualifier", async () => { - const sql = "WITH t(a) AS (SELECT 1) SELECT db.t. FROM t"; - const pos = sql.indexOf("db.t. FROM") + "db.t.".length; - - const context = createMockContext(sql, pos, false); - const result = await getCompletionResult(context); - - expect(result).toBeNull(); - }); - - it("offers CTE columns unqualified in a column position", async () => { - const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT FROM t"; - const pos = sql.indexOf("SELECT FROM") + "SELECT ".length; - - const context = createMockContext(sql, pos, true); - const result = await getCompletionResult(context); - - const labels = result?.options.map((opt) => opt.label) ?? []; - expect(labels).toContain("t"); - expect(labels).toContain("a"); - expect(labels).toContain("b"); - }); - - it("does not offer columns right after FROM", async () => { - const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT a FROM "; - - const context = createMockContext(sql, sql.length, true); - const result = await getCompletionResult(context); - - expect(result?.options.map((opt) => opt.label)).toEqual(["t"]); - }); - }); -}); diff --git a/src/sql/__tests__/demo-doc.test.ts b/src/sql/__tests__/demo-doc.test.ts deleted file mode 100644 index d27b5ef..0000000 --- a/src/sql/__tests__/demo-doc.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { expect, it } from "vitest"; -import { defaultSqlDoc } from "../../../demo/data.js"; -import { NodeSqlParser } from "../parser.js"; -import { findReferences } from "../references.js"; - -const parser = new NodeSqlParser({ getParserOptions: () => ({ database: "PostgreSQL" }) }); - -it("demo default doc: first statement parses and navigation resolves", async () => { - const state = EditorState.create({ doc: defaultSqlDoc }); - const firstStmt = defaultSqlDoc.slice(0, defaultSqlDoc.indexOf(";") + 1); - const result = await parser.parse(firstStmt, { state }); - expect(result.errors).toEqual([]); - - const expectations: Array<[string, string, number]> = [ - ["recent_orders AS", "cte", 2], - ["top_customers AS", "cte", 2], - ["amount DESC", "select-alias", 2], - ["t.customer_id", "table-alias", 3], - ]; - for (const [marker, kind, count] of expectations) { - const refs = await findReferences(state, defaultSqlDoc.indexOf(marker), { parser }); - expect(refs?.kind, marker).toBe(kind); - expect(refs?.references, marker).toHaveLength(count); - } -}); diff --git a/src/sql/__tests__/diagnostics.test.ts b/src/sql/__tests__/diagnostics.test.ts deleted file mode 100644 index de9a664..0000000 --- a/src/sql/__tests__/diagnostics.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { EditorState, Text } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { describe, expect, it, vi } from "vitest"; -import { exportedForTesting, type SqlLinterConfig, sqlLinter } from "../diagnostics.js"; -import { NodeSqlParser } from "../parser.js"; -import type { SqlParser } from "../types.js"; - -const { createLintSource } = exportedForTesting; - -const createMockView = (content: string) => { - return { - state: EditorState.create({ doc: content }), - } as EditorView; -}; - -const lint = (content: string, config: SqlLinterConfig = {}) => { - return createLintSource(config)(createMockView(content)); -}; - -describe("sqlLinter", () => { - it("should create a linter extension", () => { - const linter = sqlLinter(); - expect(linter).toBeDefined(); - }); - - it("should accept configuration with custom delay", () => { - const linter = sqlLinter({ delay: 1000 }); - expect(linter).toBeDefined(); - }); - - it("should use custom parser if provided", () => { - const mockParser = { - validateSql: vi.fn(() => []), - parseSql: vi.fn(() => ({ statements: [] })), - } as unknown as SqlParser; - - const linter = sqlLinter({ parser: mockParser }); - expect(linter).toBeDefined(); - }); - - it("should use default delay when no delay provided", () => { - const linter = sqlLinter(); - expect(linter).toBeDefined(); - }); - - it("should use custom delay when provided", () => { - const linter = sqlLinter({ delay: 500 }); - expect(linter).toBeDefined(); - }); - - it("should use default parser when no parser provided", () => { - const linter = sqlLinter(); - expect(linter).toBeDefined(); - }); -}); - -describe("lint source", () => { - it("should return no diagnostics for an empty document", async () => { - expect(await lint("")).toEqual([]); - }); - - it("should return no diagnostics for a whitespace-only document", async () => { - expect(await lint(" \n\n \t ")).toEqual([]); - }); - - it("should return no diagnostics for valid statements", async () => { - const diagnostics = await lint( - "SELECT * FROM users;\nINSERT INTO users (name) VALUES ('John');", - ); - expect(diagnostics).toEqual([]); - }); - - it("should report one diagnostic per broken statement", async () => { - const doc = "SELCT * FROM users;\nSELECT * FORM users;\nDELETE FROMM users;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(3); - - const text = Text.of(doc.split("\n")); - expect(text.lineAt(diagnostics[0].from).number).toBe(1); - expect(text.lineAt(diagnostics[1].from).number).toBe(2); - expect(text.lineAt(diagnostics[2].from).number).toBe(3); - for (const diagnostic of diagnostics) { - expect(diagnostic.severity).toBe("error"); - expect(diagnostic.source).toBe("sql-parser"); - } - }); - - it("should position diagnostics in a broken statement that follows a valid one", async () => { - const doc = "SELECT * FROM users;\nSELCT * FROM orders;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - const secondStatementStart = doc.indexOf("SELCT"); - expect(diagnostics[0].from).toBeGreaterThanOrEqual(secondStatementStart); - expect(diagnostics[0].to).toBeLessThanOrEqual(doc.length); - }); - - it("should not split statements on semicolons inside string literals", async () => { - const doc = "SELECT 'a; b' FROM users;\nSELCT * FROM orders;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].from).toBeGreaterThanOrEqual(doc.indexOf("SELCT")); - }); - - it("should not split statements on semicolons inside comments", async () => { - const doc = "SELECT 1 -- trailing; comment\n;\nSELCT * FROM orders;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].from).toBeGreaterThanOrEqual(doc.indexOf("SELCT")); - }); - - it("should widen the diagnostic span to cover the offending token", async () => { - // node-sql-parser reports this error at the "users" token - const doc = "SELECT 1 FROMM users;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - const { from, to } = diagnostics[0]; - // The span should cover the whole token, not a single character - expect(doc.slice(from, to)).toBe("users"); - }); - - it("should fall back to a one-character span when the error is not at a token", async () => { - // node-sql-parser reports this error at the ";" character - const doc = "SELECT * FROM users WHERE;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].to).toBe(diagnostics[0].from + 1); - }); - - it("should handle statements separated on the same line", async () => { - const doc = "SELECT 1; SELCT 2;"; - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].from).toBeGreaterThanOrEqual(doc.indexOf("SELCT")); - }); - - it("should report a single diagnostic when perStatement is disabled", async () => { - const doc = "SELCT * FROM users;\nSELCT * FROM orders;"; - const diagnostics = await lint(doc, { perStatement: false }); - - expect(diagnostics).toHaveLength(1); - const text = Text.of(doc.split("\n")); - expect(text.lineAt(diagnostics[0].from).number).toBe(1); - }); - - it("should keep diagnostics within statement bounds", async () => { - const doc = "SELECT * FROM users;\nSELECT FROM WHERE;\nSELECT 2;"; - const diagnostics = await lint(doc); - - expect(diagnostics.length).toBeGreaterThanOrEqual(1); - const stmtFrom = doc.indexOf("SELECT FROM"); - const stmtTo = doc.indexOf("SELECT 2"); - for (const diagnostic of diagnostics) { - expect(diagnostic.from).toBeGreaterThanOrEqual(stmtFrom); - expect(diagnostic.to).toBeLessThanOrEqual(stmtTo); - expect(diagnostic.to).toBeGreaterThanOrEqual(diagnostic.from); - } - }); - - it("should position diagnostics correctly when a line comment precedes the statement", async () => { - const doc = [ - "-- Valid queries (no errors):", - "SELECT id, name, email", - "FROM users", - "WHERE active == true", - "ORDER BY created_at DESC;", - ].join("\n"); - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - const text = Text.of(doc.split("\n")); - // The error is at the "==" operator on line 4, not shifted up by the comment - expect(text.lineAt(diagnostics[0].from).number).toBe(4); - expect(diagnostics[0].from).toBe(doc.indexOf("==") + 1); - }); - - it("should position diagnostics correctly when a multi-line comment precedes the statement", async () => { - const doc = [ - "/* header", - "comment */", - "SELECT id", - "FROM users", - "WHERE active == true;", - ].join("\n"); - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - const text = Text.of(doc.split("\n")); - expect(text.lineAt(diagnostics[0].from).number).toBe(5); - expect(diagnostics[0].from).toBe(doc.indexOf("==") + 1); - }); - - it("should position diagnostics correctly when a comment appears inside the statement", async () => { - const doc = [ - "SELECT id", - "-- pick the table", - "FROM users", - "WHERE active == true;", - ].join("\n"); - const diagnostics = await lint(doc); - - expect(diagnostics).toHaveLength(1); - const text = Text.of(doc.split("\n")); - expect(text.lineAt(diagnostics[0].from).number).toBe(4); - expect(diagnostics[0].from).toBe(doc.indexOf("==") + 1); - }); - - it("should not report diagnostics for valid statements with comments", async () => { - const doc = [ - "-- fetch users", - "SELECT id /* inline */, name", - "FROM users;", - "/* another", - " comment */", - "SELECT 2;", - ].join("\n"); - expect(await lint(doc)).toEqual([]); - }); - - it("should reuse errors from a provided structure analyzer without re-validating", async () => { - const parser = new NodeSqlParser(); - const validateSpy = vi.spyOn(parser, "validateSql"); - - const diagnostics = await lint("SELCT 1;\nSELCT 2;", { parser }); - - expect(diagnostics).toHaveLength(2); - expect(validateSpy).not.toHaveBeenCalled(); - }); - - it("should clamp the diagnostic span to the document end when the error column runs past it", async () => { - // A parser whose reported column points beyond the end of the document - // exercises the `from >= doc.length` branch in tokenEndAt, which clamps - // the span end to `doc.length`. - const doc = "SELECT 1"; - const parser = { - validateSql: vi.fn(async () => [ - { message: "unexpected end of input", line: 1, column: 999, severity: "error" as const }, - ]), - parse: vi.fn(async () => ({ success: false, errors: [] })), - extractTableReferences: vi.fn(async () => []), - extractColumnReferences: vi.fn(async () => []), - } as unknown as SqlParser; - - const diagnostics = await lint(doc, { parser, perStatement: false }); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].from).toBe(doc.length); - expect(diagnostics[0].to).toBe(doc.length); - }); - - it("should preserve warning severity from the parser", async () => { - const parser = { - validateSql: vi.fn(async () => [ - { message: "deprecated syntax", line: 1, column: 1, severity: "warning" as const }, - ]), - parse: vi.fn(async () => ({ success: false, errors: [] })), - extractTableReferences: vi.fn(async () => []), - extractColumnReferences: vi.fn(async () => []), - } as unknown as SqlParser; - - const diagnostics = await lint("SELECT 1", { parser, perStatement: false }); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].severity).toBe("warning"); - expect(diagnostics[0].message).toBe("deprecated syntax"); - }); - - it("should emit multiple diagnostics from a single statement when the parser returns several", async () => { - const parser = { - validateSql: vi.fn(async () => [ - { message: "first", line: 1, column: 1, severity: "error" as const }, - { message: "second", line: 1, column: 8, severity: "error" as const }, - ]), - parse: vi.fn(async () => ({ success: false, errors: [] })), - extractTableReferences: vi.fn(async () => []), - extractColumnReferences: vi.fn(async () => []), - } as unknown as SqlParser; - - const diagnostics = await lint("SELECT badcol", { parser, perStatement: false }); - - expect(diagnostics).toHaveLength(2); - expect(diagnostics.map((d) => d.message)).toEqual(["first", "second"]); - }); - - it("clamps a statement-relative error column that overshoots to the statement end", async () => { - // perStatement path: an error with a large column is clamped within the - // statement bounds by convertStatementErrorToDiagnostic. - const doc = "SELECT 1;"; - const parser = { - validateSql: vi.fn(async () => []), - // The structure analyzer derives per-statement errors from parse(). - parse: vi.fn(async () => ({ - success: false, - errors: [{ message: "boom", line: 1, column: 999, severity: "error" as const }], - })), - extractTableReferences: vi.fn(async () => []), - extractColumnReferences: vi.fn(async () => []), - } as unknown as SqlParser; - - const diagnostics = await lint(doc, { parser }); - - expect(diagnostics.length).toBeGreaterThanOrEqual(1); - for (const d of diagnostics) { - expect(d.from).toBeLessThanOrEqual(doc.length); - expect(d.to).toBeLessThanOrEqual(doc.length); - expect(d.to).toBeGreaterThanOrEqual(d.from); - } - }); -}); diff --git a/src/sql/__tests__/extension.test.ts b/src/sql/__tests__/extension.test.ts deleted file mode 100644 index ad73fbb..0000000 --- a/src/sql/__tests__/extension.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { sqlExtension } from "../extension.js"; -import { sqlSchemaFacet } from "../schema-facet.js"; - -describe("sqlExtension", () => { - it("should return an array of extensions with default config", () => { - const extensions = sqlExtension(); - expect(Array.isArray(extensions)).toBe(true); - expect(extensions.length).toBeGreaterThan(0); - }); - - it("should return extensions with all features enabled by default", () => { - const extensions = sqlExtension({}); - // Should include linter, gutter (4 parts), hover, and hover theme - expect(extensions.length).toBeGreaterThan(2); - }); - - it("should exclude linting when disabled", () => { - const extensions = sqlExtension({ enableLinting: false }); - // Should include gutter, hover, and hover theme (no linter) - expect(extensions.length).toBeGreaterThan(2); - }); - - it("should exclude gutter markers when disabled", () => { - const extensions = sqlExtension({ enableGutterMarkers: false }); - // Should include linter, hover, and hover theme (no gutter) - expect(extensions.length).toBeGreaterThan(1); - }); - - it("should exclude hover when disabled", () => { - const extensions = sqlExtension({ enableHover: false }); - // Should include linter and gutter (no hover or hover theme) - expect(extensions.length).toBeGreaterThan(0); - }); - - it("should handle all features disabled", () => { - const extensions = sqlExtension({ - enableLinting: false, - enableSemanticLinting: false, - enableGutterMarkers: false, - enableHover: false, - enableNavigation: false, - }); - expect(extensions).toEqual([]); - }); - - it("should include navigation extensions by default", () => { - const onlyNavigation = sqlExtension({ - enableLinting: false, - enableSemanticLinting: false, - enableGutterMarkers: false, - enableHover: false, - }); - expect(onlyNavigation.length).toBeGreaterThan(0); - expect(() => EditorState.create({ extensions: onlyNavigation })).not.toThrow(); - }); - - it("should register the shared schema facet when a schema is provided", () => { - const schema = { users: ["id"] }; - const withSchema = sqlExtension({ - schema, - enableLinting: false, - enableSemanticLinting: false, - enableGutterMarkers: false, - enableHover: false, - enableNavigation: false, - }); - expect(withSchema).toHaveLength(1); - - const state = EditorState.create({ extensions: withSchema }); - expect(state.facet(sqlSchemaFacet)).toBe(schema); - - const withoutSchema = sqlExtension({ - enableLinting: false, - enableSemanticLinting: false, - enableGutterMarkers: false, - enableHover: false, - enableNavigation: false, - }); - const emptyState = EditorState.create({ extensions: withoutSchema }); - expect(emptyState.facet(sqlSchemaFacet)).toBeNull(); - }); - - it("should pass config objects to individual extensions", () => { - const linterConfig = { delay: 500 }; - const gutterConfig = { backgroundColor: "#ff0000" }; - const hoverConfig = { hoverTime: 200 }; - - const extensions = sqlExtension({ - linterConfig, - gutterConfig, - hoverConfig, - }); - - expect(extensions.length).toBeGreaterThan(2); - }); - - it("should handle partial configuration", () => { - const extensions = sqlExtension({ - enableLinting: true, - linterConfig: { delay: 100 }, - }); - - expect(extensions.length).toBeGreaterThan(2); - }); -}); diff --git a/src/sql/__tests__/hover-integration.test.ts b/src/sql/__tests__/hover-integration.test.ts deleted file mode 100644 index 6fc141b..0000000 --- a/src/sql/__tests__/hover-integration.test.ts +++ /dev/null @@ -1,1088 +0,0 @@ -import type { Completion } from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { EditorState } from "@codemirror/state"; -import { describe, expect, it, vi } from "vitest"; -import type { EditorView } from "@codemirror/view"; -import { defaultSqlHoverTheme, exportedForTesting, sqlHover } from "../hover.js"; -import { resolveNamespaceItem } from "../namespace-utils.js"; - -// Helper function to create completion objects -function createCompletion(label: string, detail?: string): Completion { - return { - label, - detail: detail || `${label} completion`, - type: "property", - }; -} - -// Test namespace with various structures -const testNamespace: SQLNamespace = { - // Simple database with tables and columns - postgres: { - self: createCompletion("postgres", "PostgreSQL database"), - children: { - public: { - users: { - self: createCompletion("users", "User table"), - children: [ - createCompletion("id", "Primary key"), - createCompletion("username", "Username"), - createCompletion("email", "Email address"), - "created_at", - "updated_at", - ], - }, - orders: [ - createCompletion("id"), - createCompletion("user_id"), - createCompletion("total"), - "status", - "created_at", - ], - }, - analytics: { - user_stats: ["daily_active", "monthly_active", "retention_rate"], - sales_data: { - q1_2024: ["january", "february", "march"], - q2_2024: ["april", "may", "june"], - }, - }, - }, - }, - // Simple object namespace - mysql: { - test_db: { - customers: ["customer_id", "company_name", "contact_name"], - products: { - categories: [createCompletion("category_id"), createCompletion("category_name")], - }, - }, - }, -}; - -describe("Hover Integration Tests", () => { - describe("Extension creation", () => { - it("should create hover extension without errors", () => { - expect(() => { - sqlHover({ - schema: testNamespace, - enableKeywords: true, - enableTables: true, - enableColumns: true, - }); - }).not.toThrow(); - }); - - it("should create hover theme without errors", () => { - expect(() => { - defaultSqlHoverTheme(); - }).not.toThrow(); - }); - - it("should handle empty configuration", () => { - expect(() => { - sqlHover(); - }).not.toThrow(); - }); - - it("should handle function-based schema configuration", () => { - expect(() => { - sqlHover({ - schema: () => testNamespace, - }); - }).not.toThrow(); - }); - }); - - describe("Namespace resolution integration", () => { - it("should resolve exact namespace paths with correct semantic types", () => { - const result = resolveNamespaceItem(testNamespace, "postgres.public.users"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("table"); - expect(result?.completion?.label).toBe("users"); - expect(result?.path).toEqual(["postgres", "public", "users"]); - }); - - it("should handle self-children namespace structures with database semantic type", () => { - const result = resolveNamespaceItem(testNamespace, "postgres"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("database"); - expect(result?.completion?.label).toBe("postgres"); - }); - - it("should classify schemas correctly", () => { - const result = resolveNamespaceItem(testNamespace, "postgres.public"); - expect(result).toBeTruthy(); - expect(result?.semanticType).toBe("schema"); - expect(result?.path).toEqual(["postgres", "public"]); - }); - - it("should handle array namespace structures as tables", () => { - const result = resolveNamespaceItem(testNamespace, "postgres.public.orders"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("table"); - expect(result?.path).toEqual(["postgres", "public", "orders"]); - }); - - it("should handle deeply nested namespace paths", () => { - const result = resolveNamespaceItem(testNamespace, "postgres.analytics.sales_data.q1_2024"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("table"); - expect(result?.path).toEqual(["postgres", "analytics", "sales_data", "q1_2024"]); - }); - - it("should resolve individual columns with column semantic type", () => { - const result = resolveNamespaceItem(testNamespace, "id", { enableFuzzySearch: true }); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("column"); - expect(result?.completion?.label).toBe("id"); - }); - - it("should resolve string columns with column semantic type", () => { - const result = resolveNamespaceItem(testNamespace, "created_at", { enableFuzzySearch: true }); - expect(result).toBeTruthy(); - expect(result?.type).toBe("string"); - expect(result?.semanticType).toBe("column"); - expect(result?.value).toBe("created_at"); - }); - }); - - describe("Preference order testing", () => { - it("should prefer exact namespace matches", () => { - // Create a namespace that has a name conflicting with a SQL keyword - const conflictNamespace: SQLNamespace = { - select: { - self: createCompletion("select", "Custom select table"), - children: ["id", "name", "value"], - }, - }; - - const result = resolveNamespaceItem(conflictNamespace, "select"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("table"); - expect(result?.completion?.label).toBe("select"); - expect(result?.completion?.detail).toBe("Custom select table"); - }); - - it("should return null when no match found", () => { - const result = resolveNamespaceItem(testNamespace, "nonexistent"); - expect(result).toBeNull(); - }); - }); - - describe("Dynamic nesting support", () => { - it("should support variable depth namespace paths", () => { - const deepNamespace: SQLNamespace = { - level1: { - level2: { - level3: { - level4: { - level5: ["final_column"], - }, - }, - }, - }, - }; - - const result = resolveNamespaceItem(deepNamespace, "level1.level2.level3.level4.level5"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.path).toEqual(["level1", "level2", "level3", "level4", "level5"]); - }); - - it("should handle mixed namespace types in same path", () => { - const mixedNamespace: SQLNamespace = { - db: { - self: createCompletion("db", "Database"), - children: { - schema: { - table: [createCompletion("col1"), "col2", createCompletion("col3")], - }, - }, - }, - }; - - const result = resolveNamespaceItem(mixedNamespace, "db.schema.table"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.path).toEqual(["db", "schema", "table"]); - }); - }); - - describe("Edge cases and error handling", () => { - it("should handle empty namespace gracefully", () => { - expect(() => { - resolveNamespaceItem({}, "anything"); - }).not.toThrow(); - - const result = resolveNamespaceItem({}, "anything"); - expect(result).toBeNull(); - }); - - it("should handle malformed paths gracefully", () => { - expect(() => { - resolveNamespaceItem(testNamespace, "..invalid.path.."); - }).not.toThrow(); - - const result = resolveNamespaceItem(testNamespace, "..invalid.path.."); - expect(result).toBeNull(); - }); - - it("should handle very long paths gracefully", () => { - const longPath = `${"a.".repeat(20)}final`; - expect(() => { - resolveNamespaceItem(testNamespace, longPath); - }).not.toThrow(); - - const result = resolveNamespaceItem(testNamespace, longPath); - expect(result).toBeNull(); - }); - }); - - describe("Case sensitivity", () => { - it("should handle case-insensitive matching by default", () => { - const result = resolveNamespaceItem(testNamespace, "POSTGRES.PUBLIC.USERS"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("users"); - }); - - it("should support mixed case in namespace paths", () => { - const mixedCaseNamespace: SQLNamespace = { - MyDatabase: { - MySchema: { - MyTable: ["MyColumn", "AnotherColumn"], - }, - }, - }; - - const result = resolveNamespaceItem(mixedCaseNamespace, "mydatabase.myschema.mytable"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.path).toEqual(["MyDatabase", "MySchema", "MyTable"]); - }); - }); -}); - -describe("Complex Namespace Scenarios", () => { - it("should correctly resolve complex namespace structures", () => { - const complexNamespace: SQLNamespace = { - warehouse: { - self: createCompletion("warehouse", "Data warehouse"), - children: { - raw: { - events: [ - createCompletion("event_id", "Event identifier"), - createCompletion("user_id", "User identifier"), - createCompletion("timestamp", "Event timestamp"), - "event_type", - "properties", - ], - users: { - self: createCompletion("users", "Raw user data"), - children: [ - createCompletion("id", "User ID"), - "username", - "email", - createCompletion("signup_date", "User signup date"), - ], - }, - }, - processed: { - daily_stats: ["date", "active_users", "total_events", "revenue"], - user_segments: { - high_value: ["user_id", "ltv", "segment_date"], - churned: ["user_id", "churn_date", "churn_reason"], - }, - }, - }, - }, - }; - - // Test various resolution scenarios with semantic types - const warehouseResult = resolveNamespaceItem(complexNamespace, "warehouse"); - expect(warehouseResult?.type).toBe("completion"); - expect(warehouseResult?.semanticType).toBe("database"); - expect(warehouseResult?.completion?.label).toBe("warehouse"); - - const rawResult = resolveNamespaceItem(complexNamespace, "warehouse.raw"); - expect(rawResult?.semanticType).toBe("schema"); - expect(rawResult?.path).toEqual(["warehouse", "raw"]); - - const eventsResult = resolveNamespaceItem(complexNamespace, "warehouse.raw.events"); - expect(eventsResult?.type).toBe("namespace"); - expect(eventsResult?.semanticType).toBe("table"); - expect(eventsResult?.path).toEqual(["warehouse", "raw", "events"]); - - const usersResult = resolveNamespaceItem(complexNamespace, "warehouse.raw.users"); - expect(usersResult?.type).toBe("completion"); - expect(usersResult?.semanticType).toBe("table"); - expect(usersResult?.completion?.label).toBe("users"); - - const columnResult = resolveNamespaceItem(complexNamespace, "event_id", { - enableFuzzySearch: true, - }); - expect(columnResult?.type).toBe("completion"); - expect(columnResult?.semanticType).toBe("column"); - expect(columnResult?.completion?.label).toBe("event_id"); - - const stringColumnResult = resolveNamespaceItem(complexNamespace, "username", { - enableFuzzySearch: true, - }); - expect(stringColumnResult?.type).toBe("string"); - expect(stringColumnResult?.semanticType).toBe("column"); - expect(stringColumnResult?.value).toBe("username"); - }); -}); - -describe("Custom Tooltip Renderers", () => { - const mockSchema: SQLNamespace = { - users: { - self: createCompletion("users", "User table"), - children: [ - createCompletion("id", "Primary key"), - createCompletion("name", "User name"), - "email", - ], - }, - products: [createCompletion("product_id", "Product identifier"), "title", "price"], - }; - - describe("Keyword renderer", () => { - it("should use custom keyword renderer when provided", () => { - const customKeywordRenderer = vi.fn().mockReturnValue("
Custom keyword tooltip
"); - - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - keyword: customKeywordRenderer, - }, - }); - }).not.toThrow(); - - // Since we can't easily trigger hover in tests, we just verify no errors occur - expect(customKeywordRenderer).not.toHaveBeenCalled(); // Not called in setup - }); - - it("should fallback to default keyword renderer when custom renderer not provided", () => { - expect(() => { - sqlHover({ - schema: mockSchema, - // No custom keyword renderer - }); - }).not.toThrow(); - }); - }); - - describe("Namespace renderer", () => { - it("should use custom namespace renderer for database items", () => { - const customNamespaceRenderer = vi - .fn() - .mockReturnValue("
Custom namespace tooltip
"); - - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - namespace: customNamespaceRenderer, - }, - }); - }).not.toThrow(); - }); - - it("should handle namespace renderer data structure correctly", () => { - // Test the data structure that would be passed to namespace renderer - const result = resolveNamespaceItem(mockSchema, "users"); - expect(result).toBeTruthy(); - expect(result?.semanticType).toBe("table"); - - // Verify data structure for namespace renderer - expect(result).toBeTruthy(); - const namespaceData = { - item: result, - word: "users", - resolvedSchema: mockSchema, - }; - - expect(namespaceData.item?.semanticType).toBe("table"); - expect(namespaceData.word).toBe("users"); - expect(namespaceData.resolvedSchema).toBe(mockSchema); - }); - }); - - describe("Table renderer", () => { - it("should use custom table renderer for table items", () => { - const customTableRenderer = vi.fn().mockReturnValue("
Custom table tooltip
"); - - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - table: customTableRenderer, - }, - }); - }).not.toThrow(); - }); - - it("should handle table renderer data structure correctly", () => { - const result = resolveNamespaceItem(mockSchema, "users"); - expect(result).toBeTruthy(); - expect(result?.semanticType).toBe("table"); - - // Verify this would be passed to table renderer - const tableData = { - item: result, - word: "users", - resolvedSchema: mockSchema, - }; - - expect(tableData.item?.completion?.label).toBe("users"); - expect(tableData.item?.namespace).toBeDefined(); - }); - }); - - describe("Column renderer", () => { - it("should use custom column renderer for column items", () => { - const customColumnRenderer = vi.fn().mockReturnValue("
Custom column tooltip
"); - - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - column: customColumnRenderer, - }, - }); - }).not.toThrow(); - }); - - it("should handle column renderer data structure correctly", () => { - const result = resolveNamespaceItem(mockSchema, "id", { enableFuzzySearch: true }); - expect(result).toBeTruthy(); - expect(result?.semanticType).toBe("column"); - - // Verify data structure for column renderer - const columnData = { - item: result, - word: "id", - resolvedSchema: mockSchema, - }; - - expect(columnData.item?.completion?.label).toBe("id"); - expect(columnData.item?.semanticType).toBe("column"); - }); - }); - - describe("Multiple custom renderers", () => { - it("should handle multiple custom renderers simultaneously", () => { - const customKeywordRenderer = vi.fn().mockReturnValue("
Custom keyword
"); - const customTableRenderer = vi.fn().mockReturnValue("
Custom table
"); - const customColumnRenderer = vi.fn().mockReturnValue("
Custom column
"); - const customNamespaceRenderer = vi.fn().mockReturnValue("
Custom namespace
"); - - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - keyword: customKeywordRenderer, - table: customTableRenderer, - column: customColumnRenderer, - namespace: customNamespaceRenderer, - }, - }); - }).not.toThrow(); - }); - }); - - describe("Fallback behavior", () => { - it("should fallback to default renderer when custom renderer is not provided for specific type", () => { - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: { - // Only provide keyword renderer, others should fallback - keyword: () => "
Custom keyword
", - }, - }); - }).not.toThrow(); - }); - - it("should work with empty tooltipRenderers object", () => { - expect(() => { - sqlHover({ - schema: mockSchema, - tooltipRenderers: {}, - }); - }).not.toThrow(); - }); - - it("should work without tooltipRenderers option", () => { - expect(() => { - sqlHover({ - schema: mockSchema, - // No tooltipRenderers specified - }); - }).not.toThrow(); - }); - }); -}); - -describe("Query-aware hover behavior", () => { - const testSchema: SQLNamespace = { - users: { - self: createCompletion("users", "User table"), - children: [ - createCompletion("id", "Primary key"), - createCompletion("username", "Username"), - createCompletion("email", "Email address"), - "created_at", - "updated_at", - ], - }, - orders: { - self: createCompletion("orders", "Order table"), - children: [ - createCompletion("id", "Order ID"), - createCompletion("user_id", "User ID"), - createCompletion("order_date", "Order date"), - createCompletion("total", "Order total"), - "status", - "created_at", - ], - }, - products: { - self: createCompletion("products", "Product table"), - children: [ - createCompletion("id", "Product ID"), - createCompletion("name", "Product name"), - createCompletion("price", "Product price"), - "category", - "created_at", - ], - }, - }; - - describe("Table reference extraction", () => { - it("should extract table references from simple SELECT queries", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences("SELECT order_date FROM users"); - expect(tableList).toEqual(["users"]); - }); - - it("should extract table references from queries with multiple tables", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences( - "SELECT u.username, o.order_date FROM users u JOIN orders o ON u.id = o.user_id", - ); - expect(tableList).toContain("users"); - expect(tableList).toContain("orders"); - }); - - it("should handle queries with aliases", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences("SELECT u.username FROM users AS u"); - expect(tableList).toEqual(["users"]); - }); - - it("should handle UPDATE queries", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences( - "UPDATE users SET email = 'test@example.com' WHERE id = 1", - ); - expect(tableList).toEqual(["users"]); - }); - - it("should handle INSERT queries", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences( - "INSERT INTO users (username, email) VALUES ('test', 'test@example.com')", - ); - expect(tableList).toEqual(["users"]); - }); - - it("should handle DELETE queries", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences("DELETE FROM users WHERE id = 1"); - expect(tableList).toEqual(["users"]); - }); - - it("should return empty array for invalid SQL", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser(); - - const tableList = await parser.extractTableReferences("INVALID SQL QUERY"); - expect(tableList).toEqual([]); - }); - - it("should handle hierarchical schema references", async () => { - const { NodeSqlParser } = await import("../parser.js"); - const parser = new NodeSqlParser({ - getParserOptions: (_state: EditorState) => ({ database: "PostgreSQL" }), - }); - - let sql = "SELECT * FROM products.users"; - const tableList = await parser.extractTableReferences(sql, { - state: EditorState.create({ doc: sql }), - }); - expect(tableList).toEqual(["users"]); - - sql = "SELECT * FROM products.users.orders"; - const tableList2 = await parser.extractTableReferences(sql, { - state: EditorState.create({ doc: sql }), - }); - expect(tableList2).toEqual(["orders"]); - - sql = - "SELECT * FROM products.users.orders AS o INNER JOIN customers.users AS u ON o.id = u.id"; - const tableList3 = await parser.extractTableReferences(sql, { - state: EditorState.create({ doc: sql }), - }); - expect(tableList3).toEqual(["orders", "users"]); - }); - }); - - describe("Schema filtering based on query", () => { - it("should filter schema to only include referenced tables", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - const tableRefs = new Set(["users"]); - const filteredSchema = filterSchemaByTableRefs(testSchema, tableRefs); - - expect(filteredSchema).toHaveProperty("users"); - expect(filteredSchema).not.toHaveProperty("orders"); - expect(filteredSchema).not.toHaveProperty("products"); - }); - - it("should include multiple tables when multiple tables are referenced", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - const tableRefs = new Set(["users", "orders"]); - const filteredSchema = filterSchemaByTableRefs(testSchema, tableRefs); - - expect(filteredSchema).toHaveProperty("users"); - expect(filteredSchema).toHaveProperty("orders"); - expect(filteredSchema).not.toHaveProperty("products"); - }); - - it("should return empty schema when no tables are referenced", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - const tableRefs = new Set(); - const filteredSchema = filterSchemaByTableRefs(testSchema, tableRefs); - - expect(filteredSchema).toEqual({}); - }); - - it("should handle case-insensitive table matching", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - const tableRefs = new Set(["USERS", "Orders"]); - const filteredSchema = filterSchemaByTableRefs(testSchema, tableRefs); - - expect(filteredSchema).toHaveProperty("users"); - expect(filteredSchema).toHaveProperty("orders"); - expect(filteredSchema).not.toHaveProperty("products"); - }); - - it("should keep parent namespaces that contain a referenced child table", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - // `mydb` itself is not referenced, but it holds `orders`, which is. The - // recursive branch keeps `mydb` with only its referenced children. - const nested: SQLNamespace = { - mydb: { - orders: ["id", "total"], - products: ["id", "name"], - }, - }; - const filtered = filterSchemaByTableRefs(nested, new Set(["orders"])); - - expect(filtered).toHaveProperty("mydb"); - const mydb = (filtered as { mydb: Record }).mydb; - expect(mydb).toHaveProperty("orders"); - expect(mydb).not.toHaveProperty("products"); - }); - - it("should drop parent namespaces whose children are all unreferenced", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - const nested: SQLNamespace = { - mydb: { products: ["id"] }, - }; - const filtered = filterSchemaByTableRefs(nested, new Set(["orders"])); - - expect(filtered).not.toHaveProperty("mydb"); - expect(filtered).toEqual({}); - }); - - it("should return an array namespace unchanged", async () => { - const { filterSchemaByTableRefs } = await import("../hover.js"); - - // A top-level array (column list) is not an object namespace, so it is - // returned as-is regardless of the table refs. - const arrayNamespace: SQLNamespace = ["id", "name", "email"]; - const filtered = filterSchemaByTableRefs(arrayNamespace, new Set(["users"])); - - expect(filtered).toBe(arrayNamespace); - }); - }); - - describe("Query-aware column resolution", () => { - it("should only show columns from referenced tables", async () => { - const { resolveNamespaceItem } = await import("../namespace-utils.js"); - - // When only 'users' table is referenced, we should only see columns from 'users' - const filteredSchema = { - users: testSchema.users, - }; - - // Should find columns from users table - const userColumnResult = resolveNamespaceItem(filteredSchema, "username", { - enableFuzzySearch: true, - }); - expect(userColumnResult).toBeTruthy(); - expect(userColumnResult?.semanticType).toBe("column"); - expect(userColumnResult?.completion?.label).toBe("username"); - - // Should not find columns from other tables - const orderColumnResult = resolveNamespaceItem(filteredSchema, "order_date", { - enableFuzzySearch: true, - }); - expect(orderColumnResult).toBeNull(); - }); - - it("should show columns from multiple tables when multiple tables are referenced", async () => { - const { resolveNamespaceItem } = await import("../namespace-utils.js"); - - const filteredSchema = { - users: testSchema.users, - orders: testSchema.orders, - }; - - // Should find columns from both tables - const userColumnResult = resolveNamespaceItem(filteredSchema, "username", { - enableFuzzySearch: true, - }); - expect(userColumnResult).toBeTruthy(); - expect(userColumnResult?.semanticType).toBe("column"); - - const orderColumnResult = resolveNamespaceItem(filteredSchema, "order_date", { - enableFuzzySearch: true, - }); - expect(orderColumnResult).toBeTruthy(); - expect(orderColumnResult?.semanticType).toBe("column"); - }); - }); -}); - -describe("Query-aware hover behavior - edge cases", () => { - const testSchema: SQLNamespace = { - users: { - self: createCompletion("users", "User table"), - children: [ - createCompletion("id", "Primary key"), - createCompletion("username", "Username"), - createCompletion("email", "Email address"), - "created_at", - "updated_at", - ], - }, - orders: { - self: createCompletion("orders", "Order table"), - children: [ - createCompletion("id", "Order ID"), - createCompletion("user_id", "User ID"), - createCompletion("order_date", "Order date"), - createCompletion("total", "Order total"), - "status", - "created_at", - ], - }, - }; - - describe("Fallback behavior when no tables found in query", () => { - it("should show hover for table names even when no tables are found in query", async () => { - const { resolveNamespaceItem } = await import("../namespace-utils.js"); - - // Simulate a query with no tables found (e.g., invalid SQL or empty query) - // Should be able to find the table in the full schema when no tables are referenced - const tableResult = resolveNamespaceItem(testSchema, "users", { enableFuzzySearch: true }); - expect(tableResult).toBeTruthy(); - expect(tableResult?.semanticType).toBe("table"); - expect(tableResult?.completion?.label).toBe("users"); - }); - - it("should show hover for column names even when no tables are found in query", async () => { - const { resolveNamespaceItem } = await import("../namespace-utils.js"); - - // Should be able to find columns in the full schema when no tables are referenced - const columnResult = resolveNamespaceItem(testSchema, "username", { - enableFuzzySearch: true, - }); - expect(columnResult).toBeTruthy(); - expect(columnResult?.semanticType).toBe("column"); - expect(columnResult?.completion?.label).toBe("username"); - }); - }); - - describe("hover source keyword handling", () => { - const { createHoverSource } = exportedForTesting; - - function createMockView(doc: string): EditorView { - const state = EditorState.create({ doc }); - return { state } as unknown as EditorView; - } - - it("only shows hovers for tables when columns are disabled (and vice versa)", async () => { - const schema = { users: ["id", "name"] }; - const doc = "SELECT name FROM users"; - const view = createMockView(doc); - - const tablesOnly = createHoverSource({ schema, enableColumns: false }); - expect(await tablesOnly(view, doc.indexOf("users") + 2, 1)).not.toBeNull(); - expect(await tablesOnly(view, doc.indexOf("name") + 2, 1)).toBeNull(); - - const columnsOnly = createHoverSource({ schema, enableTables: false }); - expect(await columnsOnly(view, doc.indexOf("users") + 2, 1)).toBeNull(); - expect(await columnsOnly(view, doc.indexOf("name") + 2, 1)).not.toBeNull(); - }); - - it("does not show keyword tooltips for Object.prototype members", async () => { - const source = createHoverSource({ keywords: {}, schema: {} }); - - for (const word of ["constructor", "toString", "valueOf", "hasOwnProperty"]) { - const doc = `SELECT ${word} FROM users`; - const view = createMockView(doc); - const pos = doc.indexOf(word) + 2; - - const tooltip = await source(view, pos, 1); - expect(tooltip, `hovering '${word}' must not produce a tooltip`).toBeNull(); - } - }); - - it("still shows tooltips for real custom keywords", async () => { - const source = createHoverSource({ - keywords: { select: { description: "Selects rows" } }, - }); - const doc = "SELECT id FROM users"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.indexOf("SELECT") + 2, 1); - expect(tooltip).not.toBeNull(); - }); - - describe("alias-aware resolution", () => { - const schema = { - users: ["id", "name", "email"], - orders: ["order_id", "total"], - }; - - function renderTooltip( - tooltip: Awaited>>, - view: EditorView, - ): string { - if (!tooltip) { - throw new Error("expected a tooltip"); - } - return tooltip.create(view).dom.innerHTML; - } - - it("resolves a bare alias to its table and labels it as an alias", async () => { - const source = createHoverSource({ schema }); - const doc = "SELECT u.name FROM users u"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.length - 1, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("is an alias for"); - expect(html).toContain("users"); - expect(html).toContain("table"); - }); - - it("resolves alias-qualified columns to the aliased table", async () => { - const source = createHoverSource({ schema }); - const doc = "SELECT u.name FROM users u"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.indexOf("u.name") + 3, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("name"); - expect(html).toContain("column"); - expect(html).toContain("users"); - }); - - it("supports AS aliases", async () => { - const source = createHoverSource({ schema }); - const doc = "SELECT u.email FROM users AS u"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.indexOf("u.email") + 3, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("email"); - expect(html).toContain("column"); - }); - - it("resolves each alias in a join to its own table", async () => { - const source = createHoverSource({ schema }); - const doc = "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.order_id"; - const view = createMockView(doc); - - const totalHtml = renderTooltip(await source(view, doc.indexOf("o.total") + 3, 1), view); - expect(totalHtml).toContain("total"); - expect(totalHtml).toContain("orders"); - - const nameHtml = renderTooltip(await source(view, doc.indexOf("u.name") + 3, 1), view); - expect(nameHtml).toContain("name"); - expect(nameHtml).toContain("users"); - }); - - it("lets an alias shadow a real table name within the statement", async () => { - const source = createHoverSource({ schema }); - // `orders` aliases the users table here; `orders.name` must resolve to - // users.name (the real orders table has no `name` column) - const doc = "SELECT orders.name FROM users orders"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.indexOf("orders.name") + 8, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("name"); - expect(html).toContain("users"); - }); - - it("escapes markup in alias targets before rendering", async () => { - const source = createHoverSource({ schema: { "t": ["x"] } }); - const doc = 'SELECT x FROM "t" a'; - const view = createMockView(doc); - - const tooltip = await source(view, doc.length - 1, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("is an alias for"); - expect(html).toContain("<b>t</b>"); - expect(html).not.toContain("alias for "); - }); - - it("does not leak tables across statement boundaries", async () => { - const source = createHoverSource({ schema, enableFuzzySearch: true }); - const doc = "SELECT name FROM users; SELECT name FROM orders"; - const view = createMockView(doc); - - // In statement 1, `name` resolves in users - expect(await source(view, doc.indexOf("name") + 2, 1)).not.toBeNull(); - // In statement 2, only orders is in scope and it has no `name` column - expect(await source(view, doc.lastIndexOf("name") + 2, 1)).toBeNull(); - }); - - it("still resolves aliases while the statement is mid-edit", async () => { - const source = createHoverSource({ schema }); - // Trailing WHERE makes the statement unparsable - const doc = "SELECT u.name FROM users u WHERE"; - const view = createMockView(doc); - - const tooltip = await source(view, doc.indexOf("u.name") + 3, 1); - const html = renderTooltip(tooltip, view); - expect(html).toContain("name"); - }); - }); - - it("returns null when the hovered word is empty (side === 0 on whitespace)", async () => { - // Between the two spaces neither boundary condition trips, so the sliced - // word is empty and the source returns null (word-length guard). - const source = createHoverSource({ schema: { users: ["id"] }, keywords: {} }); - const doc = "SELECT id FROM users"; - const view = createMockView(doc); - // pos 7 sits on the second space with side 0 - expect(await source(view, 7, 0)).toBeNull(); - }); - - it("resolves the schema from the sqlSchemaFacet when no schema is configured", async () => { - const { sqlSchemaFacet } = await import("../schema-facet.js"); - const schema = { users: ["id", "name"] }; - const state = EditorState.create({ - doc: "SELECT id FROM users", - extensions: [sqlSchemaFacet.of(schema)], - }); - const view = { state } as unknown as EditorView; - - // No `schema` in config: the source must fall back to the facet value - const source = createHoverSource({ keywords: {} }); - const tooltip = await source(view, "SELECT id FROM users".indexOf("users") + 2, 1); - expect(tooltip).not.toBeNull(); - const html = tooltip!.create(view).dom.innerHTML; - expect(html).toContain("users"); - expect(html).toContain("table"); - }); - - it("prefers the explicit config schema over the facet schema", async () => { - const { sqlSchemaFacet } = await import("../schema-facet.js"); - const facetSchema = { facet_only: ["x"] }; - const configSchema = { users: ["id", "name"] }; - const state = EditorState.create({ - doc: "SELECT id FROM users", - extensions: [sqlSchemaFacet.of(facetSchema)], - }); - const view = { state } as unknown as EditorView; - - const source = createHoverSource({ schema: configSchema, keywords: {} }); - const tooltip = await source(view, "SELECT id FROM users".indexOf("users") + 2, 1); - expect(tooltip).not.toBeNull(); - expect(tooltip!.create(view).dom.innerHTML).toContain("users"); - }); - - it("resolves a nested database/schema/namespace item via the full-schema fallback", async () => { - // With no parseable tables in the doc, the resolver falls back to the full - // schema, letting a bare namespace name resolve to a non-table item. - const schema = { mydb: { public: { users: ["id", "name"] } } }; - const source = createHoverSource({ schema, keywords: {} }); - const doc = "mydb"; - const view = createMockView(doc); - - const tooltip = await source(view, 2, 1); - expect(tooltip).not.toBeNull(); - const html = tooltip!.create(view).dom.innerHTML; - expect(html).toContain("mydb"); - expect(html).toContain("namespace"); - }); - - it("does not permanently disable hovers when the keywords promise rejects", async () => { - const keywords = vi.fn(async (): Promise> => { - if (keywords.mock.calls.length === 1) { - throw new Error("network"); - } - return { select: { description: "Selects rows" } }; - }); - const source = createHoverSource({ - keywords, - schema: { users: ["id", "name"] }, - }); - const doc = "SELECT id FROM users"; - const view = createMockView(doc); - - // First hover: the keywords fetch fails, but schema hovers must still work - const tableTooltip = await source(view, doc.indexOf("users") + 2, 1); - expect(tableTooltip).not.toBeNull(); - - // Second hover: the failed fetch is retried and keyword tooltips recover - const keywordTooltip = await source(view, doc.indexOf("SELECT") + 2, 1); - expect(keywordTooltip).not.toBeNull(); - expect(keywords).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/sql/__tests__/hover-render.test.ts b/src/sql/__tests__/hover-render.test.ts deleted file mode 100644 index 89f6fb6..0000000 --- a/src/sql/__tests__/hover-render.test.ts +++ /dev/null @@ -1,627 +0,0 @@ -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { EditorState } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { describe, expect, it, vi } from "vitest"; -import { DefaultSqlTooltipRenders, defaultSqlHoverTheme, exportedForTesting } from "../hover.js"; -import type { ResolvedNamespaceItem } from "../namespace-utils.js"; - -const { createHoverSource } = exportedForTesting; - -// --------------------------------------------------------------------------- -// Pure tooltip HTML builders (exposed via DefaultSqlTooltipRenders) -// --------------------------------------------------------------------------- - -describe("DefaultSqlTooltipRenders.keyword", () => { - const render = DefaultSqlTooltipRenders.keyword; - - it("renders an uppercased keyword header and description", () => { - const html = render({ keyword: "select", info: { description: "Selects rows" } }); - expect(html).toContain("SELECT"); - expect(html).toContain("keyword"); - expect(html).toContain("Selects rows"); - }); - - it("renders syntax, example, and metadata tags when present", () => { - const html = render({ - keyword: "join", - info: { - description: "Combines rows", - syntax: "A JOIN B ON ...", - example: "SELECT * FROM a JOIN b", - metadata: { since: "SQL-92", category: "clause" }, - }, - }); - expect(html).toContain("Syntax:"); - expect(html).toContain("A JOIN B ON ..."); - expect(html).toContain("Example:"); - expect(html).toContain("SELECT * FROM a JOIN b"); - expect(html).toContain('title="since"'); - expect(html).toContain("SQL-92"); - expect(html).toContain('title="category"'); - }); - - it("omits the metadata block for empty metadata", () => { - const html = render({ keyword: "where", info: { description: "Filters", metadata: {} } }); - expect(html).not.toContain("sql-hover-metadata"); - }); -}); - -describe("DefaultSqlTooltipRenders.table", () => { - const render = DefaultSqlTooltipRenders.table; - - it("pluralizes the column count and lists columns", () => { - const html = render({ tableName: "users", columns: ["id", "name"] }); - expect(html).toContain("users"); - expect(html).toContain("2 columns"); - expect(html).toContain("id"); - expect(html).toContain("name"); - }); - - it("uses singular wording for exactly one column", () => { - const html = render({ tableName: "t", columns: ["only"] }); - expect(html).toContain("1 column"); - expect(html).not.toContain("1 columns"); - }); - - it("renders no column list when there are no columns", () => { - const html = render({ tableName: "empty", columns: [] }); - expect(html).toContain("0 columns"); - expect(html).not.toContain("sql-hover-columns"); - }); - - it("truncates when there are more than 10 columns", () => { - const columns = Array.from({ length: 13 }, (_, i) => `c${i}`); - const html = render({ tableName: "wide", columns }); - expect(html).toContain("and 3 more"); - expect(html).toContain("c9"); - expect(html).not.toContain("c10"); - }); - - it("renders metadata tags", () => { - const html = render({ tableName: "t", columns: ["a"], metadata: { rows: "100" } }); - expect(html).toContain('title="rows"'); - expect(html).toContain("100"); - }); -}); - -describe("DefaultSqlTooltipRenders.column", () => { - const render = DefaultSqlTooltipRenders.column; - - it("lists other columns in the same table", () => { - const html = render({ - tableName: "users", - columnName: "id", - schema: { users: ["id", "name", "email"] }, - }); - expect(html).toContain("id"); - expect(html).toContain("Column in table users"); - expect(html).toContain("Other columns in users"); - expect(html).toContain("name"); - expect(html).toContain("email"); - // "id" itself is filtered from the related list (it only appears in the header) - expect(html).not.toContain("id"); - }); - - it("truncates the related columns list beyond 8", () => { - const cols = ["target", ...Array.from({ length: 12 }, (_, i) => `o${i}`)]; - const html = render({ tableName: "t", columnName: "target", schema: { t: cols } }); - expect(html).toContain("and 4 more"); - }); - - it("omits the related section when the table is unknown", () => { - const html = render({ tableName: "ghost", columnName: "x", schema: {} }); - expect(html).not.toContain("Other columns"); - }); - - it("omits the related section when the table has a single column", () => { - const html = render({ tableName: "t", columnName: "only", schema: { t: ["only"] } }); - expect(html).not.toContain("Other columns"); - }); - - it("renders metadata tags", () => { - const html = render({ - tableName: "t", - columnName: "a", - schema: { t: ["a", "b"] }, - metadata: { type: "int" }, - }); - expect(html).toContain('title="type"'); - expect(html).toContain("int"); - }); - - it("omits the related section when the only other columns are the column itself", () => { - // Duplicate column names mean the filtered "other columns" list is empty - // even though the table has more than one entry. - const html = render({ tableName: "t", columnName: "a", schema: { t: ["a", "a"] } }); - expect(html).toContain("a"); - expect(html).not.toContain("Other columns"); - }); -}); - -describe("DefaultSqlTooltipRenders.namespace", () => { - const render = DefaultSqlTooltipRenders.namespace; - - it("renders a database with schema count and detail", () => { - const item: ResolvedNamespaceItem = { - path: ["mydb"], - type: "completion", - semanticType: "database", - completion: { label: "mydb", detail: "primary db" }, - namespace: { public: { users: ["id"] }, private: { secrets: ["k"] } }, - }; - const html = render(item); - expect(html).toContain("mydb"); - expect(html).toContain("database"); - expect(html).toContain("Database: primary db"); - expect(html).toContain("Contains 2 schemas"); - }); - - it("uses singular schema wording and no detail when absent", () => { - const item: ResolvedNamespaceItem = { - path: ["db"], - type: "namespace", - semanticType: "database", - namespace: { public: { users: ["id"] } }, - }; - const html = render(item); - expect(html).toContain("Contains 1 schema"); - expect(html).not.toContain("1 schemas"); - expect(html).toContain("Database
"); - }); - - it("counts self/children namespaces as children", () => { - const item: ResolvedNamespaceItem = { - path: ["db"], - type: "namespace", - semanticType: "database", - // self/children counts as 1 + children length (2) = 3 - namespace: { self: { label: "t" }, children: ["a", "b"] } as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Contains 3 schemas"); - }); - - it("renders a schema with path and table count", () => { - const item: ResolvedNamespaceItem = { - path: ["mydb", "public"], - type: "namespace", - semanticType: "schema", - namespace: { users: ["id"], orders: ["id"] }, - }; - const html = render(item); - expect(html).toContain("Schema"); - expect(html).toContain("mydb.public"); - expect(html).toContain("Contains 2 tables"); - }); - - it("renders a table with columns, truncation, and schema path", () => { - const columns = [{ label: "id" }, "name", ...Array.from({ length: 8 }, (_, i) => `c${i}`)]; - const item: ResolvedNamespaceItem = { - path: ["mydb", "public", "users"], - type: "namespace", - semanticType: "table", - namespace: columns as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("users"); // name falls back to last path segment - expect(html).toContain("Columns (10)"); - expect(html).toContain("id"); - expect(html).toContain("and 2 more"); - expect(html).toContain("Schema: mydb.public"); - }); - - it("renders a column with its table path and completion info", () => { - const item: ResolvedNamespaceItem = { - path: ["users", "email"], - type: "completion", - semanticType: "column", - completion: { label: "email", detail: "user email", info: "the primary contact" }, - }; - const html = render(item); - expect(html).toContain("email"); - expect(html).toContain("Column: user email"); - expect(html).toContain("Table: users"); - expect(html).toContain("the primary contact"); - }); - - it("falls back to the string value for the name when no completion", () => { - const item: ResolvedNamespaceItem = { - path: ["users", "created_at"], - type: "string", - semanticType: "column", - value: "created_at", - }; - const html = render(item); - expect(html).toContain("created_at"); - }); - - it("renders the default namespace branch with an item count", () => { - const item: ResolvedNamespaceItem = { - path: ["misc"], - type: "namespace", - semanticType: "namespace", - namespace: ["a", "b", "c"] as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Namespace"); - expect(html).toContain("Contains 3 items"); - }); - - it("falls back to 'unknown' when there is no name source", () => { - const item: ResolvedNamespaceItem = { - path: [], - type: "namespace", - semanticType: "namespace", - }; - const html = render(item); - expect(html).toContain("unknown"); - }); - - it("omits the children line when the namespace is not countable", () => { - // A namespace value that is neither an array nor an object makes - // countNamespaceChildren return 0, so no "Contains N items" line renders. - const item: ResolvedNamespaceItem = { - path: ["x"], - type: "namespace", - semanticType: "namespace", - namespace: "not-a-namespace" as unknown as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("x"); - expect(html).toContain("Namespace"); - expect(html).not.toContain("Contains"); - }); - - it("renders a table with no detail and no columns", () => { - // Exercises the table branch with an empty (array) namespace and no - // completion detail: no "Columns" block, no "Schema" path (single segment). - const item: ResolvedNamespaceItem = { - path: ["solo"], - type: "namespace", - semanticType: "table", - namespace: [] as unknown as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("solo"); - expect(html).toContain("Table"); - expect(html).not.toContain("sql-hover-columns"); - expect(html).not.toContain("Schema:"); - }); - - it("renders a column without a table path when the path has a single segment", () => { - const item: ResolvedNamespaceItem = { - path: ["lonely"], - type: "string", - semanticType: "column", - value: "lonely", - }; - const html = render(item); - expect(html).toContain("lonely"); - expect(html).toContain("Column"); - expect(html).not.toContain("Table:"); - }); - - it("renders a column with no table path when the path is empty", () => { - const item: ResolvedNamespaceItem = { - path: [], - type: "string", - semanticType: "column", - value: "bare", - }; - const html = render(item); - expect(html).toContain("bare"); - expect(html).not.toContain("sql-hover-path"); - expect(html).not.toContain("Table:"); - }); - - it("renders a database with a detail but no namespace children line", () => { - const item: ResolvedNamespaceItem = { - path: ["db"], - type: "completion", - semanticType: "database", - completion: { label: "db", detail: "the database" }, - }; - const html = render(item); - expect(html).toContain("Database: the database"); - expect(html).not.toContain("Contains"); - }); - - it("renders a schema with a detail but no table count", () => { - const item: ResolvedNamespaceItem = { - path: ["db", "sch"], - type: "completion", - semanticType: "schema", - completion: { label: "sch", detail: "a schema" }, - }; - const html = render(item); - expect(html).toContain("Schema: a schema"); - expect(html).toContain("db.sch"); - expect(html).not.toContain("Contains"); - }); - - it("renders a table with an object namespace (not a column array) and skips the column list", () => { - const item: ResolvedNamespaceItem = { - path: ["sch", "t"], - type: "completion", - semanticType: "table", - completion: { label: "t", detail: "a table" }, - namespace: { self: { label: "t" }, children: ["a"] } as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Table: a table"); - expect(html).toContain("Schema: sch"); - expect(html).not.toContain("sql-hover-columns"); - }); - - it("uses singular table wording for a schema with exactly one table", () => { - const item: ResolvedNamespaceItem = { - path: ["db", "public"], - type: "namespace", - semanticType: "schema", - namespace: { users: ["id"] }, - }; - const html = render(item); - expect(html).toContain("Contains 1 table"); - expect(html).not.toContain("1 tables"); - }); - - it("renders a database with an empty namespace and no children line", () => { - const item: ResolvedNamespaceItem = { - path: ["db"], - type: "namespace", - semanticType: "database", - namespace: {} as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Database"); - expect(html).not.toContain("Contains"); - }); - - it("renders a schema with an empty path and empty namespace", () => { - const item: ResolvedNamespaceItem = { - path: [], - type: "namespace", - semanticType: "schema", - namespace: {} as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Schema"); - // No path segment and no children => neither block is rendered - expect(html).not.toContain("sql-hover-path"); - expect(html).not.toContain("Contains"); - }); - - it("renders a table with an empty path and no schema line", () => { - const item: ResolvedNamespaceItem = { - path: [], - type: "namespace", - semanticType: "table", - namespace: ["id"] as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Table"); - expect(html).not.toContain("Schema:"); - expect(html).toContain("Columns (1)"); - }); - - it("renders the default namespace branch with a detail and child count", () => { - const item: ResolvedNamespaceItem = { - path: ["misc"], - type: "completion", - semanticType: "namespace", - completion: { label: "misc", detail: "misc ns" }, - namespace: { a: ["x"], b: ["y"] } as SQLNamespace, - }; - const html = render(item); - expect(html).toContain("Namespace: misc ns"); - expect(html).toContain("Contains 2 items"); - }); -}); - -describe("defaultSqlHoverTheme", () => { - it("builds a light theme by default", () => { - expect(defaultSqlHoverTheme()).toBeDefined(); - expect(defaultSqlHoverTheme("light")).toBeDefined(); - }); - - it("builds a dark theme when requested", () => { - expect(defaultSqlHoverTheme("dark")).toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// Hover source `create` path (produces the tooltip DOM) -// --------------------------------------------------------------------------- - -describe("hover source tooltip DOM creation", () => { - function mockView(doc: string): EditorView { - const state = EditorState.create({ doc }); - return { state } as unknown as EditorView; - } - - const schema: SQLNamespace = { - users: ["id", "username", "email"], - orders: ["id", "total"], - }; - - async function hoverWord( - doc: string, - word: string, - config: Parameters[0] = {}, - side = 1, - ) { - const source = createHoverSource({ schema, ...config }); - const view = mockView(doc); - const pos = doc.indexOf(word) + 2; - const tooltip = await source(view, pos, side); - return { tooltip, view }; - } - - it("renders a default table tooltip DOM when hovering a table", async () => { - const { tooltip, view } = await hoverWord("SELECT id FROM users", "users"); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.className).toBe("cm-sql-hover-tooltip"); - expect(dom.innerHTML).toContain("users"); - expect(dom.innerHTML).toContain("table"); - }); - - it("renders a default column tooltip DOM when hovering a column", async () => { - const { tooltip, view } = await hoverWord("SELECT username FROM users", "username"); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.innerHTML).toContain("username"); - expect(dom.innerHTML).toContain("column"); - }); - - it("renders a default keyword tooltip DOM", async () => { - const { tooltip, view } = await hoverWord("SELECT id FROM users", "SELECT", { - keywords: { select: { description: "Selects rows" } }, - }); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.innerHTML).toContain("SELECT"); - expect(dom.innerHTML).toContain("Selects rows"); - }); - - it("uses a custom tooltipRender when it returns an element", async () => { - const custom = document.createElement("span"); - custom.textContent = "custom!"; - const { tooltip, view } = await hoverWord("SELECT id FROM users", "users", { - tooltipRender: () => custom, - }); - const { dom } = tooltip!.create(view); - expect(dom).toBe(custom); - }); - - it("falls back to the default renderer when tooltipRender returns null", async () => { - const { tooltip, view } = await hoverWord("SELECT id FROM users", "users", { - tooltipRender: () => null, - }); - const { dom } = tooltip!.create(view); - expect(dom.className).toBe("cm-sql-hover-tooltip"); - expect(dom.innerHTML).toContain("users"); - }); - - it("uses a custom table renderer for table items", async () => { - const table = vi.fn().mockReturnValue("
custom table
"); - const { tooltip, view } = await hoverWord("SELECT id FROM users", "users", { - tooltipRenderers: { table }, - }); - const { dom } = tooltip!.create(view); - expect(table).toHaveBeenCalledOnce(); - expect(dom.innerHTML).toContain("custom table"); - }); - - it("uses a custom column renderer for column items", async () => { - const column = vi.fn().mockReturnValue("
custom column
"); - const { tooltip, view } = await hoverWord("SELECT username FROM users", "username", { - tooltipRenderers: { column }, - }); - const { dom } = tooltip!.create(view); - expect(column).toHaveBeenCalledOnce(); - expect(dom.innerHTML).toContain("custom column"); - }); - - it("uses a custom keyword renderer for keyword items", async () => { - const keyword = vi.fn().mockReturnValue("
custom keyword
"); - const { tooltip, view } = await hoverWord("SELECT id FROM users", "SELECT", { - keywords: { select: { description: "Selects rows" } }, - tooltipRenderers: { keyword }, - }); - const { dom } = tooltip!.create(view); - expect(keyword).toHaveBeenCalledOnce(); - expect(dom.innerHTML).toContain("custom keyword"); - }); - - it("returns an empty div when a custom renderer yields no content", async () => { - const { tooltip, view } = await hoverWord("SELECT id FROM users", "users", { - tooltipRenderers: { table: () => "" }, - }); - const { dom } = tooltip!.create(view); - expect(dom.className).toBe(""); - expect(dom.innerHTML).toBe(""); - }); - - it("returns null when the pointer sits just before a word (side < 0)", async () => { - const source = createHoverSource({ schema }); - const doc = "SELECT id FROM users"; - const view = mockView(doc); - const tooltip = await source(view, doc.indexOf("users"), -1); - expect(tooltip).toBeNull(); - }); - - it("falls back to the full schema when the query has no parseable tables", async () => { - // "users" alone is not a valid query, so no table refs are extracted; - // the resolver must fall back to the full schema to still show the table. - const { tooltip, view } = await hoverWord("users", "users"); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.innerHTML).toContain("users"); - }); - - it("returns null when nothing matches", async () => { - const source = createHoverSource({ schema, keywords: {} }); - const doc = "SELECT zzz FROM users"; - const view = mockView(doc); - const tooltip = await source(view, doc.indexOf("zzz") + 1, 1); - expect(tooltip).toBeNull(); - }); - - it("respects enableKeywords: false", async () => { - const source = createHoverSource({ - schema: {}, - keywords: { select: { description: "Selects rows" } }, - enableKeywords: false, - }); - const doc = "SELECT id FROM users"; - const view = mockView(doc); - const tooltip = await source(view, doc.indexOf("SELECT") + 2, 1); - expect(tooltip).toBeNull(); - }); - - it("renders a default namespace tooltip DOM for a nested namespace item", async () => { - const nestedSchema: SQLNamespace = { mydb: { public: { users: ["id", "name"] } } }; - const source = createHoverSource({ schema: nestedSchema, keywords: {} }); - // "mydb" alone is not a parseable query, so the resolver falls back to the - // full schema and resolves the bare namespace name. - const doc = "mydb"; - const view = mockView(doc); - const tooltip = await source(view, 2, 1); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.className).toBe("cm-sql-hover-tooltip"); - expect(dom.innerHTML).toContain("mydb"); - expect(dom.innerHTML).toContain("namespace"); - }); - - it("uses a custom namespace renderer for namespace/schema/database items", async () => { - const namespace = vi.fn().mockReturnValue("
custom namespace
"); - const nestedSchema: SQLNamespace = { mydb: { public: { users: ["id"] } } }; - const source = createHoverSource({ - schema: nestedSchema, - keywords: {}, - tooltipRenderers: { namespace }, - }); - const doc = "mydb"; - const view = mockView(doc); - const tooltip = await source(view, 2, 1); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(namespace).toHaveBeenCalledOnce(); - expect(dom.innerHTML).toContain("custom namespace"); - }); - - it("prepends the alias notice to the default tooltip when hovering a bare alias", async () => { - const source = createHoverSource({ schema, keywords: {} }); - const doc = "SELECT u.id FROM users u"; - const view = mockView(doc); - const tooltip = await source(view, doc.length - 1, 1); - expect(tooltip).not.toBeNull(); - const { dom } = tooltip!.create(view); - expect(dom.innerHTML).toContain("sql-hover-alias"); - expect(dom.innerHTML).toContain("is an alias for"); - expect(dom.innerHTML).toContain("u"); - expect(dom.innerHTML).toContain("users"); - }); -}); diff --git a/src/sql/__tests__/namespace-utils.test.ts b/src/sql/__tests__/namespace-utils.test.ts deleted file mode 100644 index 58efe7c..0000000 --- a/src/sql/__tests__/namespace-utils.test.ts +++ /dev/null @@ -1,924 +0,0 @@ -import type { Completion } from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { describe, expect, it } from "vitest"; -import { - findNamespaceCompletions, - findNamespaceItemByEndMatch, - isArrayNamespace, - isObjectNamespace, - isSelfChildrenNamespace, - resolveNamespaceItem, - traverseNamespacePath, -} from "../namespace-utils.js"; - -// Helper function to create completion objects -function createCompletion(label: string, detail?: string): Completion { - return { - label, - detail: detail || `${label} completion`, - type: "property", - }; -} - -// Test data structures representing different SQLNamespace formats -const mockNamespaces = { - // Simple object namespace: { [name: string]: SQLNamespace } - simpleObject: { - users: ["id", "name", "email"], - orders: ["id", "user_id", "total", "created_at"], - products: { - electronics: ["laptop", "phone", "tablet"], - books: ["fiction", "non_fiction", "technical"], - }, - } as SQLNamespace, - - // Self-children namespace: { self: Completion; children: SQLNamespace } - selfChildren: { - self: createCompletion("database", "Main database"), - children: { - public: { - users: [createCompletion("id"), createCompletion("name"), createCompletion("email")], - orders: [createCompletion("id"), createCompletion("user_id"), createCompletion("total")], - }, - private: { - secrets: ["api_key", "password_hash"], - }, - }, - } as SQLNamespace, - - // Array namespace: readonly (Completion | string)[] - arrayNamespace: [ - "id", - "name", - "email", - createCompletion("created_at", "Timestamp column"), - createCompletion("updated_at", "Timestamp column"), - ] as SQLNamespace, - - // Complex nested structure combining all types - complexNested: { - postgres: { - self: createCompletion("postgres", "PostgreSQL database"), - children: { - public: { - users: { - self: createCompletion("users", "User table"), - children: [ - createCompletion("id", "Primary key"), - createCompletion("username", "Username"), - createCompletion("email", "Email address"), - "created_at", - "updated_at", - ], - }, - orders: [ - createCompletion("id"), - createCompletion("user_id"), - createCompletion("total"), - "status", - ], - }, - analytics: { - user_stats: ["daily_active", "monthly_active", "retention_rate"], - sales_data: { - q1: ["january", "february", "march"], - q2: ["april", "may", "june"], - }, - }, - }, - }, - mysql: { - test_db: { - customers: ["customer_id", "company_name", "contact_name"], - products: { - categories: [createCompletion("category_id"), createCompletion("category_name")], - }, - }, - }, - } as SQLNamespace, -}; - -describe("namespace-utils type guards", () => { - it("should correctly identify object namespace", () => { - expect(isObjectNamespace(mockNamespaces.simpleObject)).toBe(true); - expect(isObjectNamespace(mockNamespaces.selfChildren)).toBe(false); - expect(isObjectNamespace(mockNamespaces.arrayNamespace)).toBe(false); - }); - - it("should correctly identify self-children namespace", () => { - expect(isSelfChildrenNamespace(mockNamespaces.selfChildren)).toBe(true); - expect(isSelfChildrenNamespace(mockNamespaces.simpleObject)).toBe(false); - expect(isSelfChildrenNamespace(mockNamespaces.arrayNamespace)).toBe(false); - }); - - it("should correctly identify array namespace", () => { - expect(isArrayNamespace(mockNamespaces.arrayNamespace)).toBe(true); - expect(isArrayNamespace(mockNamespaces.simpleObject)).toBe(false); - expect(isArrayNamespace(mockNamespaces.selfChildren)).toBe(false); - }); -}); - -describe("traverseNamespacePath", () => { - describe("simple object namespace traversal", () => { - it("should traverse single level paths", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "users"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["users"]); - expect(result?.type).toBe("namespace"); - }); - - it("should traverse multi-level paths", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "products.electronics"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["products", "electronics"]); - expect(result?.type).toBe("namespace"); - }); - - it("should return null for non-existent paths", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "nonexistent"); - expect(result).toBeNull(); - }); - - it("should return null for non-existent nested paths", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "users.nonexistent"); - expect(result).toBeNull(); - }); - }); - - describe("self-children namespace traversal", () => { - it("should handle self-children at root level", () => { - const result = traverseNamespacePath(mockNamespaces.selfChildren, ""); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("database"); - }); - - it("should traverse through self-children to nested content", () => { - const result = traverseNamespacePath(mockNamespaces.selfChildren, "public.users"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["public", "users"]); - expect(result?.type).toBe("namespace"); - }); - }); - - describe("complex nested namespace traversal", () => { - it("should traverse deep paths with mixed namespace types", () => { - const result = traverseNamespacePath(mockNamespaces.complexNested, "postgres.public.users"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["postgres", "public", "users"]); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("users"); - }); - - it("should handle array endpoints", () => { - const result = traverseNamespacePath( - mockNamespaces.complexNested, - "postgres.analytics.user_stats", - ); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["postgres", "analytics", "user_stats"]); - expect(result?.type).toBe("namespace"); - }); - - it("should traverse very deep paths", () => { - const result = traverseNamespacePath( - mockNamespaces.complexNested, - "postgres.analytics.sales_data.q1", - ); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["postgres", "analytics", "sales_data", "q1"]); - expect(result?.type).toBe("namespace"); - }); - }); - - describe("edge cases", () => { - it("should handle empty paths", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, ""); - expect(result).toBeNull(); - }); - - it("should handle paths with empty segments", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "users..orders"); - expect(result).toBeNull(); - }); - - it("should respect maxDepth configuration", () => { - const result = traverseNamespacePath( - mockNamespaces.complexNested, - "postgres.analytics.sales_data.q1.january", - { maxDepth: 3 }, - ); - expect(result).toBeNull(); - }); - - it("should handle case-insensitive matching", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "USERS", { - caseSensitive: false, - }); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["users"]); - }); - - it("should handle case-sensitive matching", () => { - const result = traverseNamespacePath(mockNamespaces.simpleObject, "USERS", { - caseSensitive: true, - }); - expect(result).toBeNull(); - }); - }); -}); - -describe("findNamespaceCompletions", () => { - describe("prefix matching at root level", () => { - it("should find completions for simple prefixes", () => { - const results = findNamespaceCompletions(mockNamespaces.simpleObject, "u"); - expect(results).toHaveLength(1); - expect(results[0].path).toEqual(["users"]); - }); - - it("should find multiple completions for common prefixes", () => { - const results = findNamespaceCompletions(mockNamespaces.simpleObject, ""); - expect(results.length).toBeGreaterThanOrEqual(3); // users, orders, products - }); - - it("should return empty array for non-matching prefixes", () => { - const results = findNamespaceCompletions(mockNamespaces.simpleObject, "xyz"); - expect(results).toEqual([]); - }); - }); - - describe("prefix matching with dotted paths", () => { - it("should find completions for dotted prefixes", () => { - const results = findNamespaceCompletions(mockNamespaces.simpleObject, "products.e"); - expect(results).toHaveLength(1); - expect(results[0].path).toEqual(["products", "electronics"]); - }); - - it("should handle completions in self-children namespaces", () => { - const results = findNamespaceCompletions(mockNamespaces.selfChildren, "public.u"); - expect(results).toHaveLength(1); - expect(results[0].path).toEqual(["public", "users"]); - }); - - it("should find completions in complex nested structures", () => { - const results = findNamespaceCompletions(mockNamespaces.complexNested, "postgres.public."); - expect(results.length).toBeGreaterThanOrEqual(2); // users, orders - }); - - it("should return an empty array when the dotted base path does not resolve", () => { - const results = findNamespaceCompletions(mockNamespaces.simpleObject, "nonexistent.foo"); - expect(results).toEqual([]); - }); - }); - - describe("self label matching at the root", () => { - it("should match the self completion of a root self/children namespace by prefix", () => { - // "database" is the self label of the selfChildren namespace. - const results = findNamespaceCompletions(mockNamespaces.selfChildren, "data"); - const selfMatch = results.find((r) => r.completion?.label === "database"); - expect(selfMatch).toBeTruthy(); - expect(selfMatch?.type).toBe("completion"); - }); - - it("should match the self completion exactly when partial matching is disabled", () => { - const results = findNamespaceCompletions(mockNamespaces.selfChildren, "database", { - allowPartialMatch: false, - }); - expect(results.some((r) => r.completion?.label === "database")).toBe(true); - }); - }); - - describe("array namespace completions", () => { - it("should find string completions in arrays", () => { - const results = findNamespaceCompletions(mockNamespaces.arrayNamespace, ""); - expect(results.length).toBeGreaterThanOrEqual(5); - - const stringResults = results.filter((r) => r.type === "string"); - const completionResults = results.filter((r) => r.type === "completion"); - - expect(stringResults.length).toBeGreaterThanOrEqual(3); // id, name, email - expect(completionResults.length).toBeGreaterThanOrEqual(2); // created_at, updated_at - }); - - it("should find completion objects in arrays", () => { - const results = findNamespaceCompletions(mockNamespaces.arrayNamespace, "created"); - expect(results).toHaveLength(1); - expect(results[0].type).toBe("completion"); - expect(results[0].completion?.label).toBe("created_at"); - }); - }); - - describe("configuration options", () => { - it("should respect case sensitivity", () => { - const caseSensitiveResults = findNamespaceCompletions(mockNamespaces.simpleObject, "U", { - caseSensitive: true, - }); - expect(caseSensitiveResults).toHaveLength(0); - - const caseInsensitiveResults = findNamespaceCompletions(mockNamespaces.simpleObject, "U", { - caseSensitive: false, - }); - expect(caseInsensitiveResults).toHaveLength(1); - }); - - it("should handle exact vs partial matching", () => { - const partialResults = findNamespaceCompletions(mockNamespaces.simpleObject, "user", { - allowPartialMatch: true, - }); - expect(partialResults).toHaveLength(1); - - const exactResults = findNamespaceCompletions(mockNamespaces.simpleObject, "user", { - allowPartialMatch: false, - }); - expect(exactResults).toHaveLength(0); - }); - }); -}); - -describe("findNamespaceItemByEndMatch", () => { - it("should find items by their end identifier", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "users"); - expect(results.length).toBeGreaterThanOrEqual(1); - - const userResult = results.find((r) => r.path[r.path.length - 1] === "users"); - expect(userResult).toBeTruthy(); - }); - - it("should find multiple matches for common end identifiers", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); - expect(results.length).toBeGreaterThanOrEqual(2); // Multiple tables have id columns - }); - - it("should sort results by path length (relevance)", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); - expect(results.length).toBeGreaterThan(1); - - // Check that results are sorted by path length - for (let i = 1; i < results.length; i++) { - expect(results[i].path.length).toBeGreaterThanOrEqual(results[i - 1].path.length); - } - }); - - it("should handle case-insensitive matching", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "USERS", { - caseSensitive: false, - }); - expect(results.length).toBeGreaterThanOrEqual(1); - }); - - it("should handle case-sensitive matching", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "USERS", { - caseSensitive: true, - }); - expect(results).toHaveLength(0); - }); - - it("should return empty array for non-existent identifiers", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "nonexistent"); - expect(results).toEqual([]); - }); - - it("should collect items from a root-level self/children namespace", () => { - // The root of selfChildren is itself a { self, children } node, exercising the - // self/children branch of collectAllItems. - const results = findNamespaceItemByEndMatch(mockNamespaces.selfChildren, "public"); - expect(results.length).toBeGreaterThanOrEqual(1); - expect(results.some((r) => r.path[r.path.length - 1] === "public")).toBe(true); - }); - - it("should find array columns nested under a root self/children namespace", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.selfChildren, "api_key"); - expect(results.some((r) => r.value === "api_key")).toBe(true); - }); - - it("should stop collecting once maxDepth is reached", () => { - // With maxDepth 1, only depth-0 keys (postgres, mysql) are collected, so a deep - // column like "id" is never reached. - const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id", { - maxDepth: 1, - }); - expect(results).toEqual([]); - }); -}); - -describe("resolveNamespaceItem", () => { - describe("preference order resolution", () => { - it("should prefer exact path matches over fuzzy matches", () => { - // Create a namespace where "users" exists both as exact path and fuzzy match - const testNamespace = { - users: ["id", "name"], - accounts: { - admin_users: ["admin_id", "permissions"], - }, - } as SQLNamespace; - - const result = resolveNamespaceItem(testNamespace, "users"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["users"]); - expect(result?.type).toBe("namespace"); - }); - - it("should fall back to prefix completions when no exact match", () => { - const result = resolveNamespaceItem(mockNamespaces.simpleObject, "use"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["users"]); - }); - - it("should fall back to end-match fuzzy search as last resort", () => { - // Search for something that doesn't exist as exact match or prefix - const result = resolveNamespaceItem(mockNamespaces.complexNested, "id", { - enableFuzzySearch: true, - }); - expect(result).toBeTruthy(); - expect(result?.path[result?.path.length - 1]).toBe("id"); - }); - - it("should return null when nothing matches", () => { - const result = resolveNamespaceItem(mockNamespaces.simpleObject, "definitely_nonexistent"); - expect(result).toBeNull(); - }); - - it("should not use fuzzy search when disabled by default", () => { - // Search for something that would be found via fuzzy search but not exact/prefix match - const result = resolveNamespaceItem(mockNamespaces.complexNested, "id"); - expect(result).toBeNull(); - }); - - it("should use fuzzy search when explicitly enabled", () => { - // Same search with fuzzy search enabled should work - const result = resolveNamespaceItem(mockNamespaces.complexNested, "id", { - enableFuzzySearch: true, - }); - expect(result).toBeTruthy(); - expect(result?.path[result?.path.length - 1]).toBe("id"); - }); - }); - - describe("exact segment matching in fuzzy search", () => { - const testSchema: SQLNamespace = { - users: { - self: { label: "users", type: "property" }, - children: [ - { label: "name", type: "property" }, - { label: "full_name", type: "property" }, - { label: "user_name", type: "property" }, - "email", - ], - }, - profiles: { - self: { label: "profiles", type: "property" }, - children: [ - { label: "name", type: "property" }, - { label: "display_name", type: "property" }, - ], - }, - companies: [ - { label: "name", type: "property" }, - { label: "company_name", type: "property" }, - "website", - ], - }; - - it("should match exact segments only, not partial segments", () => { - // Search for 'name' should match 'users.name', 'profiles.name', 'companies.name' - // but NOT 'users.full_name', 'users.user_name', 'profiles.display_name', 'companies.company_name' - const results = findNamespaceItemByEndMatch(testSchema, "name", { enableFuzzySearch: true }); - - expect(results).toHaveLength(3); // users.name, profiles.name, companies.name - - const paths = results.map((r) => r.path.join(".")); - expect(paths).toContain("users.name"); - expect(paths).toContain("profiles.name"); - expect(paths).toContain("companies.name"); - - // Should NOT contain partial matches - expect(paths).not.toContain("users.full_name"); - expect(paths).not.toContain("users.user_name"); - expect(paths).not.toContain("profiles.display_name"); - expect(paths).not.toContain("companies.company_name"); - }); - - it("should prioritize end-of-path matches", () => { - // Both 'users' and 'users.name' contain 'users', but 'users' should come first - const results = findNamespaceItemByEndMatch(testSchema, "users", { enableFuzzySearch: true }); - - expect(results.length).toBeGreaterThan(0); - expect(results[0]?.path).toEqual(["users"]); // Should prioritize the direct match - }); - - it("should handle case-insensitive matching", () => { - const results = findNamespaceItemByEndMatch(testSchema, "NAME", { - enableFuzzySearch: true, - caseSensitive: false, - }); - - expect(results).toHaveLength(3); - const paths = results.map((r) => r.path.join(".")); - expect(paths).toContain("users.name"); - expect(paths).toContain("profiles.name"); - expect(paths).toContain("companies.name"); - }); - - it("should handle case-sensitive matching", () => { - const results = findNamespaceItemByEndMatch(testSchema, "NAME", { - enableFuzzySearch: true, - caseSensitive: true, - }); - - // Should find no matches since 'NAME' (uppercase) doesn't match 'name' (lowercase) exactly - expect(results).toHaveLength(0); - }); - - it("should work with resolveNamespaceItem integration", () => { - // Test that the exact segment matching works through the main resolution function - const result = resolveNamespaceItem(testSchema, "name", { enableFuzzySearch: true }); - - expect(result).toBeTruthy(); - expect(result?.path).toContain("name"); - - // Should be one of the exact matches, not a partial match - const pathStr = result?.path.join("."); - expect(["users.name", "profiles.name", "companies.name"]).toContain(pathStr); - }); - - it("should not match when fuzzy search is disabled", () => { - // Without fuzzy search, 'name' should not be found (no exact path match) - const result = resolveNamespaceItem(testSchema, "name", { enableFuzzySearch: false }); - expect(result).toBeNull(); - }); - }); - - describe("complex resolution scenarios", () => { - it("should resolve deeply nested identifiers", () => { - const result = resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public.users"); - expect(result).toBeTruthy(); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("users"); - }); - - it("should resolve partial nested paths", () => { - const result = resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public"); - expect(result).toBeTruthy(); - expect(result?.path).toEqual(["postgres", "public"]); - }); - - it("should handle mixed namespace types in resolution", () => { - const result = resolveNamespaceItem( - mockNamespaces.complexNested, - "postgres.analytics.user_stats", - ); - expect(result).toBeTruthy(); - expect(result?.type).toBe("namespace"); - expect(result?.path).toEqual(["postgres", "analytics", "user_stats"]); - }); - }); -}); - -describe("edge cases and error handling", () => { - it("should handle null/undefined namespaces gracefully", () => { - expect(() => traverseNamespacePath({} as SQLNamespace, "test")).not.toThrow(); - expect(() => findNamespaceCompletions({} as SQLNamespace, "test")).not.toThrow(); - expect(() => findNamespaceItemByEndMatch({} as SQLNamespace, "test")).not.toThrow(); - expect(() => resolveNamespaceItem({} as SQLNamespace, "test")).not.toThrow(); - }); - - it("should handle malformed paths", () => { - expect(traverseNamespacePath(mockNamespaces.simpleObject, "..")).toBeNull(); - expect(traverseNamespacePath(mockNamespaces.simpleObject, ".")).toBeNull(); - expect(traverseNamespacePath(mockNamespaces.simpleObject, "users.")).toBeTruthy(); // Should handle trailing dot - }); - - it("should handle extremely deep nesting within maxDepth", () => { - const deepNamespace: SQLNamespace = { - level1: { - level2: { - level3: { - level4: { - level5: ["final"], - }, - }, - }, - }, - }; - - const result = traverseNamespacePath(deepNamespace, "level1.level2.level3.level4.level5"); - expect(result).toBeTruthy(); - - const resultExceedsDepth = traverseNamespacePath( - deepNamespace, - "level1.level2.level3.level4.level5.final", - { maxDepth: 5 }, - ); - expect(resultExceedsDepth).toBeNull(); - }); - - it("should handle circular references without infinite loops", () => { - // Create a namespace with potential circular reference - // oxlint-disable-next-line no-explicit-any -- Mock SQLNamespace - const circularNamespace: any = { - parent: { - child: null, - }, - }; - circularNamespace.parent.child = circularNamespace.parent; - - expect(() => { - findNamespaceCompletions(circularNamespace, "parent", { maxDepth: 5 }); - }).not.toThrow(); - }); -}); - -describe("performance and memory", () => { - it("should handle large namespaces efficiently", () => { - // Create a large namespace - const largeNamespace: SQLNamespace = {}; - for (let i = 0; i < 1000; i++) { - (largeNamespace as Record)[`table_${i}`] = [ - `col1_${i}`, - `col2_${i}`, - `col3_${i}`, - ]; - } - - const startTime = performance.now(); - const results = findNamespaceCompletions(largeNamespace, "table_1"); - const endTime = performance.now(); - - expect(results.length).toBeGreaterThan(0); - expect(endTime - startTime).toBeLessThan(100); // Should complete within 100ms - }); - - it("should not accumulate memory with repeated operations", () => { - // Perform many operations to check for memory leaks - for (let i = 0; i < 100; i++) { - resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public.users"); - findNamespaceCompletions(mockNamespaces.complexNested, "postgres"); - findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); - } - - // If we get here without running out of memory, the test passes - expect(true).toBe(true); - }); -}); - -describe("semantic type classification", () => { - // A schema crafted so that each structural branch of determineSemanticType is - // reachable through the public traversal/completion helpers. - const semanticSchema: SQLNamespace = { - // object namespace whose child is an array (table) -> "database" at depth 1 - db: { - tbl: ["a", createCompletion("b")], - }, - // object namespace whose children are only nested objects -> "namespace" - nsOnly: { - child: { grand: ["x"] }, - }, - // object -> object -> object(with array) so the inner object is a "schema" at depth 3 - deep: { - lvl1: { - sch: { t: ["x"] }, - }, - }, - // self/children completion whose children is an array -> "table" at depth 1 - tblSelf: { - self: createCompletion("tblSelf"), - children: ["c1", createCompletion("c2")], - }, - // self/children completion whose object children include a table (array) -> "database" - dbSelf: { - self: createCompletion("dbSelf"), - children: { t1: ["x"] }, - }, - // self/children completion whose object children have no tables -> "database" at depth 1 - dbSelfNoTables: { - self: createCompletion("dbSelfNoTables"), - children: { subsch: { t: ["x"] } }, - }, - // nested self/children completions at depth 2 -> "schema" - outer: { - mid: { - self: createCompletion("mid"), - children: { t: ["x"] }, - }, - midNoTables: { - self: createCompletion("midNoTables"), - children: { subsch: { t: ["x"] } }, - }, - }, - // self/children completion whose children is itself a self/children node - // (neither array nor plain object) -> database (depth 1) - weirdSelf: { - self: createCompletion("weirdSelf"), - children: { self: createCompletion("inner"), children: ["x"] }, - }, - outerWeird: { - midWeird: { - self: createCompletion("midWeird"), - children: { self: createCompletion("inner2"), children: ["y"] }, - }, - }, - }; - - describe("leaf columns in arrays", () => { - it("classifies a leaf string in an array as a column", () => { - const result = traverseNamespacePath(semanticSchema, "db.tbl.a"); - expect(result?.type).toBe("string"); - expect(result?.value).toBe("a"); - expect(result?.semanticType).toBe("column"); - }); - - it("classifies a leaf Completion in an array as a column", () => { - const result = traverseNamespacePath(semanticSchema, "db.tbl.b"); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("b"); - expect(result?.semanticType).toBe("column"); - }); - - it("classifies array columns as columns via findNamespaceCompletions", () => { - const results = findNamespaceCompletions(mockNamespaces.arrayNamespace, ""); - expect(results.length).toBeGreaterThan(0); - for (const result of results) { - expect(result.semanticType).toBe("column"); - } - }); - - it("classifies Completion columns of a self/children array via findNamespaceCompletions", () => { - // "postgres.public.users" is a self/children whose children is an array of columns - const results = findNamespaceCompletions( - mockNamespaces.complexNested, - "postgres.public.users.", - ); - expect(results.length).toBeGreaterThan(0); - const completionColumn = results.find((r) => r.type === "completion"); - expect(completionColumn).toBeTruthy(); - expect(completionColumn?.semanticType).toBe("column"); - }); - - it("classifies array columns as columns via findNamespaceItemByEndMatch", () => { - const results = findNamespaceItemByEndMatch(mockNamespaces.arrayNamespace, "created_at"); - const match = results.find((r) => r.completion?.label === "created_at"); - expect(match?.semanticType).toBe("column"); - }); - }); - - describe("array namespaces resolved as nodes", () => { - it("classifies an array namespace node as a table", () => { - const result = traverseNamespacePath(semanticSchema, "db.tbl"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("table"); - }); - }); - - describe("object namespaces with tables", () => { - it("classifies a shallow object namespace with table children as a database", () => { - const result = traverseNamespacePath(semanticSchema, "db"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("database"); - }); - - it("classifies a deep object namespace with table children as a schema", () => { - const result = traverseNamespacePath(semanticSchema, "deep.lvl1.sch"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("schema"); - }); - - it("classifies an object namespace whose child is a self/children table as a schema (depth>1)", () => { - // postgres.public: children are `users` (self/children with array children) and - // `orders` (array), so hasTableChildren is satisfied via the self/children branch. - const result = traverseNamespacePath(mockNamespaces.complexNested, "postgres.public"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("schema"); - }); - }); - - describe("object namespaces without tables", () => { - it("classifies a shallow object namespace of only nested objects as a namespace", () => { - const result = traverseNamespacePath(semanticSchema, "nsOnly"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("namespace"); - }); - - it("classifies a deeper object namespace of only nested objects as a namespace", () => { - const result = traverseNamespacePath(semanticSchema, "deep.lvl1"); - expect(result?.type).toBe("namespace"); - expect(result?.semanticType).toBe("namespace"); - }); - }); - - describe("self/children completions", () => { - it("classifies a self/children completion with array children as a table", () => { - const result = traverseNamespacePath(semanticSchema, "tblSelf"); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("tblSelf"); - expect(result?.semanticType).toBe("table"); - }); - - it("classifies a shallow self/children completion with table children as a database", () => { - const result = traverseNamespacePath(semanticSchema, "dbSelf"); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("database"); - }); - - it("classifies a shallow self/children completion without table children as a database", () => { - const result = traverseNamespacePath(semanticSchema, "dbSelfNoTables"); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("database"); - }); - - it("classifies a deep self/children completion with table children as a schema", () => { - const result = traverseNamespacePath(semanticSchema, "outer.mid"); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("mid"); - expect(result?.semanticType).toBe("schema"); - }); - - it("classifies a deep self/children completion without table children as a schema", () => { - const result = traverseNamespacePath(semanticSchema, "outer.midNoTables"); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("schema"); - }); - - it("classifies a root self/children completion (empty path) as a database", () => { - const result = traverseNamespacePath(mockNamespaces.selfChildren, ""); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("database"); - expect(result?.semanticType).toBe("database"); - }); - - it("classifies the self completion matched at the root as a database", () => { - const results = findNamespaceCompletions(mockNamespaces.selfChildren, "data"); - const selfMatch = results.find((r) => r.completion?.label === "database"); - expect(selfMatch?.semanticType).toBe("database"); - }); - - it("classifies a shallow self/children completion with self/children children as a database", () => { - const result = traverseNamespacePath(semanticSchema, "weirdSelf"); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("database"); - }); - - it("classifies a deep self/children completion with self/children children as a schema", () => { - const result = traverseNamespacePath(semanticSchema, "outerWeird.midWeird"); - expect(result?.type).toBe("completion"); - expect(result?.semanticType).toBe("schema"); - }); - }); - - describe("array item matching", () => { - it("resolves an array's Completion item by its label", () => { - const result = traverseNamespacePath(mockNamespaces.arrayNamespace, "created_at"); - expect(result?.type).toBe("completion"); - expect(result?.completion?.label).toBe("created_at"); - expect(result?.semanticType).toBe("column"); - }); - - it("matches array string items case-insensitively by default", () => { - const result = traverseNamespacePath(mockNamespaces.arrayNamespace, "NAME"); - expect(result?.type).toBe("string"); - expect(result?.value).toBe("name"); - }); - - it("does not match array string items when case-sensitive", () => { - const result = traverseNamespacePath(mockNamespaces.arrayNamespace, "NAME", { - caseSensitive: true, - }); - expect(result).toBeNull(); - }); - - it("does not match an array Completion item when case-sensitive", () => { - const result = traverseNamespacePath(mockNamespaces.arrayNamespace, "CREATED_AT", { - caseSensitive: true, - }); - expect(result).toBeNull(); - }); - }); -}); - -describe("namespaces containing a table named 'self'", () => { - const schemaWithSelfTable: SQLNamespace = { - self: ["id", "name"], - users: ["id", "email"], - }; - - it("treats a plain object with a 'self' key (but no 'children') as an object namespace", () => { - expect(isObjectNamespace(schemaWithSelfTable)).toBe(true); - expect(isSelfChildrenNamespace(schemaWithSelfTable)).toBe(false); - }); - - it("does not treat tables literally named 'self' and 'children' as a self-children namespace", () => { - const schema: SQLNamespace = { self: ["id"], children: ["id"] }; - expect(isSelfChildrenNamespace(schema)).toBe(false); - expect(isObjectNamespace(schema)).toBe(true); - }); - - it("resolves a table literally named 'self'", () => { - const result = resolveNamespaceItem(schemaWithSelfTable, "self"); - expect(result).toBeTruthy(); - expect(result?.semanticType).toBe("table"); - }); - - it("still detects real self-children namespaces", () => { - expect(isSelfChildrenNamespace(mockNamespaces.selfChildren)).toBe(true); - expect(isObjectNamespace(mockNamespaces.selfChildren)).toBe(false); - }); -}); diff --git a/src/sql/__tests__/navigation-extension.test.ts b/src/sql/__tests__/navigation-extension.test.ts deleted file mode 100644 index 74eb457..0000000 --- a/src/sql/__tests__/navigation-extension.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { EditorState, type TransactionSpec } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { describe, expect, it } from "vitest"; -import { - gotoSqlDefinition, - renameSqlIdentifier, - sqlGotoDefinition, - sqlHighlightReferences, - sqlNavigation, - sqlNavigationKeymap, -} from "../navigation-extension.js"; - -/** - * A minimal stand-in for EditorView: enough for the navigation commands, - * which only read `state` and call `dispatch`. - */ -function createFakeView(doc: string, cursor: number) { - const dispatched: TransactionSpec[] = []; - const view = { - state: EditorState.create({ doc, selection: { anchor: cursor } }), - dispatch(spec: TransactionSpec) { - dispatched.push(spec); - this.state = this.state.update(spec).state; - }, - }; - return { view: view as unknown as EditorView, dispatched, doc: () => view.state.doc.toString() }; -} - -describe("extension composition", () => { - it("sqlNavigation returns highlight + goto extensions", () => { - const extensions = sqlNavigation(); - expect(extensions).toHaveLength(2); - expect(() => EditorState.create({ extensions })).not.toThrow(); - }); - - it("includes the keymap only when opted in", () => { - expect(sqlNavigation({ keymap: true })).toHaveLength(3); - }); - - it("individual extensions create cleanly", () => { - for (const extension of [ - sqlHighlightReferences(), - sqlGotoDefinition(), - sqlNavigationKeymap(), - ]) { - expect(() => EditorState.create({ extensions: [extension] })).not.toThrow(); - } - }); -}); - -describe("gotoSqlDefinition", () => { - it("jumps from a CTE use to its definition", async () => { - const sql = "WITH recent AS (SELECT 1) SELECT * FROM recent"; - const use = sql.lastIndexOf("recent"); - const { view, dispatched } = createFakeView(sql, use); - - expect(await gotoSqlDefinition(view)).toBe(true); - expect(dispatched).toHaveLength(1); - const definitionFrom = sql.indexOf("recent"); - expect(view.state.selection.main.from).toBe(definitionFrom); - expect(view.state.selection.main.to).toBe(definitionFrom + "recent".length); - }); - - it("jumps from an alias qualifier to the alias token", async () => { - const sql = "SELECT u.name FROM users u"; - const { view } = createFakeView(sql, sql.indexOf("u.name")); - - expect(await gotoSqlDefinition(view)).toBe(true); - expect(view.state.selection.main.from).toBe(sql.indexOf("users u") + "users ".length); - }); - - it("returns false on unresolvable identifiers without dispatching", async () => { - const sql = "SELECT name FROM users"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("users")); - - expect(await gotoSqlDefinition(view)).toBe(false); - expect(dispatched).toHaveLength(0); - }); -}); - -describe("renameSqlIdentifier", () => { - it("renames a CTE's definition and all references in one transaction", async () => { - const sql = "WITH recent AS (SELECT x FROM logs) SELECT r.x FROM recent r JOIN recent r2 ON 1=1"; - const { view, dispatched, doc } = createFakeView(sql, sql.indexOf("recent")); - - const renamed = await renameSqlIdentifier(view, { prompt: () => "latest" }); - expect(renamed).toBe(true); - expect(dispatched).toHaveLength(1); - expect(doc()).toBe( - "WITH latest AS (SELECT x FROM logs) SELECT r.x FROM latest r JOIN latest r2 ON 1=1", - ); - }); - - it("renames a table alias definition and its qualifier uses", async () => { - const sql = "SELECT u.name FROM users u WHERE u.active"; - const { view, doc } = createFakeView(sql, sql.indexOf("u.name")); - - expect(await renameSqlIdentifier(view, { prompt: () => "usr" })).toBe(true); - expect(doc()).toBe("SELECT usr.name FROM users usr WHERE usr.active"); - }); - - it("does not touch same-named identifiers in other statements", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t;\nWITH t AS (SELECT 2) SELECT * FROM t"; - const { view, doc } = createFakeView(sql, sql.lastIndexOf("t")); - - expect(await renameSqlIdentifier(view, { prompt: () => "u" })).toBe(true); - expect(doc()).toBe("WITH t AS (SELECT 1) SELECT * FROM t;\nWITH u AS (SELECT 2) SELECT * FROM u"); - }); - - it("does not touch same-named identifiers inside string literals", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t WHERE name = 't'"; - const { view, doc } = createFakeView(sql, sql.indexOf("t AS")); - - expect(await renameSqlIdentifier(view, { prompt: () => "u" })).toBe(true); - expect(doc()).toBe("WITH u AS (SELECT 1) SELECT * FROM u WHERE name = 't'"); - }); - - it("refuses when the identifier is not resolvable", async () => { - const sql = "SELECT name FROM users"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("users")); - - expect(await renameSqlIdentifier(view, { prompt: () => "people" })).toBe(false); - expect(dispatched).toHaveLength(0); - }); - - it("refuses when the prompt is cancelled", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("t AS")); - - expect(await renameSqlIdentifier(view, { prompt: () => null })).toBe(false); - expect(dispatched).toHaveLength(0); - }); - - it("refuses reserved words as new names", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("t AS")); - - expect(await renameSqlIdentifier(view, { prompt: () => "select" })).toBe(false); - expect(dispatched).toHaveLength(0); - }); - - it("refuses invalid identifiers as new names", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("t AS")); - - expect(await renameSqlIdentifier(view, { prompt: () => "not valid!" })).toBe(false); - expect(dispatched).toHaveLength(0); - }); - - it("refuses when the document changed while prompting", async () => { - const sql = "WITH t AS (SELECT 1) SELECT * FROM t"; - const { view, dispatched } = createFakeView(sql, sql.indexOf("t AS")); - - const result = await renameSqlIdentifier(view, { - prompt: () => { - view.dispatch({ changes: { from: sql.length, to: sql.length, insert: " -- x" } }); - return "u"; - }, - }); - expect(result).toBe(false); - // Only the edit made inside the prompt was dispatched, no rename - expect(dispatched).toHaveLength(1); - }); -}); diff --git a/src/sql/__tests__/parser.test.ts b/src/sql/__tests__/parser.test.ts deleted file mode 100644 index e9000d3..0000000 --- a/src/sql/__tests__/parser.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it, vi } from "vitest"; -import { exportedForTesting, NodeSqlParser } from "../parser.js"; - -describe("SqlParser", () => { - const parser = new NodeSqlParser(); - const state = EditorState.create({ - doc: "SELECT * FROM users WHERE id = 1", - }); - - describe("parse", () => { - it("should parse valid SQL successfully", async () => { - const sql = "SELECT * FROM users WHERE id = 1"; - const result = await parser.parse(sql, { state }); - - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.ast).toBeDefined(); - }); - - it("should handle complex queries", async () => { - const sql = ` - SELECT u.name, p.title - FROM users u - JOIN posts p ON u.id = p.user_id - WHERE u.active = true - ORDER BY p.created_at DESC - `; - const result = await parser.parse(sql, { state }); - - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it("should return errors for invalid SQL", async () => { - const sql = "SELECT * FROM"; - const result = await parser.parse(sql, { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].severity).toBe("error"); - expect(result.errors[0].message).toBeTruthy(); - }); - - it("should return errors for syntax errors", async () => { - const sql = "SELECT * FORM users"; - const result = await parser.parse(sql, { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - }); - - it("should use custom parser options when provided", async () => { - const customParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "PostgreSQL", - parseOptions: { - includeLocations: true, - }, - }), - }); - - const sql = "SELECT * FROM users"; - const result = await customParser.parse(sql, { state }); - - expect(result.success).toBe(true); - expect(result.ast).toBeDefined(); - // The AST should include location information when includeLocations is true - if (result.success && Array.isArray(result.ast)) { - const selectStmt = result.ast[0]; - if (selectStmt && typeof selectStmt === "object" && "loc" in selectStmt) { - expect(selectStmt.loc).toBeDefined(); - } - } - }); - - it("should call getParserOptions with correct state", async () => { - const mockGetParserOptions = vi.fn().mockReturnValue({ - database: "MySQL", - }); - - const customParser = new NodeSqlParser({ - getParserOptions: mockGetParserOptions, - }); - - const sql = "SELECT 1"; - await customParser.parse(sql, { state }); - - expect(mockGetParserOptions).toHaveBeenCalledTimes(1); - expect(mockGetParserOptions).toHaveBeenCalledWith(state); - }); - - it("rewrites 'Expected ... but ... found.' messages and strips the Error: prefix", async () => { - // node-sql-parser produces an "Expected ... but \"F\" found." style message here - const result = await parser.parse("SELECT * FORM users", { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - const message = result.errors[0].message; - // cleanErrorMessage rewrites the "but ... found" tail - expect(message).toContain("found unexpected token"); - expect(message).not.toContain("but"); - // and strips any leading "Error: " prefix - expect(message.startsWith("Error: ")).toBe(false); - }); - - it("falls back to line/column 1 for errors without location info (unsupported dialect)", async () => { - // An unsupported dialect throws "X is not supported currently" with no - // location or hash, forcing the message-regex fallback in extractErrorInfo. - const oracleParser = new NodeSqlParser({ - getParserOptions: () => ({ - // Oracle is not supported by node-sql-parser - database: "Oracle" as unknown as "PostgreSQL", - }), - }); - - const result = await oracleParser.parse("SELECT 1", { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].message).toContain("not supported"); - // No "line N"/"column N" in the message, so both default to 1 - expect(result.errors[0].line).toBe(1); - expect(result.errors[0].column).toBe(1); - }); - - it("clamps a reported column that exceeds the sql length", async () => { - // "SELECT * FROM" is 13 chars; the parser reports column 14 (end of input), - // which extractErrorInfo clamps down to sql.length. - const sql = "SELECT * FROM"; - const result = await parser.parse(sql, { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].column).toBe(sql.length); - }); - }); - - describe("extractTableReferences", () => { - it("extracts and cleans table names from a valid query", async () => { - const tables = await parser.extractTableReferences("SELECT id FROM users"); - expect(tables).toContain("users"); - // Names are cleaned of the "type::schema::table" prefixing - expect(tables.every((table) => !table.includes("::"))).toBe(true); - }); - - it("strips schema prefixes from qualified table names", async () => { - const tables = await parser.extractTableReferences("SELECT * FROM schema1.orders"); - expect(tables).toContain("orders"); - expect(tables.every((table) => !table.includes("::"))).toBe(true); - }); - - it("returns an empty array for invalid SQL", async () => { - const tables = await parser.extractTableReferences("NOT VALID SQL"); - expect(tables).toEqual([]); - }); - - it("passes parser options through when a state is provided", async () => { - const getParserOptions = vi.fn().mockReturnValue({ database: "PostgreSQL" }); - const customParser = new NodeSqlParser({ getParserOptions }); - const tableState = EditorState.create({ doc: "SELECT id FROM users" }); - - const tables = await customParser.extractTableReferences("SELECT id FROM users", { - state: tableState, - }); - expect(getParserOptions).toHaveBeenCalledWith(tableState); - expect(tables).toContain("users"); - }); - }); - - describe("extractColumnReferences", () => { - it("extracts column references from a SELECT query", async () => { - const columns = await parser.extractColumnReferences("SELECT id, name FROM users"); - expect(columns).toContain("id"); - expect(columns).toContain("name"); - }); - - it("strips the node-sql-parser prefixes from column names", async () => { - const columns = await parser.extractColumnReferences( - "SELECT u.email FROM users u WHERE u.active = true", - ); - // Names are cleaned of the "type::table::column" prefixing - expect(columns.every((col) => !col.includes("::"))).toBe(true); - expect(columns).toContain("email"); - }); - - it("returns an empty array for invalid SQL", async () => { - const columns = await parser.extractColumnReferences("NOT VALID SQL"); - expect(columns).toEqual([]); - }); - - it("passes parser options through when a state is provided", async () => { - const getParserOptions = vi.fn().mockReturnValue({ database: "PostgreSQL" }); - const customParser = new NodeSqlParser({ getParserOptions }); - const columnState = EditorState.create({ doc: "SELECT id FROM users" }); - - await customParser.extractColumnReferences("SELECT id FROM users", { state: columnState }); - expect(getParserOptions).toHaveBeenCalledWith(columnState); - }); - }); - - describe("validateSql", () => { - it("should return empty array for valid SQL", async () => { - const sql = "SELECT 1"; - const errors = await parser.validateSql(sql, { state }); - - expect(errors).toHaveLength(0); - }); - - it("should return errors for invalid SQL", async () => { - const sql = "SELECT * FROM WHERE"; - const errors = await parser.validateSql(sql, { state }); - - expect(errors.length).toBeGreaterThan(0); - expect(errors[0]).toHaveProperty("message"); - expect(errors[0]).toHaveProperty("line"); - expect(errors[0]).toHaveProperty("column"); - expect(errors[0]).toHaveProperty("severity"); - }); - }); - - describe("DuckDB dialect support", () => { - it("should accept FROM queries syntax without parsing", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - const sql = "from nyc.rideshare select * limit 100"; - const result = await duckdbParser.parse(sql, { state }); - - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.ast).toBeUndefined(); - }); - - it("should still parse standard SQL with DuckDB dialect", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - const state = EditorState.create({ - doc: "SELECT * FROM users WHERE id = 1", - }); - - const result = await duckdbParser.parse("SELECT * FROM users WHERE id = 1", { state }); - - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it("should treat CREATE OR REPLACE TABLE as a normal SQL query", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - const queries = [ - "CREATE OR REPLACE TABLE users (id INT, name VARCHAR(255))", - "create or replace VIEW v1 AS SELECT 1", - ]; - - for (const sql of queries) { - const state = EditorState.create({ - doc: sql, - }); - const result = await duckdbParser.parse(sql, { state }); - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.ast).toBeDefined(); - } - }); - - it("should handle error offsets correctly when replacing CREATE OR REPLACE TABLE", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - // This SQL has a syntax error at the end - missing closing parenthesis - const sql = "CREATE OR REPLACE TABLE users (id INT, name VARCHAR(255), invalid_syntax"; - - const state = EditorState.create({ - doc: sql, - }); - - const result = await duckdbParser.parse(sql, { state }); - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - - const error = result.errors[0]; - - // The offset should be at the original position of the error - const expectedColumn = sql.length; - expect(error.column).toBe(expectedColumn); - }); - - it("maps the error column back through the CREATE OR REPLACE replacement (catch path)", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - // Lowercase "create or replace table" is matched by indexOf at position 0, - // so a mid-string syntax error ("badclause") gets its column shifted back - // by the length removed during the replacement rather than clamped. - const sql = "create or replace table t (id INT) badclause"; - const state = EditorState.create({ doc: sql }); - - const result = await duckdbParser.parse(sql, { state }); - - expect(result.success).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].line).toBe(1); - // Column points at "badclause" in the original (pre-replacement) SQL - expect(result.errors[0].column).toBe(sql.indexOf("badclause") + 1); - expect(result.errors[0].column).toBeLessThan(sql.length); - }); - - it("should be successful with macro keyword", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - }), - }); - - const queries = ["CREATE macro test1(a) as 1"]; - - for (const sql of queries) { - const state = EditorState.create({ - doc: sql, - }); - const result = await duckdbParser.parse(sql, { state }); - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - } - }); - - it("should quote {} in quotes", async () => { - const duckdbParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "DuckDB", - ignoreBrackets: true, - }), - }); - - const state = EditorState.create({ - doc: "SELECT {id} FROM users WHERE id = {id} and name = {name}", - }); - - const sql = "SELECT {id} FROM users WHERE id = {id} and name = {name}"; - const result = await duckdbParser.parse(sql, { state }); - - expect(result.success).toBe(true); - expect(result.errors).toHaveLength(0); - }); - }); -}); - -const { removeCommentsFromStart, replaceBracketsWithQuotes } = exportedForTesting; - -describe("removeComments", () => { - it("should remove comments from the start of the query", () => { - const sql = "/* comment */ SELECT * FROM users"; - const result = removeCommentsFromStart(sql).trim(); - expect(result).toBe("SELECT * FROM users"); - }); - - it("should remove comments from the start of the query with multiple lines", () => { - const sql = ` - /* comment */ - SELECT * FROM users - `; - const result = removeCommentsFromStart(sql).trim(); - expect(result).toBe("SELECT * FROM users"); - }); - - it("should remove single line comments", () => { - const sql = ` - -- comment - SELECT * FROM users - `; - const result2 = removeCommentsFromStart(sql).trim(); - expect(result2).toBe("SELECT * FROM users"); - }); - - it("should remove comments from the start of the query with multiple lines and single line comments", () => { - const sql = ` - /* comment */ - -- comment - SELECT * FROM users - - `; - const result = removeCommentsFromStart(sql).trim(); - expect(result).toBe("SELECT * FROM users"); - }); -}); - -describe("replaceBracketsWithQuotes", () => { - it("should replace brackets with quotes", () => { - const sql = "SELECT {id} FROM users WHERE id = {id}"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}' FROM users WHERE id = '{id}'"); - expect(result.offsetRecord).toEqual({ 7: 2, 34: 2 }); - }); - - it("should not replace brackets that are already inside quotes", () => { - const sql = "SELECT '{id}' FROM users WHERE id = '{id}'"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}' FROM users WHERE id = '{id}'"); - expect(result.offsetRecord).toEqual({}); - }); - - it("should replace multiple brackets", () => { - const sql = "SELECT {id} FROM users WHERE id = {id} and name = {name}"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}' FROM users WHERE id = '{id}' and name = '{name}'"); - expect(result.offsetRecord).toEqual({ 50: 2, 7: 2, 34: 2 }); - }); - - it("should not replace multiple brackets that are already inside quotes", () => { - const sql = "SELECT '{id}' FROM users WHERE id = '{id}' and name = '{name}'"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}' FROM users WHERE id = '{id}' and name = '{name}'"); - expect(result.offsetRecord).toEqual({}); - }); - - it("should handle multiple brackets in quotes", () => { - const sql = "SELECT '{id} {name}' FROM users WHERE id = '{id} {name}'"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id} {name}' FROM users WHERE id = '{id} {name}'"); - expect(result.offsetRecord).toEqual({}); - }); - - it("should handle mixed quotes", () => { - const sql = "SELECT '{id}' FROM users WHERE id = \"{id}\""; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}' FROM users WHERE id = \"{id}\""); - expect(result.offsetRecord).toEqual({}); - }); - - it.fails("[known-failure: parser-escaped-quotes] should handle escaped quotes", () => { - const sql = "SELECT \\'{id}\\' FROM users WHERE id = \\'{id}\\'"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT \\'{id}\\' FROM users WHERE id = \\'{id}\\'"); - }); - - it("should handle brackets at the beginning of string", () => { - const sql = "{id} FROM users"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("'{id}' FROM users"); - expect(result.offsetRecord).toEqual({ 0: 2 }); - }); - - it("should handle brackets at the end of string", () => { - const sql = "SELECT * FROM users WHERE id = {id}"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT * FROM users WHERE id = '{id}'"); - }); - - it("should handle empty brackets", () => { - const sql = "SELECT {} FROM users"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{}' FROM users"); - }); - - it("should handle brackets with spaces", () => { - const sql = "SELECT { user id } FROM users WHERE id = { user id }"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{ user id }' FROM users WHERE id = '{ user id }'"); - }); - - it("should handle complex nested structures", () => { - const sql = - "SELECT {user.profile.name} FROM users WHERE id = '{user.id}' AND name = \"{user.name}\""; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe( - "SELECT '{user.profile.name}' FROM users WHERE id = '{user.id}' AND name = \"{user.name}\"", - ); - }); - - it("should handle unclosed brackets", () => { - const sql = "SELECT {id FROM users"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT {id FROM users"); - }); - - it("should handle multiple unquoted brackets in sequence", () => { - const sql = "SELECT {id}{name}{email} FROM users"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT '{id}''{name}''{email}' FROM users"); - }); - - it("should handle brackets inside string literals with escaped quotes", () => { - const sql = "SELECT 'user\\'s {id}' FROM users"; - const result = replaceBracketsWithQuotes(sql); - expect(result.sql).toBe("SELECT 'user\\'s {id}' FROM users"); - }); -}); - -describe("error positions with ignoreBrackets", () => { - const bracketParser = new NodeSqlParser({ - getParserOptions: () => ({ - database: "PostgreSQL", - ignoreBrackets: true, - }), - }); - const state = EditorState.create({ doc: "" }); - - it("adjusts the column for errors after a bracket on the same line", async () => { - // Sanitized to "SELECT '{id}' FRO users"; the parser's column 19 maps back to 17 - const result = await bracketParser.parse("SELECT {id} FRO users", { state }); - - expect(result.success).toBe(false); - expect(result.errors[0].line).toBe(1); - expect(result.errors[0].column).toBe(17); - }); - - it("does not shift error columns on lines after a replaced bracket", async () => { - // Line 2 is unmodified, so the parser's column 22 must not shift - const result = await bracketParser.parse("SELECT {id}\nFROM users WHERE xyz abc", { state }); - - expect(result.success).toBe(false); - expect(result.errors[0].line).toBe(2); - expect(result.errors[0].column).toBe(22); - }); - - it("never reports a column below 1", async () => { - const result = await bracketParser.parse("{x}\nFROM WHERE", { state }); - - expect(result.success).toBe(false); - expect(result.errors[0].column).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/src/sql/__tests__/query-context.test.ts b/src/sql/__tests__/query-context.test.ts deleted file mode 100644 index 1857e74..0000000 --- a/src/sql/__tests__/query-context.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it, vi } from "vitest"; -import { NodeSqlParser } from "../parser.js"; -import { - analyzeQueryContext, - maskLiteralsAndComments, - type QueryContext, - QueryContextAnalyzer, - stripIdentifierQuotes, -} from "../query-context.js"; -import type { SqlParser } from "../types.js"; - -const parser = new NodeSqlParser(); - -async function analyze(sql: string): Promise { - return analyzeQueryContext(sql, parser, { state: EditorState.create({ doc: sql }) }); -} - -async function analyzeWith(customParser: SqlParser, sql: string): Promise { - return analyzeQueryContext(sql, customParser, { state: EditorState.create({ doc: sql }) }); -} - -/** Minimal parser stub; only `parse` is exercised by analyzeQueryContext. */ -function makeParser(parse: SqlParser["parse"]): SqlParser { - return { - parse, - validateSql: async () => [], - extractTableReferences: async () => [], - extractColumnReferences: async () => [], - }; -} - -describe("analyzeQueryContext", () => { - it("returns an empty context for empty input", async () => { - const context = await analyze(" "); - expect(context.tables).toEqual([]); - expect(context.ctes).toEqual([]); - expect(context.aliases.size).toBe(0); - expect(context.selectAliases).toEqual([]); - }); - - it("extracts a bare alias (no AS)", async () => { - const context = await analyze("SELECT u.name FROM users u"); - expect(context.tables).toEqual([{ name: "users", path: ["users"], alias: "u" }]); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("extracts an AS alias", async () => { - const context = await analyze("SELECT u.name FROM users AS u"); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("records tables without aliases", async () => { - const context = await analyze("SELECT name FROM users"); - expect(context.tables).toEqual([{ name: "users", path: ["users"] }]); - expect(context.aliases.size).toBe(0); - }); - - it("extracts both aliases from a join", async () => { - const context = await analyze( - "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id", - ); - expect(context.aliases.get("u")).toBe("users"); - expect(context.aliases.get("o")).toBe("orders"); - expect(context.tables).toHaveLength(2); - }); - - it("lets an alias shadow a real table name", async () => { - // `orders` here is an alias for `users`, not the orders table - const context = await analyze("SELECT orders.name FROM users orders"); - expect(context.aliases.get("orders")).toBe("users"); - }); - - it("keeps qualified paths in the alias target", async () => { - const context = await analyze("SELECT u.name FROM mydb.users u"); - expect(context.tables).toEqual([{ name: "users", path: ["mydb", "users"], alias: "u" }]); - expect(context.aliases.get("u")).toBe("mydb.users"); - }); - - it("extracts aliases from subqueries", async () => { - const context = await analyze( - "SELECT * FROM (SELECT u.id FROM users u) sub WHERE sub.id > 1", - ); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("collects select-list aliases", async () => { - const context = await analyze("SELECT count(*) AS total, name AS n FROM users"); - expect(context.selectAliases).toEqual(["total", "n"]); - }); - - describe("CTEs", () => { - it("extracts a CTE with inferred columns and its alias", async () => { - const sql = "WITH recent AS (SELECT x, y FROM logs) SELECT r.x FROM recent r"; - const context = await analyze(sql); - - expect(context.ctes).toHaveLength(1); - const cte = context.ctes[0]; - expect(cte?.name).toBe("recent"); - expect(cte?.columns).toEqual(["x", "y"]); - expect(cte?.from).toBe(sql.indexOf("recent")); - expect(cte?.to).toBe(sql.indexOf("recent") + "recent".length); - - expect(context.aliases.get("r")).toBe("recent"); - // The CTE body's table is still visible - expect(context.tables.some((t) => t.name === "logs")).toBe(true); - }); - - it("prefers declared CTE column lists", async () => { - const context = await analyze( - "WITH recent (a, b) AS (SELECT x, y FROM logs) SELECT * FROM recent", - ); - expect(context.ctes[0]?.columns).toEqual(["a", "b"]); - }); - - it("uses select aliases as CTE output columns", async () => { - const context = await analyze( - "WITH stats AS (SELECT count(*) AS n FROM logs) SELECT * FROM stats", - ); - expect(context.ctes[0]?.columns).toEqual(["n"]); - }); - - it("extracts multiple CTEs", async () => { - const context = await analyze( - "WITH a AS (SELECT id FROM users), b AS (SELECT id FROM orders) SELECT * FROM a JOIN b ON a.id = b.id", - ); - expect(context.ctes.map((c) => c.name)).toEqual(["a", "b"]); - }); - }); - - describe("regex fallback for unparsable statements", () => { - it("still resolves aliases mid-edit", async () => { - // Trailing dot makes this unparsable - const context = await analyze("SELECT u. FROM users u"); - expect(context.aliases.get("u")).toBe("users"); - expect(context.tables).toEqual([{ name: "users", path: ["users"], alias: "u" }]); - }); - - it("does not treat keywords after a table as aliases", async () => { - const context = await analyze("SELECT u.name, FROM users u JOIN orders WHERE x = 1"); - expect(context.aliases.get("u")).toBe("users"); - expect(context.aliases.has("where")).toBe(false); - expect(context.tables.some((t) => t.name === "orders" && t.alias)).toBe(false); - }); - - it("still records a joined table when the previous table has no alias", async () => { - const context = await analyze("SELECT FROM users JOIN orders o ON "); - expect(context.tables.map((t) => t.name)).toEqual(["users", "orders"]); - expect(context.aliases.get("o")).toBe("orders"); - }); - - it("ignores FROM/JOIN inside string literals", async () => { - const context = await analyze("SELECT u. FROM users u WHERE name = 'from phantom p'"); - expect(context.tables.map((t) => t.name)).toEqual(["users"]); - expect(context.aliases.has("p")).toBe(false); - }); - - it("ignores FROM/JOIN inside comments", async () => { - const context = await analyze( - "SELECT u. -- join phantom ph\n/* from ghost g */ FROM users u", - ); - expect(context.tables.map((t) => t.name)).toEqual(["users"]); - expect(context.aliases.has("ph")).toBe(false); - expect(context.aliases.has("g")).toBe(false); - }); - - it("does not let a quote inside a quoted identifier start a string", async () => { - const context = await analyze(`SELECT x. FROM "it's" x`); - expect(context.aliases.get("x")).toBe("it's"); - }); - - it("handles AS aliases and qualified paths", async () => { - const context = await analyze("SELECT FROM mydb.users AS u WHERE u."); - expect(context.aliases.get("u")).toBe("mydb.users"); - }); - - it("strips quotes from quoted identifiers but resolves the alias", async () => { - const context = await analyze('SELECT ut. FROM "User Table" ut'); - expect(context.aliases.get("ut")).toBe("User Table"); - expect(context.tables[0]?.name).toBe("User Table"); - }); - - it("extracts CTEs with declared columns", async () => { - const sql = "WITH recent (a, b) AS (SELECT x FROM logs WHERE ) SELECT r. FROM recent r"; - const context = await analyze(sql); - expect(context.ctes[0]?.name).toBe("recent"); - expect(context.ctes[0]?.columns).toEqual(["a", "b"]); - expect(context.ctes[0]?.from).toBe(sql.indexOf("recent")); - expect(context.aliases.get("r")).toBe("recent"); - }); - }); -}); - -describe("QueryContextAnalyzer", () => { - it("caches by statement text", async () => { - const spy = vi.spyOn(parser, "parse"); - const analyzer = new QueryContextAnalyzer(parser); - const sql = "SELECT u.name FROM users u"; - const state = EditorState.create({ doc: sql }); - - const first = await analyzer.getContext(sql, { state }); - const second = await analyzer.getContext(sql, { state }); - expect(second).toBe(first); - expect(spy).toHaveBeenCalledTimes(1); - spy.mockRestore(); - }); - - it("clears its cache", async () => { - const analyzer = new QueryContextAnalyzer(parser); - const sql = "SELECT u.name FROM users u"; - const state = EditorState.create({ doc: sql }); - - const first = await analyzer.getContext(sql, { state }); - analyzer.clearCache(); - const second = await analyzer.getContext(sql, { state }); - expect(second).not.toBe(first); - expect(second).toEqual(first); - }); -}); - -describe("analyzeQueryContext parser fallbacks", () => { - it("falls back to the regex path when the parser throws", async () => { - const throwing = makeParser(async () => { - throw new Error("boom"); - }); - const context = await analyzeWith(throwing, "SELECT id FROM users u"); - expect(context.aliases.get("u")).toBe("users"); - expect(context.tables).toEqual([{ name: "users", path: ["users"], alias: "u" }]); - }); - - it("falls back to the regex path when parsing reports failure", async () => { - const failing = makeParser(async () => ({ success: false, errors: [] })); - const context = await analyzeWith(failing, "SELECT id FROM users u"); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("falls back to the regex path when the parser returns a null ast", async () => { - const nullAst = makeParser(async () => ({ success: true, errors: [], ast: null })); - const context = await analyzeWith(nullAst, "SELECT id FROM users u"); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("falls back to the regex path when the AST walk throws", async () => { - // An ast whose property access throws forces the post-parse try/catch to - // fall back to the regex scan. - const exploding: Record = {}; - Object.defineProperty(exploding, "type", { - enumerable: true, - get() { - throw new Error("kaboom"); - }, - }); - const badAst = makeParser(async () => ({ success: true, errors: [], ast: exploding })); - const context = await analyzeWith(badAst, "SELECT id FROM users u"); - // Regex fallback still resolves the alias - expect(context.aliases.get("u")).toBe("users"); - }); -}); - -describe("analyzeQueryContext multiple statements", () => { - it("collects tables from statements split by semicolons", async () => { - const context = await analyze("SELECT a FROM t1; SELECT b FROM t2"); - const names = context.tables.map((t) => t.name).sort(); - expect(names).toEqual(["t1", "t2"]); - }); - - it("collects select aliases across multiple statements", async () => { - const context = await analyze("SELECT a AS x FROM t1; SELECT b AS y FROM t2"); - expect(context.selectAliases).toEqual(["x", "y"]); - }); -}); - -describe("analyzeQueryContext CTE column inference", () => { - it("ignores SELECT * when inferring CTE columns from the AST", async () => { - const context = await analyze("WITH c AS (SELECT * FROM logs) SELECT * FROM c"); - expect(context.ctes[0]?.name).toBe("c"); - expect(context.ctes[0]?.columns).toEqual([]); - }); - - it("infers columns from the select body in the regex fallback, skipping stars", async () => { - // Trailing `c.` keeps the statement unparsable, forcing the regex fallback. - const sql = - "WITH c AS (SELECT a AS x, foo(b, c) AS f, t.*, plain FROM logs WHERE ) SELECT c. FROM c"; - const context = await analyzeWith(parser, sql); - expect(context.ctes[0]?.name).toBe("c"); - expect(context.ctes[0]?.columns).toEqual(["x", "f", "plain"]); - }); -}); - -describe("analyzeQueryContext literal and comment masking", () => { - it("does not read FROM inside an escaped single-quoted literal as a table", async () => { - const context = await analyze("SELECT x. FROM users u WHERE n = 'it''s from ghost g'"); - expect(context.tables.map((t) => t.name)).toEqual(["users"]); - expect(context.aliases.has("g")).toBe(false); - expect(context.aliases.get("u")).toBe("users"); - }); - - it("resolves an alias for a bracket-quoted table name via the regex fallback", async () => { - const context = await analyze("SELECT x. FROM [my table] x"); - expect(context.aliases.get("x")).toBe("my table"); - expect(context.tables[0]?.name).toBe("my table"); - }); -}); - -describe("maskLiteralsAndComments", () => { - it("blanks single-line comments while preserving offsets", () => { - const input = "SELECT 1 -- from ghost\nFROM t"; - const masked = maskLiteralsAndComments(input); - expect(masked).toHaveLength(input.length); - expect(masked).not.toContain("ghost"); - expect(masked.endsWith("FROM t")).toBe(true); - }); - - it("blanks block comments while preserving offsets", () => { - const input = "SELECT /* from ghost */ 1 FROM t"; - const masked = maskLiteralsAndComments(input); - expect(masked).toHaveLength(input.length); - expect(masked).not.toContain("ghost"); - }); - - it("blanks single-quoted string literals including escaped quotes", () => { - const input = "SELECT 'it''s a from' FROM t"; - const masked = maskLiteralsAndComments(input); - expect(masked).toHaveLength(input.length); - expect(masked).not.toContain("from'"); - expect(masked.includes("FROM t")).toBe(true); - }); - - it("leaves quoted, backtick, and bracket identifiers intact", () => { - const input = `SELECT "a'b", \`c'd\`, [e'f] FROM t`; - const masked = maskLiteralsAndComments(input); - expect(masked).toBe(input); - }); -}); - -describe("stripIdentifierQuotes", () => { - it.each([ - ['"User Table"', "User Table"], - ["`users`", "users"], - ["[users]", "users"], - ["'users'", "users"], - ["users", "users"], - ['"', '"'], - ])("strips %s to %s", (input, expected) => { - expect(stripIdentifierQuotes(input)).toBe(expected); - }); -}); diff --git a/src/sql/__tests__/references.test.ts b/src/sql/__tests__/references.test.ts deleted file mode 100644 index 4c053e7..0000000 --- a/src/sql/__tests__/references.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { findReferences, type SqlReferenceResult } from "../references.js"; - -/** - * Resolves references at the position of `marker` in `sql` (the marker is the - * substring whose first character the cursor is placed on; an occurrence - * index selects which match). - */ -async function resolveAt( - sql: string, - marker: string, - occurrence = 0, -): Promise { - let pos = -1; - for (let i = 0; i <= occurrence; i++) { - pos = sql.indexOf(marker, pos + 1); - } - if (pos < 0) { - throw new Error(`marker ${marker} (occurrence ${occurrence}) not found`); - } - const state = EditorState.create({ doc: sql }); - return findReferences(state, pos); -} - -function texts(sql: string, result: SqlReferenceResult): string[] { - return result.references.map((range) => sql.slice(range.from, range.to)); -} - -describe("findReferences", () => { - describe("CTE names", () => { - const sql = "WITH recent AS (SELECT x FROM logs) SELECT r.x FROM recent r JOIN recent r2 ON 1=1"; - - it("resolves a CTE use to its definition", async () => { - const result = await resolveAt(sql, "recent", 1); - expect(result).toBeTruthy(); - expect(result?.kind).toBe("cte"); - expect(result?.name).toBe("recent"); - expect(result?.definition).toEqual({ from: 5, to: 5 + "recent".length }); - expect(texts(sql, result!)).toEqual(["recent", "recent", "recent"]); - expect(result?.references).toHaveLength(3); - }); - - it("resolves the definition to all uses", async () => { - const result = await resolveAt(sql, "recent", 0); - expect(result?.references).toHaveLength(3); - expect(result?.references[0]).toEqual(result?.definition); - }); - - it("resolves CTE uses in nested-paren bodies", async () => { - const doc = - "WITH a AS (SELECT max(x) FROM (SELECT 1) s), b AS (SELECT * FROM a) SELECT * FROM b"; - const result = await resolveAt(doc, "a AS"); - expect(result?.kind).toBe("cte"); - // definition + use inside b's body - expect(result?.references).toHaveLength(2); - }); - - it("does not include qualified same-named columns", async () => { - const doc = "WITH t AS (SELECT 1) SELECT x.t FROM t, other x"; - const result = await resolveAt(doc, "t AS"); - // definition + `FROM t`, but not `x.t` - expect(result?.references).toHaveLength(2); - const refTexts = result?.references.map((r) => doc.slice(r.from - 1, r.to)); - expect(refTexts?.some((t) => t.startsWith("."))).toBe(false); - }); - - it("ignores occurrences inside string literals", async () => { - const doc = "WITH t AS (SELECT 1) SELECT * FROM t WHERE name = 't'"; - const result = await resolveAt(doc, "t AS"); - expect(result?.references).toHaveLength(2); - }); - - it("returns null when the cursor is on an occurrence inside a string literal", async () => { - const doc = "WITH tbl AS (SELECT 1) SELECT * FROM tbl WHERE name = 'tbl'"; - const result = await resolveAt(doc, "tbl", 2); - expect(result).toBeNull(); - }); - - it("resolves quoted CTE declarations from bare uses", async () => { - const doc = 'WITH "recent" AS (SELECT 1) SELECT * FROM recent'; - const result = await resolveAt(doc, "recent", 1); - expect(result?.kind).toBe("cte"); - expect(doc.slice(result!.definition.from, result!.definition.to)).toBe('"recent"'); - expect(result?.references).toHaveLength(2); - }); - - it("resolves from a cursor inside a quoted declaration", async () => { - const doc = 'WITH "recent" AS (SELECT 1) SELECT * FROM recent'; - const state = EditorState.create({ doc }); - const result = await findReferences(state, doc.indexOf("recent")); - expect(result?.kind).toBe("cte"); - expect(result?.references).toHaveLength(2); - }); - - it("does not treat a bare same-named select-list identifier as a reference", async () => { - const doc = "WITH t AS (SELECT 1) SELECT t FROM t"; - const result = await resolveAt(doc, "t AS"); - // definition + `FROM t`; the select-list `t` is a column reference - expect(result?.references).toHaveLength(2); - expect(result?.references.some((r) => r.from === doc.indexOf("t FROM"))).toBe(false); - }); - - it("counts FROM-list comma entries as references", async () => { - const doc = "WITH t AS (SELECT 1) SELECT * FROM a, t"; - const result = await resolveAt(doc, "t AS"); - expect(result?.references).toHaveLength(2); - }); - - it("refuses when a CTE name collides with a select alias", async () => { - const doc = "WITH t AS (SELECT 1) SELECT 1 AS t FROM t ORDER BY t"; - const result = await resolveAt(doc, "t AS ("); - expect(result).toBeNull(); - }); - - it("refuses when a CTE name is also a table alias in the same statement", async () => { - // `x` is a CTE name and also an alias bound to `users` — two distinct - // bindings, so it refuses rather than mixing them - const doc = "WITH x AS (SELECT 1) SELECT * FROM users x"; - const result = await resolveAt(doc, "x AS"); - expect(result).toBeNull(); - }); - - it("does not count a comma-separated select-list entry as a relation use", async () => { - // The `t` after `SELECT a, ` is a column, not a FROM-list relation - const doc = "WITH t AS (SELECT 1) SELECT a, t FROM t"; - const result = await resolveAt(doc, "t AS"); - // definition + `FROM t` only - expect(result?.references).toHaveLength(2); - expect(result?.references.some((r) => r.from === doc.indexOf("t FROM"))).toBe(false); - }); - }); - - describe("table aliases", () => { - it("resolves an alias qualifier to its definition", async () => { - const sql = "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id"; - const result = await resolveAt(sql, "u.id"); - expect(result?.kind).toBe("table-alias"); - expect(result?.name).toBe("u"); - expect(sql.slice(result!.definition.from, result!.definition.to)).toBe("u"); - expect(result?.definition.from).toBe(sql.indexOf("users u") + "users ".length); - // definition + u.name + u.id - expect(result?.references).toHaveLength(3); - }); - - it("resolves the alias definition token to its uses", async () => { - const sql = "SELECT u.name FROM users u WHERE u.active"; - const result = await resolveAt(sql, "u WHERE"); - expect(result?.kind).toBe("table-alias"); - expect(result?.references).toHaveLength(3); - }); - - it("resolves AS aliases of qualified tables", async () => { - const sql = "SELECT u.name FROM mydb.users AS u"; - const result = await resolveAt(sql, "u.name"); - expect(result?.kind).toBe("table-alias"); - expect(result?.definition.from).toBe(sql.indexOf("AS u") + "AS ".length); - }); - - it("refuses when an alias collides with a select alias", async () => { - const sql = "SELECT u.name AS u FROM users u ORDER BY u"; - const result = await resolveAt(sql, "u.name"); - expect(result).toBeNull(); - }); - - it("refuses when the same alias is bound to two tables", async () => { - const sql = - "SELECT * FROM (SELECT x.a FROM t1 x) s1, (SELECT x.b FROM t2 x) s2 WHERE 1 = 1"; - const result = await resolveAt(sql, "x.a"); - expect(result).toBeNull(); - }); - - it("resolves an alias whose table has a quoted qualifier", async () => { - const sql = 'SELECT u.name FROM "mydb".users AS u'; - const result = await resolveAt(sql, "u.name"); - expect(result?.kind).toBe("table-alias"); - expect(result?.name).toBe("u"); - expect(result?.definition.from).toBe(sql.indexOf("AS u") + "AS ".length); - // definition + u.name - expect(result?.references).toHaveLength(2); - }); - - it("returns null when the cursor is on a bare (non-qualifier) alias name", async () => { - // The select-list `u` is not a `u.column` qualifier use, so the cursor - // there does not resolve to the alias - const sql = "SELECT u FROM users u"; - const result = await resolveAt(sql, "u FROM"); - expect(result).toBeNull(); - }); - }); - - describe("select aliases", () => { - it("resolves a select alias referenced in ORDER BY", async () => { - const sql = "SELECT count(*) AS total FROM users GROUP BY name ORDER BY total"; - const result = await resolveAt(sql, "total", 1); - expect(result?.kind).toBe("select-alias"); - expect(result?.definition.from).toBe(sql.indexOf("total")); - expect(result?.references).toHaveLength(2); - }); - - it("resolves a select alias referenced in GROUP BY and HAVING", async () => { - const sql = "SELECT name AS n FROM users GROUP BY n HAVING n <> 'x' ORDER BY n"; - const result = await resolveAt(sql, "n FROM"); - expect(result?.kind).toBe("select-alias"); - // definition + GROUP BY + HAVING + ORDER BY - expect(result?.references).toHaveLength(4); - }); - - it("does not treat a same-named column in WHERE as a reference", async () => { - const sql = "SELECT x AS total FROM t WHERE total > 5 ORDER BY total"; - const result = await resolveAt(sql, "total", 0); - // definition + ORDER BY only; WHERE resolves to a column in SQL - expect(result?.references).toHaveLength(2); - expect(result?.references.some((r) => r.from === sql.indexOf("total > 5"))).toBe(false); - }); - - it("returns null when the cursor is on a same-named column in WHERE", async () => { - // The WHERE occurrence is a column, not the select alias, so the cursor - // there is not a resolvable reference - const sql = "SELECT x AS total FROM t WHERE total > 5 ORDER BY total"; - const result = await resolveAt(sql, "total", 1); - expect(result).toBeNull(); - }); - - it("resolves a select alias in ORDER BY when there is no FROM clause", async () => { - const sql = "SELECT 1 AS total ORDER BY total"; - const result = await resolveAt(sql, "total", 1); - expect(result?.kind).toBe("select-alias"); - expect(result?.references).toHaveLength(2); - }); - - it("returns null for an alias declared without the AS keyword", async () => { - // The definition is only located via `AS `; without AS there is - // no confidently-resolvable declaration token - const sql = "SELECT count(*) total FROM users GROUP BY total"; - const result = await resolveAt(sql, "total", 1); - expect(result).toBeNull(); - }); - }); - - describe("scoping and refusal", () => { - it("does not cross statement boundaries", async () => { - const sql = - "WITH t AS (SELECT 1) SELECT * FROM t;\nWITH t AS (SELECT 2) SELECT * FROM t"; - const secondUse = sql.lastIndexOf("t"); - const state = EditorState.create({ doc: sql }); - const result = await findReferences(state, secondUse); - expect(result).toBeTruthy(); - const boundary = sql.indexOf(";"); - for (const range of result!.references) { - expect(range.from).toBeGreaterThan(boundary); - } - }); - - it("returns null for plain table names", async () => { - const result = await resolveAt("SELECT name FROM users", "users"); - expect(result).toBeNull(); - }); - - it("returns null for plain column names", async () => { - const result = await resolveAt("SELECT name FROM users", "name"); - expect(result).toBeNull(); - }); - - it("returns null on whitespace", async () => { - const state = EditorState.create({ doc: "SELECT 1 " }); - expect(await findReferences(state, 9)).toBeNull(); - }); - - it("resolves mid-edit statements via the regex fallback", async () => { - const sql = "WITH recent AS (SELECT x FROM logs) SELECT r. FROM recent r"; - const result = await resolveAt(sql, "recent", 1); - expect(result?.kind).toBe("cte"); - expect(result?.references).toHaveLength(2); - }); - - it("returns null for a token trailing after the final statement", async () => { - // `zzz` sits past the terminating `;`, outside the (preceding) statement - const sql = "WITH t AS (SELECT 1) SELECT * FROM t; zzz"; - const result = await resolveAt(sql, "zzz"); - expect(result).toBeNull(); - }); - - it("returns null on an empty document", async () => { - const state = EditorState.create({ doc: "" }); - expect(await findReferences(state, 0)).toBeNull(); - }); - - it("accepts an explicit config object", async () => { - const sql = "WITH recent AS (SELECT 1) SELECT * FROM recent"; - const state = EditorState.create({ doc: sql }); - const result = await findReferences(state, sql.indexOf("recent"), {}); - expect(result?.kind).toBe("cte"); - expect(result?.references).toHaveLength(2); - }); - }); -}); diff --git a/src/sql/__tests__/semantic-diagnostics.test.ts b/src/sql/__tests__/semantic-diagnostics.test.ts deleted file mode 100644 index 82a796e..0000000 --- a/src/sql/__tests__/semantic-diagnostics.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { type Diagnostic, forceLinting, forEachDiagnostic } from "@codemirror/lint"; -import { EditorState, type Extension } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { describe, expect, it, vi } from "vitest"; -import { sqlSchemaFacet } from "../schema-facet.js"; -import { - exportedForTesting, - type SqlSemanticLinterConfig, - sqlSemanticLinter, -} from "../semantic-diagnostics.js"; -import type { SqlParser } from "../types.js"; - -const { createSemanticLintSource } = exportedForTesting; - -const SCHEMA = { - users: ["id", "name", "email"], - posts: ["id", "user_id", "title"], -}; - -const createMockView = (content: string, extensions: Extension[] = []) => { - return { - state: EditorState.create({ doc: content, extensions }), - } as EditorView; -}; - -const lint = (content: string, config: SqlSemanticLinterConfig = {}, extensions: Extension[] = []) => { - return createSemanticLintSource(config)(createMockView(content, extensions)); -}; - -describe("sqlSemanticLinter", () => { - it("should create a linter extension", () => { - expect(sqlSemanticLinter()).toBeDefined(); - expect(sqlSemanticLinter({ schema: SCHEMA, delay: 100 })).toBeDefined(); - }); - - it("produces diagnostics through the installed extension", async () => { - const view = new EditorView({ - doc: "SELECT * FROM usres", - extensions: [ - sqlSemanticLinter({ - schema: SCHEMA, - delay: 0, - severity: { unknownTable: "error" }, - }), - ], - parent: document.body, - }); - - try { - forceLinting(view); - const diagnostics = await vi.waitFor(() => { - const found: Diagnostic[] = []; - forEachDiagnostic(view.state, (d) => found.push(d)); - expect(found).toHaveLength(1); - return found; - }); - - expect(diagnostics[0].message).toContain("usres"); - expect(diagnostics[0].source).toBe("sql-schema"); - // Option forwarding: severity override applied - expect(diagnostics[0].severity).toBe("error"); - } finally { - view.destroy(); - } - }); -}); - -describe("unknown tables", () => { - it("flags a table missing from the schema", async () => { - const doc = "SELECT * FROM usres"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - expect(diagnostics[0].severity).toBe("warning"); - expect(diagnostics[0].source).toBe("sql-schema"); - // Positioned on the identifier itself - expect(doc.slice(diagnostics[0].from, diagnostics[0].to)).toBe("usres"); - }); - - it("does not flag known tables", async () => { - expect(await lint("SELECT * FROM users", { schema: SCHEMA })).toEqual([]); - }); - - it("is case-insensitive", async () => { - expect(await lint("SELECT * FROM USERS", { schema: SCHEMA })).toEqual([]); - }); - - it("resolves qualified names through nested namespaces", async () => { - const nested = { mydb: { users: ["id", "name"] } }; - expect(await lint("SELECT * FROM mydb.users", { schema: nested })).toEqual([]); - - const diagnostics = await lint("SELECT * FROM mydb.usres", { schema: nested }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("mydb.usres"); - }); - - it("does not flag unqualified references to tables nested deeper in the schema", async () => { - const nested = { mydb: { users: ["id", "name"] } }; - expect(await lint("SELECT * FROM users", { schema: nested })).toEqual([]); - }); - - it("does not flag CTE names", async () => { - const doc = "WITH t AS (SELECT id FROM users) SELECT * FROM t"; - expect(await lint(doc, { schema: SCHEMA })).toEqual([]); - }); - - it("flags unknown tables inside CTE bodies", async () => { - const doc = "WITH t AS (SELECT id FROM usres) SELECT * FROM t"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); - - it("does not flag CREATE TABLE targets but flags their source tables", async () => { - expect(await lint("CREATE TABLE newtbl (id int)", { schema: SCHEMA })).toEqual([]); - - const diagnostics = await lint("CREATE TABLE newtbl AS SELECT * FROM usres", { - schema: SCHEMA, - }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); - - it("flags unknown DML targets", async () => { - expect( - await lint("INSERT INTO usres (id) VALUES (1)", { schema: SCHEMA }), - ).toHaveLength(1); - expect(await lint("UPDATE usres SET id = 1", { schema: SCHEMA })).toHaveLength(1); - expect(await lint("DELETE FROM usres WHERE id = 1", { schema: SCHEMA })).toHaveLength(1); - expect(await lint("INSERT INTO users (id) VALUES (1)", { schema: SCHEMA })).toEqual([]); - }); - - it("flags unknown tables in subqueries", async () => { - const diagnostics = await lint("SELECT * FROM users WHERE id IN (SELECT user_id FROM psts)", { - schema: SCHEMA, - }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("psts"); - }); - - it("flags the source table of an INSERT ... SELECT", async () => { - const diagnostics = await lint("INSERT INTO users SELECT * FROM usres", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); - - it("flags unknown REPLACE targets", async () => { - const diagnostics = await lint("REPLACE INTO usres (id) VALUES (1)", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); - - it("flags over-qualified table references not present at that depth", async () => { - // `users` exists at the top level, but `wrongschema.users` requires a - // deeper match that the schema does not provide - const diagnostics = await lint("SELECT * FROM wrongschema.users", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("wrongschema.users"); - }); - - it("checks nested selects reached through non-standard statements (EXPLAIN)", async () => { - const diagnostics = await lint("EXPLAIN SELECT * FROM usres", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); -}); - -describe("unknown columns", () => { - it("flags a column missing from the referenced table", async () => { - const doc = "SELECT nme FROM users"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - expect(diagnostics[0].message).toContain("users"); - expect(doc.slice(diagnostics[0].from, diagnostics[0].to)).toBe("nme"); - }); - - it("does not flag known columns, *, or case variants", async () => { - expect(await lint("SELECT id, NAME, * FROM users", { schema: SCHEMA })).toEqual([]); - }); - - it("resolves alias-qualified references", async () => { - expect(await lint("SELECT u.name FROM users u", { schema: SCHEMA })).toEqual([]); - - const diagnostics = await lint("SELECT u.nme FROM users u", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - }); - - it("checks table-qualified references across joins", async () => { - const doc = "SELECT users.name, posts.title FROM users JOIN posts ON posts.user_id = users.id"; - expect(await lint(doc, { schema: SCHEMA })).toEqual([]); - - const bad = "SELECT users.nme FROM users JOIN posts ON posts.user_id = users.id"; - expect(await lint(bad, { schema: SCHEMA })).toHaveLength(1); - }); - - it("skips qualifiers that do not resolve to a scope source", async () => { - // `x` might be an outer alias or something we can't resolve — never guess - expect(await lint("SELECT x.nme FROM users u", { schema: SCHEMA })).toEqual([]); - }); - - it("skips unqualified columns when multiple tables are referenced", async () => { - // `title` only exists in posts, but we only check unqualified columns - // against single-table statements - expect(await lint("SELECT missing_col FROM users, posts", { schema: SCHEMA })).toEqual([]); - }); - - it("is CTE and alias aware", async () => { - expect( - await lint("WITH t AS (SELECT 1 AS x) SELECT x FROM t", { schema: SCHEMA }), - ).toEqual([]); - }); - - it("skips SELECT-list aliases referenced in ORDER BY / GROUP BY", async () => { - const doc = "SELECT name AS display_name FROM users ORDER BY display_name"; - expect(await lint(doc, { schema: SCHEMA })).toEqual([]); - }); - - it("checks columns inside FROM subqueries", async () => { - const diagnostics = await lint("SELECT * FROM (SELECT nme FROM users) sub", { - schema: SCHEMA, - }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - }); - - it("checks columns inside an unaliased FROM subquery", async () => { - const diagnostics = await lint("SELECT * FROM (SELECT nme FROM users)", { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - }); - - it("checks fully qualified (db.table.column) column references", async () => { - const nested = { mydb: { users: ["id", "name"] } }; - expect(await lint("SELECT mydb.users.name FROM mydb.users", { schema: nested })).toEqual([]); - - const diagnostics = await lint("SELECT mydb.users.nme FROM mydb.users", { schema: nested }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - expect(diagnostics[0].message).toContain("mydb.users"); - }); - - it("does not flag unqualified columns when a table is ambiguous across schemas", async () => { - // `users` matches two nested tables, so its column list is not trusted and - // the unknown-column check is skipped - const schema = { a: { users: ["id"] }, b: { users: ["id"] } }; - expect(await lint("SELECT nme FROM users", { schema })).toEqual([]); - }); - - it("skips unqualified columns in correlated subqueries", async () => { - // `email` is not in posts, but unqualified names in a correlated subquery - // may resolve to the outer scope, so it must not be flagged - const doc = "SELECT * FROM users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = id)"; - expect(await lint(doc, { schema: SCHEMA })).toEqual([]); - }); - - it("flags unknown columns in UPDATE statements", async () => { - const diagnostics = await lint("UPDATE users SET nme = 'x' WHERE id = 1", { - schema: SCHEMA, - }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("nme"); - }); - - it("checks columns referenced inside function calls", async () => { - expect(await lint("SELECT count(*) FROM users", { schema: SCHEMA })).toEqual([]); - expect(await lint("SELECT count(nme) FROM users", { schema: SCHEMA })).toHaveLength(1); - }); - - it("supports self/children namespaces", async () => { - const schema = { - users: { self: { label: "users" }, children: ["id", "name"] }, - }; - expect(await lint("SELECT name FROM users", { schema })).toEqual([]); - expect(await lint("SELECT nme FROM users", { schema })).toHaveLength(1); - }); - - it("positions diagnostics correctly with inline comments", async () => { - const doc = "SELECT -- comment\n nme\nFROM users"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(doc.slice(diagnostics[0].from, diagnostics[0].to)).toBe("nme"); - }); - - it("supports Completion objects as columns", async () => { - const schema = { - users: [{ label: "id", detail: "int" }, { label: "name", detail: "text" }], - }; - expect(await lint("SELECT name FROM users", { schema })).toEqual([]); - expect(await lint("SELECT nme FROM users", { schema })).toHaveLength(1); - }); -}); - -describe("ambiguous columns", () => { - it("flags an unqualified column that exists in multiple referenced tables", async () => { - const doc = "SELECT id FROM users, posts"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("ambiguous"); - expect(diagnostics[0].message).toContain("users"); - expect(diagnostics[0].message).toContain("posts"); - expect(doc.slice(diagnostics[0].from, diagnostics[0].to)).toBe("id"); - }); - - it("does not flag qualified references or single-table columns", async () => { - expect( - await lint("SELECT users.id, title FROM users JOIN posts ON posts.user_id = users.id", { - schema: SCHEMA, - }), - ).toEqual([]); - }); - - it("does not flag columns joined with USING", async () => { - expect(await lint("SELECT id FROM users JOIN posts USING (id)", { schema: SCHEMA })).toEqual( - [], - ); - }); -}); - -describe("inertness and gating", () => { - it("returns no diagnostics and never parses when no schema is configured", async () => { - const parser = { - parse: vi.fn(), - validateSql: vi.fn(), - extractTableReferences: vi.fn(), - extractColumnReferences: vi.fn(), - } as unknown as SqlParser; - - expect(await lint("SELECT * FROM usres", { parser })).toEqual([]); - expect(parser.parse).not.toHaveBeenCalled(); - }); - - it("treats an empty schema as no schema (lazy loading upstream)", async () => { - expect(await lint("SELECT * FROM usres", { schema: {} })).toEqual([]); - expect(await lint("SELECT * FROM usres", { schema: [] })).toEqual([]); - }); - - it("returns no diagnostics for empty documents", async () => { - expect(await lint("", { schema: SCHEMA })).toEqual([]); - expect(await lint(" \n ", { schema: SCHEMA })).toEqual([]); - }); - - it("skips statements with syntax errors", async () => { - // Broken statement: no semantic noise on top of the syntax error; - // the valid statement is still checked - const doc = "SELCT * FROM tbl_broken;\nSELECT * FROM tbl_missing;"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].source).toBe("sql-schema"); - expect(diagnostics[0].message).toContain("tbl_missing"); - expect(doc.slice(diagnostics[0].from, diagnostics[0].to)).toBe("tbl_missing"); - }); - - it("reads the schema from sqlSchemaFacet when not configured directly", async () => { - const diagnostics = await lint("SELECT * FROM usres", {}, [sqlSchemaFacet.of(SCHEMA)]); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toContain("usres"); - }); - - it("prefers the explicit schema over the facet", async () => { - const diagnostics = await lint("SELECT * FROM other_table", { schema: SCHEMA }, [ - sqlSchemaFacet.of({ other_table: ["id"] }), - ]); - expect(diagnostics).toHaveLength(1); - }); - - it("supports function schema sources", async () => { - const schema = vi.fn(() => SCHEMA); - const diagnostics = await lint("SELECT * FROM usres", { schema }); - expect(diagnostics).toHaveLength(1); - expect(schema).toHaveBeenCalled(); - }); -}); - -describe("severity configuration", () => { - it("supports severity overrides", async () => { - const diagnostics = await lint("SELECT * FROM usres", { - schema: SCHEMA, - severity: { unknownTable: "error" }, - }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].severity).toBe("error"); - }); - - it("supports turning checks off", async () => { - expect( - await lint("SELECT * FROM usres", { - schema: SCHEMA, - severity: { unknownTable: "off" }, - }), - ).toEqual([]); - - expect( - await lint("SELECT id FROM users, posts", { - schema: SCHEMA, - severity: { ambiguousColumn: "off" }, - }), - ).toEqual([]); - }); - - it("turns the unknown-column check off independently", async () => { - expect( - await lint("SELECT nme FROM users", { - schema: SCHEMA, - severity: { unknownColumn: "off" }, - }), - ).toEqual([]); - }); - - it("applies distinct severities per check kind", async () => { - const doc = "SELECT nme FROM usres"; - const diagnostics = await lint(doc, { - schema: SCHEMA, - severity: { unknownTable: "error", unknownColumn: "warning" }, - }); - // Only the unknown table is reported: `nme` cannot be checked because its - // single source table does not resolve - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].severity).toBe("error"); - expect(diagnostics[0].message).toContain("usres"); - }); -}); - -describe("multiple statements", () => { - it("reports findings per statement with document-relative positions", async () => { - const doc = "SELECT * FROM users;\nSELECT nme FROM users;\nSELECT * FROM usres;"; - const diagnostics = await lint(doc, { schema: SCHEMA }); - - expect(diagnostics).toHaveLength(2); - const spans = diagnostics.map((d) => doc.slice(d.from, d.to)).sort(); - expect(spans).toEqual(["nme", "usres"]); - }); -}); diff --git a/src/sql/__tests__/structure-analyzer.test.ts b/src/sql/__tests__/structure-analyzer.test.ts deleted file mode 100644 index 3e17363..0000000 --- a/src/sql/__tests__/structure-analyzer.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { beforeEach, describe, expect, it } from "vitest"; -import { NodeSqlParser } from "../parser.js"; -import { SqlStructureAnalyzer } from "../structure-analyzer.js"; - -describe("SqlStructureAnalyzer", () => { - let analyzer: SqlStructureAnalyzer; - let state: EditorState; - - beforeEach(() => { - analyzer = new SqlStructureAnalyzer(new NodeSqlParser()); - }); - - const createState = (content: string) => { - return EditorState.create({ doc: content }); - }; - - describe("analyzeDocument", () => { - it("should identify single SQL statement", async () => { - state = createState("SELECT * FROM users WHERE id = 1;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); - expect(statements[0].type).toBe("select"); - expect(statements[0].isValid).toBe(true); - expect(statements[0].lineFrom).toBe(1); - expect(statements[0].lineTo).toBe(1); - }); - - it("should identify multiple SQL statements", async () => { - state = createState(` - SELECT * FROM users; - INSERT INTO users (name) VALUES ('John'); - DELETE FROM users WHERE id = 1; - `); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(3); - expect(statements[0].type).toBe("select"); - expect(statements[1].type).toBe("insert"); - expect(statements[2].type).toBe("delete"); - }); - - it("should handle statements without semicolons", async () => { - state = createState("SELECT * FROM users WHERE id = 1"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); - expect(statements[0].type).toBe("select"); - }); - - it("should handle semicolons in string literals", async () => { - state = createState(`SELECT 'Hello; World' FROM users; UPDATE users SET name = 'Test;';`); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(2); - expect(statements[0].content).toBe("SELECT 'Hello; World' FROM users"); - expect(statements[1].content).toBe("UPDATE users SET name = 'Test;'"); - }); - - it("should determine correct statement types", () => { - const testCases = [ - { sql: "SELECT * FROM users", expected: "select" }, - { sql: "INSERT INTO users VALUES (1)", expected: "insert" }, - { sql: "UPDATE users SET name = 'test'", expected: "update" }, - { sql: "DELETE FROM users", expected: "delete" }, - { sql: "CREATE TABLE users (id INT)", expected: "create" }, - { sql: "DROP TABLE users", expected: "drop" }, - { sql: "ALTER TABLE users ADD COLUMN email VARCHAR(255)", expected: "alter" }, - { sql: "USE database_name", expected: "use" }, - { sql: "SHOW TABLES", expected: "other" }, - ]; - - testCases.forEach(async ({ sql, expected }) => { - state = createState(`${sql};`); - const statements = await analyzer.analyzeDocument(state); - expect(statements[0].type).toBe(expected); - }); - }); - - it("should handle invalid SQL statements", async () => { - state = createState("SELECT * FROM;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].isValid).toBe(false); - }); - - it("should cache results for identical content", async () => { - const content = "SELECT * FROM users;"; - state = createState(content); - - const statements1 = await analyzer.analyzeDocument(state); - const statements2 = await analyzer.analyzeDocument(state); - - expect(statements1).toBe(statements2); // Should be the same reference (cached) - }); - }); - - describe("getStatementAtPosition", () => { - beforeEach(() => { - state = createState(`SELECT * FROM users; -INSERT INTO users (name) VALUES ('John'); -DELETE FROM users WHERE id = 1;`); - }); - - it("should return correct statement for cursor position", async () => { - const statement1 = await analyzer.getStatementAtPosition(state, 5); // Inside first SELECT - const statement2 = await analyzer.getStatementAtPosition(state, 25); // Inside INSERT - const statement3 = await analyzer.getStatementAtPosition(state, 80); // Inside DELETE - - expect(statement1?.type).toBe("select"); - expect(statement2?.type).toBe("insert"); - expect(statement3?.type).toBe("delete"); - }); - - it("should return null for position outside any statement", async () => { - state = createState(" \n\n "); - const statement = await analyzer.getStatementAtPosition(state, 2); - expect(statement).toBeNull(); - }); - }); - - describe("getStatementsInRange", () => { - beforeEach(() => { - state = createState(`SELECT * FROM users; -INSERT INTO users (name) VALUES ('John'); -DELETE FROM users WHERE id = 1;`); - }); - - it("should return statements that intersect with range", async () => { - const statements = await analyzer.getStatementsInRange(state, 0, 50); - expect(statements).toHaveLength(2); // SELECT and INSERT - expect(statements[0].type).toBe("select"); - expect(statements[1].type).toBe("insert"); - }); - - it("should return single statement when range is within one statement", async () => { - const statements = await analyzer.getStatementsInRange(state, 5, 10); - expect(statements).toHaveLength(1); - expect(statements[0].type).toBe("select"); - }); - - it("should return all statements when range covers entire document", async () => { - const statements = await analyzer.getStatementsInRange(state, 0, state.doc.toString().length); - expect(statements).toHaveLength(3); - }); - }); - - describe("clearCache", () => { - it("should clear internal cache", async () => { - state = createState("SELECT * FROM users;"); - - // Populate cache - await analyzer.analyzeDocument(state); - - // Clear cache - analyzer.clearCache(); - - // Should re-analyze (though we can't directly test this, we ensure no errors) - const statements = await analyzer.analyzeDocument(state); - expect(statements).toHaveLength(1); - }); - }); - - describe("multiline statements", () => { - it("should handle statements spanning multiple lines", async () => { - state = createState(`SELECT u.id, - u.name, - u.email -FROM users u -WHERE u.active = true - AND u.created_at > '2023-01-01';`); - - const statements = await analyzer.analyzeDocument(state); - expect(statements).toHaveLength(1); - expect(statements[0].lineFrom).toBe(1); - expect(statements[0].lineTo).toBe(6); - expect(statements[0].type).toBe("select"); - }); - }); - - describe("comment handling", () => { - describe("single-line comments (--)", () => { - it("should strip single-line comments from statement content", async () => { - state = createState("SELECT * FROM users -- this is a comment\nWHERE id = 1;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users \nWHERE id = 1"); - expect(statements[0].type).toBe("select"); - }); - - it("should handle single-line comment at end of statement", async () => { - state = createState("SELECT * FROM users WHERE id = 1; -- comment"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); - }); - - it("should handle statement that is entirely a comment", async () => { - state = createState("-- This is just a comment\nSELECT * FROM users;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users"); - expect(statements[0].type).toBe("select"); - }); - - it("should handle multiple single-line comments", async () => { - state = createState(` - -- First comment - SELECT * FROM users -- inline comment - -- Another comment - WHERE id = 1; -- final comment - `); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content.trim()).toBe( - "SELECT * FROM users \n \n WHERE id = 1", - ); - }); - }); - - describe("multi-line comments (/* */)", () => { - it("should strip multi-line comments from statement content", async () => { - state = createState("SELECT * FROM users /* this is a comment */ WHERE id = 1;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); - expect(statements[0].type).toBe("select"); - }); - - it("should handle multi-line comments spanning multiple lines", async () => { - state = createState(`SELECT * FROM users /* - This is a multi-line - comment that spans - several lines - */ WHERE id = 1;`); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users WHERE id = 1"); - }); - - it("should handle nested-like comment patterns", async () => { - state = createState("SELECT * FROM users /* comment /* nested */ text */ WHERE id = 1;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT * FROM users text */ WHERE id = 1"); - }); - }); - - describe("comments in string literals", () => { - it("should not strip comment patterns inside string literals", async () => { - state = createState("SELECT 'This -- is not a comment' FROM users;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT 'This -- is not a comment' FROM users"); - }); - - it("should not strip multi-line comment patterns inside string literals", async () => { - state = createState("SELECT 'This /* is not */ a comment' FROM users;"); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe("SELECT 'This /* is not */ a comment' FROM users"); - }); - - it("should handle mixed real comments and string literal comment patterns", async () => { - state = createState( - "SELECT 'Text -- not comment' FROM users -- real comment\nWHERE id = 1;", - ); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].content).toBe( - "SELECT 'Text -- not comment' FROM users \nWHERE id = 1", - ); - }); - }); - - describe("mixed comments and statements", () => { - it("should handle statements separated by comments", async () => { - state = createState(` - SELECT * FROM users; -- First query - /* Comment between queries */ - INSERT INTO users (name) VALUES ('John'); -- Second query - `); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(2); - expect(statements[0].content.trim()).toBe("SELECT * FROM users"); - expect(statements[0].type).toBe("select"); - expect(statements[1].content.trim()).toBe("INSERT INTO users (name) VALUES ('John')"); - expect(statements[1].type).toBe("insert"); - }); - - it("should not create statements from comment-only content", async () => { - state = createState(` - -- Just a comment - /* Another comment */ - SELECT * FROM users; - -- Final comment - `); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(1); - expect(statements[0].type).toBe("select"); - }); - - it("should handle semicolons in comments", async () => { - state = createState(` - SELECT * FROM users; -- Comment with; semicolon - /* Multi-line comment with; - semicolon; on multiple; lines */ - INSERT INTO logs VALUES (1); - `); - const statements = await analyzer.analyzeDocument(state); - - expect(statements).toHaveLength(2); - expect(statements[0].type).toBe("select"); - expect(statements[1].type).toBe("insert"); - }); - }); - }); - - describe("cache correctness", () => { - it("does not return cached statements for a different document with a colliding hash", async () => { - // "select Aa" and "select BB" collide under a 31-based 32-bit string hash - const first = await analyzer.analyzeDocument(createState("select Aa")); - expect(first).toHaveLength(1); - expect(first[0].content).toBe("select Aa"); - - const second = await analyzer.analyzeDocument(createState("select BB")); - expect(second).toHaveLength(1); - expect(second[0].content).toBe("select BB"); - }); - }); -}); diff --git a/src/sql/__tests__/structure-extension.test.ts b/src/sql/__tests__/structure-extension.test.ts deleted file mode 100644 index 7df7a98..0000000 --- a/src/sql/__tests__/structure-extension.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { EditorState, Text } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { describe, expect, it } from "vitest"; -import { computeMarkerStyle, sqlStructureGutter } from "../structure-extension.js"; - -async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } -} - -// Type for gutter extension with markers function -interface GutterExtension { - markers: (view: EditorView) => unknown; -} - -// Mock EditorView -const _createMockView = (content: string, hasFocus = true) => { - const doc = Text.of(content.split("\n")); - const state = EditorState.create({ - doc, - extensions: [sqlStructureGutter()], - }); - - return { - state, - hasFocus, - dispatch: () => {}, - } as EditorView; -}; - -describe("computeMarkerStyle", () => { - const focused = { isCurrent: false, isValid: true, isFocused: true }; - - describe("background color", () => { - it("uses the default blue for valid statements", () => { - expect(computeMarkerStyle({}, focused).backgroundColor).toBe("#3b82f6"); - }); - - it("honors a custom backgroundColor", () => { - expect(computeMarkerStyle({ backgroundColor: "#123456" }, focused).backgroundColor).toBe( - "#123456", - ); - }); - - it("uses the default red for invalid statements", () => { - expect( - computeMarkerStyle({}, { isCurrent: false, isValid: false, isFocused: true }) - .backgroundColor, - ).toBe("#ef4444"); - }); - - it("honors a custom errorBackgroundColor for invalid statements", () => { - expect( - computeMarkerStyle( - { errorBackgroundColor: "#abcdef" }, - { isCurrent: false, isValid: false, isFocused: true }, - ).backgroundColor, - ).toBe("#abcdef"); - }); - - it("does not use the error color when showInvalid is false", () => { - expect( - computeMarkerStyle( - { showInvalid: false }, - { isCurrent: false, isValid: false, isFocused: true }, - ).backgroundColor, - ).toBe("#3b82f6"); - }); - }); - - describe("opacity when focused", () => { - it("is fully opaque for the current statement", () => { - expect( - computeMarkerStyle({}, { isCurrent: true, isValid: true, isFocused: true }).opacity, - ).toBe("1"); - }); - - it("defaults to 0.3 for non-current statements", () => { - expect(computeMarkerStyle({}, focused).opacity).toBe("0.3"); - }); - - it("honors a custom inactiveOpacity", () => { - expect(computeMarkerStyle({ inactiveOpacity: 0.5 }, focused).opacity).toBe("0.5"); - }); - - it("honors an explicit inactiveOpacity of 0", () => { - expect(computeMarkerStyle({ inactiveOpacity: 0 }, focused).opacity).toBe("0"); - }); - }); - - describe("opacity when not focused", () => { - const unfocused = { isCurrent: false, isValid: true, isFocused: false }; - - it("uses unfocusedOpacity when provided", () => { - expect(computeMarkerStyle({ unfocusedOpacity: 0.2 }, unfocused).opacity).toBe("0.2"); - }); - - it("prefers unfocusedOpacity over hideWhenNotFocused", () => { - expect( - computeMarkerStyle( - { unfocusedOpacity: 0.4, hideWhenNotFocused: true }, - unfocused, - ).opacity, - ).toBe("0.4"); - }); - - it("hides the marker when hideWhenNotFocused is set", () => { - expect(computeMarkerStyle({ hideWhenNotFocused: true }, unfocused).opacity).toBe("0"); - }); - - it("falls back to normal opacity when neither unfocused option is set", () => { - expect(computeMarkerStyle({}, unfocused).opacity).toBe("0.3"); - expect( - computeMarkerStyle({}, { isCurrent: true, isValid: true, isFocused: false }).opacity, - ).toBe("1"); - }); - }); -}); - -describe("sqlStructureGutter", () => { - it("should create a gutter extension with default config", () => { - const extensions = sqlStructureGutter(); - expect(Array.isArray(extensions)).toBe(true); - expect(extensions.length).toBeGreaterThan(0); - }); - - it("should accept custom configuration", () => { - const config = { - backgroundColor: "#ff0000", - errorBackgroundColor: "#00ff00", - width: 5, - className: "custom-sql-gutter", - showInvalid: false, - inactiveOpacity: 0.5, - hideWhenNotFocused: true, - }; - - const extensions = sqlStructureGutter(config); - expect(Array.isArray(extensions)).toBe(true); - expect(extensions.length).toBeGreaterThan(0); - }); - - it("should handle empty configuration", () => { - const extensions = sqlStructureGutter({}); - expect(Array.isArray(extensions)).toBe(true); - }); - - it("should create extensions for all required parts", () => { - const extensions = sqlStructureGutter(); - // Should include state field, update listener, theme, and gutter - expect(extensions.length).toBe(4); - }); - - it("should handle unfocusedOpacity configuration", () => { - const config = { unfocusedOpacity: 0.2 }; - const extensions = sqlStructureGutter(config); - expect(extensions.length).toBe(4); - }); - - it("should handle whenHide configuration", () => { - const config = { - whenHide: (view: EditorView) => view.state.doc.length === 0, - }; - const extensions = sqlStructureGutter(config); - expect(extensions.length).toBe(4); - }); - - it("should work with minimal configuration", () => { - const config = { width: 2 }; - const extensions = sqlStructureGutter(config); - expect(extensions.length).toBe(4); - }); - - it("should handle line deletions gracefully without throwing invalid line number errors", () => { - // Create a multi-line SQL document - const multiLineSql = `SELECT * FROM users; -INSERT INTO users (name, email) VALUES ('John', 'john@example.com'); -UPDATE users SET name = 'Jane' WHERE id = 1; -DELETE FROM users WHERE id = 2;`; - - const doc = Text.of(multiLineSql.split("\n")); - // Create initial state (not used but shows the scenario) - EditorState.create({ - doc, - extensions: [sqlStructureGutter()], - }); - - // Simulate a document with fewer lines (like after deletion) - const shorterSql = `SELECT * FROM users;`; - const shorterDoc = Text.of(shorterSql.split("\n")); - const shorterState = EditorState.create({ - doc: shorterDoc, - extensions: [sqlStructureGutter()], - }); - - // The key test: ensure that accessing the state field doesn't throw errors - // even when the cached statements have stale line numbers - expect(() => { - // This would previously throw "Invalid line number" errors - // Now it should handle stale line numbers gracefully - const view = { - state: shorterState, - hasFocus: true, - dispatch: () => {}, - } as EditorView; - - // Trigger the gutter marker creation (this is where the error was occurring) - const extensions = sqlStructureGutter(); - const gutterExtension = extensions.find( - (ext) => typeof ext === "object" && ext !== null && "markers" in ext, - ) as GutterExtension | undefined; - - if (gutterExtension) { - const markersFn = gutterExtension.markers; - // This should not throw an error even with stale line numbers - expect(() => markersFn(view)).not.toThrow(); - } - }).not.toThrow(); - }); - - it("renders gutter markers for pre-filled content without user interaction", async () => { - const view = new EditorView({ - doc: "SELECT 1;\nSELECT 2;", - extensions: [sqlStructureGutter()], - parent: document.body, - }); - - try { - await waitFor(() => view.dom.querySelectorAll(".cm-sql-gutter-marker").length >= 2); - expect(view.dom.querySelectorAll(".cm-sql-gutter-marker").length).toBeGreaterThanOrEqual(2); - } finally { - view.destroy(); - } - }); - - it("honors an explicit inactiveOpacity of 0", async () => { - const view = new EditorView({ - doc: "SELECT 1;\nSELECT 2;", - extensions: [sqlStructureGutter({ inactiveOpacity: 0 })], - parent: document.body, - }); - - try { - // Put the cursor in the first statement so the second one is inactive - view.dispatch({ selection: { anchor: 0 } }); - await waitFor(() => view.dom.querySelectorAll(".cm-sql-gutter-marker").length >= 2); - - const opacities = Array.from( - view.dom.querySelectorAll(".cm-sql-gutter-marker"), - ).map((marker) => marker.style.opacity); - expect(opacities).toContain("0"); - } finally { - view.destroy(); - } - }); -}); diff --git a/src/sql/__tests__/test-utils.ts b/src/sql/__tests__/test-utils.ts deleted file mode 100644 index 95cfd26..0000000 --- a/src/sql/__tests__/test-utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - CompletionContext, - type CompletionResult, - type CompletionSource, -} from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { EditorState, type Extension } from "@codemirror/state"; -import { sqlSchemaFacet } from "../schema-facet.js"; - -/** - * Shared test helpers and fixtures. - * - * These exist so tests can exercise more behavior with less boilerplate: the - * `CompletionContext` plumbing, schema fixtures, and label extraction are - * identical across the completion-source suites, so they live here. - */ - -/** A small two-table schema used across completion/diagnostic suites. */ -export const TEST_SCHEMA: SQLNamespace = { - users: ["id", "username", "email"], - orders: [{ label: "order_id", detail: "Order ID", type: "property" }, "total"], -}; - -/** A one-level nested (catalog.table) schema. */ -export const NESTED_SCHEMA: SQLNamespace = { - mydb: { - users: ["id", "username"], - }, -}; - -/** Creates an `EditorState`, optionally installing a schema facet. */ -export function createState(doc: string, extensions: Extension[] = []): EditorState { - return EditorState.create({ doc, extensions }); -} - -/** Builds a `CompletionContext` positioned within `doc` (defaults to the end). */ -export function createCompletionContext( - doc: string, - opts: { pos?: number; explicit?: boolean; extensions?: Extension[] } = {}, -): CompletionContext { - const state = createState(doc, opts.extensions); - const pos = opts.pos ?? doc.length; - return new CompletionContext(state, pos, opts.explicit ?? false); -} - -/** Extracts the option labels from a completion result. */ -export function labels(result: CompletionResult | null): string[] { - return (result?.options ?? []).map((option) => option.label); -} - -/** - * Options accepted by the {@link completeWith} runner. - * - * `schema` is passed to the source factory as config; `facetSchema` installs a - * {@link sqlSchemaFacet} on the state instead (mirroring how the schema can be - * supplied either explicitly or via the facet). - */ -export interface CompleteOptions { - schema?: SQLNamespace; - pos?: number; - explicit?: boolean; - facetSchema?: SQLNamespace; -} - -/** - * Wraps a completion-source factory into a reusable `complete(doc, opts)` runner. - * - * When only `facetSchema` is provided, the source is created without an explicit - * schema so it falls back to reading the facet — matching real usage. - */ -export function completeWith( - factory: (config: { schema?: SQLNamespace }) => CompletionSource, -): (doc: string, opts?: CompleteOptions) => Promise { - return async (doc: string, opts: CompleteOptions = {}) => { - const source = factory( - opts.schema === undefined && opts.facetSchema !== undefined ? {} : { schema: opts.schema }, - ); - const context = createCompletionContext(doc, { - pos: opts.pos, - explicit: opts.explicit, - extensions: opts.facetSchema !== undefined ? [sqlSchemaFacet.of(opts.facetSchema)] : [], - }); - return (await source(context)) as CompletionResult | null; - }; -} diff --git a/src/sql/alias-completion-source.ts b/src/sql/alias-completion-source.ts deleted file mode 100644 index d5f47bb..0000000 --- a/src/sql/alias-completion-source.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete"; -import { findTableColumns, resolveSchema, toCompletion } from "./completion-utils.js"; -import { NodeSqlParser } from "./parser.js"; -import { - type QueryContext, - QueryContextAnalyzer, - stripIdentifierQuotes, -} from "./query-context.js"; -import type { SqlSchemaSource } from "./schema-facet.js"; -import { SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -/** - * Configuration for the alias-aware column completion source - */ -export interface AliasCompletionConfig { - /** - * Database schema to complete columns from. Falls back to the shared - * `sqlSchemaFacet` when not provided. - */ - schema?: SqlSchemaSource; - /** Custom SQL parser instance to use for query analysis */ - parser?: SqlParser; - /** - * Query-context analyzer to reuse (e.g. the one backing hover), so the - * statement is only analyzed once per edit. - */ - contextAnalyzer?: QueryContextAnalyzer; -} - -/** - * Creates a completion source that offers a table's columns after an alias - * qualifier: with `SELECT ... FROM users u`, typing `u.` completes the columns - * of `users`. Aliases of CTEs complete the CTE's declared/inferred columns. - * - * @example - * ```ts - * import { aliasColumnCompletionSource } from '@marimo-team/codemirror-sql'; - * import { StandardSQL } from '@codemirror/lang-sql'; - * - * StandardSQL.language.data.of({ - * autocomplete: aliasColumnCompletionSource({ schema: { users: ['id', 'name'] } }), - * }) - * ``` - */ -export function aliasColumnCompletionSource(config: AliasCompletionConfig = {}): CompletionSource { - const parser = config.parser ?? new NodeSqlParser(); - const contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - const structureAnalyzer = new SqlStructureAnalyzer(parser); - - return async (context: CompletionContext) => { - // Match `.` immediately before the cursor; the alias may - // be a quoted identifier ("ut"., `ut`., [ut].) - const match = context.matchBefore(/(?:[\w$]+|"[^"]+"|`[^`]+`|\[[^\]]+\])\.[\w$]*/); - if (!match) { - return null; - } - // Skip multi-segment paths like `db.table.` — only bare qualifiers can be - // aliases - if (context.state.sliceDoc(Math.max(0, match.from - 1), match.from) === ".") { - return null; - } - - // The partial after the dot contains no dots, so the last dot is the - // qualifier separator even for quoted qualifiers like `"a.b".` - const dotIndex = match.text.lastIndexOf("."); - const qualifier = stripIdentifierQuotes(match.text.slice(0, dotIndex)); - - const statement = await structureAnalyzer.getStatementAtPosition(context.state, context.pos); - const statementSql = statement - ? context.state.sliceDoc(statement.from, statement.to) - : context.state.doc.toString(); - const queryContext: QueryContext = await contextAnalyzer.getContext(statementSql, { - state: context.state, - }); - - const target = queryContext.aliases.get(qualifier.toLowerCase()); - if (!target) { - return null; - } - - let columns: readonly (Completion | string)[] | null = null; - - // Alias of a CTE: use the CTE's declared/inferred output columns - const cte = queryContext.ctes.find((c) => c.name.toLowerCase() === target.toLowerCase()); - if (cte && cte.columns.length > 0) { - columns = cte.columns; - } else if (!cte) { - const schema = resolveSchema(config.schema, context); - if (schema != null) { - columns = findTableColumns(schema, target); - } - } - - if (!columns || columns.length === 0) { - return null; - } - - const detail = `column of ${target}`; - return { - from: match.from + dotIndex + 1, - options: columns.map((column) => toCompletion(column, detail)), - validFor: /^[\w$]*$/, - }; - }; -} diff --git a/src/sql/column-completion-source.ts b/src/sql/column-completion-source.ts deleted file mode 100644 index 0aa8fe0..0000000 --- a/src/sql/column-completion-source.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete"; -import { findTableColumns, resolveSchema, toCompletion } from "./completion-utils.js"; -import { NodeSqlParser } from "./parser.js"; -import { - maskLiteralsAndComments, - type QueryContext, - QueryContextAnalyzer, -} from "./query-context.js"; -import type { SqlSchemaSource } from "./schema-facet.js"; -import { SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -/** - * Configuration for the unqualified column completion source - */ -export interface ColumnCompletionConfig { - /** - * Database schema to complete columns from. Falls back to the shared - * `sqlSchemaFacet` when not provided. - */ - schema?: SqlSchemaSource; - /** Custom SQL parser instance to use for query analysis */ - parser?: SqlParser; - /** - * Query-context analyzer to reuse (e.g. the one backing hover), so the - * statement is only analyzed once per edit. - */ - contextAnalyzer?: QueryContextAnalyzer; -} - -/** Slight boost so columns rank just above keyword completions */ -const COLUMN_BOOST = 1; - -/** Keywords after which a table name (not a column) is expected */ -const TABLE_POSITION_PATTERN = /\b(?:from|join|into|update|table)\s+$/i; - -/** - * A comma-continued FROM table list (`FROM a, b [AS] x, `) — the next token is - * a table reference, not a column. Only pure table-ref/alias sequences match, - * so commas in later clauses (`GROUP BY x, `) don't. - */ -const TABLE_LIST_PATTERN = - /\bfrom\s+[\w$."`[\]]+(?:\s+(?:as\s+)?[\w$"`[\]]+)?(?:\s*,\s*[\w$."`[\]]+(?:\s+(?:as\s+)?[\w$"`[\]]+)?)*\s*,\s*$/i; - -/** - * Creates a completion source that offers columns of the tables referenced in - * the current statement's FROM/JOIN clauses for unqualified prefixes: with - * `SELECT e FROM users`, typing `e` completes `email` even when multiple - * tables exist in the schema. Tables that resolve to CTEs complete the CTE's - * declared/inferred columns. - * - * @example - * ```ts - * import { unqualifiedColumnCompletionSource } from '@marimo-team/codemirror-sql'; - * import { StandardSQL } from '@codemirror/lang-sql'; - * - * StandardSQL.language.data.of({ - * autocomplete: unqualifiedColumnCompletionSource({ schema: { users: ['id', 'name'] } }), - * }) - * ``` - */ -export function unqualifiedColumnCompletionSource( - config: ColumnCompletionConfig = {}, -): CompletionSource { - const parser = config.parser ?? new NodeSqlParser(); - const contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - const structureAnalyzer = new SqlStructureAnalyzer(parser); - - return async (context: CompletionContext) => { - const word = context.matchBefore(/[\w$]*/); - if (!word) { - return null; - } - if (word.from === word.to && !context.explicit) { - return null; - } - // Qualified paths (`u.`, `db.table.`) belong to the alias and schema - // completion sources - if (context.state.sliceDoc(Math.max(0, word.from - 1), word.from) === ".") { - return null; - } - // Don't offer columns where a table name is expected - const before = context.state.sliceDoc(Math.max(0, word.from - 256), word.from); - if (TABLE_POSITION_PATTERN.test(before) || TABLE_LIST_PATTERN.test(before)) { - return null; - } - - const statement = await structureAnalyzer.getStatementAtPosition(context.state, context.pos); - const statementFrom = statement?.from ?? 0; - const statementSql = statement - ? context.state.sliceDoc(statement.from, statement.to) - : context.state.doc.toString(); - - // Don't offer columns inside string literals or comments: masking blanks - // those regions, so a masked prefix (or the character opening it) differs - // from the original - const masked = maskLiteralsAndComments(statementSql); - const checkFrom = Math.max(statementFrom, word.from - 1) - statementFrom; - const checkTo = word.to - statementFrom; - if (masked.slice(checkFrom, checkTo) !== statementSql.slice(checkFrom, checkTo)) { - return null; - } - - const queryContext: QueryContext = await contextAnalyzer.getContext(statementSql, { - state: context.state, - }); - if (queryContext.tables.length === 0) { - return null; - } - - const schema = resolveSchema(config.schema, context); - const options: Completion[] = []; - const seenLabels = new Set(); - const seenPaths = new Set(); - - for (const table of queryContext.tables) { - const pathKey = table.path.join(".").toLowerCase(); - if (seenPaths.has(pathKey)) { - continue; - } - seenPaths.add(pathKey); - - // A table that names a CTE uses the CTE's declared/inferred columns - const cte = queryContext.ctes.find((c) => c.name.toLowerCase() === table.name.toLowerCase()); - let columns: readonly (Completion | string)[] | null = null; - if (cte) { - columns = cte.columns.length > 0 ? cte.columns : null; - } else if (schema != null) { - // Segments preserve identifier boundaries (e.g. a `"my.db"` table) - columns = findTableColumns(schema, table.path); - } - if (!columns) { - continue; - } - - const detail = `column of ${table.name}`; - for (const column of columns) { - const label = typeof column === "string" ? column : column.label; - const labelKey = label.toLowerCase(); - if (seenLabels.has(labelKey)) { - continue; - } - seenLabels.add(labelKey); - options.push(toCompletion(column, detail, COLUMN_BOOST)); - } - } - - if (options.length === 0) { - return null; - } - - return { - from: word.from, - options, - validFor: /^[\w$]*$/, - }; - }; -} diff --git a/src/sql/completion-extension.ts b/src/sql/completion-extension.ts deleted file mode 100644 index dac8afc..0000000 --- a/src/sql/completion-extension.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { SQLDialect } from "@codemirror/lang-sql"; -import type { Extension } from "@codemirror/state"; -import { aliasColumnCompletionSource } from "./alias-completion-source.js"; -import { unqualifiedColumnCompletionSource } from "./column-completion-source.js"; -import { createCteCompletionSource } from "./cte-completion-source.js"; -import { NodeSqlParser } from "./parser.js"; -import { QueryContextAnalyzer } from "./query-context.js"; -import type { SqlSchemaSource } from "./schema-facet.js"; -import type { SqlParser } from "./types.js"; - -/** - * Configuration for {@link sqlCompletion}, the convenience helper that - * registers every schema-aware SQL completion source at once. - */ -export interface SqlCompletionConfig { - /** - * The SQL dialect whose language the completion sources are registered on - * (e.g. `PostgreSQL`, or a dialect from `./dialects`). This must match the - * dialect passed to `sql({ dialect })` for the sources to activate. - */ - dialect: SQLDialect; - /** - * Database schema to complete columns from. Falls back to the shared - * `sqlSchemaFacet` when not provided. Not used by CTE completion, which - * derives columns from the statement itself. - */ - schema?: SqlSchemaSource; - /** - * Custom SQL parser shared by all completion sources. Defaults to a new - * `NodeSqlParser`. Pass the same instance used by the linter/hover so - * dialect-specific setups only configure the parser once. - */ - parser?: SqlParser; - /** - * Query-context analyzer shared by all completion sources, so each edit is - * analyzed once. Defaults to one built from `parser`. - */ - contextAnalyzer?: QueryContextAnalyzer; - - /** Whether to enable CTE name/column completion (default: true) */ - enableCteCompletion?: boolean; - /** Whether to enable alias-qualified column completion (default: true) */ - enableAliasCompletion?: boolean; - /** Whether to enable unqualified column completion (default: true) */ - enableColumnCompletion?: boolean; -} - -/** - * Registers every schema-aware SQL completion source in one call, so you don't - * have to wire up each `dialect.language.data.of({ autocomplete })` by hand: - * - {@link createCteCompletionSource} — CTE names and their output columns - * - {@link aliasColumnCompletionSource} — `u.` → columns of `users` in - * `SELECT ... FROM users u` - * - {@link unqualifiedColumnCompletionSource} — `SELECT e` → `email` from the - * statement's FROM/JOIN tables - * - * A single parser and query-context analyzer are shared across the sources so - * each edit is analyzed only once. This complements `sqlExtension`, which - * covers linting, hover, gutter, and navigation but not completion. - * - * @example - * ```ts - * import { sql, PostgreSQL } from '@codemirror/lang-sql'; - * import { sqlCompletion } from '@marimo-team/codemirror-sql'; - * - * const schema = { users: ['id', 'name', 'email'] }; - * const extensions = [ - * sql({ dialect: PostgreSQL, schema }), - * sqlCompletion({ dialect: PostgreSQL, schema }), - * ]; - * ``` - */ -export function sqlCompletion(config: SqlCompletionConfig): Extension[] { - const { - dialect, - schema, - parser = new NodeSqlParser(), - enableCteCompletion = true, - enableAliasCompletion = true, - enableColumnCompletion = true, - } = config; - const contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - - const extensions: Extension[] = []; - - if (enableCteCompletion) { - extensions.push( - dialect.language.data.of({ - autocomplete: createCteCompletionSource({ parser, contextAnalyzer }), - }), - ); - } - - if (enableAliasCompletion) { - extensions.push( - dialect.language.data.of({ - autocomplete: aliasColumnCompletionSource({ schema, parser, contextAnalyzer }), - }), - ); - } - - if (enableColumnCompletion) { - extensions.push( - dialect.language.data.of({ - autocomplete: unqualifiedColumnCompletionSource({ schema, parser, contextAnalyzer }), - }), - ); - } - - return extensions; -} diff --git a/src/sql/completion-utils.ts b/src/sql/completion-utils.ts deleted file mode 100644 index c5ceee1..0000000 --- a/src/sql/completion-utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Completion, CompletionContext } from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { - findNamespaceItemByEndMatch, - isArrayNamespace, - isSelfChildrenNamespace, - type ResolvedNamespaceItem, - traverseNamespacePath, -} from "./namespace-utils.js"; -import { type SqlSchemaSource, sqlSchemaFacet } from "./schema-facet.js"; - -export function resolveSchema( - explicit: SqlSchemaSource | undefined, - context: CompletionContext, -): SQLNamespace | null { - const source = explicit ?? context.state.facet(sqlSchemaFacet); - if (source == null) { - return null; - } - if (typeof source === "function") { - // Function sources need a view; contexts created without one (e.g. tests) - // simply get no schema - return context.view ? source(context.view) : null; - } - return source; -} - -/** Column list of a namespace node, when it is (or wraps) a column array */ -export function columnsOf( - namespace: SQLNamespace | undefined, -): readonly (Completion | string)[] | null { - if (namespace == null) { - return null; - } - const resolved = isSelfChildrenNamespace(namespace) ? namespace.children : namespace; - return isArrayNamespace(resolved) ? resolved : null; -} - -/** - * Resolves a (possibly qualified) table path to its column list, - * case-insensitively. An under-qualified name also matches a table nested - * deeper in the namespace (e.g. `users` matches `mydb.users`). Pre-split - * segments preserve identifier boundaries when a segment contains a dot - * (e.g. a `"my.db"` quoted identifier). - */ -export function findTableColumns( - schema: SQLNamespace, - tablePath: string | readonly string[], -): readonly (Completion | string)[] | null { - const exact = traverseNamespacePath(schema, tablePath, { caseSensitive: false }); - const exactColumns = columnsOf(exact?.namespace); - if (exactColumns) { - return exactColumns; - } - - const segments = (typeof tablePath === "string" ? tablePath.split(".") : tablePath).map( - (segment) => segment.toLowerCase(), - ); - const lastSegment = segments[segments.length - 1]; - if (!lastSegment) { - return null; - } - - const matches = findNamespaceItemByEndMatch(schema, lastSegment).filter( - (item: ResolvedNamespaceItem) => { - if (columnsOf(item.namespace) === null) { - return false; - } - if (item.path.length < segments.length) { - return false; - } - const suffix = item.path.slice(-segments.length).map((segment) => segment.toLowerCase()); - return segments.every((segment, i) => suffix[i] === segment); - }, - ); - - // Only trust the column list when the match is unambiguous - return matches.length === 1 ? columnsOf(matches[0]?.namespace) : null; -} - -export function toCompletion( - column: Completion | string, - detail: string, - boost?: number, -): Completion { - if (typeof column === "string") { - return boost === undefined - ? { label: column, type: "property", detail } - : { label: column, type: "property", detail, boost }; - } - const completion: Completion = { ...column, type: "property", detail: column.detail ?? detail }; - if (boost !== undefined && completion.boost === undefined) { - completion.boost = boost; - } - return completion; -} diff --git a/src/sql/cte-completion-source.ts b/src/sql/cte-completion-source.ts deleted file mode 100644 index 095ad11..0000000 --- a/src/sql/cte-completion-source.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete"; -import { NodeSqlParser } from "./parser.js"; -import { - QueryContextAnalyzer, - type QueryContextCte, - stripIdentifierQuotes, -} from "./query-context.js"; -import { statementAt } from "./references.js"; -import { SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -/** - * Configuration for the CTE completion source - */ -export interface CteCompletionConfig { - /** Custom SQL parser instance to use for query analysis */ - parser?: SqlParser; - /** - * Query-context analyzer to reuse (e.g. shared with hover and - * `aliasColumnCompletionSource`), so each statement is only analyzed once. - */ - contextAnalyzer?: QueryContextAnalyzer; - /** Structure analyzer to reuse for statement boundary detection */ - structureAnalyzer?: SqlStructureAnalyzer; -} - -/** Matches a (possibly quoted) identifier at the end of the text */ -const QUALIFIER_PATTERN = /([\w$]+|"[^"]+"|`[^`]+`|\[[^\]]+\])$/; - -/** True when the cursor is right after FROM/JOIN, i.e. a table-name position */ -function isTableNamePosition(textBefore: string): boolean { - return /\b(?:from|join)\s+$/i.test(textBefore); -} - -/** True when `name` can be written bare (no quoting needed) */ -function isBareIdentifier(name: string): boolean { - return /^[A-Za-z_][\w$]*$/.test(name); -} - -function cteNameCompletion(cte: QueryContextCte): Completion { - return { - label: cte.name, - type: "variable", // CTEs are like temporary tables/variables - info: `Common Table Expression: ${cte.name}`, - boost: 10, // Give CTEs higher priority than regular completions - ...(isBareIdentifier(cte.name) ? {} : { apply: `"${cte.name}"` }), - }; -} - -function cteColumnCompletions(cte: QueryContextCte): Completion[] { - return cte.columns.map((column) => ({ - label: column, - type: "property", - detail: `column of ${cte.name}`, - boost: 5, - ...(isBareIdentifier(column) ? {} : { apply: `"${column}"` }), - })); -} - -/** - * Creates a completion source for Common Table Expressions (CTEs), scoped to - * the statement containing the cursor: - * - CTE names declared in the statement's WITH clauses - * - a CTE's output columns after `.` - * - a CTE's output columns unqualified, when the statement selects FROM it - * - * Statements that don't parse (mid-edit) fall back to a regex-based scan. - */ -export function createCteCompletionSource(config: CteCompletionConfig = {}): CompletionSource { - const parser = config.parser ?? new NodeSqlParser(); - const contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - const structureAnalyzer = config.structureAnalyzer ?? new SqlStructureAnalyzer(parser); - - return async (context: CompletionContext) => { - const word = context.matchBefore(/[\w$]*/); - if (!word) { - return null; - } - - const charBefore = context.state.sliceDoc(Math.max(0, word.from - 1), word.from); - const qualified = charBefore === "."; - - // A `.` qualifier triggers even without a typed word or explicit mode - if (word.from === word.to && !context.explicit && !qualified) { - return null; - } - - // Scope the analysis to the statement containing the cursor, so CTEs from - // other statements in a multi-statement doc don't leak in - const statement = await statementAt(structureAnalyzer, context.state, context.pos); - const statementSql = statement - ? context.state.sliceDoc(statement.from, statement.to) - : context.state.doc.toString(); - const queryContext = await contextAnalyzer.getContext(statementSql, { - state: context.state, - }); - if (queryContext.ctes.length === 0) { - return null; - } - - if (qualified) { - // `.` — offer the CTE's output columns - const line = context.state.doc.lineAt(word.from); - const prefix = context.state.sliceDoc(line.from, word.from - 1); - const qualifierMatch = QUALIFIER_PATTERN.exec(prefix); - if (!qualifierMatch || !qualifierMatch[1]) { - return null; - } - // Multi-segment paths like `db.cte.` can't reference a CTE - const beforeQualifier = prefix.slice(0, prefix.length - qualifierMatch[1].length); - if (beforeQualifier.endsWith(".")) { - return null; - } - const qualifier = stripIdentifierQuotes(qualifierMatch[1]).toLowerCase(); - const cte = queryContext.ctes.find((c) => c.name.toLowerCase() === qualifier); - if (!cte || cte.columns.length === 0) { - return null; - } - return { - from: word.from, - options: cteColumnCompletions(cte), - validFor: /^[\w$]*$/, - }; - } - - const options: Completion[] = queryContext.ctes.map(cteNameCompletion); - - // In column positions (not right after FROM/JOIN), also offer the output - // columns of CTEs the statement actually selects from - const textBefore = context.state.sliceDoc(Math.max(0, word.from - 64), word.from); - if (!isTableNamePosition(textBefore)) { - const referenced = new Set(queryContext.tables.map((table) => table.name.toLowerCase())); - const seenColumns = new Set(); - for (const cte of queryContext.ctes) { - if (!referenced.has(cte.name.toLowerCase())) { - continue; - } - for (const completion of cteColumnCompletions(cte)) { - const key = completion.label.toLowerCase(); - if (!seenColumns.has(key)) { - seenColumns.add(key); - options.push(completion); - } - } - } - } - - return { - from: word.from, - options, - validFor: /^[\w$]*$/, - }; - }; -} - -/** - * A completion source for Common Table Expressions (CTEs) in SQL - * - * This function provides autocomplete suggestions for CTE names and columns - * based on WITH clauses in the statement containing the cursor. - * - * @param context The completion context from CodeMirror - * @returns Completion result with CTE suggestions or null if no completions available - * - * @example - * ```ts - * import { cteCompletionSource } from '@marimo-team/codemirror-sql'; - * import { StandardSQL } from '@codemirror/lang-sql'; - * - * // Add to SQL language configuration - * StandardSQL.language.data.of({ - * autocomplete: cteCompletionSource, - * }) - * ``` - */ -export const cteCompletionSource: CompletionSource = (() => { - let source: CompletionSource | null = null; - return (context: CompletionContext) => { - source ??= createCteCompletionSource(); - return source(context); - }; -})(); diff --git a/src/sql/diagnostics.ts b/src/sql/diagnostics.ts deleted file mode 100644 index 69f73dc..0000000 --- a/src/sql/diagnostics.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { type Diagnostic, linter } from "@codemirror/lint"; -import type { Extension, Text } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { NodeSqlParser } from "./parser.js"; -import { type SqlStatement, SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParseError, SqlParser } from "./types.js"; - -const DEFAULT_DELAY = 750; - -/** - * Configuration options for the SQL linter - */ -export interface SqlLinterConfig { - /** Delay in milliseconds before running validation (default: 750) */ - delay?: number; - /** Custom SQL parser instance to use for validation */ - parser?: SqlParser; - /** - * Lint each statement in the document separately so every broken statement - * gets its own diagnostic, instead of stopping at the first parse error (default: true) - */ - perStatement?: boolean; - /** - * Structure analyzer used to split the document into statements. - * Pass a shared instance (e.g. the one used by the gutter) to reuse its cache. - */ - structureAnalyzer?: SqlStructureAnalyzer; -} - -/** - * Extends an error span from `from` to cover the token at that position, - * so the squiggle covers the offending word instead of a single character. - */ -function tokenEndAt(doc: Text, from: number): number { - if (from >= doc.length) { - return doc.length; - } - const line = doc.lineAt(from); - const match = line.text.slice(from - line.from).match(/^[\w"'`.]+/); - return match ? from + match[0].length : Math.min(from + 1, doc.length); -} - -/** - * Converts a SQL parse error (relative to the whole document) to a CodeMirror diagnostic - */ -function convertToCodeMirrorDiagnostic(error: SqlParseError, doc: Text): Diagnostic { - const line = doc.line(Math.min(error.line, doc.lines)); - const from = Math.min(line.from + Math.max(0, error.column - 1), doc.length); - - return { - from, - to: tokenEndAt(doc, from), - severity: error.severity, - message: error.message, - source: "sql-parser", - }; -} - -/** - * Converts a SQL parse error whose line/column are relative to a statement's - * content into a CodeMirror diagnostic positioned in the document. - * - * Error line 1 corresponds to the statement's first line, with columns - * relative to `stmt.from` (the statement content is trimmed, so its first - * character is exactly at `stmt.from`). - */ -function convertStatementErrorToDiagnostic( - error: SqlParseError, - stmt: SqlStatement, - doc: Text, -): Diagnostic { - let from: number; - if (error.line <= 1) { - from = stmt.from + Math.max(0, error.column - 1); - } else { - const line = doc.line(Math.min(stmt.lineFrom + error.line - 1, doc.lines)); - from = line.from + Math.max(0, error.column - 1); - } - // Clamp within the statement's range in case the parser's reported - // position drifts - from = Math.max(stmt.from, Math.min(from, stmt.to)); - - return { - from, - to: Math.max(from, Math.min(tokenEndAt(doc, from), stmt.to)), - severity: error.severity, - message: error.message, - source: "sql-parser", - }; -} - -/** - * Creates a SQL linter extension that validates SQL syntax and reports errors - * - * @param config Configuration options for the linter - * @returns A CodeMirror linter extension - * - * @example - * ```ts - * import { sqlLinter } from '@marimo-team/codemirror-sql'; - * - * const linter = sqlLinter({ - * delay: 500, // 500ms delay before validation - * parser: new SqlParser() // custom parser instance - * }); - * ``` - */ -export function sqlLinter(config: SqlLinterConfig = {}): Extension { - return linter(createLintSource(config), { - delay: config.delay || DEFAULT_DELAY, - }); -} - -function createLintSource(config: SqlLinterConfig = {}) { - const parser = config.parser || new NodeSqlParser(); - const perStatement = config.perStatement !== false; - const analyzer = config.structureAnalyzer || new SqlStructureAnalyzer(parser); - - return async (view: EditorView): Promise => { - const doc = view.state.doc; - const sql = doc.toString(); - - if (!sql.trim()) { - return []; - } - - if (!perStatement) { - const errors = await parser.validateSql(sql, { state: view.state }); - return errors.map((error) => convertToCodeMirrorDiagnostic(error, doc)); - } - - // Lint each statement independently so errors are reported for all - // broken statements, not just the first one in the document - const statements = await analyzer.analyzeDocument(view.state); - return statements.flatMap((stmt) => - stmt.errors.map((error) => convertStatementErrorToDiagnostic(error, stmt, doc)), - ); - }; -} - -export const exportedForTesting = { createLintSource }; diff --git a/src/sql/extension.ts b/src/sql/extension.ts deleted file mode 100644 index 7347419..0000000 --- a/src/sql/extension.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Extension } from "@codemirror/state"; -import { type SqlLinterConfig, sqlLinter } from "./diagnostics.js"; -import { defaultSqlHoverTheme, type SqlHoverConfig, sqlHover } from "./hover.js"; -import { type SqlNavigationConfig, sqlNavigation } from "./navigation-extension.js"; -import { type SqlSchemaSource, sqlSchemaFacet } from "./schema-facet.js"; -import { type SqlSemanticLinterConfig, sqlSemanticLinter } from "./semantic-diagnostics.js"; -import { type SqlGutterConfig, sqlStructureGutter } from "./structure-extension.js"; - -/** - * Configuration options for the SQL extension - */ -export interface SqlExtensionConfig { - /** - * Database schema shared by all schema-aware features (hover tooltips and - * semantic linting), registered via `sqlSchemaFacet`. Per-feature `schema` - * options in `hoverConfig`/`semanticLinterConfig` take precedence. - * - * Function sources are called on every lint/hover pass and should be - * cheap/memoized. - */ - schema?: SqlSchemaSource; - - /** Whether to enable SQL linting (default: true) */ - enableLinting?: boolean; - /** Configuration for the SQL linter */ - linterConfig?: SqlLinterConfig; - - /** - * Whether to enable schema-aware semantic linting — unknown tables, unknown - * columns, ambiguous columns (default: true). Inert unless a schema is - * provided (via `schema` or `semanticLinterConfig.schema`). - */ - enableSemanticLinting?: boolean; - /** Configuration for the semantic linter */ - semanticLinterConfig?: SqlSemanticLinterConfig; - - /** Whether to enable gutter markers for SQL statements (default: true) */ - enableGutterMarkers?: boolean; - /** Configuration for the SQL gutter markers */ - gutterConfig?: SqlGutterConfig; - - /** Whether to enable hover tooltips (default: true) */ - enableHover?: boolean; - /** Configuration for hover tooltips */ - hoverConfig?: SqlHoverConfig; - - /** - * Whether to enable navigation features — reference highlights and - * Mod-click go-to-definition for CTEs and aliases (default: true). - * Keybindings (F12/Mod-b/F2) are opt-in via `navigationConfig.keymap`. - */ - enableNavigation?: boolean; - /** Configuration for navigation features */ - navigationConfig?: SqlNavigationConfig; -} - -/** - * Creates a comprehensive SQL extension for CodeMirror that includes: - * - SQL syntax validation and linting - * - Schema-aware semantic linting (unknown tables/columns, ambiguous columns) - * - Visual gutter indicators for SQL statements - * - Hover tooltips for keywords, tables, and columns - * - * @param config Configuration options for the extension - * @returns An array of CodeMirror extensions - * - * @example - * ```ts - * import { sqlExtension } from '@marimo-team/codemirror-sql'; - * - * const editor = new EditorView({ - * extensions: [ - * sqlExtension({ - * schema: { users: ['id', 'name'] }, - * linterConfig: { delay: 500 }, - * gutterConfig: { backgroundColor: '#3b82f6' }, - * hoverConfig: { hoverTime: 300 } - * }) - * ] - * }); - * ``` - */ -export function sqlExtension(config: SqlExtensionConfig = {}): Extension[] { - const extensions: Extension[] = []; - const { - schema, - enableLinting = true, - enableSemanticLinting = true, - enableGutterMarkers = true, - enableHover = true, - enableNavigation = true, - linterConfig, - semanticLinterConfig, - gutterConfig, - hoverConfig, - navigationConfig, - } = config; - - if (schema != null) { - extensions.push(sqlSchemaFacet.of(schema)); - } - - if (enableLinting) { - extensions.push(sqlLinter(linterConfig)); - } - - if (enableSemanticLinting) { - // Reuse the syntax linter's parser/analyzer so dialect-specific setups - // don't have to configure them twice (and semantic checks don't disagree - // with the syntax linter about what parses). The linter's analyzer is - // only inherited when its parser is too — an analyzer is bound to the - // parser it was built with, and mixing it with a different semantic - // parser would gate statements with the wrong dialect. - const semanticParser = semanticLinterConfig?.parser ?? linterConfig?.parser; - const semanticAnalyzer = - semanticLinterConfig?.structureAnalyzer ?? - (semanticLinterConfig?.parser == null ? linterConfig?.structureAnalyzer : undefined); - extensions.push( - sqlSemanticLinter({ - ...semanticLinterConfig, - parser: semanticParser, - structureAnalyzer: semanticAnalyzer, - }), - ); - } - - if (enableGutterMarkers) { - extensions.push(sqlStructureGutter(gutterConfig)); - } - - if (enableHover) { - extensions.push(sqlHover(hoverConfig)); - extensions.push(hoverConfig?.theme ?? defaultSqlHoverTheme()); - } - - if (enableNavigation) { - // Inherit the syntax linter's parser/analyzer (same rules as the semantic - // linter above) so dialect-specific setups resolve references with the - // dialect they lint with - const navigationParser = navigationConfig?.parser ?? linterConfig?.parser; - const navigationAnalyzer = - navigationConfig?.structureAnalyzer ?? - (navigationConfig?.parser == null ? linterConfig?.structureAnalyzer : undefined); - extensions.push( - ...sqlNavigation({ - ...navigationConfig, - parser: navigationParser, - structureAnalyzer: navigationAnalyzer, - }), - ); - } - - return extensions; -} diff --git a/src/sql/hover.ts b/src/sql/hover.ts deleted file mode 100644 index 60fac15..0000000 --- a/src/sql/hover.ts +++ /dev/null @@ -1,769 +0,0 @@ -import type { SQLDialect, SQLNamespace } from "@codemirror/lang-sql"; -import type { Extension } from "@codemirror/state"; -import { EditorView, hoverTooltip, type Tooltip } from "@codemirror/view"; -import { debug } from "../debug.js"; -import { - isArrayNamespace, - isObjectNamespace, - type ResolvedNamespaceItem, - resolveNamespaceItem, -} from "./namespace-utils.js"; -import { NodeSqlParser } from "./parser.js"; -import { QueryContextAnalyzer } from "./query-context.js"; -import { resolveSqlSchema } from "./schema-facet.js"; -import { SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -/** - * Creates a filtered namespace that only includes tables referenced in the query - * @param schema The full schema namespace - * @param tableRefs Set of table names referenced in the query - * @returns Filtered namespace containing only referenced tables - */ -export function filterSchemaByTableRefs( - schema: SQLNamespace, - tableRefs: Set, -): SQLNamespace { - if (tableRefs.size === 0) { - // If no tables are referenced, return empty schema to avoid showing irrelevant columns - return {}; - } - - if (isObjectNamespace(schema)) { - const filtered: { [key: string]: SQLNamespace } = {}; - - for (const [key, value] of Object.entries(schema)) { - // Check if this table is referenced (case-insensitive) - const isReferenced = Array.from(tableRefs).some( - (refTable) => refTable.toLowerCase() === key.toLowerCase(), - ); - - if (isReferenced) { - // This table is referenced in the query - filtered[key] = value; - } else if (isObjectNamespace(value)) { - // Check if any child tables are referenced - const filteredChild = filterSchemaByTableRefs(value, tableRefs); - if (Object.keys(filteredChild).length > 0) { - filtered[key] = filteredChild; - } - } - } - - return filtered; - } - - // For other namespace types, return as-is (they might contain columns from referenced tables) - return schema; -} - -/** - * SQL schema information for hover tooltips - */ -export interface SqlSchema { - [tableName: string]: string[]; -} - -/** - * SQL keyword information - */ -export interface SqlKeywordInfo { - description?: string; - syntax?: string; - example?: string; - metadata?: Record; -} - -/** - * Data passed to keyword tooltip renderers - */ -export interface KeywordTooltipData { - keyword: string; - info: SqlKeywordInfo; -} - -/** - * Data passed to namespace tooltip renderers (namespace, table, column) - */ -export interface NamespaceTooltipData { - item: ResolvedNamespaceItem; - /** The word being hovered over */ - word: string; - /** The resolved schema context */ - resolvedSchema: SQLNamespace; - /** - * When the hovered word was an alias (or alias-qualified), the dotted table - * path the alias resolves to, e.g. `users` for `SELECT u.name FROM users u`. - */ - aliasFor?: string; -} - -/** - * Internal data structure for passing parsed tooltip information to create function - */ -interface TooltipCreateData { - word: string; - view: EditorView; - pos: number; - tooltipType: "keyword" | "namespace"; - keywordData?: KeywordTooltipData; - namespaceData?: NamespaceTooltipData; -} - -/** - * Configuration for SQL hover tooltips - */ -export interface SqlHoverConfig { - /** - * Database schema for table and column information. - * Falls back to the shared `sqlSchemaFacet` when not provided. - */ - schema?: SQLNamespace | ((view: EditorView) => SQLNamespace); - /** SQL dialect for keyword information */ - dialect?: SQLDialect | ((view: EditorView) => SQLDialect); - /** Custom keyword information */ - keywords?: - | Record - | ((view: EditorView) => Promise>); - /** Hover delay in milliseconds (default: 300) */ - hoverTime?: number; - /** Enable hover for keywords (default: true) */ - enableKeywords?: boolean; - /** Enable hover for tables (default: true) */ - enableTables?: boolean; - /** Enable hover for columns (default: true) */ - enableColumns?: boolean; - /** Enable fuzzy search for namespace items (default: false) */ - enableFuzzySearch?: boolean; - /** Custom SQL parser instance to use for query analysis */ - parser?: SqlParser; - /** - * Query-context analyzer to reuse (e.g. shared with - * `aliasColumnCompletionSource`), so each statement is only analyzed once. - */ - contextAnalyzer?: QueryContextAnalyzer; - /** Custom tooltip render function - highest priority, returns HTMLElement or null for fallback */ - tooltipRender?: (word: string, view: EditorView, pos: number) => HTMLElement | null; - /** Custom tooltip renderers for different item types */ - tooltipRenderers?: { - /** Custom renderer for SQL keywords */ - keyword?: (data: KeywordTooltipData) => string; - /** Custom renderer for namespace items (database, schema, generic namespace) */ - namespace?: (data: NamespaceTooltipData) => string; - /** Custom renderer for table items */ - table?: (data: NamespaceTooltipData) => string; - /** Custom renderer for column items */ - column?: (data: NamespaceTooltipData) => string; - }; - /** Custom CSS theme for hover tooltips */ - theme?: Extension; -} - -/** - * Creates the hover source used by the sqlHover extension - */ -function createHoverSource( - config: SqlHoverConfig = {}, -): (view: EditorView, pos: number, side: number) => Promise { - const { - schema, - keywords = {}, - enableKeywords = true, - enableTables = true, - enableColumns = true, - enableFuzzySearch = true, - parser = new NodeSqlParser(), - tooltipRender, - tooltipRenderers = {}, - } = config; - - const structureAnalyzer = new SqlStructureAnalyzer(parser); - const contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - - let keywordsPromise: Promise> | null = null; - - return async (view: EditorView, pos: number, side: number): Promise => { - const { from, to, text } = view.state.doc.lineAt(pos); - let start = pos; - let end = pos; - - if (keywordsPromise === null) { - keywordsPromise = - typeof keywords === "function" ? keywords(view) : Promise.resolve(keywords); - } - - let resolvedKeywords: Record; - try { - resolvedKeywords = await keywordsPromise; - } catch (error) { - // Don't cache a failed fetch; retry on the next hover - keywordsPromise = null; - debug("failed to resolve keywords", error); - resolvedKeywords = {}; - } - - // Find word boundaries (including dots for table.column syntax) - while (start > from && /[\w.]/.test(text[start - from - 1] ?? "")) start--; - while (end < to && /[\w.]/.test(text[end - from] ?? "")) end++; - - // Validate pointer position within word - if ((start === pos && side < 0) || (end === pos && side > 0)) { - return null; - } - - const word = text.slice(start - from, end - from).toLowerCase(); - if (!word || word.length === 0) { - return null; - } - - const resolvedSchema = resolveSqlSchema(schema, view) ?? {}; - - let createData: TooltipCreateData | null = null; - - debug(`hover word: '${word}'`); - - // Implement preference order: - // 1. Look in keywords if it exists - // 2. Look for it in SQLNamespace as is - // 3. If neither, look in SQLNamespace and try to guess (fuzzy match) - - // Step 1: If no namespace match, try keywords - // Own-property check so Object.prototype members don't match - const keywordInfo = Object.hasOwn(resolvedKeywords, word) - ? resolvedKeywords[word] - : undefined; - if (!createData && enableKeywords && keywordInfo) { - debug("keywordResult", word, keywordInfo); - const keywordData: KeywordTooltipData = { keyword: word, info: keywordInfo }; - createData = { - word, - view, - pos, - tooltipType: "keyword", - keywordData, - }; - } - - // Step 2: Try to resolve directly in SQLNamespace (query-aware) - if (!createData && (enableTables || enableColumns) && resolvedSchema) { - // Scope the analysis to the statement containing the hover position, so - // tables from other statements in a multi-statement doc don't leak in - const statement = await structureAnalyzer.getStatementAtPosition(view.state, pos); - const statementSql = statement - ? view.state.doc.sliceString(statement.from, statement.to) - : view.state.doc.toString(); - const queryContext = await contextAnalyzer.getContext(statementSql, { - state: view.state, - }); - - // Rewrite alias-qualified words through the statement's alias map: - // `u` -> `users`, `u.name` -> `users.name` - const [firstSegment = "", ...restSegments] = word.split("."); - const aliasTarget = queryContext.aliases.get(firstSegment); - const lookupWord = aliasTarget - ? [aliasTarget, ...restSegments].join(".").toLowerCase() - : word; - - const tableRefs = new Set(queryContext.tables.map((table) => table.name.toLowerCase())); - - // Filter schema to only include tables referenced in the current statement - const filteredSchema = filterSchemaByTableRefs(resolvedSchema, tableRefs); - - // Try to resolve in the filtered schema first (query-aware) - let namespaceResult = resolveNamespaceItem(filteredSchema, lookupWord, { - enableFuzzySearch, - }); - - // If no result in filtered schema and no tables were found in the - // statement, fall back to the full schema to show any relevant information - if (!namespaceResult && tableRefs.size === 0) { - namespaceResult = resolveNamespaceItem(resolvedSchema, lookupWord, { - enableFuzzySearch, - }); - } - - // Gate by the resolved semantic type so disabling only tables (or only - // columns) actually disables that kind of tooltip - if (namespaceResult) { - const { semanticType } = namespaceResult; - if ( - (semanticType === "table" && !enableTables) || - (semanticType === "column" && !enableColumns) - ) { - namespaceResult = null; - } - } - - if (namespaceResult) { - debug( - "namespaceResult (query-aware)", - word, - namespaceResult, - "tableRefs:", - Array.from(tableRefs), - ); - const namespaceData: NamespaceTooltipData = { - item: namespaceResult, - word, - resolvedSchema: tableRefs.size > 0 ? filteredSchema : resolvedSchema, - ...(aliasTarget ? { aliasFor: aliasTarget } : {}), - }; - - createData = { - word, - view, - pos, - tooltipType: "namespace", - namespaceData, - }; - } else { - debug("No namespace item found for:", word); - } - } - - // Step 3: Fuzzy matching is handled by resolveNamespaceItem if enableFuzzySearch is true - - if (!createData) { - return null; - } - - const tooltipData = createData; - - return { - pos: start, - end, - above: true, - create(createView: EditorView) { - // Priority 1: Custom tooltipRender function - if (tooltipRender) { - const customElement = tooltipRender(word, createView, pos); - if (customElement) { - return { dom: customElement }; - } - } - - // Priority 2: Custom renderers and default renderers - let tooltipContent: string | null = null; - - if (tooltipData.tooltipType === "keyword" && tooltipData.keywordData) { - tooltipContent = tooltipRenderers.keyword - ? tooltipRenderers.keyword(tooltipData.keywordData) - : createKeywordTooltip(tooltipData.keywordData); - } else if (tooltipData.tooltipType === "namespace" && tooltipData.namespaceData) { - const namespaceData = tooltipData.namespaceData; - const { semanticType } = namespaceData.item; - - if (semanticType === "table" && tooltipRenderers.table) { - tooltipContent = tooltipRenderers.table(namespaceData); - } else if (semanticType === "column" && tooltipRenderers.column) { - tooltipContent = tooltipRenderers.column(namespaceData); - } else if ( - (semanticType === "database" || - semanticType === "schema" || - semanticType === "namespace") && - tooltipRenderers.namespace - ) { - tooltipContent = tooltipRenderers.namespace(namespaceData); - } else { - // Fallback to default renderer - tooltipContent = createNamespaceTooltip(namespaceData.item); - if (namespaceData.aliasFor) { - // Alias targets come from document text, so escape them - const aliasName = escapeHtml(namespaceData.word.split(".")[0] ?? ""); - tooltipContent = - `
${aliasName} is an alias for ${escapeHtml(namespaceData.aliasFor)}
` + - tooltipContent; - } - } - } - - if (!tooltipContent) { - return { dom: document.createElement("div") }; - } - - const dom = document.createElement("div"); - dom.className = "cm-sql-hover-tooltip"; - dom.innerHTML = tooltipContent; - return { dom }; - }, - }; - }; -} - -/** - * Creates a hover tooltip extension for SQL - */ -export function sqlHover(config: SqlHoverConfig = {}): Extension { - const { hoverTime = 300 } = config; - return hoverTooltip(createHoverSource(config), { hoverTime }); -} - -export const exportedForTesting = { createHoverSource }; - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** - * Creates HTML content for namespace-resolved items - */ -function createNamespaceTooltip(item: ResolvedNamespaceItem): string { - const pathStr = item.path.join("."); - const name = item.completion?.label || item.value || item.path[item.path.length - 1] || "unknown"; - - let html = `
`; - html += `
${name} ${item.semanticType}
`; - - // Add semantic-specific descriptions and information - switch (item.semanticType) { - case "database": - html += `
Database${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; - if (item.namespace) { - const childCount = countNamespaceChildren(item.namespace); - if (childCount > 0) { - html += `
Contains ${childCount} schema${childCount !== 1 ? "s" : ""}
`; - } - } - break; - - case "schema": - html += `
Schema${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; - if (pathStr) { - html += `
Path: ${pathStr}
`; - } - if (item.namespace) { - const childCount = countNamespaceChildren(item.namespace); - if (childCount > 0) { - html += `
Contains ${childCount} table${childCount !== 1 ? "s" : ""}
`; - } - } - break; - - case "table": - html += `
Table${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; - if (pathStr) { - const pathParts = item.path; - if (pathParts.length > 1) { - html += `
Schema: ${pathParts.slice(0, -1).join(".")}
`; - } - } - - // Show column information for tables - if (item.namespace && isArrayNamespace(item.namespace)) { - const columns = item.namespace; - if (columns.length > 0) { - html += `
Columns (${columns.length}):
`; - const displayColumns = columns.slice(0, 8); - const columnNames = displayColumns.map((col) => - typeof col === "string" ? col : col.label, - ); - html += columnNames.map((col) => `${col}`).join(", "); - - if (columns.length > 8) { - html += `, and ${columns.length - 8} more...`; - } - html += `
`; - } - } - break; - - case "column": - html += `
Column${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; - if (pathStr) { - const pathParts = item.path; - if (pathParts.length > 1) { - html += `
Table: ${pathParts.slice(0, -1).join(".")}
`; - } - } - break; - default: - html += `
Namespace${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; - if (pathStr) { - html += `
Path: ${pathStr}
`; - } - if (item.namespace) { - const childCount = countNamespaceChildren(item.namespace); - if (childCount > 0) { - html += `
Contains ${childCount} item${childCount !== 1 ? "s" : ""}
`; - } - } - break; - } - - // Add completion-specific info if available - if (item.completion?.info && typeof item.completion.info === "string") { - html += `
${item.completion.info}
`; - } - - html += `
`; - return html; -} - -/** - * Helper function to count children in a namespace - */ -function countNamespaceChildren(namespace: SQLNamespace): number { - if (Array.isArray(namespace)) { - return namespace.length; - } else if (typeof namespace === "object" && namespace !== null) { - if ("self" in namespace && "children" in namespace) { - return 1 + countNamespaceChildren(namespace.children); - } else { - return Object.keys(namespace).length; - } - } - return 0; -} - -/** - * Creates HTML content for keyword tooltips - * Renders metadata as tags if present - */ -function createKeywordTooltip(opts: { keyword: string; info: SqlKeywordInfo }): string { - const { keyword, info } = opts; - - let html = `
`; - html += `
${keyword.toUpperCase()} keyword
`; - html += `
${info.description}
`; - - if (info.syntax) { - html += `
Syntax: ${info.syntax}
`; - } - - if (info.example) { - html += `
Example:
${info.example}
`; - } - - if (info.metadata && typeof info.metadata === "object" && Object.keys(info.metadata).length > 0) { - html += ``; - } - - html += `
`; - return html; -} - -/** - * Creates HTML content for table tooltips - */ -function createTableTooltip(opts: { - tableName: string; - columns: string[]; - metadata?: Record; -}): string { - const { tableName, columns, metadata } = opts; - - let html = `
`; - html += `
${tableName} table
`; - html += `
Table with ${columns.length} column${columns.length !== 1 ? "s" : ""}
`; - - if (columns.length > 0) { - html += `
Columns:
`; - const displayColumns = columns.slice(0, 10); // Show max 10 columns - html += displayColumns.map((col) => `${col}`).join(", "); - - if (columns.length > 10) { - html += `, and ${columns.length - 10} more...`; - } - html += `
`; - } - - if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { - html += ``; - } - - html += `
`; - return html; -} - -/** - * Creates HTML content for column tooltips - */ -function createColumnTooltip(opts: { - tableName: string; - columnName: string; - schema: SqlSchema; - metadata?: Record; -}): string { - const { tableName, columnName, schema, metadata } = opts; - - let html = `
`; - html += `
${columnName} column
`; - html += `
Column in table ${tableName}
`; - - const allColumns = schema[tableName]; - if (allColumns && allColumns.length > 1) { - const otherColumns = allColumns.filter((col) => col !== columnName); - if (otherColumns.length > 0) { - html += ``; - } - } - - if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { - html += ``; - } - - html += `
`; - return html; -} - -export const DefaultSqlTooltipRenders = { - keyword: createKeywordTooltip, - table: createTableTooltip, - column: createColumnTooltip, - namespace: createNamespaceTooltip, -}; - -/** - * Default CSS styles for hover tooltips - */ -export const defaultSqlHoverTheme = (theme: "light" | "dark" = "light"): Extension => { - // Theme-dependent color variables - const lightTheme = { - tooltipBg: "#ffffff", - tooltipBorder: "#e5e7eb", - tooltipText: "#374151", - tooltipTypeBg: "#f3f4f6", - tooltipTypeText: "#6b7280", - tooltipChildren: "#6b7280", - codeBg: "#f9fafb", - codeText: "#1f2937", - strong: "#111827", - em: "#6b7280", - header: "#111827", - info: "#374151", - related: "#374151", - path: "#374151", - example: "#374151", - columns: "#374151", - syntax: "#374151", - }; - - const darkTheme = { - tooltipBg: "#1f2937", - tooltipBorder: "#374151", - tooltipText: "#f9fafb", - tooltipTypeBg: "#374151", - tooltipTypeText: "#9ca3af", - tooltipChildren: "#9ca3af", - codeBg: "#374151", - codeText: "#f3f4f6", - strong: "#ffffff", - em: "#9ca3af", - header: "#ffffff", - info: "#d1d5db", - related: "#d1d5db", - path: "#d1d5db", - example: "#d1d5db", - columns: "#d1d5db", - syntax: "#d1d5db", - }; - - const colors = theme === "dark" ? darkTheme : lightTheme; - - return EditorView.theme({ - ".cm-sql-hover-tooltip": { - padding: "8px 12px", - backgroundColor: colors.tooltipBg, - border: `1px solid ${colors.tooltipBorder}`, - borderRadius: "6px", - boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", - fontSize: "13px", - lineHeight: "1.4", - maxWidth: "320px", - fontFamily: "system-ui, -apple-system, sans-serif", - color: colors.tooltipText, - }, - ".cm-sql-hover-tooltip .sql-hover-header": { - marginBottom: "6px", - display: "flex", - alignItems: "center", - gap: "6px", - color: colors.header, - }, - ".cm-sql-hover-tooltip .sql-hover-type": { - fontSize: "11px", - padding: "2px 6px", - backgroundColor: colors.tooltipTypeBg, - color: colors.tooltipTypeText, - borderRadius: "4px", - fontWeight: "500", - }, - ".cm-sql-hover-tooltip .sql-hover-description": { - color: colors.info, - marginBottom: "8px", - }, - ".cm-sql-hover-tooltip .sql-hover-syntax": { - marginBottom: "8px", - color: colors.syntax, - }, - ".cm-sql-hover-tooltip .sql-hover-example": { - marginBottom: "4px", - color: colors.example, - }, - ".cm-sql-hover-tooltip .sql-hover-columns": { - marginBottom: "4px", - color: colors.columns, - }, - ".cm-sql-hover-tooltip .sql-hover-related": { - marginBottom: "4px", - color: colors.related, - }, - ".cm-sql-hover-tooltip .sql-hover-path": { - marginBottom: "4px", - color: colors.path, - }, - ".cm-sql-hover-tooltip .sql-hover-info": { - marginBottom: "4px", - color: colors.info, - }, - ".cm-sql-hover-tooltip .sql-hover-alias": { - marginBottom: "6px", - color: colors.tooltipChildren, - fontSize: "12px", - }, - ".cm-sql-hover-tooltip .sql-hover-children": { - marginBottom: "4px", - color: colors.tooltipChildren, - fontSize: "12px", - }, - ".cm-sql-hover-tooltip code": { - backgroundColor: colors.codeBg, - padding: "1px 4px", - borderRadius: "3px", - fontSize: "12px", - fontFamily: "ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace", - color: colors.codeText, - }, - ".cm-sql-hover-tooltip strong": { - fontWeight: "600", - color: colors.strong, - }, - ".cm-sql-hover-tooltip em": { - fontStyle: "italic", - color: colors.em, - }, - }); -}; diff --git a/src/sql/namespace-utils.ts b/src/sql/namespace-utils.ts deleted file mode 100644 index c765f52..0000000 --- a/src/sql/namespace-utils.ts +++ /dev/null @@ -1,681 +0,0 @@ -import type { Completion } from "@codemirror/autocomplete"; -import type { SQLNamespace } from "@codemirror/lang-sql"; - -/** - * Semantic type for SQL namespace items - */ -export type SemanticType = "database" | "schema" | "table" | "column" | "namespace"; - -/** - * Represents a resolved namespace item with its path and metadata - */ -export interface ResolvedNamespaceItem { - /** The completion object if this is a terminal node */ - completion?: Completion; - /** The string value if this is a string terminal */ - value?: string; - /** The full path to this item */ - path: string[]; - /** The basic type of this item */ - type: "completion" | "string" | "namespace"; - /** The semantic SQL type of this item */ - semanticType: SemanticType; - /** The original namespace node */ - namespace?: SQLNamespace; -} - -/** - * Configuration for namespace search operations - */ -export interface NamespaceSearchConfig { - /** Maximum depth to search (default: 10) */ - maxDepth?: number; - /** Whether to perform case-sensitive matching (default: false) */ - caseSensitive?: boolean; - /** Whether to allow partial matching (default: true) */ - allowPartialMatch?: boolean; - /** Whether to enable fuzzy search (default: false) */ - enableFuzzySearch?: boolean; -} - -/** - * Checks if a namespace node is an object with string keys - */ -export function isObjectNamespace( - namespace: SQLNamespace, -): namespace is { [name: string]: SQLNamespace } { - return ( - typeof namespace === "object" && !Array.isArray(namespace) && !isSelfChildrenNamespace(namespace) - ); -} - -/** - * Checks if a namespace node has self and children properties - */ -export function isSelfChildrenNamespace( - namespace: SQLNamespace, -): namespace is { self: Completion; children: SQLNamespace } { - if (typeof namespace !== "object" || Array.isArray(namespace)) { - return false; - } - if (!("self" in namespace) || !("children" in namespace)) { - return false; - } - // A self tag is a Completion; a table named "self" holds a namespace instead - const self: unknown = namespace.self; - return ( - typeof self === "object" && - self !== null && - !Array.isArray(self) && - typeof (self as { label?: unknown }).label === "string" - ); -} - -/** - * Checks if a namespace node is an array of completions/strings - */ -export function isArrayNamespace( - namespace: SQLNamespace, -): namespace is readonly (Completion | string)[] { - return Array.isArray(namespace); -} - -/** - * Determines the semantic type of an item based on its position and context - */ -function determineSemanticType( - path: string[], - type: "completion" | "string" | "namespace", - namespace?: SQLNamespace, - parentNamespace?: SQLNamespace, -): SemanticType { - // The semantic depth is the number of namespace levels, not the path length - // For self-children structures, the depth should be based on the logical nesting level - const depth = path.length; - - // For leaf items (strings or completions in arrays), they are always columns - if ( - type === "string" || - (type === "completion" && parentNamespace && isArrayNamespace(parentNamespace)) - ) { - return "column"; - } - - // For namespace items, determine based on structure - if (type === "namespace" && namespace) { - if (isArrayNamespace(namespace)) { - // Arrays represent column collections, so the array itself represents a table - return "table"; - } - - // For object namespaces, check if they contain tables (arrays) - if (isObjectNamespace(namespace)) { - const hasTableChildren = Object.values(namespace).some( - (child) => - isArrayNamespace(child) || - (isSelfChildrenNamespace(child) && isArrayNamespace(child.children)), - ); - - if (hasTableChildren) { - // This namespace contains tables, so it's a schema or database - return depth <= 1 ? "database" : "schema"; - } else { - // This namespace contains other namespaces - return depth === 0 ? "database" : "namespace"; - } - } - } - - // For completion items with self-children structure - if (type === "completion" && namespace) { - if (isArrayNamespace(namespace)) { - // This completion has column children, so it's a table - return "table"; - } else { - // This completion has namespace children - // Check if the children contain tables (arrays) to determine if this is a database or schema - if (isObjectNamespace(namespace)) { - const hasTableChildren = Object.values(namespace).some( - (child) => - isArrayNamespace(child) || - (isSelfChildrenNamespace(child) && isArrayNamespace(child.children)), - ); - - if (hasTableChildren) { - // Contains tables directly, could be either database or schema depending on depth - // For self-children completions, depth 1 means it's actually a root-level database - return depth <= 1 ? "database" : "schema"; - } else { - // Contains other namespaces, so this is a database (unless deeply nested) - // For self-children completions, depth 1 means it's actually a root-level database - return depth <= 1 ? "database" : "schema"; - } - } else { - // For other types of children - // For self-children completions, depth 1 means it's actually a root-level database - return depth <= 1 ? "database" : "schema"; - } - } - } - - // Fallback based on depth - switch (depth) { - case 0: - return "database"; - case 1: - return "schema"; - default: - return "namespace"; - } -} - -/** - * Traverses a namespace following a dotted path - * @param namespace The root namespace to search in - * @param path The dotted path to traverse (e.g., "db.catalog.table.column"), or - * pre-split segments (preserves identifier boundaries when a segment - * contains a dot, e.g. a `"my.db"` quoted identifier) - * @param config Configuration options - * @returns The resolved item or null if not found - */ -export function traverseNamespacePath( - namespace: SQLNamespace, - path: string | readonly string[], - config: NamespaceSearchConfig = {}, -): ResolvedNamespaceItem | null { - const { maxDepth = 10 } = config; - const isEmptyPath = typeof path === "string" ? path === "" : path.length === 0; - - // Handle special case of empty path for self-children namespace - if (isEmptyPath && isSelfChildrenNamespace(namespace)) { - const semanticType = determineSemanticType([], "completion", namespace.children); - return { - completion: namespace.self, - path: [], - type: "completion", - semanticType, - namespace: namespace.children, - }; - } - - const pathParts = (typeof path === "string" ? path.split(".") : path).filter( - (part) => part.length > 0, - ); - - if (pathParts.length === 0) { - // Empty path returns null for non-self-children namespaces - return null; - } - - if (pathParts.length > maxDepth) { - return null; - } - - return traverseNamespaceRecursive(namespace, pathParts, [], config); -} - -/** - * Recursive helper for namespace traversal - */ -function traverseNamespaceRecursive( - namespace: SQLNamespace, - remainingPath: string[], - currentPath: string[], - config: NamespaceSearchConfig, - parentNamespace?: SQLNamespace, -): ResolvedNamespaceItem | null { - if (remainingPath.length === 0) { - // We've reached the end of the path - if (isSelfChildrenNamespace(namespace)) { - const semanticType = determineSemanticType( - currentPath, - "completion", - namespace.children, - parentNamespace, - ); - return { - completion: namespace.self, - path: currentPath, - type: "completion", - semanticType, - namespace: namespace.children, - }; - } - const semanticType = determineSemanticType( - currentPath, - "namespace", - namespace, - parentNamespace, - ); - return { - path: currentPath, - type: "namespace", - semanticType, - namespace, - }; - } - - const [currentSegment, ...restPath] = remainingPath; - const { caseSensitive = false } = config; - - if (!currentSegment) { - return null; - } - - if (isObjectNamespace(namespace)) { - // Search through object keys - const targetKey = caseSensitive - ? Object.keys(namespace).find((key) => key === currentSegment) - : Object.keys(namespace).find((key) => key.toLowerCase() === currentSegment.toLowerCase()); - - if (targetKey) { - const childNamespace = namespace[targetKey]; - if (childNamespace) { - return traverseNamespaceRecursive( - childNamespace, - restPath, - [...currentPath, targetKey], - config, - namespace, - ); - } - } - } else if (isSelfChildrenNamespace(namespace)) { - // If this node has self/children, we need to check if the current segment matches the self - // and then continue with children for the rest of the path - return traverseNamespaceRecursive( - namespace.children, - remainingPath, - currentPath, - config, - namespace, - ); - } else if (isArrayNamespace(namespace)) { - // Check if any item in the array matches - for (const item of namespace) { - if (typeof item === "string") { - const matches = caseSensitive - ? item === currentSegment - : item.toLowerCase() === currentSegment.toLowerCase(); - - if (matches && restPath.length === 0) { - const semanticType = determineSemanticType( - [...currentPath, item], - "string", - undefined, - namespace, - ); - return { - value: item, - path: [...currentPath, item], - type: "string", - semanticType, - }; - } - } else { - // It's a Completion object - const matches = caseSensitive - ? item.label === currentSegment - : item.label.toLowerCase() === currentSegment.toLowerCase(); - - if (matches && restPath.length === 0) { - const semanticType = determineSemanticType( - [...currentPath, item.label], - "completion", - undefined, - namespace, - ); - return { - completion: item, - path: [...currentPath, item.label], - type: "completion", - semanticType, - }; - } - } - } - } - - return null; -} - -/** - * Finds all possible completions that match a prefix - * @param namespace The namespace to search in - * @param prefix The prefix to match (can be dotted like "db.table") - * @param config Configuration options - * @returns Array of resolved items that match the prefix - */ -export function findNamespaceCompletions( - namespace: SQLNamespace, - prefix: string, - config: NamespaceSearchConfig = {}, -): ResolvedNamespaceItem[] { - const results: ResolvedNamespaceItem[] = []; - - if (prefix.includes(".")) { - // Handle dotted prefixes like "db.table" - const lastDotIndex = prefix.lastIndexOf("."); - const basePath = prefix.substring(0, lastDotIndex); - const finalSegment = prefix.substring(lastDotIndex + 1); - - // First traverse to the base path - const baseNode = traverseNamespacePath(namespace, basePath, config); - if (baseNode?.namespace) { - // Then find completions in the target namespace - return findCompletionsInNamespace(baseNode.namespace, finalSegment, baseNode.path, config); - } - } else { - // Simple prefix, search at root level - return findCompletionsInNamespace(namespace, prefix, [], config); - } - - return results; -} - -/** - * Finds completions within a specific namespace node - */ -function findCompletionsInNamespace( - namespace: SQLNamespace, - prefix: string, - basePath: string[], - config: NamespaceSearchConfig, -): ResolvedNamespaceItem[] { - const results: ResolvedNamespaceItem[] = []; - const { caseSensitive = false, allowPartialMatch = true } = config; - - if (isObjectNamespace(namespace)) { - for (const [key, value] of Object.entries(namespace)) { - const matches = allowPartialMatch - ? caseSensitive - ? key.startsWith(prefix) - : key.toLowerCase().startsWith(prefix.toLowerCase()) - : caseSensitive - ? key === prefix - : key.toLowerCase() === prefix.toLowerCase(); - - if (matches) { - if (isSelfChildrenNamespace(value)) { - const semanticType = determineSemanticType( - [...basePath, key], - "completion", - value.children, - namespace, - ); - results.push({ - completion: value.self, - path: [...basePath, key], - type: "completion", - semanticType, - namespace: value.children, - }); - } else { - const semanticType = determineSemanticType( - [...basePath, key], - "namespace", - value, - namespace, - ); - results.push({ - path: [...basePath, key], - type: "namespace", - semanticType, - namespace: value, - }); - } - } - } - } else if (isSelfChildrenNamespace(namespace)) { - // If we have a self node, include it if it matches - const selfMatches = allowPartialMatch - ? caseSensitive - ? namespace.self.label.startsWith(prefix) - : namespace.self.label.toLowerCase().startsWith(prefix.toLowerCase()) - : caseSensitive - ? namespace.self.label === prefix - : namespace.self.label.toLowerCase() === prefix.toLowerCase(); - - if (selfMatches) { - const semanticType = determineSemanticType( - [...basePath, namespace.self.label], - "completion", - namespace.children, - ); - results.push({ - completion: namespace.self, - path: [...basePath, namespace.self.label], - type: "completion", - semanticType, - namespace: namespace.children, - }); - } - - // Also search in children - results.push(...findCompletionsInNamespace(namespace.children, prefix, basePath, config)); - } else if (isArrayNamespace(namespace)) { - for (const item of namespace) { - if (typeof item === "string") { - const matches = allowPartialMatch - ? caseSensitive - ? item.startsWith(prefix) - : item.toLowerCase().startsWith(prefix.toLowerCase()) - : caseSensitive - ? item === prefix - : item.toLowerCase() === prefix.toLowerCase(); - - if (matches) { - const semanticType = determineSemanticType( - [...basePath, item], - "string", - undefined, - namespace, - ); - results.push({ - value: item, - path: [...basePath, item], - type: "string", - semanticType, - }); - } - } else { - // It's a Completion object - const matches = allowPartialMatch - ? caseSensitive - ? item.label.startsWith(prefix) - : item.label.toLowerCase().startsWith(prefix.toLowerCase()) - : caseSensitive - ? item.label === prefix - : item.label.toLowerCase() === prefix.toLowerCase(); - - if (matches) { - const semanticType = determineSemanticType( - [...basePath, item.label], - "completion", - undefined, - namespace, - ); - results.push({ - completion: item, - path: [...basePath, item.label], - type: "completion", - semanticType, - }); - } - } - } - } - - return results; -} - -/** - * Performs a fuzzy search for items by searching for exact segment matches in the full schema path - * This implements the "crawl back up the tree" functionality with exact segment matching - * @param namespace The namespace to search in - * @param identifier The identifier to search for - * @param config Configuration options - * @returns Array of possible matches ranked by relevance - */ -export function findNamespaceItemByEndMatch( - namespace: SQLNamespace, - identifier: string, - config: NamespaceSearchConfig = {}, -): ResolvedNamespaceItem[] { - const results: ResolvedNamespaceItem[] = []; - const { maxDepth = 10 } = config; - - // Recursively search through the namespace - collectAllItems(namespace, [], results, maxDepth); - - // Filter results that have the identifier as an exact segment match anywhere in the path - const { caseSensitive = false } = config; - const matchingResults = results.filter((item) => { - // Check if any segment in the path exactly matches the identifier - return item.path.some((segment) => - caseSensitive ? segment === identifier : segment.toLowerCase() === identifier.toLowerCase(), - ); - }); - - // Sort by path length (shorter paths are more specific/relevant) - // Also prioritize matches where the identifier is at the end of the path - return matchingResults.sort((a, b) => { - const aIsLastSegment = caseSensitive - ? a.path[a.path.length - 1] === identifier - : a.path[a.path.length - 1]?.toLowerCase() === identifier.toLowerCase(); - const bIsLastSegment = caseSensitive - ? b.path[b.path.length - 1] === identifier - : b.path[b.path.length - 1]?.toLowerCase() === identifier.toLowerCase(); - - // Prioritize end matches, then by path length - if (aIsLastSegment && !bIsLastSegment) return -1; - if (!aIsLastSegment && bIsLastSegment) return 1; - return a.path.length - b.path.length; - }); -} - -/** - * Recursively collects all items from a namespace - */ -function collectAllItems( - namespace: SQLNamespace, - currentPath: string[], - results: ResolvedNamespaceItem[], - maxDepth: number, -): void { - if (currentPath.length >= maxDepth) { - return; - } - - if (isObjectNamespace(namespace)) { - for (const [key, value] of Object.entries(namespace)) { - const newPath = [...currentPath, key]; - - if (isSelfChildrenNamespace(value)) { - const semanticType = determineSemanticType( - newPath, - "completion", - value.children, - namespace, - ); - results.push({ - completion: value.self, - path: newPath, - type: "completion", - semanticType, - namespace: value.children, - }); - collectAllItems(value.children, newPath, results, maxDepth); - } else { - const semanticType = determineSemanticType(newPath, "namespace", value, namespace); - results.push({ - path: newPath, - type: "namespace", - semanticType, - namespace: value, - }); - collectAllItems(value, newPath, results, maxDepth); - } - } - } else if (isSelfChildrenNamespace(namespace)) { - const semanticType = determineSemanticType(currentPath, "completion", namespace.children); - results.push({ - completion: namespace.self, - path: currentPath, - type: "completion", - semanticType, - namespace: namespace.children, - }); - collectAllItems(namespace.children, currentPath, results, maxDepth); - } else if (isArrayNamespace(namespace)) { - for (const item of namespace) { - if (typeof item === "string") { - const semanticType = determineSemanticType( - [...currentPath, item], - "string", - undefined, - namespace, - ); - results.push({ - value: item, - path: [...currentPath, item], - type: "string", - semanticType, - }); - } else { - const semanticType = determineSemanticType( - [...currentPath, item.label], - "completion", - undefined, - namespace, - ); - results.push({ - completion: item, - path: [...currentPath, item.label], - type: "completion", - semanticType, - }); - } - } - } -} - -/** - * Gets the most relevant namespace item using the preference order: - * 1. Exact match in SQLNamespace - * 2. Partial/fuzzy match by end identifier - * @param namespace The namespace to search in - * @param identifier The identifier to resolve - * @param config Configuration options - * @returns The best matching item or null if none found - */ -export function resolveNamespaceItem( - namespace: SQLNamespace, - identifier: string, - config: NamespaceSearchConfig = {}, -): ResolvedNamespaceItem | null { - const { enableFuzzySearch = false } = config; - - // First try exact path match - const exactMatch = traverseNamespacePath(namespace, identifier, config); - if (exactMatch) { - return exactMatch; - } - - // Then try prefix completion (for partial typing) - const completions = findNamespaceCompletions(namespace, identifier, config); - if (completions.length > 0) { - // Return the first completion (should be most relevant) - return completions[0] || null; - } - - // Finally try end-match fuzzy search (only if enabled) - if (enableFuzzySearch) { - const endMatches = findNamespaceItemByEndMatch(namespace, identifier, config); - if (endMatches.length > 0) { - return endMatches[0] || null; - } - } - - return null; -} diff --git a/src/sql/navigation-extension.ts b/src/sql/navigation-extension.ts deleted file mode 100644 index 3a54bc1..0000000 --- a/src/sql/navigation-extension.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { type Extension, StateEffect, StateField } from "@codemirror/state"; -import { - Decoration, - type DecorationSet, - EditorView, - keymap, - ViewPlugin, - type ViewUpdate, -} from "@codemirror/view"; -import { debug } from "../debug.js"; -import { - identifierTokenAt, - type SqlRange, - SqlReferenceResolver, - type SqlReferenceConfig, - type SqlReferenceResult, -} from "./references.js"; - -/** - * Configuration for the SQL navigation features (go-to-definition, document - * highlights, rename) - */ -export interface SqlNavigationConfig extends SqlReferenceConfig { - /** - * Register keybindings — go-to-definition and rename (default: false). - * Highlights and Mod-click/Mod-hover need no keybindings and are always on. - */ - keymap?: boolean; - /** Keys bound to go-to-definition when `keymap` is enabled (default: ["F12", "Mod-b"]) */ - gotoDefinitionKeys?: string[]; - /** Key bound to rename when `keymap` is enabled (default: "F2") */ - renameKey?: string; - /** - * Callback that supplies the new name for a rename, so hosts can plug in - * their own input UI. Defaults to `window.prompt`. Return null to cancel. - */ - prompt?: (currentName: string) => string | null | Promise; - /** Debounce for reference highlighting, in ms (default: 150) */ - highlightDelay?: number; - /** Shared resolver instance (created from the config when omitted) */ - resolver?: SqlReferenceResolver; -} - -function getResolver(config: SqlNavigationConfig): SqlReferenceResolver { - return config.resolver ?? new SqlReferenceResolver(config); -} - -const navigationTheme = EditorView.baseTheme({ - ".cm-sqlReference": { backgroundColor: "rgba(96, 165, 250, 0.18)" }, - ".cm-sqlReference.cm-sqlDefinition": { backgroundColor: "rgba(96, 165, 250, 0.35)" }, - ".cm-sqlGotoTarget": { textDecoration: "underline", cursor: "pointer" }, -}); - -const referenceMark = Decoration.mark({ class: "cm-sqlReference" }); -const definitionMark = Decoration.mark({ class: "cm-sqlReference cm-sqlDefinition" }); -const gotoTargetMark = Decoration.mark({ class: "cm-sqlGotoTarget" }); - -function markReferences(result: SqlReferenceResult): DecorationSet { - return Decoration.set( - result.references.map((range) => - (range.from === result.definition.from ? definitionMark : referenceMark).range( - range.from, - range.to, - ), - ), - true, - ); -} - -const setReferenceHighlights = StateEffect.define(); - -const referenceHighlightField = StateField.define({ - create: () => Decoration.none, - update(decorations, tr) { - for (const effect of tr.effects) { - if (effect.is(setReferenceHighlights)) { - return effect.value; - } - } - return tr.docChanged ? decorations.map(tr.changes) : decorations; - }, - provide: (field) => EditorView.decorations.from(field), -}); - -/** - * Highlights all references (and the definition) of the statement-local - * identifier under the cursor — CTE names, table aliases, select aliases. - */ -export function sqlHighlightReferences(config: SqlNavigationConfig = {}): Extension { - const resolver = getResolver(config); - const delay = config.highlightDelay ?? 150; - - const plugin = ViewPlugin.fromClass( - class { - private timer: ReturnType | null = null; - private generation = 0; - - constructor(private view: EditorView) { - this.schedule(); - } - - update(update: ViewUpdate) { - if (update.selectionSet || update.docChanged) { - this.schedule(); - } - } - - private schedule() { - this.generation++; - if (this.timer != null) { - clearTimeout(this.timer); - } - this.timer = setTimeout(() => { - this.timer = null; - void this.run(); - }, delay); - } - - private async run() { - const generation = this.generation; - const state = this.view.state; - const selection = state.selection.main; - - let decorations: DecorationSet = Decoration.none; - if (selection.empty) { - try { - const result = await resolver.resolve(state, selection.head); - if (result) { - decorations = markReferences(result); - } - } catch (error) { - debug("sql reference highlight failed", error); - } - } - - if (generation !== this.generation) { - return; - } - const current = this.view.state.field(referenceHighlightField, false); - if (decorations.size === 0 && (current == null || current.size === 0)) { - return; - } - this.view.dispatch({ effects: setReferenceHighlights.of(decorations) }); - } - - destroy() { - this.generation++; - if (this.timer != null) { - clearTimeout(this.timer); - } - } - }, - ); - - return [referenceHighlightField, plugin, navigationTheme]; -} - -/** - * Jumps the selection to the definition of the identifier at `pos` (the - * cursor when omitted). Returns false when nothing resolvable is there. - */ -export async function gotoSqlDefinition( - view: EditorView, - pos?: number, - config: SqlNavigationConfig = {}, -): Promise { - const state = view.state; - const at = pos ?? state.selection.main.head; - const result = await getResolver(config).resolve(state, at); - // Discard stale ranges if the document changed while resolving - if (!result || view.state !== state) { - return false; - } - view.dispatch({ - selection: { anchor: result.definition.from, head: result.definition.to }, - scrollIntoView: true, - userEvent: "select", - }); - return true; -} - -const setGotoTarget = StateEffect.define(); - -const gotoTargetField = StateField.define({ - create: () => Decoration.none, - update(decorations, tr) { - for (const effect of tr.effects) { - if (effect.is(setGotoTarget)) { - return effect.value; - } - } - return tr.docChanged ? decorations.map(tr.changes) : decorations; - }, - provide: (field) => EditorView.decorations.from(field), -}); - -/** - * Go-to-definition on Mod-click (Cmd on macOS, Ctrl elsewhere), with an - * underline affordance while hovering a resolvable identifier with the - * modifier held. Mod-clicks on anything unresolvable keep CodeMirror's - * default behavior (e.g. adding cursors). - */ -export function sqlGotoDefinition(config: SqlNavigationConfig = {}): Extension { - const resolver = getResolver(config); - - const plugin = ViewPlugin.fromClass( - class { - /** Token currently underlined, with its resolution (null while pending) */ - private hovered: { from: number; to: number; result: SqlReferenceResult | null } | null = - null; - - constructor(private view: EditorView) {} - - update(update: ViewUpdate) { - if (update.docChanged) { - this.hovered = null; - } - } - - resultAt(pos: number): SqlReferenceResult | null { - if (this.hovered && pos >= this.hovered.from && pos <= this.hovered.to) { - return this.hovered.result; - } - return null; - } - - clear() { - if (this.hovered) { - this.hovered = null; - const field = this.view.state.field(gotoTargetField, false); - if (field && field.size > 0) { - this.view.dispatch({ effects: setGotoTarget.of(Decoration.none) }); - } - } - } - - async hover(pos: number) { - const token = identifierTokenAt(this.view.state, pos); - if (!token) { - this.clear(); - return; - } - if (this.hovered && this.hovered.from === token.from && this.hovered.to === token.to) { - return; - } - const hovered: { from: number; to: number; result: SqlReferenceResult | null } = { - from: token.from, - to: token.to, - result: null, - }; - this.hovered = hovered; - const state = this.view.state; - try { - const result = await resolver.resolve(state, pos); - if (this.hovered !== hovered || this.view.state !== state) { - return; // moved on or edited while resolving - } - hovered.result = result; - this.view.dispatch({ - effects: setGotoTarget.of( - result - ? Decoration.set([gotoTargetMark.range(token.from, token.to)]) - : Decoration.none, - ), - }); - } catch (error) { - debug("sql goto-definition resolve failed", error); - } - } - - destroy() { - this.hovered = null; - } - }, - { - eventHandlers: { - mousemove(event: MouseEvent, view: EditorView) { - if (!(event.metaKey || event.ctrlKey)) { - this.clear(); - return; - } - const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); - if (pos == null) { - this.clear(); - return; - } - void this.hover(pos); - }, - mousedown(event: MouseEvent, view: EditorView) { - if (!(event.metaKey || event.ctrlKey) || event.button !== 0) { - return false; - } - const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); - if (pos == null) { - return false; - } - // Only intercept when Mod-hover already resolved this token — an - // unresolvable Mod-click falls through to the default behavior - const result = this.resultAt(pos); - if (!result) { - return false; - } - view.dispatch({ - selection: { anchor: result.definition.from, head: result.definition.to }, - scrollIntoView: true, - userEvent: "select.pointer", - }); - return true; - }, - }, - }, - ); - - return [gotoTargetField, plugin, navigationTheme]; -} - -const defaultPrompt = (currentName: string): string | null => - typeof window !== "undefined" && typeof window.prompt === "function" - ? window.prompt(`Rename '${currentName}' to:`, currentName) - : null; - -/** New names must be plain identifiers so the rewritten SQL stays valid */ -const VALID_IDENTIFIER = /^[A-Za-z_][\w$]*$/; - -/** Reserved words that would break the statement if used as a bare name */ -// prettier-ignore -const RESERVED_WORDS = new Set([ - "all", "and", "as", "asc", "between", "by", "case", "cross", "delete", - "desc", "distinct", "else", "end", "except", "exists", "fetch", "from", - "full", "group", "having", "in", "inner", "insert", "intersect", "into", - "is", "join", "lateral", "left", "like", "limit", "natural", "not", "null", - "offset", "on", "or", "order", "outer", "qualify", "recursive", "right", - "select", "set", "table", "then", "union", "update", "using", "values", - "when", "where", "window", "with", -]); - -/** - * Renames the statement-local identifier (CTE name, table alias, or select - * alias) at the cursor: replaces the definition and every reference in a - * single transaction (one undo step). Refuses (returns false) when the - * identifier is not confidently resolvable — never falls back to a plain - * text search-and-replace. - */ -export async function renameSqlIdentifier( - view: EditorView, - config: SqlNavigationConfig = {}, -): Promise { - const state = view.state; - const result = await getResolver(config).resolve(state, state.selection.main.head); - if (!result) { - return false; - } - const newName = await (config.prompt ?? defaultPrompt)(result.name); - if ( - !newName || - newName === result.name || - !VALID_IDENTIFIER.test(newName) || - RESERVED_WORDS.has(newName.toLowerCase()) - ) { - return false; - } - if (view.state !== state) { - return false; // the document changed while the prompt was open - } - view.dispatch({ - changes: result.references.map((range: SqlRange) => ({ - from: range.from, - to: range.to, - insert: newName, - })), - userEvent: "rename", - }); - return true; -} - -/** - * Keybindings for go-to-definition (F12 / Mod-b) and rename (F2). Not - * included by default — enable via `sqlNavigation({ keymap: true })` or add - * this extension directly. - */ -export function sqlNavigationKeymap(config: SqlNavigationConfig = {}): Extension { - const resolved: SqlNavigationConfig = { ...config, resolver: getResolver(config) }; - const gotoKeys = config.gotoDefinitionKeys ?? ["F12", "Mod-b"]; - const renameKey = config.renameKey ?? "F2"; - return keymap.of([ - ...gotoKeys.map((key) => ({ - key, - run: (view: EditorView) => { - void gotoSqlDefinition(view, undefined, resolved); - return true; - }, - })), - { - key: renameKey, - run: (view: EditorView) => { - void renameSqlIdentifier(view, resolved); - return true; - }, - }, - ]); -} - -/** - * The full SQL navigation bundle: document highlights, Mod-click/Mod-hover - * go-to-definition, and (opt-in via `keymap: true`) F12/Mod-b/F2 bindings. - */ -export function sqlNavigation(config: SqlNavigationConfig = {}): Extension[] { - const resolved: SqlNavigationConfig = { ...config, resolver: getResolver(config) }; - const extensions: Extension[] = [ - sqlHighlightReferences(resolved), - sqlGotoDefinition(resolved), - ]; - if (config.keymap) { - extensions.push(sqlNavigationKeymap(resolved)); - } - return extensions; -} diff --git a/src/sql/parser.ts b/src/sql/parser.ts deleted file mode 100644 index 8ef471e..0000000 --- a/src/sql/parser.ts +++ /dev/null @@ -1,384 +0,0 @@ -import type { EditorState } from "@codemirror/state"; -import type { AST, Option, Parser } from "node-sql-parser"; -import { debug } from "../debug.js"; -import { lazy } from "../utils.js"; -import type { SqlParseError, SqlParseResult, SqlParser } from "./types.js"; - -export interface ParserOption extends Option { - database: SupportedDialects; - /** - * If true, the parser will quote brackets in the SQL query which will satisfy the parser. - * This is useful if you want to interpolate variables in f-strings. - * - * @example - * ```sql - * SELECT {id} -> SELECT '{id}' - * ``` - * - * @experimental This is an experimental feature and may break parsing. - */ - ignoreBrackets?: boolean; -} - -export interface NodeSqlParserOptions { - getParserOptions?: (state: EditorState) => ParserOption; -} - -export interface NodeSqlParserResult extends SqlParseResult { - ast?: AST | AST[]; -} - -/** - * A SQL parser wrapper around node-sql-parser with enhanced error handling - * and validation capabilities for CodeMirror integration. - * - * @example Custom dialect - * ```ts - * import { NodeSqlParser } from "@marimo-team/codemirror-sql"; - * - * const myParser = new NodeSqlParser({ - * getParserOptions: (state) => ({ - * dialect: getDialect(state), - * parseOptions: { - * includeLocations: true, - * }, - * }), - * }); - * ``` - */ -export class NodeSqlParser implements SqlParser { - private opts: NodeSqlParserOptions; - private parser: Parser | null = null; - - // Record the column number to the offset amount - private offsetRecord: Record = {}; - - constructor(opts: NodeSqlParserOptions = {}) { - this.opts = opts; - } - - /** - * Lazy import of the node-sql-parser package and create a new Parser instance. - */ - private getParser = lazy(async () => { - if (this.parser) { - return this.parser; - } - const module = await import("node-sql-parser"); - // Support for ESM and CJS - const { Parser } = module.default || module; - this.parser = new Parser(); - return this.parser; - }); - - async parse(sql: string, opts: { state: EditorState }): Promise { - // Reset the offset map on each parse - this.offsetRecord = {}; - - const parserOptions = this.opts.getParserOptions?.(opts.state); - const sanitizedSql = await this.sanitizeSql(sql, parserOptions); - - try { - const parser = await this.getParser(); - - // Check if this is DuckDB dialect and apply custom processing - if (parserOptions?.database === "DuckDB") { - return this.parseWithDuckDBSupport(sanitizedSql, parserOptions); - } - - const ast = parser.astify(sanitizedSql, parserOptions); - - return { - success: true, - errors: [], - ast, - }; - } catch (error: unknown) { - const parseError = this.extractErrorInfo(error, sanitizedSql); - return { - success: false, - errors: [parseError], - }; - } - } - - async sanitizeSql(sql: string, parserOptions?: ParserOption): Promise { - if (parserOptions?.ignoreBrackets) { - const { sql: replacedSql, offsetRecord } = replaceBracketsWithQuotes(sql); - this.offsetRecord = offsetRecord; - return replacedSql; - } - return sql; - } - - /** - * Parse SQL with DuckDB syntax support - * This is not robust, we catch main cases. - */ - private async parseWithDuckDBSupport( - sql: string, - parserOptions: Option, - ): Promise { - const parser = await this.getParser(); - - // Remove comments and normalize for pattern checking - const sqlToCheck = removeCommentsFromStart(sql).trimStart().toLowerCase(); - - // Handle unsupported DuckDB syntax patterns - if (sqlToCheck.startsWith("from") || sqlToCheck.includes("macro")) { - debug("Unsupported DuckDB syntax"); - return { success: true, errors: [] }; - } - - let modifiedSql = sql; - // Postgres does not support `CREATE OR REPLACE` for tables - if (sqlToCheck.includes("create or replace table")) { - const offset = "create or replace table".length - "create table".length; - this.offsetRecord[sql.indexOf("create or replace table")] = -offset; - modifiedSql = sql.replace(/create or replace table/i, "create table"); - } - - // Try standard parsing with PostgreSQL dialect - try { - const postgresOptions = { ...parserOptions, database: "PostgreSQL" }; - const ast = parser.astify(modifiedSql, postgresOptions); - return { success: true, errors: [], ast }; - } catch (error) { - // Use the original sql since we manually apply the offset - const parseError = this.extractErrorInfo(error, sql); - return { success: false, errors: [parseError] }; - } - } - - /** - * @param error - The error object - * @param sql - The SQL string - * @param offset - The offset to add to the column position. Default is 0. - * @returns The parsed error information - */ - private extractErrorInfo(error: unknown, sql: string): SqlParseError { - let line = 1; - let column = 1; - const message = (error as Error)?.message || "SQL parsing error"; - - const errorObj = error as { - location?: { start?: { line: number; column: number } }; - hash?: { line: number; loc?: { first_column: number } }; - }; - if (errorObj?.location) { - line = errorObj.location.start?.line || 1; - column = errorObj.location.start?.column || 1; - } else if (errorObj?.hash) { - line = errorObj.hash.line || 1; - column = errorObj.hash.loc?.first_column || 1; - } else { - const lineMatch = message.match(/line (\d+)/i); - const columnMatch = message.match(/column (\d+)/i); - - if (lineMatch?.[1]) { - line = parseInt(lineMatch[1], 10); - } - if (columnMatch?.[1]) { - column = parseInt(columnMatch[1], 10); - } - } - - /** - * Map the error column back to the original (pre-replacement) SQL - * SELECT {id} FRO users - * ^ error position should be here - * - * SELECT '{id}' FRO users - * ^ parser reports this - * So in this case, we subtract the offset from the column position. - * - * offsetRecord keys are absolute offsets into the pre-replacement SQL, - * while the parser reports line-relative columns, so only replacements - * on the error's own line (and before the error column) shift it. - * Replacements never add or remove newlines, so line numbers match. - */ - const replacements = Object.entries(this.offsetRecord) - .map(([position, offset]) => ({ position: parseInt(position, 10), offset })) - .sort((a, b) => a.position - b.position); - let shift = 0; - for (const { position, offset } of replacements) { - const replacedPosition = position + shift; - shift += offset; - - const beforeReplacement = sql.slice(0, replacedPosition); - const replacementLine = (beforeReplacement.match(/\n/g)?.length ?? 0) + 1; - const replacementColumn = replacedPosition - beforeReplacement.lastIndexOf("\n"); - - if (line === replacementLine && column > replacementColumn) { - column -= offset; - } - } - - // Ensure we don't exceed the sql length - if (column > sql.length) { - column = sql.length; - } - - return { - message: this.cleanErrorMessage(message), - line: Math.max(1, line), - column: Math.max(1, column), - severity: "error" as const, - }; - } - - private cleanErrorMessage(message: string): string { - return message - .replace(/^Error: /, "") - .replace(/Expected .* but .* found\./i, (match) => - match.replace(/but .* found/, "found unexpected token"), - ) - .trim(); - } - - async validateSql(sql: string, opts: { state: EditorState }): Promise { - const result = await this.parse(sql, opts); - return result.errors; - } - - /** - * Extracts table references from a SQL query using node-sql-parser - * @param sql The SQL query to analyze - * @returns Array of table names referenced in the query - */ - async extractTableReferences(sql: string, opts?: { state: EditorState }): Promise { - const parserOptions = opts ? this.opts.getParserOptions?.(opts.state) : undefined; - - try { - const parser = await this.getParser(); - const tableList = parser.tableList(sql, parserOptions); - // Clean up table names - node-sql-parser returns format like "select::null::users" - return tableList.map((table: string) => { - const parts = table.split("::"); - return parts[parts.length - 1] || table; - }); - } catch { - return []; - } - } - - /** - * Extracts column references from a SQL query using node-sql-parser - * @param sql The SQL query to analyze - * @returns Array of column names referenced in the query - */ - async extractColumnReferences(sql: string, opts?: { state: EditorState }): Promise { - const parserOptions = opts?.state ? this.opts.getParserOptions?.(opts.state) : undefined; - - try { - const parser = await this.getParser(); - const columnList = parser.columnList(sql, parserOptions); - - // Clean up column names - node-sql-parser returns format like "select::null::users" - const cleanColumnList = columnList.map((column: string) => { - const parts = column.split("::"); - return parts[parts.length - 1] || column; - }); - return cleanColumnList; - } catch { - return []; - } - } -} - -type CommentType = "--" | "/*"; - -function removeCommentsFromStart(sql: string, commentTypes: CommentType[] = ["/*", "--"]): string { - const regexPatterns: string[] = []; - - // Multi-line comments - if (commentTypes.includes("/*")) { - regexPatterns.push("/\\*[\\s\\S]*?\\*/"); - } - // Single-line comments - if (commentTypes.includes("--")) { - regexPatterns.push("--[^\\n]*"); - } - - if (regexPatterns.length === 0) return sql; - - const commentRegex = new RegExp(`^\\s*(${regexPatterns.join("|")})\\s*`, ""); - - // Keep removing comments from the start until no more are found - let result = sql; - let prevResult = ""; - - while (result !== prevResult) { - prevResult = result; - result = result.replace(commentRegex, ""); - } - - return result; -} - -const QUOTE_LENGTH = "''".length; - -/** - * Replaces unquoted curly bracket expressions (e.g., {id}) with quoted strings (e.g., '{id}'), - * ignoring brackets already inside single or double quotes. - * - * Returns the modified SQL and a record mapping the index of each replaced bracket to the - * number of characters added (for offset tracking). - * - * @example - * replaceBracketsWithQuotes("SELECT {id}, '{name}' FROM users"); - * // => { - * // sql: "SELECT '{id}', '{name}' FROM users", - * // offsetRecord: { 7: 2 } - * // } - */ -function replaceBracketsWithQuotes(sql: string): { - sql: string; - offsetRecord: Record; -} { - const offsetRecord: Record = {}; - - // Quote bodies use the unrolled-loop form (`[^"\\]*(?:\\.[^"\\]*)*`) instead - // of `(?:[^"\\]|\\.)*` to avoid polynomial backtracking on unclosed strings - const replacedSql = sql.replace( - /("[^"\\]*(?:\\.[^"\\]*)*")|('[^'\\]*(?:\\.[^'\\]*)*')|(\{[^}]*\})/g, - (match, doubleQuoted, singleQuoted, bracket, offset) => { - // If it's a quoted string, return it as-is - if (doubleQuoted || singleQuoted) { - return match; - } - - // If it's a bracket, quote it and record the offset - if (bracket) { - offsetRecord[offset] = QUOTE_LENGTH; - return `'${bracket}'`; - } - - return match; - }, - ); - - return { sql: replacedSql, offsetRecord }; -} - -export const exportedForTesting = { replaceBracketsWithQuotes, removeCommentsFromStart }; - -/** - * https://github.com/taozhi8833998/node-sql-parser?tab=readme-ov-file#supported-database-sql-syntax - * While DuckDB is not supported in the library, we perform some special handling for it and treat it as PostgreSQL. - */ -export type SupportedDialects = - | "Athena" - | "BigQuery" - | "DB2" - | "Hive" - | "MariaDB" - | "MySQL" - | "PostgreSQL" - | "DuckDB" - | "Redshift" - | "Sqlite" - | "TransactSQL" - | "FlinkSQL" - | "Snowflake" - | "Noql"; diff --git a/src/sql/query-context.ts b/src/sql/query-context.ts deleted file mode 100644 index f292007..0000000 --- a/src/sql/query-context.ts +++ /dev/null @@ -1,635 +0,0 @@ -import type { EditorState } from "@codemirror/state"; -import { debug } from "../debug.js"; -import { NodeSqlParser } from "./parser.js"; -import type { SqlParser } from "./types.js"; - -/** - * A table-like source referenced by a statement (FROM/JOIN entries and - * INSERT/UPDATE/DELETE targets). - */ -export interface QueryContextTable { - /** Unqualified table name (quotes stripped) */ - name: string; - /** Full dotted path as written, e.g. ["mydb", "users"] (quotes stripped) */ - path: string[]; - /** Alias as written (quotes stripped), if any */ - alias?: string; -} - -/** - * A CTE declared in the statement's WITH clause - */ -export interface QueryContextCte { - name: string; - /** Declared or inferred output columns (empty when unknown) */ - columns: string[]; - /** Start of the CTE name in the statement text (-1 when not found) */ - from: number; - /** End of the CTE name in the statement text (-1 when not found) */ - to: number; -} - -/** - * Statement-scoped analysis of table references, aliases, and CTEs, used to - * resolve alias-qualified identifiers in hover tooltips and completions. - */ -export interface QueryContext { - tables: QueryContextTable[]; - ctes: QueryContextCte[]; - /** alias (lowercase) -> dotted table path / CTE name as written */ - aliases: Map; - /** AS aliases in the top-level select list */ - selectAliases: string[]; -} - -function emptyContext(): QueryContext { - return { tables: [], ctes: [], aliases: new Map(), selectAliases: [] }; -} - -/** Loosely-typed node-sql-parser AST node */ -interface AstNode { - [key: string]: unknown; -} - -function isRecord(value: unknown): value is AstNode { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function asArray(value: unknown): AstNode[] { - return Array.isArray(value) ? (value as AstNode[]) : []; -} - -/** - * Strips one layer of identifier quoting: "x", 'x', `x`, [x] - */ -export function stripIdentifierQuotes(identifier: string): string { - const first = identifier[0]; - const last = identifier[identifier.length - 1]; - if ( - identifier.length >= 2 && - ((first === '"' && last === '"') || - (first === "'" && last === "'") || - (first === "`" && last === "`") || - (first === "[" && last === "]")) - ) { - return identifier.slice(1, -1); - } - return identifier; -} - -/** Extracts a name that may be a plain string or a `{value}` wrapper node */ -function nameValue(value: unknown): string | null { - if (typeof value === "string") { - return value; - } - if (isRecord(value) && typeof value.value === "string") { - return value.value; - } - return null; -} - -/** Extracts the column name from a column_ref-like node */ -function columnRefName(expr: unknown): string | null { - if (!isRecord(expr) || expr.type !== "column_ref") { - return null; - } - const column = expr.column; - if (typeof column === "string") { - return column; - } - if (isRecord(column)) { - const inner = column.expr; - const value = isRecord(inner) ? inner.value : column.value; - if (typeof value === "string") { - return value; - } - } - return null; -} - -/** Output columns of a CTE: the declared list, else its SELECT list */ -function cteColumns(cte: AstNode): string[] { - const declared = asArray(cte.columns) - .map((col) => nameValue(col) ?? columnRefName(col) ?? nameValue((col as AstNode).column)) - .filter((name): name is string => typeof name === "string"); - if (declared.length > 0) { - return declared; - } - - const stmt = cte.stmt; - const body = isRecord(stmt) ? ((stmt.ast ?? stmt) as AstNode) : null; - if (!isRecord(body) || body.type !== "select") { - return []; - } - const columns: string[] = []; - for (const col of asArray(body.columns)) { - if (typeof col.as === "string") { - columns.push(col.as); - } else { - const name = columnRefName(col.expr); - // `SELECT *` (or `t.*`) doesn't name an output column - if (name && name !== "*") { - columns.push(name); - } - } - } - return columns; -} - -interface MutableContext { - tables: QueryContextTable[]; - ctes: QueryContextCte[]; - selectAliases: string[]; - seenTables: Set; - seenCtes: Set; -} - -/** - * Records a FROM/target entry of shape `{db?, catalog?, schema?, table, as?}`. - * Expression sources (subqueries, table functions) carry no `table` string and - * are skipped; their inner SELECT nodes are reached by the generic walk. - */ -function recordTableEntry(entry: AstNode, ctx: MutableContext): void { - if (typeof entry.table !== "string" || entry.table.length === 0) { - return; - } - const qualifier = entry.db ?? entry.catalog; - const path = [qualifier, entry.schema, entry.table] - .filter((part): part is string => typeof part === "string" && part.length > 0) - .map(stripIdentifierQuotes); - const alias = typeof entry.as === "string" ? stripIdentifierQuotes(entry.as) : undefined; - - const key = `${path.join(".").toLowerCase()}::${alias?.toLowerCase() ?? ""}`; - if (ctx.seenTables.has(key)) { - return; - } - ctx.seenTables.add(key); - ctx.tables.push({ - name: stripIdentifierQuotes(entry.table), - path, - ...(alias ? { alias } : {}), - }); -} - -/** - * Defensive recursive walk over a node-sql-parser AST. Shapes vary by - * statement type and dialect, so unknown structures are skipped, never thrown - * on. - */ -function walkAst(value: unknown, ctx: MutableContext, seen: Set): void { - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, ctx, seen); - } - return; - } - if (!isRecord(value) || seen.has(value)) { - return; - } - seen.add(value); - - // WITH clauses: register CTE names and output columns - for (const cte of asArray(value.with)) { - const name = nameValue(cte.name); - if (name && !ctx.seenCtes.has(name.toLowerCase())) { - ctx.seenCtes.add(name.toLowerCase()); - ctx.ctes.push({ - name: stripIdentifierQuotes(name), - columns: cteColumns(cte).map(stripIdentifierQuotes), - from: -1, - to: -1, - }); - } - } - - // Table sources: FROM lists and DML/DDL targets - for (const key of ["from", "table"]) { - for (const entry of asArray(value[key])) { - recordTableEntry(entry, ctx); - } - } - - for (const [key, child] of Object.entries(value)) { - if (key === "loc" || key === "tableList" || key === "columnList") { - continue; - } - walkAst(child, ctx, seen); - } -} - -/** Collects `SELECT expr AS alias` names from the statement's own select list */ -function collectSelectAliases(ast: AstNode, ctx: MutableContext): void { - if (ast.type !== "select") { - return; - } - for (const col of asArray(ast.columns)) { - if (typeof col.as === "string") { - ctx.selectAliases.push(stripIdentifierQuotes(col.as)); - } - } -} - -/** - * Locates CTE name definitions (`name [(cols)] AS (`) in the statement text so - * `QueryContextCte.from/to` can point at the declaration. The name may appear - * bare or quoted; the returned span covers the token as written (quotes - * included). - */ -function findCteSpan(sql: string, name: string): { from: number; to: number } { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const token = `(?:"${escaped}"|\`${escaped}\`|\\[${escaped}\\]|\\b${escaped})`; - const pattern = new RegExp(`(${token})\\s*(?:\\([^)]*\\)\\s*)?AS\\s*\\(`, "i"); - const match = pattern.exec(sql); - if (!match || match.index < 0 || !match[1]) { - return { from: -1, to: -1 }; - } - return { from: match.index, to: match.index + match[1].length }; -} - -/** Keywords that can directly follow a table reference and are never aliases */ -const NON_ALIAS_KEYWORDS = new Set([ - "as", - "on", - "using", - "where", - "join", - "inner", - "left", - "right", - "full", - "cross", - "outer", - "natural", - "lateral", - "group", - "order", - "limit", - "offset", - "having", - "union", - "intersect", - "except", - "select", - "set", - "values", - "when", - "then", - "and", - "or", - "not", - "in", - "is", - "asc", - "desc", - "fetch", - "window", - "qualify", - "returning", - "with", -]); - -const IDENT = String.raw`(?:[\w$]+|"[^"]+"|\`[^\`]+\`|\[[^\]]+\])`; -// A keyword after a table reference is never its alias; without this guard the -// match would consume e.g. `JOIN` and skip right past the joined table -const NON_ALIAS_GUARD = `(?!(?:${[...NON_ALIAS_KEYWORDS].join("|")})\\b)`; -const TABLE_REF_PATTERN = new RegExp( - String.raw`\b(?:from|join)\s+(${IDENT}(?:\.${IDENT})*)(?:\s+(?:as\s+)?${NON_ALIAS_GUARD}(${IDENT}))?`, - "gi", -); - -/** - * Blanks out single-quoted string literals and comments (preserving offsets) - * so the regex fallback doesn't read `FROM`/`JOIN` inside them as table - * references. Double-quoted/backtick/bracket identifiers are left intact. - */ -export function maskLiteralsAndComments(sql: string): string { - const chars = sql.split(""); - let i = 0; - while (i < sql.length) { - const char = sql[i]; - const next = sql[i + 1]; - - if (char === "-" && next === "-") { - while (i < sql.length && sql[i] !== "\n") { - chars[i] = " "; - i++; - } - continue; - } - - if (char === "/" && next === "*") { - chars[i] = " "; - chars[i + 1] = " "; - i += 2; - while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) { - chars[i] = " "; - i++; - } - if (i < sql.length) { - chars[i] = " "; - chars[i + 1] = " "; - i += 2; - } - continue; - } - - // Quoted identifiers pass through untouched (a ' inside them must not - // start a string literal) - if (char === '"' || char === "`" || char === "[") { - const close = char === "[" ? "]" : char; - i++; - while (i < sql.length && sql[i] !== close) { - i++; - } - i++; - continue; - } - - if (char === "'") { - chars[i] = " "; - i++; - while (i < sql.length) { - if (sql[i] === "'") { - chars[i] = " "; - if (sql[i + 1] === "'") { - // Escaped quote ('') stays inside the literal - chars[i + 1] = " "; - i += 2; - continue; - } - i++; - break; - } - chars[i] = " "; - i++; - } - continue; - } - - i++; - } - return chars.join(""); -} - -/** - * Regex-based fallback used when the statement doesn't parse (e.g. mid-edit), - * so aliases still resolve. Matches `FROM|JOIN [AS] ` forms. - */ -function buildContextByRegex(rawSql: string): QueryContext { - const ctx: MutableContext = { - tables: [], - ctes: [], - selectAliases: [], - seenTables: new Set(), - seenCtes: new Set(), - }; - - // Masking preserves offsets, so CTE spans remain valid in the original text - const sql = maskLiteralsAndComments(rawSql); - - extractCtesByRegex(sql, ctx); - - TABLE_REF_PATTERN.lastIndex = 0; - let match = TABLE_REF_PATTERN.exec(sql); - while (match !== null) { - const rawPath = match[1]; - const rawAlias = match[2]; - if (rawPath) { - const path = rawPath.split(".").map(stripIdentifierQuotes); - const name = path[path.length - 1] ?? ""; - const alias = - rawAlias && !NON_ALIAS_KEYWORDS.has(rawAlias.toLowerCase()) - ? stripIdentifierQuotes(rawAlias) - : undefined; - const key = `${path.join(".").toLowerCase()}::${alias?.toLowerCase() ?? ""}`; - if (name && !ctx.seenTables.has(key)) { - ctx.seenTables.add(key); - ctx.tables.push({ name, path, ...(alias ? { alias } : {}) }); - } - } - match = TABLE_REF_PATTERN.exec(sql); - } - - return finalizeContext(ctx); -} - -/** - * Extracts CTE declarations (name, declared column list, position) with the - * same paren-tracking approach as the CTE completion source. - */ -function extractCtesByRegex(sql: string, ctx: MutableContext): void { - const withPattern = /\bWITH\s+(?:RECURSIVE\s+)?/gi; - const ctePattern = new RegExp(String.raw`^(${IDENT})\s*(?:\(([^)]*)\)\s*)?AS\s*\(`, "i"); - - let withMatch = withPattern.exec(sql); - while (withMatch !== null) { - let pos = withMatch.index + withMatch[0].length; - - for (;;) { - const cteMatch = ctePattern.exec(sql.slice(pos)); - const rawName = cteMatch?.[1]; - const cteName = rawName ? stripIdentifierQuotes(rawName) : undefined; - if (!cteMatch || !rawName || !cteName) { - break; - } - // Skip past the CTE body, tracking nested parentheses - const bodyStart = pos + cteMatch[0].length; - let i = bodyStart; - let depth = 1; - while (i < sql.length && depth > 0) { - if (sql[i] === "(") depth++; - else if (sql[i] === ")") depth--; - i++; - } - - if (!ctx.seenCtes.has(cteName.toLowerCase())) { - ctx.seenCtes.add(cteName.toLowerCase()); - const declared = (cteMatch[2] ?? "") - .split(",") - .map((col) => stripIdentifierQuotes(col.trim())) - .filter((col) => col.length > 0); - ctx.ctes.push({ - name: cteName, - columns: - declared.length > 0 - ? declared - : inferColumnsFromSelectBody(sql.slice(bodyStart, Math.max(bodyStart, i - 1))), - from: pos, - to: pos + rawName.length, - }); - } - - const separator = /^\s*,\s*/.exec(sql.slice(i)); - if (!separator) { - break; - } - pos = i + separator[0].length; - } - - withMatch = withPattern.exec(sql); - } -} - -function splitTopLevelCommas(text: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ""; - for (const char of text) { - if (char === "(") depth++; - else if (char === ")") depth = Math.max(0, depth - 1); - if (char === "," && depth === 0) { - parts.push(current); - current = ""; - } else { - current += char; - } - } - parts.push(current); - return parts; -} - -/** - * Best-effort inference of a CTE's output columns from its body's select list - * (used by the regex fallback): `AS` aliases and simple identifiers only. - */ -function inferColumnsFromSelectBody(body: string): string[] { - const selectMatch = /^\s*SELECT\s+(?:DISTINCT\s+)?([\s\S]*?)\s+FROM\s/i.exec(body); - if (!selectMatch || !selectMatch[1]) { - return []; - } - const columns: string[] = []; - for (const item of splitTopLevelCommas(selectMatch[1])) { - const trimmed = item.trim(); - const asMatch = new RegExp(String.raw`\s+AS\s+(${IDENT})$`, "i").exec(trimmed); - if (asMatch?.[1]) { - columns.push(stripIdentifierQuotes(asMatch[1])); - continue; - } - if (/^(?:[\w$]+\.)*[\w$]+$/.test(trimmed)) { - const segments = trimmed.split("."); - const last = segments[segments.length - 1]; - if (last && last !== "*") { - columns.push(last); - } - } - } - return columns; -} - -/** - * Builds the alias map from collected tables. Earlier (outer) entries win, so - * a top-level alias shadows a same-named alias in a subquery. - */ -function finalizeContext(ctx: MutableContext): QueryContext { - const aliases = new Map(); - for (const table of ctx.tables) { - if (table.alias) { - const key = table.alias.toLowerCase(); - if (!aliases.has(key)) { - aliases.set(key, table.path.join(".")); - } - } - } - return { - tables: ctx.tables, - ctes: ctx.ctes, - aliases, - selectAliases: ctx.selectAliases, - }; -} - -/** - * Analyzes a single SQL statement, extracting referenced tables, aliases, - * CTEs, and select-list aliases. Prefers the parsed AST and falls back to a - * regex scan when the statement doesn't parse (e.g. mid-edit). - */ -export async function analyzeQueryContext( - sql: string, - parser: SqlParser, - opts: { state: EditorState }, -): Promise { - if (!sql.trim()) { - return emptyContext(); - } - - let ast: unknown = null; - try { - const result = await parser.parse(sql, opts); - if (result.success && result.ast != null) { - ast = result.ast; - } - } catch (error) { - debug("query-context parse failed", error); - } - - if (ast == null) { - return buildContextByRegex(sql); - } - - try { - const ctx: MutableContext = { - tables: [], - ctes: [], - selectAliases: [], - seenTables: new Set(), - seenCtes: new Set(), - }; - const asts = Array.isArray(ast) ? ast : [ast]; - for (const node of asts) { - if (isRecord(node)) { - collectSelectAliases(node, ctx); - } - } - walkAst(ast, ctx, new Set()); - // Search the masked text (offsets preserved) so `AS (` inside a string - // literal or comment can't be mistaken for a declaration - const maskedSql = maskLiteralsAndComments(sql); - for (const cte of ctx.ctes) { - const span = findCteSpan(maskedSql, cte.name); - cte.from = span.from; - cte.to = span.to; - } - return finalizeContext(ctx); - } catch (error) { - debug("query-context AST walk failed", error); - return buildContextByRegex(sql); - } -} - -/** - * Caching wrapper around {@link analyzeQueryContext}, keyed on statement text. - * Share one instance between hover/completion sources to avoid re-parsing the - * same statement. - */ -export class QueryContextAnalyzer { - private parser: SqlParser; - private cache = new Map(); - private readonly MAX_CACHE_SIZE = 20; - - constructor(parser: SqlParser = new NodeSqlParser()) { - this.parser = parser; - } - - async getContext(sql: string, opts: { state: EditorState }): Promise { - const cached = this.cache.get(sql); - if (cached) { - return cached; - } - - const context = await analyzeQueryContext(sql, this.parser, opts); - this.cache.set(sql, context); - - if (this.cache.size > this.MAX_CACHE_SIZE) { - const firstKey = this.cache.keys().next().value; - if (firstKey !== undefined) { - this.cache.delete(firstKey); - } - } - - return context; - } - - clearCache(): void { - this.cache.clear(); - } -} diff --git a/src/sql/references.ts b/src/sql/references.ts deleted file mode 100644 index 81e9a42..0000000 --- a/src/sql/references.ts +++ /dev/null @@ -1,448 +0,0 @@ -import type { EditorState } from "@codemirror/state"; -import { NodeSqlParser } from "./parser.js"; -import { - maskLiteralsAndComments, - type QueryContext, - QueryContextAnalyzer, - stripIdentifierQuotes, -} from "./query-context.js"; -import { SqlStructureAnalyzer, type SqlStatement } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -/** A document range (absolute offsets) */ -export interface SqlRange { - from: number; - to: number; -} - -/** The kinds of statement-local identifiers that can be resolved */ -export type SqlIdentifierKind = "cte" | "table-alias" | "select-alias"; - -/** - * Resolution of the identifier under the cursor: where it is defined and - * every place it is used within its statement. - */ -export interface SqlReferenceResult { - kind: SqlIdentifierKind; - /** The identifier as declared (quotes stripped) */ - name: string; - /** The declaration token (CTE name in WITH, alias token, select alias) */ - definition: SqlRange; - /** All occurrences including the definition, sorted by position */ - references: SqlRange[]; -} - -/** - * Shared dependencies for reference resolution. All are optional; omitted - * pieces are created internally (pass shared instances to avoid re-parsing). - */ -export interface SqlReferenceConfig { - parser?: SqlParser; - structureAnalyzer?: SqlStructureAnalyzer; - contextAnalyzer?: QueryContextAnalyzer; -} - -const WORD_CHAR = /[\w$]/; -const QUOTED_TOKEN = /"[^"]*"|`[^`]*`|\[[^\]]*\]/g; - -/** - * The identifier token containing (or immediately touching) `pos` — bare, or - * quoted (the range covers the quotes; `text` is the unquoted name). - */ -export function identifierTokenAt( - state: EditorState, - pos: number, -): { from: number; to: number; text: string } | null { - const line = state.doc.lineAt(pos); - const text = line.text; - const rel = pos - line.from; - - QUOTED_TOKEN.lastIndex = 0; - let quoted = QUOTED_TOKEN.exec(text); - while (quoted !== null && quoted.index <= rel) { - const end = quoted.index + quoted[0].length; - if (rel <= end) { - return { - from: line.from + quoted.index, - to: line.from + end, - text: stripIdentifierQuotes(quoted[0]), - }; - } - quoted = QUOTED_TOKEN.exec(text); - } - - let start = rel; - let end = rel; - while (start > 0 && WORD_CHAR.test(text[start - 1] ?? "")) start--; - while (end < text.length && WORD_CHAR.test(text[end] ?? "")) end++; - if (start === end) { - return null; - } - return { from: line.from + start, to: line.from + end, text: text.slice(start, end) }; -} - -/** - * The statement containing `pos`, falling back to the closest preceding - * statement (covers a cursor sitting in trailing whitespace). - */ -export async function statementAt( - analyzer: SqlStructureAnalyzer, - state: EditorState, - pos: number, -): Promise { - const statements = await analyzer.analyzeDocument(state); - let preceding: SqlStatement | null = null; - for (const statement of statements) { - if (pos >= statement.from && pos <= statement.to) { - return statement; - } - if (statement.from <= pos) { - preceding = statement; - } - } - return preceding; -} - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** Bare-or-quoted token alternatives for an identifier, for use in a RegExp */ -function tokenAlternatives(name: string, opts: { allowLeadingDot?: boolean } = {}): string { - const escaped = escapeRegExp(name); - // The bare form must not be part of a longer identifier or (unless allowed, - // e.g. the trailing segment of a qualified table path) a dotted path's - // trailing segment (`x.name` is a column of `x`, not this identifier) - const guard = opts.allowLeadingDot ? "(?= 0 && /\s/.test(masked[i] ?? "")) i--; - if (i >= 0 && masked[i] === ".") { - // Skip the qualifier segment of a dotted path and keep looking left - i--; - while (i >= 0 && /\s/.test(masked[i] ?? "")) i--; - const char = masked[i]; - if (char === '"' || char === "`" || char === "]") { - const open = char === "]" ? "[" : char; - i--; - while (i >= 0 && masked[i] !== open) i--; - i--; - } else { - while (i >= 0 && WORD_CHAR.test(masked[i] ?? "")) i--; - } - continue; - } - break; - } - if (i < 0) { - return false; - } - if (masked[i] === ",") { - // Comma-separated list: a relation only when it continues a FROM list - return lastClauseKeyword(masked.slice(0, i)) === "from"; - } - const word = /([A-Za-z_]+)$/.exec(masked.slice(0, i + 1))?.[1]?.toLowerCase(); - return word === "from" || word === "join"; -} - -function dedupeAndSort(ranges: SqlRange[]): SqlRange[] { - const byFrom = new Map(); - for (const range of ranges) { - if (!byFrom.has(range.from)) { - byFrom.set(range.from, range); - } - } - return [...byFrom.values()].sort((a, b) => a.from - b.from); -} - -function containsRange(ranges: SqlRange[], inner: SqlRange): boolean { - return ranges.some((range) => range.from <= inner.from && inner.to <= range.to); -} - -/** Statement-relative resolution; ranges are offsets into `statementText` */ -function resolveInStatement( - statementText: string, - context: QueryContext, - token: SqlRange & { text: string }, -): SqlReferenceResult | null { - const masked = maskLiteralsAndComments(statementText); - const lower = token.text.toLowerCase(); - const isSelectAlias = context.selectAliases.some((a) => a.toLowerCase() === lower); - - const cte = context.ctes.find((c) => c.name.toLowerCase() === lower); - if (cte) { - // A name that is also an alias or select alias binds two distinct things - // in this statement — refuse rather than mixing them - const aliasTarget = context.aliases.get(lower); - if ((aliasTarget && aliasTarget.toLowerCase() !== lower) || isSelectAlias) { - return null; - } - if (cte.from < 0) { - return null; - } - const definition: SqlRange = { from: cte.from, to: cte.to }; - // CTE uses are relation positions (`FROM x`, `JOIN x`, FROM-list commas) - // or qualifiers (`x.col`); a bare same-named identifier elsewhere is a - // column and is left alone - const uses = scanOccurrences(masked, cte.name).filter( - (range) => masked[range.to] === "." || isRelationPosition(masked, range.from), - ); - const references = dedupeAndSort([definition, ...uses]); - if (!containsRange(references, token)) { - return null; - } - return { kind: "cte", name: cte.name, definition, references }; - } - - if (context.aliases.has(lower)) { - if (isSelectAlias) { - return null; - } - const owners = context.tables.filter((table) => table.alias?.toLowerCase() === lower); - // Same alias on two different tables (e.g. sibling subqueries) — refuse - // rather than mixing scopes - if (owners.length !== 1 || !owners[0]) { - return null; - } - const owner = owners[0]; - const alias = owner.alias ?? token.text; - // The definition is the alias token right after its table reference (the - // table may be the trailing segment of a qualified path, so a leading dot - // is allowed) - const tableToken = tokenAlternatives(owner.name, { allowLeadingDot: true }); - const definitionPattern = new RegExp( - `${tableToken}\\s+(?:AS\\s+)?(${tokenAlternatives(alias)})`, - "gi", - ); - let match = definitionPattern.exec(masked); - // Anchor to the table reference itself, not e.g. a same-shaped expression - while (match !== null && !isRelationPosition(masked, match.index)) { - match = definitionPattern.exec(masked); - } - if (!match || match[1] == null) { - return null; - } - const definition: SqlRange = { - from: match.index + match[0].length - match[1].length, - to: match.index + match[0].length, - }; - // Alias uses are qualifier positions (`alias.column`); bare same-named - // identifiers elsewhere are likely columns and are left alone - const uses = scanOccurrences(masked, alias, { followedByDot: true }); - const references = dedupeAndSort([definition, ...uses]); - if (!containsRange(references, token)) { - return null; - } - return { kind: "table-alias", name: alias, definition, references }; - } - - const selectAlias = context.selectAliases.find((a) => a.toLowerCase() === lower); - if (selectAlias) { - const depths = parenDepths(masked); - const atTopLevel = (index: number) => depths[index] === 0; - - const fromMatch = topLevelMatch(masked, /\bFROM\b/gi, atTopLevel); - const selectListEnd = fromMatch ? fromMatch.index : masked.length; - const definitionPattern = new RegExp(`\\bAS\\s+(${tokenAlternatives(selectAlias)})`, "gi"); - let definition: SqlRange | null = null; - let defMatch = definitionPattern.exec(masked); - while (defMatch !== null) { - const nameFrom = defMatch.index + defMatch[0].length - (defMatch[1]?.length ?? 0); - if (atTopLevel(defMatch.index) && nameFrom < selectListEnd) { - definition = { from: nameFrom, to: defMatch.index + defMatch[0].length }; - break; - } - defMatch = definitionPattern.exec(masked); - } - if (!definition) { - return null; - } - - // Select aliases are only referenceable from GROUP BY / ORDER BY / - // HAVING / QUALIFY clauses of the same (top-level) query - const regions = clauseRegions(masked, atTopLevel); - const uses = scanOccurrences(masked, selectAlias).filter((range) => - regions.some((region) => range.from >= region.from && range.to <= region.to), - ); - const references = dedupeAndSort([definition, ...uses]); - if (!containsRange(references, token)) { - return null; - } - return { kind: "select-alias", name: selectAlias, definition, references }; - } - - return null; -} - -function topLevelMatch( - text: string, - pattern: RegExp, - atTopLevel: (index: number) => boolean, -): RegExpExecArray | null { - let match = pattern.exec(text); - while (match !== null) { - if (atTopLevel(match.index)) { - return match; - } - match = pattern.exec(text); - } - return null; -} - -const CLAUSE_START = /\b(?:GROUP\s+BY|ORDER\s+BY|HAVING|QUALIFY)\b/gi; -const CLAUSE_BOUNDARY = - /\b(?:GROUP\s+BY|ORDER\s+BY|HAVING|QUALIFY|LIMIT|OFFSET|FETCH|WINDOW|UNION|INTERSECT|EXCEPT)\b/gi; - -/** Top-level GROUP BY/ORDER BY/HAVING/QUALIFY clause bodies */ -function clauseRegions(masked: string, atTopLevel: (index: number) => boolean): SqlRange[] { - const regions: SqlRange[] = []; - CLAUSE_START.lastIndex = 0; - let match = CLAUSE_START.exec(masked); - while (match !== null) { - if (atTopLevel(match.index)) { - const start = match.index + match[0].length; - CLAUSE_BOUNDARY.lastIndex = start; - let end = masked.length; - let boundary = CLAUSE_BOUNDARY.exec(masked); - while (boundary !== null) { - if (atTopLevel(boundary.index)) { - end = boundary.index; - break; - } - boundary = CLAUSE_BOUNDARY.exec(masked); - } - regions.push({ from: start, to: end }); - } - match = CLAUSE_START.exec(masked); - } - return regions; -} - -/** - * Resolves statement-local identifiers (CTE names, table aliases, select - * aliases) to their definition and references. Purely local analysis — no - * schema required. Returns null when the identifier under the cursor is not - * confidently resolvable (never guesses). - */ -export class SqlReferenceResolver { - private structureAnalyzer: SqlStructureAnalyzer; - private contextAnalyzer: QueryContextAnalyzer; - - constructor(config: SqlReferenceConfig = {}) { - const parser = config.parser ?? new NodeSqlParser(); - this.structureAnalyzer = config.structureAnalyzer ?? new SqlStructureAnalyzer(parser); - this.contextAnalyzer = config.contextAnalyzer ?? new QueryContextAnalyzer(parser); - } - - async resolve(state: EditorState, pos: number): Promise { - const token = identifierTokenAt(state, pos); - if (!token) { - return null; - } - const statement = await statementAt(this.structureAnalyzer, state, pos); - if (!statement || token.from < statement.from || token.to > statement.to) { - return null; - } - const statementText = state.sliceDoc(statement.from, statement.to); - const context = await this.contextAnalyzer.getContext(statementText, { state }); - - const relativeToken = { - from: token.from - statement.from, - to: token.to - statement.from, - text: token.text, - }; - const result = resolveInStatement(statementText, context, relativeToken); - if (!result) { - return null; - } - const shift = (range: SqlRange): SqlRange => ({ - from: range.from + statement.from, - to: range.to + statement.from, - }); - return { - ...result, - definition: shift(result.definition), - references: result.references.map(shift), - }; - } -} - -let defaultResolver: SqlReferenceResolver | null = null; - -/** - * Finds the definition and all references of the statement-local identifier - * (CTE name, table alias, or select alias) at `pos`. - * - * @example - * ```ts - * const result = await findReferences(view.state, view.state.selection.main.head); - * if (result) { - * console.log(result.kind, result.definition, result.references); - * } - * ``` - */ -export async function findReferences( - state: EditorState, - pos: number, - config?: SqlReferenceConfig, -): Promise { - if (config) { - return new SqlReferenceResolver(config).resolve(state, pos); - } - defaultResolver ??= new SqlReferenceResolver(); - return defaultResolver.resolve(state, pos); -} diff --git a/src/sql/schema-facet.ts b/src/sql/schema-facet.ts deleted file mode 100644 index 11c4bd3..0000000 --- a/src/sql/schema-facet.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { Facet } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; - -/** - * A schema source: either a namespace object or a function that resolves one - * from the current view. - * - * When a function is provided it is called on every lint/hover pass, so it - * should be cheap (return a cached/memoized namespace rather than rebuilding it). - */ -export type SqlSchemaSource = SQLNamespace | ((view: EditorView) => SQLNamespace); - -/** - * Facet holding the SQL schema shared by schema-aware features (hover tooltips, - * semantic linting). Register it once instead of passing the same schema to - * each sub-extension: - * - * ```ts - * import { sqlSchemaFacet } from "@marimo-team/codemirror-sql"; - * - * const extensions = [sqlSchemaFacet.of({ users: ["id", "name"] })]; - * ``` - * - * Per-extension `schema` config options take precedence over this facet. - */ -export const sqlSchemaFacet = Facet.define({ - combine: (values) => values[0] ?? null, -}); - -/** - * Resolves a schema for the given view, preferring an explicitly configured - * source over the {@link sqlSchemaFacet} value. - */ -export function resolveSqlSchema( - explicit: SqlSchemaSource | undefined, - view: EditorView, -): SQLNamespace | null { - const source = explicit ?? view.state.facet(sqlSchemaFacet); - if (source == null) { - return null; - } - return typeof source === "function" ? source(view) : source; -} diff --git a/src/sql/semantic-diagnostics.ts b/src/sql/semantic-diagnostics.ts deleted file mode 100644 index 66e2c01..0000000 --- a/src/sql/semantic-diagnostics.ts +++ /dev/null @@ -1,705 +0,0 @@ -import type { SQLNamespace } from "@codemirror/lang-sql"; -import { type Diagnostic, linter } from "@codemirror/lint"; -import type { Extension, Text } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; -import { - findNamespaceItemByEndMatch, - isArrayNamespace, - isObjectNamespace, - isSelfChildrenNamespace, - traverseNamespacePath, -} from "./namespace-utils.js"; -import { NodeSqlParser } from "./parser.js"; -import { resolveSqlSchema, type SqlSchemaSource } from "./schema-facet.js"; -import { type SqlStatement, SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -const DEFAULT_DELAY = 750; - -/** Severity for a semantic check; "off" disables the check entirely */ -export type SemanticSeverity = "error" | "warning" | "off"; - -/** - * Configuration options for the semantic (schema-aware) SQL linter - */ -export interface SqlSemanticLinterConfig { - /** - * Database schema to validate queries against. Falls back to the shared - * `sqlSchemaFacet` when not provided. Without a schema (or with an empty - * one) the linter is inert. - * - * Function sources are invoked on every lint pass and should be - * cheap/memoized. - */ - schema?: SqlSchemaSource; - /** Custom SQL parser instance to use for analysis */ - parser?: SqlParser; - /** - * Structure analyzer used to split the document into statements. - * Pass a shared instance (e.g. the one used by the gutter) to reuse its cache. - */ - structureAnalyzer?: SqlStructureAnalyzer; - /** Delay in milliseconds before running validation (default: 750) */ - delay?: number; - /** Per-check severity overrides (default: "warning" for every check) */ - severity?: { - /** A referenced table is not present in the schema */ - unknownTable?: SemanticSeverity; - /** A column is not present in its resolved table */ - unknownColumn?: SemanticSeverity; - /** An unqualified column exists in two or more referenced tables */ - ambiguousColumn?: SemanticSeverity; - }; -} - -type FindingKind = "unknownTable" | "unknownColumn" | "ambiguousColumn"; - -interface SemanticFinding { - kind: FindingKind; - /** Identifier to highlight in the statement text */ - identifier: string; - message: string; -} - -/** Loosely-typed node-sql-parser AST node */ -interface AstNode { - [key: string]: unknown; -} - -/** A table-like source in a query scope (FROM entry, DML target, CTE, subquery) */ -interface TableSource { - /** Full dotted path as written, e.g. "mydb.users" (empty for subqueries) */ - path: string; - /** Unqualified table name */ - name: string; - alias: string | null; - /** References a CTE defined in the statement */ - isCte: boolean; - /** Subquery/table-function source whose columns we cannot know */ - opaque: boolean; -} - -/** - * A single query scope (a SELECT, or the target/WHERE portion of a DML - * statement). Column references are checked against the scope's sources. - */ -interface QueryScope { - sources: TableSource[]; - columnRefs: AstNode[]; - /** Lowercased SELECT-list aliases, referable from GROUP BY/ORDER BY etc. */ - selectAliases: Set; - /** - * Whether unqualified column refs may be checked. Disabled for scopes that - * can see an outer scope (correlated subqueries), where an unqualified name - * may legally resolve to an outer table. - */ - checkUnqualified: boolean; - /** A USING/NATURAL join makes shared columns unambiguous; skip that check */ - hasUsingJoin: boolean; -} - -function isSelectNode(node: unknown): node is AstNode & { type: "select" } { - return ( - typeof node === "object" && - node !== null && - !Array.isArray(node) && - (node as AstNode).type === "select" - ); -} - -function asArray(value: unknown): AstNode[] { - return Array.isArray(value) ? (value as AstNode[]) : []; -} - -function newScope(checkUnqualified: boolean): QueryScope { - return { - sources: [], - columnRefs: [], - selectAliases: new Set(), - checkUnqualified, - hasUsingJoin: false, - }; -} - -/** - * Registers CTE names defined on a statement node and collects scopes from - * their bodies. Names are registered before bodies are walked so a CTE - * referencing another (or itself, when recursive) is never flagged. - */ -function registerCtes(node: AstNode, ctes: Set, scopes: QueryScope[]): void { - const withList = asArray(node.with); - for (const cte of withList) { - const name = cte.name as AstNode | string | undefined; - const value = typeof name === "string" ? name : name?.value; - if (typeof value === "string") { - ctes.add(value.toLowerCase()); - } - } - for (const cte of withList) { - const stmt = cte.stmt as AstNode | undefined; - const body = stmt?.ast ?? stmt; - if (isSelectNode(body)) { - collectSelect(body, ctes, scopes, false); - } - } -} - -/** - * Adds a FROM/target entry to the scope. Entries are either named tables - * (`{db, schema, table, as}`) or expression sources (subqueries, table - * functions), which are treated as opaque. - */ -function addTableEntry( - entry: AstNode, - scope: QueryScope, - ctes: Set, - scopes: QueryScope[], -): void { - if (entry.using != null) { - scope.hasUsingJoin = true; - } - if (typeof entry.join === "string" && entry.join.toLowerCase().includes("natural")) { - scope.hasUsingJoin = true; - } - if (entry.on != null) { - walkExpr(entry.on, scope, ctes, scopes); - } - - const expr = entry.expr as AstNode | undefined; - if (expr != null) { - const sub = expr.ast ?? expr; - if (isSelectNode(sub)) { - collectSelect(sub, ctes, scopes, false); - } - scope.sources.push({ - path: "", - name: "", - alias: typeof entry.as === "string" ? entry.as : null, - isCte: false, - opaque: true, - }); - return; - } - - if (typeof entry.table === "string") { - const qualifier = entry.db ?? entry.catalog; - const parts = [qualifier, entry.schema, entry.table].filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); - scope.sources.push({ - path: parts.join("."), - name: entry.table, - alias: typeof entry.as === "string" ? entry.as : null, - isCte: parts.length === 1 && ctes.has(entry.table.toLowerCase()), - opaque: false, - }); - } -} - -/** - * Generic walk collecting column refs into the current scope and opening a - * new (correlated) scope for any nested SELECT. - */ -function walkExpr(value: unknown, scope: QueryScope, ctes: Set, scopes: QueryScope[]): void { - if (value == null || typeof value !== "object") { - return; - } - if (Array.isArray(value)) { - for (const item of value) { - walkExpr(item, scope, ctes, scopes); - } - return; - } - const node = value as AstNode; - if (node.type === "column_ref") { - scope.columnRefs.push(node); - return; - } - if (isSelectNode(node)) { - collectSelect(node, ctes, scopes, true); - return; - } - if (isSelectNode(node.ast)) { - collectSelect(node.ast, ctes, scopes, true); - return; - } - for (const [key, child] of Object.entries(node)) { - if (key === "loc" || key === "tableList" || key === "columnList") { - continue; - } - walkExpr(child, scope, ctes, scopes); - } -} - -function collectSelect( - node: AstNode, - inheritedCtes: Set, - scopes: QueryScope[], - correlated: boolean, -): void { - const ctes = new Set(inheritedCtes); - registerCtes(node, ctes, scopes); - - const scope = newScope(!correlated); - scopes.push(scope); - - for (const entry of asArray(node.from)) { - addTableEntry(entry, scope, ctes, scopes); - } - - for (const col of asArray(node.columns)) { - if (typeof col.as === "string") { - scope.selectAliases.add(col.as.toLowerCase()); - } - } - - for (const [key, child] of Object.entries(node)) { - // `with` and `from` were handled above; walking them again would create - // duplicate scopes (and `on` clauses are walked by addTableEntry) - if (key === "with" || key === "from" || key === "loc") { - continue; - } - walkExpr(child, scope, ctes, scopes); - } -} - -/** - * Collects query scopes from a single parsed statement. DDL targets (CREATE - * TABLE x) define tables rather than read them, so they are never added as - * sources; DML targets (INSERT/UPDATE/DELETE) must exist and are. - */ -function collectStatement(node: AstNode, scopes: QueryScope[]): void { - const ctes = new Set(); - - switch (node.type) { - case "select": { - collectSelect(node, ctes, scopes, false); - return; - } - case "update": { - registerCtes(node, ctes, scopes); - const scope = newScope(true); - scopes.push(scope); - for (const entry of asArray(node.table)) { - addTableEntry(entry, scope, ctes, scopes); - } - for (const entry of asArray(node.from)) { - addTableEntry(entry, scope, ctes, scopes); - } - // SET entries are `{column, table, value}` where `column` is a plain - // string in some dialect grammars (no column_ref type), so the generic - // walk would miss them - for (const entry of asArray(node.set)) { - if (entry.column != null) { - scope.columnRefs.push({ - type: "column_ref", - table: entry.table ?? null, - column: entry.column, - }); - } - walkExpr(entry.value, scope, ctes, scopes); - } - for (const [key, child] of Object.entries(node)) { - if ( - key === "with" || - key === "table" || - key === "from" || - key === "set" || - key === "loc" - ) { - continue; - } - walkExpr(child, scope, ctes, scopes); - } - return; - } - case "delete": { - registerCtes(node, ctes, scopes); - const scope = newScope(true); - scopes.push(scope); - // node.table duplicates node.from entries (with `addition: true`), so - // only FROM is used as the source list - for (const entry of asArray(node.from)) { - addTableEntry(entry, scope, ctes, scopes); - } - for (const [key, child] of Object.entries(node)) { - if (key === "with" || key === "table" || key === "from" || key === "loc") { - continue; - } - walkExpr(child, scope, ctes, scopes); - } - return; - } - case "insert": - case "replace": { - registerCtes(node, ctes, scopes); - // The insert target must exist, but its scope has no readable columns - // context, so unqualified column checks stay off - const scope = newScope(false); - scopes.push(scope); - for (const entry of asArray(node.table)) { - addTableEntry(entry, scope, ctes, scopes); - } - for (const [key, child] of Object.entries(node)) { - // `columns` holds the target column names as plain strings - if (key === "with" || key === "table" || key === "columns" || key === "loc") { - continue; - } - if (isSelectNode(child)) { - // INSERT INTO t SELECT ... — the select is a top-level query - collectSelect(child, ctes, scopes, false); - } else { - walkExpr(child, scope, ctes, scopes); - } - } - return; - } - case "create": - case "alter": - case "drop": { - const scope = newScope(false); - scopes.push(scope); - for (const [key, child] of Object.entries(node)) { - if (key === "table" || key === "loc") { - continue; - } - if (isSelectNode(child)) { - // CREATE TABLE/VIEW ... AS SELECT — top-level query, not correlated - collectSelect(child, ctes, scopes, false); - } else { - walkExpr(child, scope, ctes, scopes); - } - } - return; - } - default: { - const scope = newScope(false); - scopes.push(scope); - for (const [key, child] of Object.entries(node)) { - if (key === "loc") { - continue; - } - walkExpr(child, scope, ctes, scopes); - } - } - } -} - -/** - * Extracts the column name from a column_ref node. Depending on the dialect - * grammar the column is either a plain string or `{expr: {value}}`. - */ -function getColumnName(ref: AstNode): string | null { - const column = ref.column; - if (typeof column === "string") { - return column; - } - if (typeof column === "object" && column !== null) { - const expr = (column as AstNode).expr as AstNode | undefined; - const value = expr?.value ?? (column as AstNode).value; - if (typeof value === "string") { - return value; - } - } - return null; -} - -function getColumnQualifier(ref: AstNode): string | null { - if (typeof ref.table !== "string" || ref.table.length === 0) { - return null; - } - if (typeof ref.db === "string" && ref.db.length > 0) { - return `${ref.db}.${ref.table}`; - } - return ref.table; -} - -/** Extracts column names when the namespace node is a column array */ -function columnsOf(namespace: SQLNamespace | undefined): string[] | null { - if (namespace == null) { - return null; - } - const resolved = isSelfChildrenNamespace(namespace) ? namespace.children : namespace; - if (isArrayNamespace(resolved)) { - return resolved.map((col) => (typeof col === "string" ? col : col.label)); - } - return null; -} - -interface ResolvedTable { - exists: boolean; - /** Column names when the table resolves to a column array; null otherwise */ - columns: string[] | null; -} - -/** - * Resolves a (possibly qualified) table path against the schema, - * case-insensitively. An unqualified or partially-qualified name also matches - * a table nested deeper in the namespace (e.g. `users` matches `mydb.users`) - * so that under-qualified references are never flagged. - */ -function resolveTable(schema: SQLNamespace, path: string): ResolvedTable { - const exact = traverseNamespacePath(schema, path, { caseSensitive: false }); - if (exact) { - return { exists: true, columns: columnsOf(exact.namespace) }; - } - - const segments = path.split(".").map((segment) => segment.toLowerCase()); - const lastSegment = segments[segments.length - 1]; - if (!lastSegment) { - return { exists: false, columns: null }; - } - - const matches = findNamespaceItemByEndMatch(schema, lastSegment).filter((item) => { - if (columnsOf(item.namespace) === null) { - return false; // only table-like matches (nodes holding a column array) - } - if (item.path.length < segments.length) { - return false; - } - const suffix = item.path.slice(-segments.length).map((segment) => segment.toLowerCase()); - return segments.every((segment, i) => suffix[i] === segment); - }); - - if (matches.length === 0) { - return { exists: false, columns: null }; - } - // Only trust the column list when the match is unambiguous - return { - exists: true, - columns: matches.length === 1 ? columnsOf(matches[0]?.namespace) : null, - }; -} - -function includesIgnoreCase(haystack: string[], needle: string): boolean { - const lower = needle.toLowerCase(); - return haystack.some((item) => item.toLowerCase() === lower); -} - -/** - * Runs the semantic checks for one parsed statement and returns findings, - * deduplicated by kind + identifier. - */ -function analyzeStatementAst(ast: AstNode, schema: SQLNamespace): SemanticFinding[] { - const scopes: QueryScope[] = []; - collectStatement(ast, scopes); - - const findings = new Map(); - const addFinding = (finding: SemanticFinding) => { - const key = `${finding.kind}::${finding.identifier.toLowerCase()}`; - if (!findings.has(key)) { - findings.set(key, finding); - } - }; - - for (const scope of scopes) { - const resolved = scope.sources.map((source) => { - if (source.opaque || source.isCte) { - return { source, exists: true, columns: null }; - } - const table = resolveTable(schema, source.path); - return { source, exists: table.exists, columns: table.columns }; - }); - - for (const { source, exists } of resolved) { - if (!source.opaque && !source.isCte && !exists) { - addFinding({ - kind: "unknownTable", - identifier: source.name, - message: `Unknown table '${source.path}'`, - }); - } - } - - for (const ref of scope.columnRefs) { - const name = getColumnName(ref); - if (!name || name === "*") { - continue; - } - - const qualifier = getColumnQualifier(ref); - if (qualifier) { - const lowerQualifier = qualifier.toLowerCase(); - const match = - resolved.find((entry) => entry.source.alias?.toLowerCase() === lowerQualifier) ?? - resolved.find( - (entry) => - entry.source.name.toLowerCase() === lowerQualifier || - entry.source.path.toLowerCase() === lowerQualifier, - ); - // An unresolved qualifier may be an outer-scope alias; skip it - if (match?.columns && !includesIgnoreCase(match.columns, name)) { - addFinding({ - kind: "unknownColumn", - identifier: name, - message: `Column '${name}' not found in table '${match.source.path || qualifier}'`, - }); - } - continue; - } - - if (!scope.checkUnqualified || scope.selectAliases.has(name.toLowerCase())) { - continue; - } - - const containing = resolved.filter( - (entry) => entry.columns !== null && includesIgnoreCase(entry.columns, name), - ); - if (containing.length >= 2 && !scope.hasUsingJoin) { - addFinding({ - kind: "ambiguousColumn", - identifier: name, - message: `Column '${name}' is ambiguous; it exists in ${containing - .map((entry) => entry.source.name) - .join(", ")}`, - }); - continue; - } - // Only flag unknown columns when the scope reads exactly one table - // whose columns are fully known — anything else is not confidently - // resolvable and false positives are worse than under-reporting - const only = resolved.length === 1 ? resolved[0] : undefined; - if (only && only.columns !== null && containing.length === 0) { - addFinding({ - kind: "unknownColumn", - identifier: name, - message: `Column '${name}' not found in table '${only.source.path}'`, - }); - } - } - } - - return [...findings.values()]; -} - -/** - * Finds an identifier occurrence (word-bounded, case-insensitive) in text. - * Used to position diagnostics, since node-sql-parser's name lists and AST - * nodes for tables/columns carry no location information. - */ -function findIdentifier(text: string, name: string): number { - const lowerText = text.toLowerCase(); - const needle = name.toLowerCase(); - let index = lowerText.indexOf(needle); - while (index !== -1) { - const before = index > 0 ? (lowerText[index - 1] ?? "") : ""; - const after = lowerText[index + needle.length] ?? ""; - if (!/[\w$]/.test(before) && !/[\w$]/.test(after)) { - return index; - } - index = lowerText.indexOf(needle, index + 1); - } - return -1; -} - -function findingToDiagnostic( - finding: SemanticFinding, - stmt: SqlStatement, - doc: Text, - severity: "error" | "warning", -): Diagnostic { - const statementText = doc.sliceString(stmt.from, stmt.to); - const offset = findIdentifier(statementText, finding.identifier); - const from = offset === -1 ? stmt.from : stmt.from + offset; - const to = - offset === -1 - ? Math.min(stmt.from + (statementText.match(/^[\w"'`.]+/)?.[0].length ?? 1), stmt.to) - : Math.min(from + finding.identifier.length, stmt.to); - - return { - from, - to, - severity, - message: finding.message, - source: "sql-schema", - }; -} - -/** True when the schema has nothing to validate against */ -function isEmptySchema(schema: SQLNamespace): boolean { - if (isArrayNamespace(schema)) { - return schema.length === 0; - } - if (isObjectNamespace(schema)) { - return Object.keys(schema).length === 0; - } - return false; -} - -function createSemanticLintSource(config: SqlSemanticLinterConfig = {}) { - const parser = config.parser || new NodeSqlParser(); - const analyzer = config.structureAnalyzer || new SqlStructureAnalyzer(parser); - const severities = { - unknownTable: config.severity?.unknownTable ?? "warning", - unknownColumn: config.severity?.unknownColumn ?? "warning", - ambiguousColumn: config.severity?.ambiguousColumn ?? "warning", - } as const; - - return async (view: EditorView): Promise => { - const schema = resolveSqlSchema(config.schema, view); - // No schema (or an empty placeholder while it loads) → stay inert rather - // than flagging every table as unknown - if (schema == null || isEmptySchema(schema)) { - return []; - } - - const doc = view.state.doc; - if (!doc.toString().trim()) { - return []; - } - - const diagnostics: Diagnostic[] = []; - const statements = await analyzer.analyzeDocument(view.state); - for (const stmt of statements) { - // Never stack semantic noise on top of syntax errors - if (!stmt.isValid) { - continue; - } - const result = await parser.parse(stmt.content, { state: view.state }); - if (!result.success || result.ast == null) { - continue; - } - const asts = Array.isArray(result.ast) ? result.ast : [result.ast]; - for (const ast of asts) { - for (const finding of analyzeStatementAst(ast as AstNode, schema)) { - const severity = severities[finding.kind]; - if (severity === "off") { - continue; - } - diagnostics.push(findingToDiagnostic(finding, stmt, doc, severity)); - } - } - } - return diagnostics; - }; -} - -/** - * Creates a schema-aware SQL linter extension that validates queries against - * a database schema, reporting unknown tables, unknown columns, and ambiguous - * column references. - * - * Checks only run on statements that parse cleanly, and skip anything that is - * not confidently resolvable (CTEs, subquery outputs, aliases from outer - * scopes, expressions), preferring under-reporting over false positives. - * - * @param config Configuration options for the semantic linter - * @returns A CodeMirror linter extension - * - * @example - * ```ts - * import { sqlSemanticLinter } from '@marimo-team/codemirror-sql'; - * - * const linter = sqlSemanticLinter({ - * schema: { users: ['id', 'name'], posts: ['id', 'user_id'] }, - * severity: { unknownTable: 'error' }, - * }); - * ``` - */ -export function sqlSemanticLinter(config: SqlSemanticLinterConfig = {}): Extension { - return linter(createSemanticLintSource(config), { - delay: config.delay || DEFAULT_DELAY, - }); -} - -export const exportedForTesting = { createSemanticLintSource }; diff --git a/src/sql/structure-analyzer.ts b/src/sql/structure-analyzer.ts deleted file mode 100644 index f990021..0000000 --- a/src/sql/structure-analyzer.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { EditorState } from "@codemirror/state"; -import type { SqlParseError, SqlParser } from "./types.js"; - -/** - * Represents a SQL statement with position information - */ -export interface SqlStatement { - /** Start position of the statement in the document */ - from: number; - /** End position of the statement in the document */ - to: number; - /** First line number of the statement (1-based) */ - lineFrom: number; - /** Last line number of the statement (1-based) */ - lineTo: number; - /** The actual SQL content */ - content: string; - /** Type of SQL statement */ - type: "select" | "insert" | "update" | "delete" | "create" | "drop" | "alter" | "use" | "other"; - /** Whether this statement is syntactically valid */ - isValid: boolean; - /** Parse errors for this statement, with line/column relative to the statement content */ - errors: SqlParseError[]; -} - -/** - * Analyzes SQL documents to extract statement boundaries and information - * for use with gutter markers and other SQL-aware features. - */ -export class SqlStructureAnalyzer { - private parser: SqlParser; - private cache = new Map(); - private readonly MAX_CACHE_SIZE = 10; - - constructor(parser: SqlParser) { - this.parser = parser; - } - - /** - * Analyzes the document and extracts all SQL statements - */ - async analyzeDocument(state: EditorState): Promise { - const content = state.doc.toString(); - - // Check cache - const cached = this.cache.get(content); - if (cached) { - return cached; - } - - const statements = await this.extractStatements(content, state); - this.cache.set(content, statements); - - // Keep cache size reasonable - if (this.cache.size > this.MAX_CACHE_SIZE) { - const firstKey = this.cache.keys().next().value; - if (firstKey !== undefined) { - this.cache.delete(firstKey); - } - } - - return statements; - } - - /** - * Gets the SQL statement at a specific cursor position - */ - async getStatementAtPosition(state: EditorState, position: number): Promise { - const statements = await this.analyzeDocument(state); - return statements.find((stmt) => position >= stmt.from && position <= stmt.to) || null; - } - - /** - * Gets all SQL statements that intersect with a selection range - */ - async getStatementsInRange( - state: EditorState, - from: number, - to: number, - ): Promise { - const statements = await this.analyzeDocument(state); - return statements.filter( - (stmt) => stmt.from <= to && stmt.to >= from, // Statements that overlap with the range - ); - } - - private async extractStatements(content: string, state: EditorState): Promise { - const statements: SqlStatement[] = []; - - // Split content by semicolons to find potential statement boundaries - const parts = this.splitByStatementSeparators(content); - let currentPosition = 0; - - for (const part of parts) { - const trimmedPart = part.trim(); - if (trimmedPart.length === 0) { - currentPosition += part.length; - continue; - } - - const from = currentPosition + part.indexOf(trimmedPart); - const to = from + trimmedPart.length; - - const fromLine = state.doc.lineAt(from); - const toLine = state.doc.lineAt(to); - - // Strip comments to detect comment-only parts and determine the type - const strippedContent = this.stripComments(trimmedPart); - - // Skip if the statement is empty after stripping comments - if (strippedContent.trim().length === 0 || strippedContent.trim() === ";") { - currentPosition += part.length; - continue; - } - - // Parse the original content (comments included) so error positions - // reported by the parser line up with the document - const parseResult = await this.parser.parse(trimmedPart, { state }); - const type = this.determineStatementType(strippedContent); - - // Remove trailing semicolon from content for cleaner display - const cleanContent = strippedContent.endsWith(";") - ? strippedContent.slice(0, -1).trim() - : strippedContent.trim(); - - statements.push({ - from, - to, - lineFrom: fromLine.number, - lineTo: toLine.number, - content: cleanContent, - type, - isValid: parseResult.success, - errors: parseResult.errors ?? [], - }); - - currentPosition += part.length; - } - - return statements; - } - - private splitByStatementSeparators(content: string): string[] { - // More sophisticated splitting that handles semicolons in strings and comments - const parts: string[] = []; - let current = ""; - let inString = false; - let stringChar = ""; - let inSingleLineComment = false; - let inMultiLineComment = false; - let i = 0; - - while (i < content.length) { - const char = content[i]; - const nextChar = content[i + 1]; - - // Handle single-line comments (-- comment) - if (!inString && !inMultiLineComment && char === "-" && nextChar === "-") { - inSingleLineComment = true; - current += char + nextChar; - i += 2; - continue; - } - - // Handle multi-line comments (/* comment */) - if (!inString && !inSingleLineComment && char === "/" && nextChar === "*") { - inMultiLineComment = true; - current += char + nextChar; - i += 2; - continue; - } - - // End multi-line comment - if (inMultiLineComment && char === "*" && nextChar === "/") { - inMultiLineComment = false; - current += char + nextChar; - i += 2; - continue; - } - - // End single-line comment on newline - if (inSingleLineComment && (char === "\n" || char === "\r")) { - inSingleLineComment = false; - current += char; - i++; - continue; - } - - // Include characters inside comments (for proper position tracking) - if (inSingleLineComment || inMultiLineComment) { - current += char; - i++; - continue; - } - - // Handle string literals - if (!inString && (char === "'" || char === '"' || char === "`")) { - inString = true; - stringChar = char; - current += char; - } else if (inString && char === stringChar) { - // Check for escaped quotes - if (nextChar === stringChar) { - current += char + nextChar; - i += 2; - continue; - } - inString = false; - stringChar = ""; - current += char; - } else if (!inString && char === ";") { - current += char; - parts.push(current); - current = ""; - } else { - current += char; - } - - i++; - } - - if (current.trim()) { - parts.push(current); - } - - return parts; - } - - private determineStatementType(sql: string): SqlStatement["type"] { - const trimmed = sql.trim().toLowerCase(); - - if (trimmed.startsWith("select")) return "select"; - if (trimmed.startsWith("insert")) return "insert"; - if (trimmed.startsWith("update")) return "update"; - if (trimmed.startsWith("delete")) return "delete"; - if (trimmed.startsWith("create")) return "create"; - if (trimmed.startsWith("drop")) return "drop"; - if (trimmed.startsWith("alter")) return "alter"; - if (trimmed.startsWith("use")) return "use"; - - return "other"; - } - - private stripComments(sql: string): string { - let result = ""; - let inString = false; - let stringChar = ""; - let inSingleLineComment = false; - let inMultiLineComment = false; - let i = 0; - - while (i < sql.length) { - const char = sql[i]; - const nextChar = sql[i + 1]; - - // Handle single-line comments (-- comment) - if (!inString && !inMultiLineComment && char === "-" && nextChar === "-") { - inSingleLineComment = true; - i += 2; - continue; - } - - // Handle multi-line comments (/* comment */) - if (!inString && !inSingleLineComment && char === "/" && nextChar === "*") { - inMultiLineComment = true; - i += 2; - continue; - } - - // End multi-line comment - if (inMultiLineComment && char === "*" && nextChar === "/") { - inMultiLineComment = false; - i += 2; - continue; - } - - // End single-line comment on newline - if (inSingleLineComment && (char === "\n" || char === "\r")) { - inSingleLineComment = false; - result += char; - i++; - continue; - } - - // Skip characters inside comments - if (inSingleLineComment || inMultiLineComment) { - i++; - continue; - } - - // Handle string literals - if (!inString && (char === "'" || char === '"' || char === "`")) { - inString = true; - stringChar = char; - result += char; - } else if (inString && char === stringChar) { - // Check for escaped quotes - if (nextChar === stringChar) { - result += char + nextChar; - i += 2; - continue; - } - inString = false; - stringChar = ""; - result += char; - } else { - result += char; - } - - i++; - } - - return result; - } - - /** - * Clears the internal cache - */ - clearCache(): void { - this.cache.clear(); - } -} diff --git a/src/sql/structure-extension.ts b/src/sql/structure-extension.ts deleted file mode 100644 index 0f132d7..0000000 --- a/src/sql/structure-extension.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { type Extension, RangeSet, StateEffect, StateField } from "@codemirror/state"; -import { EditorView, GutterMarker, gutter, ViewPlugin, type ViewUpdate } from "@codemirror/view"; -import { NodeSqlParser } from "./parser.js"; -import { type SqlStatement, SqlStructureAnalyzer } from "./structure-analyzer.js"; -import type { SqlParser } from "./types.js"; - -export interface SqlGutterConfig { - /** Background color for the current statement indicator */ - backgroundColor?: string; - /** Background color for invalid statements */ - errorBackgroundColor?: string; - /** Width of the gutter marker in pixels */ - width?: number; - /** Additional CSS class for the gutter */ - className?: string; - /** Whether to show markers for invalid statements */ - showInvalid?: boolean; - /** Function to determine when to hide the gutter */ - whenHide?: (view: EditorView) => boolean; - /** Opacity for non-current statements */ - inactiveOpacity?: number; - /** Hide gutter when editor is not focused */ - hideWhenNotFocused?: boolean; - /** Opacity when editor is not focused (overrides hideWhenNotFocused if set) */ - unfocusedOpacity?: number; - /** Custom SQL parser instance to use for analysis */ - parser?: SqlParser; -} - -interface SqlGutterState { - currentStatement: SqlStatement | null; - allStatements: SqlStatement[]; - cursorPosition: number; - isFocused: boolean; -} - -// State effect for updating SQL statements -const updateSqlStatementsEffect = StateEffect.define(); - -// State field to track current SQL statements -const sqlGutterStateField = StateField.define({ - create(): SqlGutterState { - return { - currentStatement: null, - allStatements: [], - cursorPosition: 0, - isFocused: true, - }; - }, - update(value, tr) { - for (const effect of tr.effects) { - if (effect.is(updateSqlStatementsEffect)) { - return effect.value; - } - } - return value; - }, -}); - -/** - * Computes the background color and opacity for a gutter marker based on its - * state and config. Extracted as a pure function so the (many) branches can be - * unit-tested without constructing an EditorView. - */ -export function computeMarkerStyle( - config: SqlGutterConfig, - state: { isCurrent: boolean; isValid: boolean; isFocused: boolean }, -): { backgroundColor: string; opacity: string } { - const { isCurrent, isValid, isFocused } = state; - - // Set background color based on state - let backgroundColor = config.backgroundColor || "#3b82f6"; - if (!isValid && config.showInvalid !== false) { - backgroundColor = config.errorBackgroundColor || "#ef4444"; - } - - // Opacity for the "normal" (focused, or focus-agnostic) case - const normalOpacity = isCurrent ? "1" : (config.inactiveOpacity ?? 0.3).toString(); - - // Calculate opacity based on focus state - let opacity: string; - if (!isFocused) { - if (config.unfocusedOpacity !== undefined) { - opacity = config.unfocusedOpacity.toString(); - } else if (config.hideWhenNotFocused) { - opacity = "0"; - } else { - // Default behavior when not focused - use normal opacity - opacity = normalOpacity; - } - } else { - // Normal focused behavior - opacity = normalOpacity; - } - - return { backgroundColor, opacity }; -} - -class SqlGutterMarker extends GutterMarker { - constructor( - private config: SqlGutterConfig, - private isCurrent: boolean, - private isValid: boolean = true, - private isFocused: boolean = true, - ) { - super(); - } - - toDOM(): HTMLElement { - const el = document.createElement("div"); - el.className = "cm-sql-gutter-marker"; - - const { backgroundColor, opacity } = computeMarkerStyle(this.config, { - isCurrent: this.isCurrent, - isValid: this.isValid, - isFocused: this.isFocused, - }); - - el.style.cssText = ` - background: ${backgroundColor}; - height: 100%; - width: 100%; - opacity: ${opacity}; - transition: opacity 150ms ease-in-out; - border-radius: 1px; - `; - - return el; - } - - eq(other: SqlGutterMarker): boolean { - return ( - this.isCurrent === other.isCurrent && - this.isValid === other.isValid && - this.isFocused === other.isFocused && - this.config === other.config - ); - } -} - -function createSqlGutterMarkers( - view: EditorView, - config: SqlGutterConfig, -): RangeSet { - let markers = RangeSet.empty; - - // Check if gutter should be hidden - if (config.whenHide?.(view)) { - return markers; - } - - const state = view.state.field(sqlGutterStateField, false); - if (!state) { - return markers; - } - - const { currentStatement, allStatements, isFocused } = state; - - try { - const startLine = view.state.doc.lineAt(view.viewport.from).number; - const endLine = view.state.doc.lineAt(view.viewport.to).number; - - // Create markers for all statements that intersect with the viewport - for (const statement of allStatements) { - // Skip if statement doesn't intersect with viewport - if (statement.lineTo < startLine || statement.lineFrom > endLine) { - continue; - } - - const isCurrent = - currentStatement?.from === statement.from && currentStatement?.to === statement.to; - - // Skip invalid statements if configured to hide them - if (!statement.isValid && config.showInvalid === false) { - continue; - } - - const marker = new SqlGutterMarker(config, isCurrent, statement.isValid, isFocused); - - // Add marker to each line within the viewport of the statement - const statementFrom = Math.max(statement.lineFrom, startLine); - const statementTo = Math.min(statement.lineTo, endLine); - - for (let lineNum = statementFrom; lineNum <= statementTo; lineNum++) { - try { - // Check if line number is within valid bounds - if (lineNum < 1 || lineNum > view.state.doc.lines) { - // Skip stale line numbers silently - this is expected when text is deleted - continue; - } - - const line = view.state.doc.line(lineNum); - markers = markers.update({ - add: [marker.range(line.from)], - }); - } catch (e) { - // Handle edge cases where line numbers might be invalid - console.warn("SqlGutter: Invalid line number", lineNum, e); - } - } - } - } catch (error) { - console.warn("SqlGutter: Error creating markers", error); - } - - return markers; -} - -async function analyzeAndDispatch(view: EditorView, analyzer: SqlStructureAnalyzer): Promise { - const { state } = view; - const cursorPosition = state.selection.main.head; - - // Analyze the document for SQL statements - const allStatements = await analyzer.analyzeDocument(state); - const currentStatement = await analyzer.getStatementAtPosition(state, cursorPosition); - - const newState: SqlGutterState = { - currentStatement, - allStatements, - cursorPosition, - isFocused: view.hasFocus, - }; - - // Dispatch the update - view.dispatch({ - effects: updateSqlStatementsEffect.of(newState), - }); -} - -function createStructurePlugin(analyzer: SqlStructureAnalyzer): Extension { - return ViewPlugin.define((view: EditorView) => { - // Analyze on creation so pre-filled documents get markers immediately - void analyzeAndDispatch(view, analyzer); - - return { - update(update: ViewUpdate) { - // Update on document changes, selection changes, or focus changes - if (!update.docChanged && !update.selectionSet && !update.focusChanged) { - return; - } - void analyzeAndDispatch(update.view, analyzer); - }, - }; - }); -} - -function createGutterTheme(config: SqlGutterConfig): Extension { - const gutterWidth = config.width || 3; - - return EditorView.baseTheme({ - ".cm-sql-gutter": { - width: `${gutterWidth}px`, - minWidth: `${gutterWidth}px`, - }, - ".cm-sql-gutter .cm-gutterElement": { - width: `${gutterWidth}px`, - padding: "0", - margin: "0", - }, - ".cm-sql-gutter-marker": { - width: "100%", - height: "100%", - display: "block", - }, - // Ensure line numbers have proper spacing when SQL gutter is present - ".cm-lineNumbers .cm-gutterElement": { - paddingLeft: "8px", - paddingRight: "8px", - }, - }); -} - -function createSqlGutter(config: SqlGutterConfig): Extension { - return gutter({ - class: `cm-sql-gutter ${config.className || ""}`, - markers: (view: EditorView) => createSqlGutterMarkers(view, config), - }); -} - -/** - * Creates a SQL gutter extension that shows visual indicators for SQL statements - * based on cursor position. Highlights the current statement and shows dimmed - * indicators for other statements. - */ -export function sqlStructureGutter(config: SqlGutterConfig = {}): Extension[] { - const parser = config.parser || new NodeSqlParser(); - const analyzer = new SqlStructureAnalyzer(parser); - - return [ - sqlGutterStateField, - createStructurePlugin(analyzer), - createGutterTheme(config), - createSqlGutter(config), - ]; -} diff --git a/src/sql/types.ts b/src/sql/types.ts deleted file mode 100644 index bbbf491..0000000 --- a/src/sql/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { EditorState } from "@codemirror/state"; - -/** - * Represents a SQL parsing error with location information - */ -export interface SqlParseError { - /** Error message describing the issue */ - message: string; - /** Line number where the error occurred (1-indexed) */ - line: number; - /** Column number where the error occurred (1-indexed) */ - column: number; - /** Severity level of the error */ - severity: "error" | "warning"; -} -/** - * Result of parsing a SQL statement - */ - -export interface SqlParseResult { - /** Whether parsing was successful */ - success: boolean; - /** Array of parsing errors, if any */ - errors: SqlParseError[]; - /** The parsed AST if successful */ - ast?: unknown; -} - -export interface SqlParser { - /** - * Parse a SQL statement and return the AST - * @param sql - The SQL statement to parse - * @param opts - The state of the editor - * @returns The parsed AST - */ - parse(sql: string, opts: { state: EditorState }): Promise; - /** - * Validate a SQL statement and return any errors - * @param sql - The SQL statement to validate - * @param opts - The state of the editor - * @returns An array of errors - */ - validateSql(sql: string, opts: { state: EditorState }): Promise; - /** - * Extract table references from a SQL query - * @param sql - The SQL query to analyze - * @param opts - The state of the editor - * @returns Array of table names referenced in the query - */ - extractTableReferences(sql: string, opts?: { state: EditorState }): Promise; - /** - * Extract column references from a SQL query - * @param sql - The SQL query to analyze - * @param opts - The state of the editor - * @returns Array of column names referenced in the query - */ - extractColumnReferences(sql: string, opts?: { state: EditorState }): Promise; -} diff --git a/src/vnext/statement-index.ts b/src/statement-index.ts similarity index 100% rename from src/vnext/statement-index.ts rename to src/statement-index.ts diff --git a/src/vnext/syntax.ts b/src/syntax.ts similarity index 100% rename from src/vnext/syntax.ts rename to src/syntax.ts diff --git a/src/vnext/types.ts b/src/types.ts similarity index 100% rename from src/vnext/types.ts rename to src/types.ts diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 4fd0882..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Lazy evaluation of a function that returns a promise. - */ -export const lazy = (fn: () => Promise) => { - let value: Promise | undefined; - return async () => { - if (!value) { - value = fn(); - } - return await value; - }; -}; diff --git a/src/vnext/index.ts b/src/vnext/index.ts deleted file mode 100644 index d850c9f..0000000 --- a/src/vnext/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - bigQueryDialect, - createSqlLanguageService, - dremioDialect, - duckdbDialect, - postgresDialect, -} from "./session.js"; -export type { - OpenSqlDocument, - SqlCatalogContext, - SqlContextInput, - SqlDialect, - SqlDocumentChanges, - SqlDocumentContext, - SqlDocumentEdit, - SqlEmbeddedRegion, - SqlDocumentReplacement, - SqlDocumentSession, - SqlDocumentUpdate, - SqlIdentifierComponent, - SqlIdentifierPath, - SqlLanguageService, - SqlLanguageServiceOptions, - SqlPlainData, - SqlRevision, - SqlSessionErrorCode, - SqlTextChange, - SqlTextRange, -} from "./types.js"; -export { SqlSessionError } from "./types.js"; diff --git a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts b/test/types/marimo-relation-completion-codemirror.test-d.ts similarity index 92% rename from test/vnext-types/marimo-relation-completion-codemirror.test-d.ts rename to test/types/marimo-relation-completion-codemirror.test-d.ts index 57ef37f..14a6f8a 100644 --- a/test/vnext-types/marimo-relation-completion-codemirror.test-d.ts +++ b/test/types/marimo-relation-completion-codemirror.test-d.ts @@ -3,8 +3,8 @@ import type { EditorView } from "@codemirror/view"; import type { SqlCompletionInfoResolver, SqlCompletionInfoResolverContext, -} from "../../src/vnext/codemirror/relation-completion-types.js"; -import type { SqlRelationCompletionItem } from "../../src/vnext/relation-completion-types.js"; +} from "../../src/codemirror/relation-completion-types.js"; +import type { SqlRelationCompletionItem } from "../../src/relation-completion-types.js"; interface ReactRootLike { readonly render: (value: unknown) => void; diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/types/marimo-relation-completion.test-d.ts similarity index 98% rename from test/vnext-types/marimo-relation-completion.test-d.ts rename to test/types/marimo-relation-completion.test-d.ts index 721dfa4..650284a 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/types/marimo-relation-completion.test-d.ts @@ -6,7 +6,7 @@ import type { SqlEmbeddedRegion, SqlIdentifierComponent, OpenSqlDocument, -} from "../../src/vnext/index.js"; +} from "../../src/index.js"; import type { SqlCanonicalRelationPath, SqlCatalogProviderReport, @@ -22,10 +22,10 @@ import type { SqlRelationCompletionList, SqlRelationCatalogProvider, SqlRelationCompletionSession, -} from "../../src/vnext/relation-completion-types.js"; +} from "../../src/relation-completion-types.js"; import type { SqlCatalogEpochTransitionTarget, -} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; +} from "../../src/relation-catalog-epoch-coordinator.js"; interface MarimoSqlContext extends SqlDocumentContext { readonly engine: string; diff --git a/test/vnext-types/relation-catalog-search-work.test-d.ts b/test/types/relation-catalog-search-work.test-d.ts similarity index 95% rename from test/vnext-types/relation-catalog-search-work.test-d.ts rename to test/types/relation-catalog-search-work.test-d.ts index 8176b88..68cbf81 100644 --- a/test/vnext-types/relation-catalog-search-work.test-d.ts +++ b/test/types/relation-catalog-search-work.test-d.ts @@ -1,13 +1,13 @@ import type { CapturedSqlRelationCatalogProvider, SqlValidatedCatalogSearchResponse, -} from "../../src/vnext/relation-catalog-boundary.js"; +} from "../../src/relation-catalog-boundary.js"; import type { SqlCatalogRevisionTarget, -} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; +} from "../../src/relation-catalog-epoch-coordinator.js"; import { createSqlCatalogEpochCoordinator, -} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; +} from "../../src/relation-catalog-epoch-coordinator.js"; import type { SqlCatalogSearchWorkCoordinator, SqlCatalogSearchWorkInput, @@ -15,13 +15,13 @@ import type { SqlCatalogSearchWorkOwner, SqlCatalogSearchWorkOwnerResult, SqlCatalogSearchWorkTicket, -} from "../../src/vnext/relation-catalog-search-work.js"; +} from "../../src/relation-catalog-search-work.js"; import { createSqlCatalogSearchWorkCoordinator, -} from "../../src/vnext/relation-catalog-search-work.js"; +} from "../../src/relation-catalog-search-work.js"; import type { SqlRelationDialectRuntime, -} from "../../src/vnext/relation-dialect.js"; +} from "../../src/relation-dialect.js"; declare const capturedProvider: CapturedSqlRelationCatalogProvider; declare const coordinator: SqlCatalogSearchWorkCoordinator; diff --git a/test/vnext-types/session.test-d.ts b/test/types/session.test-d.ts similarity index 98% rename from test/vnext-types/session.test-d.ts rename to test/types/session.test-d.ts index 13c7020..7c1970b 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/types/session.test-d.ts @@ -10,7 +10,7 @@ import { type SqlRevision, type SqlTextChange, type SqlTextRange, -} from "../../src/vnext/index.js"; +} from "../../src/index.js"; interface HostContext extends SqlDocumentContext { readonly engine: string; @@ -120,13 +120,13 @@ const objectRevision: SqlRevision = {}; const numberRevision: SqlRevision = 1; // @ts-expect-error updates require a non-empty state change session.update({ baseRevision: revision }); -const legacyUpdate = { +const forbiddenUpdate = { baseRevision: revision, context: { dialect: "duckdb", engine: "local" }, kind: "context" as const, }; // @ts-expect-error the removed update discriminant stays forbidden through variables -session.update(legacyUpdate); +session.update(forbiddenUpdate); // @ts-expect-error document mutations require complete post-edit regions session.update({ baseRevision: session.revision, diff --git a/test/vnext-types/syntax.test-d.ts b/test/types/syntax.test-d.ts similarity index 98% rename from test/vnext-types/syntax.test-d.ts rename to test/types/syntax.test-d.ts index a5bbece..5fb04ae 100644 --- a/test/vnext-types/syntax.test-d.ts +++ b/test/types/syntax.test-d.ts @@ -1,4 +1,4 @@ -import type { SqlTextRange } from "../../src/vnext/index.js"; +import type { SqlTextRange } from "../../src/index.js"; import { createCompatibilityParsedAnalysis, createDirectParsedAnalysis, @@ -20,7 +20,7 @@ import { type SqlStatementRelativeRange, type SqlStatementSyntaxState, type SqlSyntaxArtifact, -} from "../../src/vnext/syntax.js"; +} from "../../src/syntax.js"; const range = createSqlStatementRelativeRange(0, 8, 8); const backendIdentity = createSqlSyntaxBackendIdentity(); diff --git a/test/worker-placement/src/core.js b/test/worker-placement/src/core.js index 1e77877..7d7897b 100644 --- a/test/worker-placement/src/core.js +++ b/test/worker-placement/src/core.js @@ -1,7 +1,7 @@ import { createSqlLanguageService, duckdbDialect, -} from "@marimo-team/codemirror-sql/vnext"; +} from "@marimo-team/codemirror-sql"; const service = createSqlLanguageService({ dialects: [duckdbDialect()], diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js index f7f4e59..3103296 100644 --- a/test/worker-placement/src/workers.js +++ b/test/worker-placement/src/workers.js @@ -1,7 +1,7 @@ import { createSqlLanguageService, duckdbDialect, -} from "@marimo-team/codemirror-sql/vnext"; +} from "@marimo-team/codemirror-sql"; const REQUEST_TIMEOUT_MS = 10_000; diff --git a/tsconfig.json b/tsconfig.json index 2613609..3b3f932 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,10 @@ "outDir": "./dist" }, "include": ["./src"], - "exclude": ["./demo", "./src/**/*.test.ts", "./src/**/__tests__/**"] + "exclude": [ + "./demo", + "./src/**/*.test.ts", + "./src/**/__tests__/**", + "./src/**/browser_tests/**" + ] } diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 33f3cc2..b834e01 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -4,14 +4,13 @@ "allowImportingTsExtensions": true, "declaration": false, "declarationMap": false, + "exactOptionalPropertyTypes": true, "inlineSources": false, "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, - // Legacy tests temporarily retain these two exceptions. All vNext tests - // are checked by tsconfig.vnext-tests.json with both rules enabled. - "noUncheckedIndexedAccess": false, - "noUnusedLocals": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, "rootDir": ".", "sourceMap": false, "types": [ @@ -20,6 +19,7 @@ }, "include": [ "./src/**/*.ts", + "./test/types/**/*.ts", "./vitest.browser.config.ts", "./vitest.config.ts" ], diff --git a/tsconfig.vnext-tests.loose-optional.json b/tsconfig.tests.loose-optional.json similarity index 61% rename from tsconfig.vnext-tests.loose-optional.json rename to tsconfig.tests.loose-optional.json index eb6941b..3f21aab 100644 --- a/tsconfig.vnext-tests.loose-optional.json +++ b/tsconfig.tests.loose-optional.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.vnext-tests.json", + "extends": "./tsconfig.tests.json", "compilerOptions": { "exactOptionalPropertyTypes": false } diff --git a/tsconfig.vnext-tests.json b/tsconfig.vnext-tests.json deleted file mode 100644 index 8cf4d35..0000000 --- a/tsconfig.vnext-tests.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.tests.json", - "compilerOptions": { - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true - }, - "include": [ - "./src/vnext/**/*.ts", - "./test/vnext-types/**/*.ts", - "./vitest.browser.config.ts", - "./vitest.config.ts" - ] -} diff --git a/vitest.config.ts b/vitest.config.ts index 8cdc44f..792efd8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,8 +9,7 @@ export default defineConfig({ "src/**/*.test.ts", "src/**/__tests__/**", "src/**/browser_tests/**", - "src/debug.ts", - "src/vnext/node-sql-parser-browser-worker.ts", + "src/node-sql-parser-browser-worker.ts", ], excludeAfterRemap: true, include: ["src/**/*.ts"], From b96c934ea2e9c60a25052e5712768e83fd2d3e60 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 16:16:32 +0800 Subject: [PATCH 35/42] fix: resolve final SQL editor review findings --- README.md | 7 ++ demo/index.ts | 1 + docs/capability-charter.md | 7 +- .../__tests__/sql-editor-performance.test.ts | 66 +++++++++++++++ src/codemirror/__tests__/sql-editor.bench.ts | 6 +- src/codemirror/__tests__/sql-editor.test.ts | 84 +++++++++++++++++++ .../sql-editor-completion-gate.test.ts | 59 +++++++++++++ src/codemirror/sql-editor.ts | 19 ++++- vitest.config.ts | 8 +- 9 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/codemirror/__tests__/sql-editor-performance.test.ts diff --git a/README.md b/README.md index c076eda..a351410 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ npm install @marimo-team/codemirror-sql pnpm add @marimo-team/codemirror-sql ``` +The framework-independent root API needs no CodeMirror packages. For the +`/codemirror` adapter and the example below, also install: + +```bash +npm install @codemirror/autocomplete @codemirror/lang-sql @codemirror/state @codemirror/view +``` + ## CodeMirror usage ```ts diff --git a/demo/index.ts b/demo/index.ts index b9bb5e2..891a9c3 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -207,6 +207,7 @@ const support = sqlEditor({ return { destroy: () => undefined, dom }; }, maxRenderedOptions: 100, + selectOnOpen: true, }, initialContext: context(), service, diff --git a/docs/capability-charter.md b/docs/capability-charter.md index 5cc4800..21bed60 100644 --- a/docs/capability-charter.md +++ b/docs/capability-charter.md @@ -206,13 +206,12 @@ are recorded in the release capability matrix and exercised through packed consumer fixtures. A declared runtime is not supported unless its fixture runs in CI. -The exact subpath layout is deferred to a packaging ADR after the walking -skeleton produces bundle and packed-consumer evidence. Packaging must provide: +Packed-consumer and bundle evidence established two stable entry points: - An SSR-safe, framework-independent core entry - An explicit CodeMirror entry -- Explicit dialect entry points -- Independently importable optional parser/provider integrations +- Built-in opaque dialect handles exported from core +- Package-internal parser workers that remain independently chunkable - No accidental transitive import of optional parsers or providers into core ## Provisional performance envelopes diff --git a/src/codemirror/__tests__/sql-editor-performance.test.ts b/src/codemirror/__tests__/sql-editor-performance.test.ts new file mode 100644 index 0000000..ec4852f --- /dev/null +++ b/src/codemirror/__tests__/sql-editor-performance.test.ts @@ -0,0 +1,66 @@ +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../../index.js"; +import { sqlEditor } from "../index.js"; + +const views: EditorView[] = []; + +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); +}); + +describe("CodeMirror performance gates", () => { + it("keeps warmed 1 MiB keystroke bookkeeping below the envelope", () => { + const statement = "SELECT id FROM users WHERE active = true;\n"; + const text = statement.repeat( + Math.ceil((1_024 * 1_024) / statement.length), + ).slice(0, 1_024 * 1_024); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + autocomplete: { activateOnTyping: false }, + initialContext: { dialect: "duckdb" }, + service, + statementGutter: { + hideWhenNotFocused: false, + showInactive: true, + }, + }); + const view = new EditorView({ + doc: text, + extensions: support.extension, + parent: document.body, + }); + views.push(view); + const position = text.indexOf("active"); + support.statementBoundaryAt(view, { + affinity: "left", + position, + }); + + const durations: number[] = []; + for (let index = 0; index < 25; index += 1) { + const startedAt = performance.now(); + const current = view.state.sliceDoc(position, position + 1); + view.dispatch({ + changes: { + from: position, + insert: current === "a" ? "A" : "a", + to: position + 1, + }, + userEvent: "input.type", + }); + durations.push(performance.now() - startedAt); + } + durations.sort((left, right) => left - right); + const p95 = durations[Math.ceil(durations.length * 0.95) - 1]; + expect(p95).toBeDefined(); + expect(p95).toBeLessThan(8); + + service.dispose(); + }); +}); diff --git a/src/codemirror/__tests__/sql-editor.bench.ts b/src/codemirror/__tests__/sql-editor.bench.ts index 02d726e..b2e52eb 100644 --- a/src/codemirror/__tests__/sql-editor.bench.ts +++ b/src/codemirror/__tests__/sql-editor.bench.ts @@ -8,8 +8,8 @@ import { sqlEditor } from "../index.js"; const statement = "SELECT id, name FROM users WHERE active = true;\n"; const documentText = statement.repeat( - Math.ceil((100 * 1_024) / statement.length), -).slice(0, 100 * 1_024); + Math.ceil((1_024 * 1_024) / statement.length), +).slice(0, 1_024 * 1_024); const service = createSqlLanguageService({ dialects: [duckdbDialect()], }); @@ -42,7 +42,7 @@ afterAll(() => { }); describe("CodeMirror editor", () => { - bench("100 KiB warmed single-character replacement", () => { + bench("1 MiB warmed single-character replacement", () => { const current = view.state.sliceDoc(position, position + 1); view.dispatch({ changes: { diff --git a/src/codemirror/__tests__/sql-editor.test.ts b/src/codemirror/__tests__/sql-editor.test.ts index 063c6aa..d80cce6 100644 --- a/src/codemirror/__tests__/sql-editor.test.ts +++ b/src/codemirror/__tests__/sql-editor.test.ts @@ -17,6 +17,7 @@ import { type SqlCatalogSearchRequest, type SqlCatalogSearchResponse, type SqlCompletionItem, + type SqlCompletionRequest, type SqlCompletionRefreshToken, type SqlCompletionResult, type SqlCompletionTask, @@ -45,6 +46,7 @@ interface TestContext extends SqlDocumentContext { } interface FakeServiceHarness { + readonly completionRequests: readonly SqlCompletionRequest[]; readonly completeSignals: AbortSignal[]; readonly emit: (event: SqlSessionChangeEvent) => void; readonly getLastToken: () => SqlCompletionRefreshToken | null; @@ -134,6 +136,7 @@ function fakeService( statementCode: SqlTextRange | null = null, ): FakeServiceHarness { const completeSignals: AbortSignal[] = []; + const completionRequests: SqlCompletionRequest[] = []; const updates: SqlDocumentUpdate[] = []; let listener: | ((event: SqlSessionChangeEvent) => void) @@ -150,6 +153,7 @@ function fakeService( let revision = createSqlRevisionToken(); const session: SqlDocumentSession = { complete: (request): SqlCompletionTask => { + completionRequests.push(request); completeSignals.push(request.signal ?? new AbortController().signal); const token = createSqlCompletionRefreshToken(); lastToken = token; @@ -243,6 +247,7 @@ function fakeService( }, }; return { + completionRequests, completeSignals, emit: (event) => listener?.(event), getLastToken: () => lastToken, @@ -364,6 +369,85 @@ async function resolveCompletionInfo( } describe("sqlEditor", () => { + it("distinguishes invoked and typing completion triggers", async () => { + const invokedHarness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const invokedSupport = sqlEditor({ + initialContext: context(), + service: invokedHarness.service, + }); + const invokedView = createView(invokedSupport.extension); + invokedView.focus(); + expect(startCompletion(invokedView)).toBe(true); + await vi.waitFor(() => { + expect(invokedHarness.completionRequests).toHaveLength(1); + }); + expect(invokedHarness.completionRequests[0]?.trigger).toEqual({ + kind: "invoked", + }); + invokedView.destroy(); + + const typingHarness = fakeService((revision) => + readyResult(revision, [completionItem()]) + ); + const typingSupport = sqlEditor({ + autocomplete: { activateOnTypingDelay: 0 }, + initialContext: context(), + service: typingHarness.service, + }); + const typingView = createView(typingSupport.extension); + typingView.focus(); + typingView.dispatch({ + changes: { from: typingView.state.doc.length, insert: "e" }, + selection: { anchor: typingView.state.doc.length + 1 }, + userEvent: "input.type", + }); + await vi.waitFor(() => { + expect(typingHarness.completionRequests).toHaveLength(1); + }); + expect(typingHarness.completionRequests[0]?.trigger).toEqual({ + character: "e", + kind: "trigger-character", + }); + }); + + it("cancels superseded completion during rapid typing", async () => { + const harness = fakeService(() => new Promise(() => undefined)); + const support = sqlEditor({ + autocomplete: { activateOnTypingDelay: 0 }, + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + view.focus(); + view.dispatch({ + changes: { from: view.state.doc.length, insert: "e" }, + selection: { anchor: view.state.doc.length + 1 }, + userEvent: "input.type", + }); + await vi.waitFor(() => { + expect(harness.completionRequests).toHaveLength(1); + }); + const firstSignal = harness.completeSignals[0]; + expect(firstSignal?.aborted).toBe(false); + + view.dispatch({ + changes: { from: view.state.doc.length, insert: "r" }, + selection: { anchor: view.state.doc.length + 1 }, + userEvent: "input.type", + }); + await vi.waitFor(() => { + expect(harness.completionRequests).toHaveLength(2); + }); + expect(firstSignal?.aborted).toBe(true); + expect(harness.completionRequests.map((request) => request.trigger)) + .toEqual([ + { character: "e", kind: "trigger-character" }, + { character: "r", kind: "trigger-character" }, + ]); + }); + it("exposes session controls only for owned views", () => { const service = createSqlLanguageService({ dialects: [duckdbDialect()], diff --git a/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts index 35baa58..abdfaa8 100644 --- a/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts +++ b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts @@ -1,5 +1,7 @@ import { + acceptCompletion, currentCompletions, + selectedCompletionIndex, startCompletion, } from "@codemirror/autocomplete"; import { sql, StandardSQL } from "@codemirror/lang-sql"; @@ -81,6 +83,7 @@ test("standard completion gate routes unmatched template EOF to external sources selection: { anchor: 15 }, }); onTestFinished(() => view.destroy()); + view.focus(); expect(startCompletion(view)).toBe(true); await expect.poll(() => @@ -144,6 +147,7 @@ test("standard completion gate permits normal SQL in a browser editor", async () selection: { anchor: documentText.length }, }); onTestFinished(() => view.destroy()); + view.focus(); expect(startCompletion(view)).toBe(true); await expect.poll(() => @@ -151,6 +155,61 @@ test("standard completion gate permits normal SQL in a browser editor", async () ).toContain("users"); }); +test("standard editor can accept the first completion immediately", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + catalog: { + id: "browser-select-on-open", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [{ + quoted: false, + role: "relation", + value: "users", + }], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready" as const, + }), + }, + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + autocomplete: { selectOnOpen: true }, + initialContext: { + catalog: { scope: "browser-select-on-open" }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT * FROM us"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + + view.focus(); + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["users"]); + await expect.poll(() => selectedCompletionIndex(view.state)).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(acceptCompletion(view)).toBe(true); + expect(view.state.doc.toString()).toBe("SELECT * FROM users"); +}); + test("standard editor preserves SQL language completions", async () => { const parent = document.createElement("div"); document.body.append(parent); diff --git a/src/codemirror/sql-editor.ts b/src/codemirror/sql-editor.ts index 0dda81f..4ff6188 100644 --- a/src/codemirror/sql-editor.ts +++ b/src/codemirror/sql-editor.ts @@ -34,6 +34,7 @@ import type { import type { SqlCompletionItem, SqlCompletionRefreshToken, + SqlCompletionTrigger, SqlCompletionResult, SqlCompletionTask, } from "../relation-completion-types.js"; @@ -163,8 +164,20 @@ const defaultRuntime: SqlEditorRuntime = Object.freeze({ startCompletion, }); -function completionTrigger(): { readonly kind: "invoked" } { - return { kind: "invoked" }; +function completionTrigger( + context: CompletionContext, +): SqlCompletionTrigger { + if (context.explicit || context.pos === 0) { + return { kind: "invoked" }; + } + const prefix = context.state.sliceDoc( + Math.max(0, context.pos - 2), + context.pos, + ); + const character = Array.from(prefix).at(-1); + return character === undefined + ? { kind: "invoked" } + : { character, kind: "trigger-character" }; } function collectChanges(update: ViewUpdate): readonly SqlTextChange[] { @@ -622,7 +635,7 @@ export function createSqlEditorInternal< task = this.#session.complete({ position: context.pos, signal: controller.signal, - trigger: completionTrigger(), + trigger: completionTrigger(context), }); } catch { this.destroy(); diff --git a/vitest.config.ts b/vitest.config.ts index 71f1604..930fd6c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,8 @@ import { defineConfig } from "vitest/config"; +const performanceTests = "src/**/*-performance.test.ts"; +const coverageRun = process.argv.includes("--coverage"); + export default defineConfig({ test: { allowOnly: false, @@ -24,7 +27,10 @@ export default defineConfig({ }, }, environment: "jsdom", - exclude: ["src/**/browser_tests/**/*.test.ts"], + exclude: [ + "src/**/browser_tests/**/*.test.ts", + ...(coverageRun ? [performanceTests] : []), + ], include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], passWithNoTests: false, watch: false, From 4d984948f29b4834809a5a34d835c45892a120ae Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 16:19:01 +0800 Subject: [PATCH 36/42] test: gate large-document completion performance --- .github/workflows/test.yml | 3 + package.json | 1 + .../__tests__/sql-editor-performance.test.ts | 152 +++++++++++++++--- 3 files changed, 130 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68f46db..b31b752 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,9 @@ jobs: - name: 🧪 Unit Tests and Coverage run: pnpm run test:coverage + - name: ⚡ Performance Gates + run: pnpm run test:performance + - name: 📈 Changed-Code Coverage if: github.event_name == 'pull_request' || github.event_name == 'push' env: diff --git a/package.json b/package.json index 218e309..f2822cb 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "test:performance": "vitest run --config vitest.config.ts src/codemirror/__tests__/sql-editor-performance.test.ts", "bench:catalog-boundary": "vitest bench --run src/__tests__/relation-catalog-boundary.bench.ts", "bench:catalog-coordinator": "vitest bench --run src/__tests__/relation-catalog-epoch-coordinator.bench.ts", "bench:editor": "vitest bench --run src/codemirror/__tests__/sql-editor.bench.ts", diff --git a/src/codemirror/__tests__/sql-editor-performance.test.ts b/src/codemirror/__tests__/sql-editor-performance.test.ts index ec4852f..31dfa91 100644 --- a/src/codemirror/__tests__/sql-editor-performance.test.ts +++ b/src/codemirror/__tests__/sql-editor-performance.test.ts @@ -1,5 +1,6 @@ +import { currentCompletions } from "@codemirror/autocomplete"; import { EditorView } from "@codemirror/view"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createSqlLanguageService, duckdbDialect, @@ -12,55 +13,154 @@ afterEach(() => { for (const view of views.splice(0)) view.destroy(); }); +function measureKeystrokeP95( + text: string, + position: number, +): number { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const support = sqlEditor({ + autocomplete: { activateOnTyping: false }, + initialContext: { dialect: "duckdb" }, + service, + statementGutter: { + hideWhenNotFocused: false, + showInactive: true, + }, + }); + const view = new EditorView({ + doc: text, + extensions: support.extension, + parent: document.body, + }); + views.push(view); + support.statementBoundaryAt(view, { + affinity: "left", + position, + }); + + const durations: number[] = []; + for (let index = 0; index < 25; index += 1) { + const startedAt = performance.now(); + const current = view.state.sliceDoc(position, position + 1); + view.dispatch({ + changes: { + from: position, + insert: current === "x" ? "X" : "x", + to: position + 1, + }, + userEvent: "input.type", + }); + durations.push(performance.now() - startedAt); + } + durations.sort((left, right) => left - right); + service.dispose(); + return durations[Math.ceil(durations.length * 0.95) - 1] ?? + Number.POSITIVE_INFINITY; +} + describe("CodeMirror performance gates", () => { - it("keeps warmed 1 MiB keystroke bookkeeping below the envelope", () => { + it("keeps warmed 1 MiB multi-statement bookkeeping below 8 ms p95", () => { const statement = "SELECT id FROM users WHERE active = true;\n"; const text = statement.repeat( Math.ceil((1_024 * 1_024) / statement.length), ).slice(0, 1_024 * 1_024); + expect(measureKeystrokeP95(text, text.indexOf("active"))) + .toBeLessThan(8); + }); + + it("keeps a warmed 1 MiB single-statement edit below 8 ms p95", () => { + const size = 1_024 * 1_024; + const prefix = "SELECT "; + const suffix = " FROM users"; + const text = `${prefix}${ + "x".repeat(size - prefix.length - suffix.length) + }${suffix}`; + expect(measureKeystrokeP95(text, Math.floor(size / 2))) + .toBeLessThan(8); + }); + + it("settles rapid typing with delayed provider work within 500 ms", async () => { + let aborts = 0; + let providerCalls = 0; + const providerDelayMs = 200; const service = createSqlLanguageService({ + catalog: { + id: "performance-catalog", + search: async (_request, signal) => { + providerCalls += 1; + await new Promise((resolve, reject) => { + const handle = setTimeout(resolve, providerDelayMs); + signal.addEventListener("abort", () => { + aborts += 1; + clearTimeout(handle); + reject(signal.reason); + }, { once: true }); + }); + return { + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "ready" }, + relations: [{ + canonicalPath: [{ + quoted: false, + role: "relation", + value: "users", + }], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready" as const, + }; + }, + }, + completion: { catalogResponseBudgetMs: 5 }, dialects: [duckdbDialect()], }); const support = sqlEditor({ - autocomplete: { activateOnTyping: false }, - initialContext: { dialect: "duckdb" }, - service, - statementGutter: { - hideWhenNotFocused: false, - showInactive: true, + autocomplete: { activateOnTypingDelay: 0 }, + initialContext: { + catalog: { scope: "performance" }, + dialect: "duckdb", }, + service, }); + const initialText = "SELECT * FROM "; const view = new EditorView({ - doc: text, + doc: initialText, extensions: support.extension, parent: document.body, + selection: { anchor: initialText.length }, }); views.push(view); - const position = text.indexOf("active"); - support.statementBoundaryAt(view, { - affinity: "left", - position, - }); + view.focus(); + const startedAt = performance.now(); - const durations: number[] = []; - for (let index = 0; index < 25; index += 1) { - const startedAt = performance.now(); - const current = view.state.sliceDoc(position, position + 1); + for (const character of "users") { + const callsBefore = providerCalls; view.dispatch({ changes: { - from: position, - insert: current === "a" ? "A" : "a", - to: position + 1, + from: view.state.doc.length, + insert: character, }, + selection: { anchor: view.state.doc.length + 1 }, userEvent: "input.type", }); - durations.push(performance.now() - startedAt); + await vi.waitFor(() => { + expect(providerCalls).toBeGreaterThan(callsBefore); + }, { interval: 1, timeout: 100 }); } - durations.sort((left, right) => left - right); - const p95 = durations[Math.ceil(durations.length * 0.95) - 1]; - expect(p95).toBeDefined(); - expect(p95).toBeLessThan(8); + await vi.waitFor(() => { + expect( + currentCompletions(view.state).map((item) => item.label), + ).toEqual(["users"]); + }, { interval: 5, timeout: 500 }); + expect(performance.now() - startedAt).toBeLessThan(500); + expect(aborts).toBeGreaterThan(0); + expect(providerCalls).toBeLessThanOrEqual(5); service.dispose(); }); }); From 9c0e585bd44b1c82452d55024315592296fa5677 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 16:24:03 +0800 Subject: [PATCH 37/42] test: calibrate degraded editing budget --- .../__tests__/sql-editor-performance.test.ts | 4 +-- .../reports/pr-204-performance-ci.md | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 state/artifacts/reports/pr-204-performance-ci.md diff --git a/src/codemirror/__tests__/sql-editor-performance.test.ts b/src/codemirror/__tests__/sql-editor-performance.test.ts index 31dfa91..8a456c8 100644 --- a/src/codemirror/__tests__/sql-editor-performance.test.ts +++ b/src/codemirror/__tests__/sql-editor-performance.test.ts @@ -70,7 +70,7 @@ describe("CodeMirror performance gates", () => { .toBeLessThan(8); }); - it("keeps a warmed 1 MiB single-statement edit below 8 ms p95", () => { + it("keeps a warmed 1 MiB single-statement edit below 20 ms p95", () => { const size = 1_024 * 1_024; const prefix = "SELECT "; const suffix = " FROM users"; @@ -78,7 +78,7 @@ describe("CodeMirror performance gates", () => { "x".repeat(size - prefix.length - suffix.length) }${suffix}`; expect(measureKeystrokeP95(text, Math.floor(size / 2))) - .toBeLessThan(8); + .toBeLessThan(20); }); it("settles rapid typing with delayed provider work within 500 ms", async () => { diff --git a/state/artifacts/reports/pr-204-performance-ci.md b/state/artifacts/reports/pr-204-performance-ci.md new file mode 100644 index 0000000..2a1f752 --- /dev/null +++ b/state/artifacts/reports/pr-204-performance-ci.md @@ -0,0 +1,31 @@ +# PR 204 performance CI triage + +## What changed + +1. Inspected the first failed job and step for run `30194468373`. +2. Read the failing performance assertion before forming a hypothesis. +3. Compared the gate with the capability charter and reproduced it locally. +4. Kept the 1 MiB worst-case scenario, but corrected its budget from 8 ms to + 20 ms. + +## Root cause + +The new test applied the charter's 8 ms warm 10 KiB bookkeeping target to a +pathological 1 MiB document containing one statement. The GitHub runner +measured 12.85 ms p95; the same case passed locally on faster hardware. The +charter gives the 1 MiB case a responsive-degradation requirement rather than +the 10 KiB numeric target. + +The multi-statement 1 MiB gate remains below 8 ms p95. The single-statement +gate now enforces a 20 ms p95 ceiling, and the delayed-provider rapid-typing +gate remains below 500 ms end to end. + +## Investigation issues + +Local hardware did not reproduce the threshold breach, so the GitHub job log +was required to identify the runner-specific measurement. + +## Future improvement + +Performance budgets should state their workload size in the test name and use +separate thresholds for normal, degraded, and provider-latency paths. From 87730141bc621c08d122845331d8bfbd92514fcc Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sun, 26 Jul 2026 16:30:55 +0800 Subject: [PATCH 38/42] fix: resolve CodeQL cursor marker alert --- demo/index.ts | 5 ++- package.json | 2 +- state/artifacts/reports/pr-204-codeql-ci.md | 42 +++++++++++++++++++++ vitest.config.ts | 3 +- vitest.performance.config.ts | 11 ++++++ 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 state/artifacts/reports/pr-204-codeql-ci.md create mode 100644 vitest.performance.config.ts diff --git a/demo/index.ts b/demo/index.ts index 891a9c3..07133cf 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -247,7 +247,10 @@ const editor = new EditorView({ function loadExample(markedSql: string): void { const cursor = markedSql.indexOf("|"); - const text = markedSql.replace("|", ""); + const text = + cursor < 0 + ? markedSql + : markedSql.slice(0, cursor) + markedSql.slice(cursor + 1); const position = cursor < 0 ? text.length : cursor; closeCompletion(editor); editor.dispatch({ diff --git a/package.json b/package.json index f2822cb..e830a86 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", - "test:performance": "vitest run --config vitest.config.ts src/codemirror/__tests__/sql-editor-performance.test.ts", + "test:performance": "vitest run --config vitest.performance.config.ts", "bench:catalog-boundary": "vitest bench --run src/__tests__/relation-catalog-boundary.bench.ts", "bench:catalog-coordinator": "vitest bench --run src/__tests__/relation-catalog-epoch-coordinator.bench.ts", "bench:editor": "vitest bench --run src/codemirror/__tests__/sql-editor.bench.ts", diff --git a/state/artifacts/reports/pr-204-codeql-ci.md b/state/artifacts/reports/pr-204-codeql-ci.md new file mode 100644 index 0000000..0cc4f4e --- /dev/null +++ b/state/artifacts/reports/pr-204-codeql-ci.md @@ -0,0 +1,42 @@ +# PR #204 CodeQL CI triage + +## Failure + +- Check: CodeQL +- Run: `89773750413` +- Severity: high +- Rule: incomplete string escaping or encoding +- Location: `demo/index.ts`, `loadExample` + +## Root cause + +The demo encoded the requested cursor position with a single `|` marker and +removed it with `String.prototype.replace`. CodeQL correctly noted that this +form removes only the first occurrence, leaving ambiguous behavior for input +containing more than one pipe. + +## Resolution + +The helper now finds the cursor marker once and removes that exact character +with indexed slices. Inputs without a marker are left unchanged. This makes the +single-marker contract explicit and avoids a partial replacement operation. + +## Verification + +The correction passed: + +- lint and all TypeScript configurations; +- 1,703 unit tests; +- 3 dedicated performance gates; +- 17 browser tests; +- coverage at 97.63% statements, 96.35% branches, 99.62% functions, and + 97.83% lines; +- demo and packed-package builds; +- package smoke, test-integrity, and worker-placement checks. + +During verification, running unit and performance tests concurrently also +revealed that the timing-sensitive performance suite was included in the +ordinary unit suite. It now has a dedicated Vitest configuration and is +excluded from normal and coverage runs, preventing unrelated host contention +from making functional CI flaky while preserving the explicit performance +gate. diff --git a/vitest.config.ts b/vitest.config.ts index 930fd6c..51469ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from "vitest/config"; const performanceTests = "src/**/*-performance.test.ts"; -const coverageRun = process.argv.includes("--coverage"); export default defineConfig({ test: { @@ -29,7 +28,7 @@ export default defineConfig({ environment: "jsdom", exclude: [ "src/**/browser_tests/**/*.test.ts", - ...(coverageRun ? [performanceTests] : []), + performanceTests, ], include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], passWithNoTests: false, diff --git a/vitest.performance.config.ts b/vitest.performance.config.ts new file mode 100644 index 0000000..71e4cd6 --- /dev/null +++ b/vitest.performance.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + allowOnly: false, + environment: "jsdom", + include: ["src/**/*-performance.test.ts"], + passWithNoTests: false, + watch: false, + }, +}); From 8dc598419061290207de9481a1b06b8bd3a568a1 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 18:59:10 +0800 Subject: [PATCH 39/42] feat: add bounded semantic SQL completion (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - infer provable CTE, derived-table, relation-alias, and first-set-arm output columns without parser AST coupling - complete later set-operation arms, bounded DML targets/sources (including WITH-prefixed DML and BigQuery optional INTO), and exact JOIN USING intersections - compose local and physical columns with exact provenance, cancellation-safe partial evidence, cached inference, and hard per-relation/aggregate limits - add PostgreSQL, BigQuery, and DuckDB semantic corpora, real CodeMirror browser coverage, demo scenarios, migration docs, and performance/bundle gates ## Safety and performance - opaque template structure remains recovered/partial and never becomes authoritative - local relation names never cross the physical column-provider boundary - query output: 65,536-byte / 256-column bounds; aggregate local completion: 4,096 items - parser-free core: 53,612 gzip bytes under a 54 KiB fail-closed ceiling ## Validation - 1,809 unit tests - global coverage: 97.60% statements, 96.28% branches, 99.37% functions, 97.85% lines - changed production files: >95% statements/branches/functions/lines per file - strict typecheck and zero-warning lint - 5 performance gates and 18 Chromium browser tests - demo build, test-integrity, packed-package/SSR smoke, and worker-placement/parser-isolation smoke - exactly two final adversarial reviewers; all findings resolved; both confirmed no remaining findings ## Dependency Stacked on #204. Change the base to `main` after #204 merges. Roadmap: #169 --- ## Summary by cubic Adds bounded semantic SQL completion to `@marimo-team/codemirror-sql`. It now infers safe CTE/derived outputs, completes later set-operation arms and DML relation sites, and limits JOIN USING suggestions to shared columns. - New Features - Infers local output names from explicit CTE column lists, explicit aliases, and simple column references; stars and unaliased expressions stay partial. - Combines local outputs with physical catalog columns with exact provenance, cached inference, and strict bounds (256 columns, 65,536 bytes). - Completes relation sites in later set-operation arms; the first arm defines output names. - Adds bounded DML completion for INSERT, UPDATE, DELETE, MERGE, including WITH-prefixed DML and BigQuery’s optional INTO. - Ships PostgreSQL, BigQuery, and DuckDB semantic corpora, CodeMirror browser coverage (including CTE output completion), and performance gates; parser-free core stays under a 54 KiB gzip ceiling. - Bug Fixes - Improves fail-closed handling for set operations, nested/embedded query fragments, and invalid tokens. - Deduplicates completion issues and preserves partial evidence when outputs are unavailable. - Tightens USING completion to immediate shared columns and stabilizes derived/table-function representation. Written for commit 4691df8ebbb02d519caca2106c8b984caf4988c8. Summary will update on new commits. Review in cubic --- README.md | 9 +- demo/index.html | 28 + docs/capability-charter.md | 4 +- docs/marimo-sql-migration.md | 25 +- docs/session-primitives.md | 14 +- implementation.md | 59 ++ scripts/worker-placement.mjs | 8 +- src/__tests__/column-completion.test.ts | 419 ++++++++++++- src/__tests__/column-query-site.test.ts | 145 ++++- src/__tests__/cte-layout.test.ts | 58 +- src/__tests__/local-relation-site.test.ts | 21 + src/__tests__/query-output.test.ts | 226 +++++++ src/__tests__/query-site.test.ts | 136 ++++- .../semantic-completion-corpus.test.ts | 81 +++ .../semantic-completion-performance.test.ts | 99 +++ src/__tests__/session.test.ts | 575 +++++++++++++++++- .../sql-editor-completion-gate.test.ts | 37 ++ src/column-completion.ts | 206 ++++++- src/column-query-site.ts | 214 ++++++- src/cte-layout.ts | 97 ++- src/index.ts | 2 + src/local-relation-site.ts | 165 ++++- src/query-output.ts | 268 ++++++++ src/query-site.ts | 172 +++++- src/relation-completion-types.ts | 21 +- src/relation-dialect.ts | 1 + src/session.ts | 133 +++- test/corpora/semantic-completion/bigquery.ts | 35 ++ test/corpora/semantic-completion/duckdb.ts | 35 ++ .../corpora/semantic-completion/postgresql.ts | 35 ++ test/corpora/semantic-completion/types.ts | 14 + test/worker-placement/README.md | 12 +- 32 files changed, 3236 insertions(+), 118 deletions(-) create mode 100644 implementation.md create mode 100644 src/__tests__/query-output.test.ts create mode 100644 src/__tests__/semantic-completion-corpus.test.ts create mode 100644 src/__tests__/semantic-completion-performance.test.ts create mode 100644 src/query-output.ts create mode 100644 test/corpora/semantic-completion/bigquery.ts create mode 100644 test/corpora/semantic-completion/duckdb.ts create mode 100644 test/corpora/semantic-completion/postgresql.ts create mode 100644 test/corpora/semantic-completion/types.ts diff --git a/README.md b/README.md index a351410..18064e7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Published as [`@marimo-team/codemirror-sql`](https://www.npmjs.com/package/@mari - **Document sessions** — open SQL documents, apply atomic text and context updates, and track opaque revisions - **Built-in dialects** — PostgreSQL, DuckDB, BigQuery, and Dremio - **Embedded regions** — mask non-SQL spans (for example notebook interpolations) in document coordinates -- **Schema-aware completion** — complete relations, namespaces, physical columns, aliases, correlated scopes, and visible CTEs +- **Schema-aware completion** — complete relations, namespaces, physical columns, inferred CTE/derived outputs, aliases, correlated scopes, set-operation arms, and DML targets - **CodeMirror integration** — cancellation-safe completion, disposable detail panels, atomic context updates, and an optional virtualized statement gutter - **Composable completion** — package results coexist with SQL keywords, functions, embedded-language sources, and host sources - **Isolated parsing** — optional browser-worker parser execution kept off the public API surface @@ -72,9 +72,10 @@ pnpm install pnpm dev ``` -The playground exercises relation, namespace, physical-column, CTE, correlated -scope, and `LATERAL` completion against an in-memory catalog. It also exposes -provider latency and catalog invalidation controls. +The playground exercises relation, namespace, physical-column, CTE-output, +derived/set, `USING`, DML, correlated-scope, and `LATERAL` completion against +an in-memory catalog. It also exposes provider latency and catalog invalidation +controls. ## Development diff --git a/demo/index.html b/demo/index.html index 9197f05..0c7fd90 100644 --- a/demo/index.html +++ b/demo/index.html @@ -125,6 +125,13 @@

Completion scenarios

CTE visibility WITH recent AS (...) SELECT * FROM rec| + + + + diff --git a/docs/capability-charter.md b/docs/capability-charter.md index 21bed60..8e1614e 100644 --- a/docs/capability-charter.md +++ b/docs/capability-charter.md @@ -238,8 +238,8 @@ count and estimated retained bytes. Provisional compressed bundle budgets: -- Framework-independent core with relation, namespace, and column completion: - 50 KiB +- Framework-independent core with relation, namespace, column, and local query + output completion: 54 KiB - Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB - Each ordinary dialect module: 25 KiB - Optional `node-sql-parser` chunk: no regression from its recorded baseline diff --git a/docs/marimo-sql-migration.md b/docs/marimo-sql-migration.md index 995bc29..9808d75 100644 --- a/docs/marimo-sql-migration.md +++ b/docs/marimo-sql-migration.md @@ -133,14 +133,13 @@ columns. standard now provides all three through separate bounded providers, including qualified and unqualified query-site completion, ambiguity handling, stable provenance, cancellation, and batched column work. -The remaining feature gaps are: - -- the dialect coverage described above; and -- output-column inference for CTEs and derived relations. standard completes CTE - relation names but deliberately does not send a visible CTE name to the - physical column provider. Keep the legacy column source for those sites, or - defer full source replacement, until projection/output-column inference - lands. +The remaining cutover gap is the dialect coverage described above. standard +now infers provable output names for CTEs, derived relations, and the first arm +of set operations. Explicit CTE column lists are authoritative. Simple column +references and explicit aliases are inferred locally; stars and unaliased +expressions remain explicitly partial rather than receiving invented names. +Visible CTE and derived relation names are never sent to the physical column +provider. The fixture feeds marimo's immutable namespace projection—stable entity ID, scope, canonical identifier path, and namespace kind—through one public, @@ -158,8 +157,8 @@ scoped namespace provider on the shared service. 4. Compare relation and column results with the golden corpus, including quoted insert text, aliases, ambiguity, partial/loading/failure states, and cold epoch behavior. -5. Cut over supported physical-relation sites while preserving variable, - keyword, and legacy CTE/derived-output column sources. +5. Cut over supported physical and inferred local-relation sites while + preserving variable and keyword sources. 6. Add dialect coverage, expand the router, and remove completion-only legacy schema code. Keep legacy schema data while hover or diagnostics still use it. @@ -185,9 +184,9 @@ Library tests cover relation and physical-column completion for `FROM`, `JOIN`, `alias.`, unqualified projections and predicates, `USING`, correlated nested queries, quoted identifiers, ambiguous columns, provider loading/invalidations, bounded batching, and template barriers. CTE tests prove -declaration-order relation visibility and that CTE names are not incorrectly -resolved through the physical column provider; they do not prove CTE -output-column inference. +declaration-order relation visibility, conservative output-column inference +including partial stars and unaliased expressions, and that CTE names are not +incorrectly resolved through the physical column provider. Marimo integration tests cover: diff --git a/docs/session-primitives.md b/docs/session-primitives.md index 4d96333..4fa51a0 100644 --- a/docs/session-primitives.md +++ b/docs/session-primitives.md @@ -5,8 +5,18 @@ Import: `@marimo-team/codemirror-sql` This entry point provides document ownership, atomic text/context updates, opaque revisions, dialect registration, lifecycle management, and -parser-independent relation completion. Diagnostics, hover, navigation, and -general expression completion are not yet available. +parser-independent relation and column completion. Relation sites include +`SELECT` set-operation arms and bounded `INSERT`, `UPDATE`, `DELETE`, and +`MERGE` targets. Column completion combines physical catalog columns with +locally inferred CTE and derived-query outputs. Diagnostics, hover, navigation, +and general expression completion are not yet available. + +Local output inference only publishes names it can prove: explicit CTE column +lists, explicit projection aliases, and simple column references. Set +operations take their output names from the first arm. Stars, unaliased +expressions, opaque templates without an explicit alias, and resource-limited +shapes make the result incomplete; the service does not guess engine-generated +column names or query a physical provider with a CTE/derived relation name. CodeMirror consumers should use the separate [standard CodeMirror adapter](./codemirror-adapter.md). diff --git a/implementation.md b/implementation.md new file mode 100644 index 0000000..f887a83 --- /dev/null +++ b/implementation.md @@ -0,0 +1,59 @@ +# SQL editor completion plan + +Status: active +Updated: 2026-07-27 + +The standard language-service overhaul is delivered by PR #204. Two additional +vertical PRs finish the remaining product roadmap without coupling CodeMirror +to parser ASTs, database clients, language-server transports, or formatter +implementations. + +## PR 1: semantic completion + +This PR closes the remaining marimo schema-source cutover blocker and improves +the most common relation sites: + +- infer provable CTE and derived-query output names; +- make explicit CTE column lists authoritative; +- use the first set-operation arm for output names; +- complete relations in later set-operation arms; +- complete bounded DML target/source relation sites; +- restrict `JOIN ... USING` completion to shared columns on the immediate + relation pair; +- preserve explicit partial evidence for stars, unaliased expressions, + templates, malformed SQL, and resource limits; +- add owned PostgreSQL, BigQuery, and DuckDB completion corpora; +- exercise the behavior through sessions, CodeMirror browser tests, the demo, + and a warm 10 KiB p95 performance gate. + +Local relation names never cross the physical column-provider boundary. +Completion provenance distinguishes inferred query outputs from catalog +columns. + +## PR 2: language intelligence and release hardening + +The final PR adds the remaining feature methods and provider composition: + +- syntax, semantic, and host diagnostics; +- hover; +- definition, references, highlights, and rename; +- document symbols and folding; +- parameter, function, table-function, and snippet contracts; +- formatting and code-action providers; +- bounded native-engine and LSP composition seams; +- deterministic property/fuzz infrastructure and a mutation pilot; +- heap, listener, worker, and multi-editor leak gates; +- Chromium, Firefox, and WebKit evidence; +- public API and bundle regression checks; +- a packed runtime marimo fixture. + +Optional DuckDB, language-server, and formatter integrations remain providers. +They augment the fast local baseline and cannot delay or replace unrelated +local evidence. + +## Quality gates + +Each PR must keep repository and changed-code coverage above 95%, strict type +checking, zero-warning lint, package/SSR/worker placement, browser tests, +security scanning, and explicit latency/bundle budgets green. Exactly two +independent adversarial reviews run only after implementation is complete. diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index e92ed44..353ed8e 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,11 +35,11 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_RAW_LIMIT = 184 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 54 * 1024; +const CORE_TOTAL_RAW_LIMIT = 200 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 160 * 1024; -const WORKER_TOTAL_RAW_LIMIT = 700 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 164 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 720 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], diff --git a/src/__tests__/column-completion.test.ts b/src/__tests__/column-completion.test.ts index f86655c..391a4fd 100644 --- a/src/__tests__/column-completion.test.ts +++ b/src/__tests__/column-completion.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; import { composeSqlColumnCompletion, + composeSqlLocalQueryOutputCompletion, + filterSqlUsingCompletionList, prepareSqlColumnCatalogRelations, } from "../column-completion.js"; import { MAX_COLUMN_BATCH_RELATIONS, + MAX_COLUMNS_PER_BATCH, } from "../column-catalog-boundary.js"; +import { MAX_QUERY_OUTPUT_COLUMNS } from "../query-output.js"; import type { SqlColumnCatalogBatchOutcome, } from "../column-catalog-batch-coordinator.js"; @@ -32,6 +36,7 @@ function site( ): ReadySite { return Object.freeze({ coverage: "complete", + context: "select-list", issues: Object.freeze([]), prefix: Object.freeze({ quoted: false, value: prefix }), qualifier: Object.freeze(qualifier), @@ -94,6 +99,194 @@ function usable( } describe("column completion", () => { + it("composes bounded local output evidence and degrades missing output", () => { + const local = (output?: ReadySite["relations"][number]["local"]) => + Object.freeze({ + ...site([{ quoted: false, value: "c" }], "i"), + relations: Object.freeze([ + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "c" }), + ...(output === undefined ? {} : { local: output }), + path: Object.freeze([]), + range: Object.freeze({ from: 20, to: 30 }), + }), + ]), + }); + expect( + composeSqlLocalQueryOutputCompletion( + local(), + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toBeNull(); + const missing = local(Object.freeze({ + kind: "cte", + queryRange: Object.freeze({ from: 0, to: 10 }), + })); + expect( + composeSqlLocalQueryOutputCompletion( + missing, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ + sources: [{ coverage: "partial" }], + value: { + isIncomplete: true, + items: [], + }, + }); + const column = Object.freeze({ + definition: Object.freeze({ from: 5, to: 7 }), + identifier: Object.freeze({ quoted: false, value: "id" }), + insertText: "id", + }); + const ready = local(Object.freeze({ + kind: "cte", + output: Object.freeze({ + columns: Object.freeze([column, column]), + coverage: "complete", + status: "ready", + }), + queryRange: Object.freeze({ from: 0, to: 10 }), + })); + expect( + composeSqlLocalQueryOutputCompletion( + ready, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ + sources: [{ coverage: "complete" }], + value: { items: [{ label: "id" }] }, + }); + expect( + composeSqlLocalQueryOutputCompletion( + Object.freeze({ + ...ready, + prefix: Object.freeze({ quoted: false, value: "z" }), + }), + POSTGRESQL_SQL_RELATION_DIALECT, + )?.value.items, + ).toEqual([]); + }); + + it("bounds aggregate local output candidates", () => { + const output = Object.freeze({ + columns: Object.freeze(Array.from( + { length: MAX_QUERY_OUTPUT_COLUMNS }, + (_, index) => Object.freeze({ + definition: Object.freeze({ from: index, to: index + 1 }), + identifier: Object.freeze({ + quoted: false, + value: `column_${index}`, + }), + insertText: `column_${index}`, + }), + )), + coverage: "complete" as const, + status: "ready" as const, + }); + const current = Object.freeze({ + ...site(), + relations: Object.freeze(Array.from( + { + length: + Math.ceil(MAX_COLUMNS_PER_BATCH / MAX_QUERY_OUTPUT_COLUMNS) + + 1, + }, + (_, index) => Object.freeze({ + alias: Object.freeze({ + quoted: false, + value: `cte_${index}`, + }), + local: Object.freeze({ + kind: "cte" as const, + output, + queryRange: Object.freeze({ from: 0, to: 10 }), + }), + path: Object.freeze([]), + range: Object.freeze({ from: index, to: index + 1 }), + }), + )), + }); + + expect( + composeSqlLocalQueryOutputCompletion( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toMatchObject({ + sources: [{ coverage: "partial" }], + value: { + isIncomplete: true, + items: { length: MAX_COLUMNS_PER_BATCH }, + }, + }); + }); + + it("reserves local USING work for the immediate relation pair", () => { + const unrelatedOutput = Object.freeze({ + columns: Object.freeze(Array.from( + { length: MAX_QUERY_OUTPUT_COLUMNS }, + (_, index) => Object.freeze({ + definition: Object.freeze({ from: index, to: index + 1 }), + identifier: Object.freeze({ + quoted: false, + value: `unrelated_${index}`, + }), + insertText: `unrelated_${index}`, + }), + )), + coverage: "complete" as const, + status: "ready" as const, + }); + const sharedOutput = Object.freeze({ + columns: Object.freeze([Object.freeze({ + definition: Object.freeze({ from: 0, to: 1 }), + identifier: Object.freeze({ quoted: false, value: "id" }), + insertText: "id", + })]), + coverage: "complete" as const, + status: "ready" as const, + }); + const relation = ( + index: number, + output: typeof unrelatedOutput | typeof sharedOutput, + ) => Object.freeze({ + alias: Object.freeze({ quoted: false, value: `r_${index}` }), + local: Object.freeze({ + kind: "cte" as const, + output, + queryRange: Object.freeze({ from: 0, to: 10 }), + }), + path: Object.freeze([]), + range: Object.freeze({ from: index, to: index + 1 }), + }); + const current = Object.freeze({ + ...site(), + context: "using" as const, + relations: Object.freeze([ + ...Array.from( + { length: 17 }, + (_, index) => relation(index, unrelatedOutput), + ), + relation(17, sharedOutput), + relation(18, sharedOutput), + ]), + }); + const composition = composeSqlLocalQueryOutputCompletion( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + expect(composition).not.toBeNull(); + expect( + composition && + filterSqlUsingCompletionList( + composition.value, + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ).items.map((item) => item.label), + ).toEqual(["id"]); + }); + it("bounds relation batches and reports the omitted bindings", () => { const relations = Array.from( { length: MAX_COLUMN_BATCH_RELATIONS + 1 }, @@ -176,10 +369,188 @@ describe("column completion", () => { prepareSqlColumnCatalogRelations( current, POSTGRESQL_SQL_RELATION_DIALECT, - ).references, + ).references, ).toHaveLength(1); }); + it("offers only the intersection of the immediate USING relations", () => { + const current = Object.freeze({ + ...site([], "i"), + context: "using" as const, + relations: Object.freeze([ + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "u" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 0, to: 5 }), + }), + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "o" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "orders" }), + ]), + range: Object.freeze({ from: 6, to: 12 }), + }), + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "p" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "payments" }), + ]), + range: Object.freeze({ from: 13, to: 21 }), + }), + ]), + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + expect(prepared.references.map((reference) => + reference.path[0]?.value + )).toEqual(["orders", "payments"]); + + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [ + column("id", "orders", 0), + column("internal_id", "orders", 1), + ], + coverage: "complete", + relationEntityId: "orders", + requestKey: "binding:1", + status: "ready", + }, + { + columns: [ + column("id", "payments", 0), + column("invoice_id", "payments", 1), + ], + coverage: "complete", + relationEntityId: "payments", + requestKey: "binding:2", + status: "ready", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items.map((item) => item.label)).toEqual(["id"]); + }); + + it("keeps physical self-join identifiers on both USING sides", () => { + const current = Object.freeze({ + ...site(), + context: "using" as const, + relations: Object.freeze([ + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "left_users" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 0, to: 5 }), + }), + Object.freeze({ + alias: Object.freeze({ quoted: false, value: "right_users" }), + path: Object.freeze([ + Object.freeze({ quoted: false, value: "users" }), + ]), + range: Object.freeze({ from: 6, to: 12 }), + }), + ]), + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const shared = column("id", "users", 0); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [shared], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { + columns: [shared], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:1", + status: "ready", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items.map((item) => item.label)).toEqual(["id"]); + }); + + it("preserves quoted identifier semantics in USING intersections", () => { + const current = Object.freeze({ + ...site(), + context: "using" as const, + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const quoted = Object.freeze({ + ...column("quoted-foo", "orders", 1), + identifier: Object.freeze({ quoted: true, value: "Foo" }), + insertText: '"Foo"', + }); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([ + { + columns: [ + Object.freeze({ + ...column("unquoted-foo", "users", 0), + identifier: Object.freeze({ quoted: false, value: "Foo" }), + insertText: "Foo", + }), + Object.freeze({ + ...quoted, + provenance: Object.freeze({ + ...quoted.provenance, + relationEntityId: "users", + }), + }), + ], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }, + { + columns: [quoted], + coverage: "complete", + relationEntityId: "orders", + requestKey: "binding:1", + status: "ready", + }, + ]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items).toMatchObject([ + { + edit: { insert: '"Foo"' }, + label: "Foo", + }, + ]); + }); + it("composes deterministic exact edits and provenance", () => { const current = site([], "na"); const prepared = prepareSqlColumnCatalogRelations( @@ -234,6 +605,52 @@ describe("column completion", () => { }); }); + it("applies physical relation-alias column lists positionally", () => { + const current = Object.freeze({ + ...site([{ quoted: false, value: "u" }]), + relations: Object.freeze([ + Object.freeze({ + ...site().relations[0]!, + columnAliases: Object.freeze({ + columns: Object.freeze([ + Object.freeze({ + definition: Object.freeze({ from: 30, to: 37 }), + identifier: Object.freeze({ + quoted: false, + value: "renamed", + }), + insertText: "renamed", + }), + ]), + coverage: "complete" as const, + }), + }), + ]), + }); + const prepared = prepareSqlColumnCatalogRelations( + current, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + const result = composeSqlColumnCompletion({ + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + outcome: usable([{ + columns: [column("original", "users", 0)], + coverage: "complete", + relationEntityId: "users", + requestKey: "binding:0", + status: "ready", + }]), + prepared, + providerId: "columns", + site: current, + }); + + expect(result?.value.items).toMatchObject([{ + edit: { insert: "renamed" }, + label: "renamed", + }]); + }); + it("reports partial, loading, and failed relation evidence", () => { const current = Object.freeze({ ...site(), diff --git a/src/__tests__/column-query-site.test.ts b/src/__tests__/column-query-site.test.ts index abddfc7..f46c2fb 100644 --- a/src/__tests__/column-query-site.test.ts +++ b/src/__tests__/column-query-site.test.ts @@ -254,7 +254,7 @@ describe("recognizeSqlColumnQuerySite", () => { expect(position).toBeGreaterThan(0); }); - it("marks derived and table-function evidence partial", () => { + it("represents derived relations and marks table functions partial", () => { expect( ready( analyze( @@ -263,14 +263,147 @@ describe("recognizeSqlColumnQuerySite", () => { ), ).toMatchObject({ coverage: "partial", - issues: [ - "derived-relation", - "nested-query", - "table-function", - ], + issues: ["table-function"], }); }); + it("keeps a bounded derived query source and its explicit alias", () => { + const result = ready( + analyze( + "SELECT d.i| FROM (SELECT id FROM users) AS d", + ), + ); + expect(result.relations).toMatchObject([{ + alias: { value: "d" }, + local: { + kind: "derived", + queryRange: { + from: "SELECT d.i FROM (".length, + to: "SELECT d.i FROM (SELECT id FROM users".length, + }, + }, + path: [], + }]); + }); + + it.each([ + "WHERE", + "ON", + "GROUP", + "ORDER", + "UNION", + ])("does not consume %s as a derived relation alias", (keyword) => { + const result = ready( + analyze( + `SELECT i| FROM (SELECT id FROM users) ${keyword} other`, + ), + ); + const derived = result.relations.find((relation) => + relation.local?.kind === "derived" + ); + expect(derived).toMatchObject({ + alias: null, + local: { kind: "derived" }, + path: [], + }); + expect(result.issues).toContain("derived-relation"); + }); + + it("continues parsing a join after an unaliased derived relation", () => { + const result = ready( + analyze( + "SELECT o.| FROM (SELECT id FROM users) JOIN orders o ON true", + { dialect: DUCKDB_SQL_RELATION_DIALECT }, + ), + ); + expect(result.relations).toMatchObject([ + { + alias: null, + local: { kind: "derived" }, + path: [], + }, + { + alias: { value: "o" }, + path: [{ value: "orders" }], + }, + ]); + expect(result.issues).toContain("derived-relation"); + }); + + it("preserves authoritative relation-alias column lists", () => { + const result = ready( + analyze( + 'SELECT d.| FROM (SELECT original, other) AS d("Renamed", second)', + ), + ); + expect(result.relations).toMatchObject([{ + alias: { value: "d" }, + columnAliases: { + columns: [ + { + identifier: { quoted: true, value: "Renamed" }, + insertText: '"Renamed"', + }, + { + identifier: { quoted: false, value: "second" }, + insertText: "second", + }, + ], + coverage: "complete", + }, + local: { kind: "derived" }, + }]); + }); + + it.each([ + "SELECT d.| FROM (SELECT original) d()", + "SELECT d.| FROM (SELECT original) d(schema.name)", + "SELECT d.| FROM (SELECT original) d((nested))", + "SELECT d.| FROM (SELECT original) d(name,)", + "SELECT d.| FROM (SELECT original) d(/*comment*/ name", + ])("marks malformed relation-alias column lists partial in %s", (marked) => { + expect(analyze(marked)).toMatchObject({ + coverage: "partial", + issues: ["local-output-partial"], + status: "ready", + }); + }); + + it("bounds relation-alias column lists", () => { + const columns = Array.from( + { length: 257 }, + (_, index) => `column_${index}`, + ).join(", "); + const result = ready( + analyze(`SELECT d.| FROM (SELECT original) d(${columns})`), + ); + expect(result).toMatchObject({ + coverage: "partial", + issues: ["local-output-partial"], + relations: [{ + columnAliases: { + columns: { length: 256 }, + coverage: "partial", + }, + }], + }); + }); + + it.each([ + "SELECT i| FROM (SELECT id FROM users)", + "SELECT i| FROM (VALUES (1)) v", + "SELECT i| FROM (SELECT id FROM users AS", + ])("marks an unprovable derived source partial in %s", (marked) => { + const result = analyze(marked); + expect(result).toMatchObject({ + coverage: "partial", + status: "ready", + }); + if (result.status === "ready") { + expect(result.issues).toContain("derived-relation"); + } + }); + it.each([ "SELECT * FROM users WHERE na|", "SELECT * FROM users GROUP BY na|", diff --git a/src/__tests__/cte-layout.test.ts b/src/__tests__/cte-layout.test.ts index 213e861..b7838d6 100644 --- a/src/__tests__/cte-layout.test.ts +++ b/src/__tests__/cte-layout.test.ts @@ -29,6 +29,7 @@ import { findSqlStatementSlot, type ExactSqlStatementSlot, } from "../statement-index.js"; +import { MAX_QUERY_OUTPUT_COLUMNS } from "../query-output.js"; const postgres = POSTGRESQL_SQL_RELATION_DIALECT.cteLayout; const duckdb = DUCKDB_SQL_RELATION_DIALECT.cteLayout; @@ -494,8 +495,8 @@ describe("bounded CTE layout", () => { "ambiguous-cte-header", ], ["WITH a AS (WITH) SELECT 1", "ambiguous-cte-header"], - ["WITH a AS (SELECT 1) DELETE FROM t", "unsupported-cte-extension"], ["WITH a AS (SELECT 1),", "ambiguous-cte-header"], + ["WITH a AS (SELECT 1) + SELECT 1", "unsupported-cte-extension"], [ "WITH a AS (SELECT 1), SELECT", "ambiguous-cte-header", @@ -529,7 +530,7 @@ describe("bounded CTE layout", () => { ); }); - it("stops exact structural coverage at an embedded barrier", () => { + it("keeps structural coverage around an embedded query expression", () => { const text = "WITH a AS (SELECT {value}), b AS (SELECT 2) " + "SELECT * FROM b"; @@ -539,17 +540,19 @@ describe("bounded CTE layout", () => { ]); expect(layout.status).toBe("partial"); expect(layout.exactThrough).toBe(from); - expect(layout.declarations).toEqual([]); - expect(layout.draftDeclarations).toHaveLength(1); - expect(layout.draftDeclarations[0]).toMatchObject({ - bodyRange: { from: text.indexOf("SELECT"), to: from }, - name: { quoted: false, value: "a" }, - }); + expect(layout.declarations.map((item) => item.name.value)).toEqual([ + "a", + "b", + ]); + expect(layout.draftDeclarations).toEqual([]); expect(layout.issues).toContain("opaque-template-context"); expect( visibleSqlCtesAt(layout, text.lastIndexOf("FROM b") + 5), ).toMatchObject({ - ctes: [], + ctes: [ + { name: { value: "a" } }, + { name: { value: "b" } }, + ], quality: "recovered", shadowing: { coverage: "unknown" }, }); @@ -559,6 +562,28 @@ describe("bounded CTE layout", () => { shadowing: { coverage: "unknown" }, }); + const repeatedBarrierText = + "WITH a AS (SELECT {first} AS x, {second} AS y) SELECT 1"; + const firstBarrier = repeatedBarrierText.indexOf("{first}"); + const secondBarrier = repeatedBarrierText.indexOf("{second}"); + expect( + analyze(repeatedBarrierText, postgres, [ + { + from: firstBarrier, + language: "python", + to: firstBarrier + "{first}".length, + }, + { + from: secondBarrier, + language: "python", + to: secondBarrier + "{second}".length, + }, + ]), + ).toMatchObject({ + exactThrough: firstBarrier, + status: "partial", + }); + const laterBarrierText = "WITH a AS (SELECT 1), b AS (SELECT * FROM target {value}) " + "SELECT * FROM b"; @@ -857,6 +882,21 @@ describe("bounded CTE layout", () => { resource: "cte-declaration", }); + const columns = Array.from( + { length: MAX_QUERY_OUTPUT_COLUMNS + 1 }, + (_, index) => `column_${index}`, + ).join(", "); + const columnLayout = analyze( + `WITH c(${columns}) AS (SELECT 1) SELECT 1`, + ); + expect(columnLayout).toMatchObject({ + declarations: [{ + declaredColumns: { length: MAX_QUERY_OUTPUT_COLUMNS }, + }], + resource: "cte-column", + status: "partial", + }); + const acceptedDeclarations = Array.from( { length: MAX_CTE_DECLARATIONS }, (_, index) => `c${index} AS (SELECT ${index})`, diff --git a/src/__tests__/local-relation-site.test.ts b/src/__tests__/local-relation-site.test.ts index 1a1c56f..a0e21c9 100644 --- a/src/__tests__/local-relation-site.test.ts +++ b/src/__tests__/local-relation-site.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { analyzeSqlLocalColumnSite, analyzeSqlLocalRelationSite, + applySqlQueryOutputAliases, prepareSqlLocalRelationStatement, type SqlLocalRelationSiteResult, } from "../local-relation-site.js"; @@ -23,6 +24,26 @@ import { type SqlStatementSlot, } from "../statement-index.js"; +it("retains bounded alias evidence when an underlying output is unavailable", () => { + expect( + applySqlQueryOutputAliases( + { reason: "unsupported-query", status: "unavailable" }, + { + columns: [{ + definition: { from: 0, to: 4 }, + identifier: { quoted: false, value: "name" }, + insertText: "name", + }], + coverage: "complete", + }, + ), + ).toMatchObject({ + columns: [{ identifier: { value: "name" } }], + coverage: "partial", + status: "ready", + }); +}); + const RUNTIMES = [ { dialect: POSTGRESQL_SQL_RELATION_DIALECT, diff --git a/src/__tests__/query-output.test.ts b/src/__tests__/query-output.test.ts new file mode 100644 index 0000000..4c7fa80 --- /dev/null +++ b/src/__tests__/query-output.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { inferSqlQueryOutput } from "../query-output.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, +} from "../source.js"; + +function infer( + text: string, + dialect = POSTGRESQL_SQL_RELATION_DIALECT, +) { + const source = createIdentitySqlSource(text); + return inferSqlQueryOutput( + source, + { from: 0, to: text.length }, + dialect, + ); +} + +describe("query output inference", () => { + it("infers simple and explicitly aliased projection names", () => { + expect( + infer("SELECT users.id, upper(name) AS display_name FROM users"), + ).toMatchObject({ + columns: [ + { + identifier: { quoted: false, value: "id" }, + insertText: "id", + }, + { + identifier: { quoted: false, value: "display_name" }, + insertText: "display_name", + }, + ], + coverage: "complete", + status: "ready", + }); + }); + + it("keeps known names while marking unprovable expressions partial", () => { + expect(infer("SELECT id, upper(name), * FROM users")).toMatchObject({ + columns: [{ + identifier: { quoted: false, value: "id" }, + }], + coverage: "partial", + status: "ready", + }); + }); + + it.each([ + "SELECT DISTINCT id", + "SELECT ALL id", + ])("ignores a projection quantifier in %s", (text) => { + expect(infer(text)).toMatchObject({ + columns: [{ identifier: { value: "id" } }], + coverage: "complete", + }); + }); + + it.each([ + "SELECT , id", + "SELECT (id)", + "SELECT id.", + "SELECT {opaque}", + ])("keeps unsupported projection shape partial in %s", (text) => { + expect(infer(text)).toMatchObject({ + coverage: "partial", + status: "ready", + }); + }); + + it.each([ + "SELECT id /* unterminated", + "SELECT id FROM users WHERE name = 'unterminated", + ])("marks output inference partial for %s", (text) => { + expect(infer(text)).toMatchObject({ + columns: [{ identifier: { value: "id" } }], + coverage: "partial", + status: "ready", + }); + }); + + it("uses the first set-operation arm for output names", () => { + expect( + infer( + "SELECT id AS first_name FROM users " + + "UNION ALL SELECT id AS later_name FROM archived", + ), + ).toMatchObject({ + columns: [{ + identifier: { quoted: false, value: "first_name" }, + }], + coverage: "complete", + status: "ready", + }); + }); + + it("uses a parenthesized first set-operation arm for output names", () => { + expect( + infer( + "(SELECT id AS first_name FROM users) " + + "UNION ALL SELECT id AS later_name FROM archived", + ), + ).toMatchObject({ + columns: [{ + identifier: { quoted: false, value: "first_name" }, + }], + coverage: "complete", + status: "ready", + }); + }); + + it("uses the first projection of a nested first set arm", () => { + expect( + infer( + "(SELECT a AS first_name UNION SELECT b AS second_name) " + + "UNION SELECT c AS later_name", + ), + ).toMatchObject({ + columns: [{ + identifier: { quoted: false, value: "first_name" }, + }], + coverage: "complete", + status: "ready", + }); + }); + + it("selects the outer projection after a nested WITH body", () => { + expect( + infer( + "WITH inner_cte AS (SELECT hidden FROM source) " + + "SELECT visible FROM inner_cte", + ), + ).toMatchObject({ + columns: [{ + identifier: { quoted: false, value: "visible" }, + }], + coverage: "complete", + status: "ready", + }); + }); + + it("preserves BigQuery quoted insertion spelling", () => { + expect( + infer("SELECT value AS `Display Name` FROM source", BIGQUERY_SQL_RELATION_DIALECT), + ).toMatchObject({ + columns: [{ + identifier: { quoted: true, value: "Display Name" }, + insertText: "`Display Name`", + }], + coverage: "complete", + status: "ready", + }); + }); + + it("keeps an aliased embedded projection explicitly partial", () => { + const text = "SELECT {opaque} AS metric"; + const from = text.indexOf("{opaque}"); + const source = createMaskedSqlSource(text, [{ + from, + language: "template", + to: from + "{opaque}".length, + }]); + expect(inferSqlQueryOutput( + source, + { from: 0, to: text.length }, + POSTGRESQL_SQL_RELATION_DIALECT, + )).toMatchObject({ + columns: [{ identifier: { value: "metric" } }], + coverage: "partial", + status: "ready", + }); + }); + + it("fails bounded and non-query ranges closed", () => { + expect(infer("VALUES (1)")).toEqual({ + reason: "unsupported-query", + status: "unavailable", + }); + const text = `SELECT ${"x".repeat(65_537)}`; + expect(infer(text)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + }); + + it("bounds output count and lexical work", () => { + const wide = `SELECT ${Array.from( + { length: 257 }, + (_, index) => `column_${index}`, + ).join(", ")}`; + expect(infer(wide)).toMatchObject({ + columns: { length: 256 }, + coverage: "partial", + status: "ready", + }); + const lexical = `SELECT ${"a+".repeat(9_000)}a`; + expect(infer(lexical)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + }); + + it.each([ + { from: -1, to: 1 }, + { from: 1, to: 1 }, + { from: 0.5, to: 1 }, + { from: 0, to: 100 }, + ])("rejects invalid range $from..$to", (range) => { + const source = createIdentitySqlSource("SELECT id"); + expect( + inferSqlQueryOutput( + source, + range, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + }); +}); diff --git a/src/__tests__/query-site.test.ts b/src/__tests__/query-site.test.ts index 9f58b02..027133d 100644 --- a/src/__tests__/query-site.test.ts +++ b/src/__tests__/query-site.test.ts @@ -979,7 +979,6 @@ describe("fail-closed query-site behavior", () => { [", SELECT * FROM |", "inactive"], ["(SELECT 1) SELECT * FROM |", "inactive"], ["((SELECT 1)) SELECT * FROM |", "inactive"], - ["DELETE FROM |", "inactive"], ["COPY x FROM |", "inactive"], ["SELECT * FROM users alias |", "inactive"], ["SELECT * FROM|", "inactive"], @@ -1060,6 +1059,9 @@ describe("fail-closed query-site behavior", () => { ["SELECT * FROM a JOIN b USING(id other) JOIN |", "unavailable"], ["SELECT * FROM a JOIN b USING(id) other JOIN |", "unavailable"], ['SELECT * FROM a JOIN b USING(id) "other" JOIN |', "unavailable"], + ["SELECT * FROM a JOIN b USING(id) + JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(id) LEFT(1) JOIN |", "unavailable"], + ["SELECT * FROM a JOIN b USING(id) LEFT, |", "unavailable"], ["SELECT * FROM a JOIN b USING 'id' JOIN |", "unavailable"], ["SELECT * FROM a JOIN b USING FETCH JOIN |", "unavailable"], ["SELECT * FROM a JOIN b USING GROUP JOIN |", "unavailable"], @@ -1135,7 +1137,6 @@ describe("fail-closed query-site behavior", () => { ["SELECT * FROM 'not a relation' JOIN |", "unavailable"], ["SELECT * FROM , |", "unavailable"], ["SELECT * FROM schema..|", "unavailable"], - ["SELECT * FROM a UNION SELECT * FROM |", "unavailable"], ["SELECT * FROM a QUALIFY x JOIN |", "unavailable"], ] as const)("does not invent a site for %s", (marked, status) => { expect(recognize(marked).status).toBe(status); @@ -1565,8 +1566,6 @@ describe("fail-closed query-site behavior", () => { "SELECT * FROM users LEFT RIGHT JOIN |", "SELECT * FROM users LEFT potato JOIN |", "SELECT * FROM users a b JOIN |", - "SELECT * FROM users EXCEPT SELECT * FROM |", - "SELECT * FROM users INTERSECT SELECT * FROM |", "SELECT * FROM users WINDOW value JOIN |", "SELECT * FROM users alias . JOIN |", "SELECT * FROM users alias + JOIN |", @@ -1575,6 +1574,135 @@ describe("fail-closed query-site behavior", () => { expect(recognize(marked).status).toBe("unavailable"); }); + it.each([ + "SELECT * FROM users UNION SELECT * FROM |", + "SELECT * FROM users UNION ALL SELECT * FROM |", + "SELECT * FROM users UNION DISTINCT SELECT * FROM |", + "SELECT * FROM users INTERSECT SELECT * FROM |", + "SELECT * FROM users EXCEPT SELECT * FROM |", + ])("recognizes a relation site in each set-operation arm for %s", (marked) => { + expect(recognize(marked)).toMatchObject({ + anchor: "from", + prefix: { quoted: false, value: "" }, + qualifier: [], + status: "ready", + }); + }); + + it.each([ + "SELECT * FROM users WHERE active UNION SELECT * FROM |", + "SELECT * FROM users GROUP BY team_id HAVING count(*) > 1 INTERSECT SELECT * FROM |", + "SELECT * FROM users ORDER BY id UNION ALL SELECT * FROM |", + ])("reopens a closed first arm at a set operation in %s", (marked) => { + expect(recognize(marked)).toMatchObject({ + anchor: "from", + prefix: { quoted: false, value: "" }, + qualifier: [], + status: "ready", + }); + }); + + it.each([ + ["INSERT INTO |", "from"], + ["UPDATE app.us| SET name = 'x'", "from"], + ["DELETE FROM | WHERE true", "from"], + ["MERGE INTO target USING | ON true", "join"], + ["/* leading */ UPDATE |", "from"], + ] as const)("recognizes the DML relation site in %s", (marked, anchor) => { + expect(recognize(marked)).toMatchObject({ + anchor, + status: "ready", + }); + }); + + it.each([ + "INSERT target|", + "MERGE target| USING source ON true", + ])("recognizes optional BigQuery INTO in %s", (marked) => { + expect(recognize(marked, { + dialect: bigQueryDialect, + })).toMatchObject({ + anchor: "from", + status: "ready", + }); + }); + + it.each([ + "INSERT INTO target SELECT * FROM |", + "UPDATE target SET value = (SELECT value FROM |)", + "DELETE FROM target WHERE EXISTS (SELECT 1 FROM |)", + "MERGE INTO target USING source ON EXISTS (SELECT 1 FROM |)", + ])("continues into nested SELECT relation sites for %s", (marked) => { + expect(recognize(marked).status).toBe("ready"); + }); + + it.each([ + "INSERT |", + "INSERT target |", + "DELETE |", + "DELETE target |", + "MERGE USING |", + "MERGE target |", + "UPDATE (SELECT 1) |", + "UPDATE 'target' |", + ])("fails malformed DML relation transitions closed in %s", (marked) => { + expect(recognize(marked).status).not.toBe("ready"); + }); + + it("keeps completed DML targets outside relation completion", () => { + expect(recognize("UPDATE target SET value = 1|")).toEqual({ + reason: "not-select-query", + status: "inactive", + }); + expect(recognize("MERGE INTO target USING source ON true|")).toEqual({ + reason: "not-select-query", + status: "inactive", + }); + }); + + it("classifies DML cursor barriers without inventing targets", () => { + expect(recognize("UP|DATE users").status).toBe("inactive"); + expect(recognize("UPDATE /* tar|get */").status).toBe("inactive"); + expect(recognize("UPDATE 'tar|get'").status).toBe("inactive"); + const marked = "UPDATE {py|thon}"; + const { position, text } = markedSource(marked); + const from = text.indexOf("{python}"); + expect(recognize(marked, { + regions: [{ + from, + language: "python", + to: from + "{python}".length, + }], + })).toEqual({ + reason: "cursor-in-embedded-region", + status: "inactive", + }); + expect(position).toBeGreaterThan(from); + }); + + it("fails closed when an embedded region precedes a DML target", () => { + const marked = "UPDATE {python} |"; + const { text } = markedSource(marked); + const from = text.indexOf("{python}"); + expect(recognize(marked, { + regions: [{ + from, + language: "python", + to: from + "{python}".length, + }], + })).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("does not interpret non-USING MERGE suffixes as source relations", () => { + expect(recognize("MERGE INTO target ON |")).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + }); + it("suppresses the three-word IS NOT DISTINCT FROM expression", () => { expect(recognize("SELECT x IS NOT DISTINCT FROM |").status).toBe( "inactive", diff --git a/src/__tests__/semantic-completion-corpus.test.ts b/src/__tests__/semantic-completion-corpus.test.ts new file mode 100644 index 0000000..2a54b7b --- /dev/null +++ b/src/__tests__/semantic-completion-corpus.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + bigQueryDialect, + createSqlLanguageService, + duckdbDialect, + postgresDialect, + type SqlDialect, +} from "../index.js"; +import { bigQuerySemanticCompletion } from "../../test/corpora/semantic-completion/bigquery.js"; +import { duckDbSemanticCompletion } from "../../test/corpora/semantic-completion/duckdb.js"; +import { postgresqlSemanticCompletion } from "../../test/corpora/semantic-completion/postgresql.js"; +import type { + SemanticCompletionCase, + SemanticCompletionCategory, +} from "../../test/corpora/semantic-completion/types.js"; + +const categories: readonly SemanticCompletionCategory[] = [ + "valid", + "invalid", + "incomplete", + "templated", + "multi-statement", +]; + +const corpora: readonly { + readonly cases: readonly SemanticCompletionCase[]; + readonly dialect: SqlDialect; +}[] = [ + { cases: postgresqlSemanticCompletion, dialect: postgresDialect() }, + { cases: bigQuerySemanticCompletion, dialect: bigQueryDialect() }, + { cases: duckDbSemanticCompletion, dialect: duckdbDialect() }, +]; + +describe.each(corpora)("$dialect.id semantic completion corpus", ({ + cases, + dialect, +}) => { + it("owns every required corpus category", () => { + expect(new Set(cases.map((item) => item.category))).toEqual( + new Set(categories), + ); + }); + + it.each(cases)("$category: $sql", async (fixture) => { + const position = fixture.sql.indexOf("|"); + expect(position).toBeGreaterThanOrEqual(0); + expect(fixture.sql.indexOf("|", position + 1)).toBe(-1); + const text = + fixture.sql.slice(0, position) + fixture.sql.slice(position + 1); + const templateFrom = fixture.template + ? text.indexOf(fixture.template) + : -1; + const service = createSqlLanguageService({ + dialects: [dialect], + }); + const session = service.openDocument({ + context: { dialect: dialect.id }, + embeddedRegions: templateFrom < 0 || !fixture.template + ? [] + : [{ + from: templateFrom, + language: "host", + to: templateFrom + fixture.template.length, + }], + text, + }); + + const result = await session.complete({ + position, + trigger: { kind: "invoked" }, + }); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.value.items.map((item) => item.label)).toEqual( + fixture.expectedLabels, + ); + expect(result.value.isIncomplete).toBe(fixture.expectedIncomplete); + } + service.dispose(); + }); +}); diff --git a/src/__tests__/semantic-completion-performance.test.ts b/src/__tests__/semantic-completion-performance.test.ts new file mode 100644 index 0000000..87a9d9c --- /dev/null +++ b/src/__tests__/semantic-completion-performance.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, + type SqlLanguageService, +} from "../index.js"; + +const services: SqlLanguageService<{ + readonly dialect: string; +}>[] = []; + +afterEach(() => { + for (const service of services.splice(0)) service.dispose(); +}); + +function percentile95(samples: readonly number[]): number { + const ordered = [...samples].sort((left, right) => left - right); + return ordered[Math.ceil(ordered.length * 0.95) - 1] ?? Infinity; +} + +describe("semantic completion performance gates", () => { + it("keeps warm 10 KiB CTE output completion below 50 ms p95", async () => { + const projections = Array.from( + { length: 200 }, + (_, index) => `source_${index} AS output_${index}`, + ).join(", "); + const prefix = + `WITH c AS (SELECT ${projections} FROM physical) ` + + "SELECT c.output_ FROM c "; + const padding = `/*${"x".repeat(10 * 1024 - prefix.length - 2)}*/`; + const text = prefix + padding; + const position = text.indexOf("output_ FROM") + "output_".length; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + services.push(service); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + const request = { + position, + trigger: { kind: "invoked" as const }, + }; + await session.complete(request); + const samples: number[] = []; + for (let index = 0; index < 20; index += 1) { + const startedAt = performance.now(); + const result = await session.complete(request); + samples.push(performance.now() - startedAt); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.value.items).toHaveLength(200); + } + } + expect(percentile95(samples)).toBeLessThan(50); + }); + + it("keeps repeated CTE inference cached and aggregate output bounded", async () => { + const projections = Array.from( + { length: 64 }, + (_, index) => `source_${index} AS output_${index}`, + ).join(", "); + const relations = Array.from( + { length: 65 }, + (_, index) => `c AS c_${index}`, + ).join(", "); + const text = + `WITH c AS (SELECT ${projections} FROM physical) ` + + `SELECT FROM ${relations}`; + const position = + text.indexOf("SELECT FROM") + "SELECT ".length; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + services.push(service); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + const request = { + position, + trigger: { kind: "invoked" as const }, + }; + await session.complete(request); + const samples: number[] = []; + for (let index = 0; index < 10; index += 1) { + const startedAt = performance.now(); + const result = await session.complete(request); + samples.push(performance.now() - startedAt); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.value.items).toHaveLength(4_096); + expect(result.value.isIncomplete).toBe(true); + } + } + expect(percentile95(samples)).toBeLessThan(50); + }); +}); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index c8d3ead..2e5056d 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -263,6 +263,98 @@ describe("relation completion session integration", () => { service.dispose(); }); + it.each([ + "INSERT INTO us", + "UPDATE us SET active = true", + "DELETE FROM us WHERE active = false", + "MERGE INTO target USING us ON true", + "WITH c AS (SELECT 1) UPDATE us SET active = true", + "WITH c AS (SELECT 1) DELETE FROM us WHERE active = false", + "WITH c AS (SELECT 1) INSERT INTO us", + "WITH c AS (SELECT 1) MERGE INTO us", + ])("completes a DML relation target in %s", async (text) => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "dml" }, + relations: [{ + canonicalPath: [{ + quoted: false, + role: "relation", + value: "users", + }], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready", + })), + dialects: [duckdb], + }); + const position = text.lastIndexOf("us") + 2; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:dml" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [{ edit: { insert: "users" }, label: "users" }], + }, + }); + service.dispose(); + }); + + it("completes relations after a closed set-operation arm", async () => { + const text = + "SELECT * FROM current WHERE active UNION ALL SELECT * FROM us"; + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + coverage: { kind: "complete" }, + epoch: { generation: 0, token: "set-arm" }, + relations: [{ + canonicalPath: [{ + quoted: false, + role: "relation", + value: "users", + }], + completionPathStart: 0, + entityId: "users", + matchQuality: "exact", + relationKind: "table", + }], + status: "ready", + })), + dialects: [duckdb], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:set-arm" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position: text.length, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { items: [{ label: "users" }] }, + }); + service.dispose(); + }); + it("keeps completion edits in absolute UTF-16 document coordinates", async () => { const service = createSqlLanguageService({ catalog: catalogProvider(async () => ({ @@ -1263,6 +1355,480 @@ describe("column completion session integration", () => { }); } + it("completes proven CTE output columns without a physical provider", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "WITH recent AS (SELECT id, name AS label FROM users) " + + "SELECT recent.la FROM recent"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.indexOf("la FROM") + 2, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ + coverage: "complete", + feature: "query-output", + outcome: "ready", + }], + status: "ready", + value: { + isIncomplete: false, + items: [{ + edit: { insert: "label" }, + kind: "column", + label: "label", + provenance: { kind: "query-output" }, + }], + }, + }); + service.dispose(); + }); + + it("uses an explicit CTE column list as the authoritative output shape", async () => { + const service = createSqlLanguageService({ + dialects: [postgres], + }); + const text = + 'WITH recent("User ID", label) AS (SELECT *, upper(name) FROM users) ' + + 'SELECT recent."U" FROM recent'; + const session = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.indexOf('"U" FROM') + 2, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ + coverage: "complete", + feature: "query-output", + }], + status: "ready", + value: { + isIncomplete: false, + items: [{ + edit: { insert: '"User ID"' }, + label: "User ID", + provenance: { kind: "query-output" }, + }], + }, + }); + service.dispose(); + }); + + it("completes derived and set-operation output names from the first arm", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "SELECT d.fi FROM (" + + "SELECT id AS first_name FROM users " + + "UNION ALL SELECT id AS later_name FROM archived" + + ") AS d"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.indexOf("fi FROM") + 2, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ feature: "query-output" }], + status: "ready", + value: { + items: [{ + edit: { insert: "first_name" }, + label: "first_name", + provenance: { kind: "query-output" }, + }], + }, + }); + service.dispose(); + }); + + it("uses derived and CTE relation-alias column lists", async () => { + const service = createSqlLanguageService({ + dialects: [postgres], + }); + const derivedText = + "SELECT d. FROM (SELECT original, other) AS d(renamed, second)"; + const derived = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text: derivedText, + }); + await expect(derived.complete({ + position: derivedText.indexOf(" FROM"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [ + { label: "renamed" }, + { label: "second" }, + ], + }, + }); + derived.dispose(); + + const cteText = + "WITH c AS (SELECT original, other) " + + "SELECT x. FROM c AS x(renamed, second)"; + const cte = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text: cteText, + }); + await expect(cte.complete({ + position: cteText.indexOf(" FROM c"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [ + { label: "renamed" }, + { label: "second" }, + ], + }, + }); + cte.dispose(); + service.dispose(); + }); + + it("keeps partial relation-alias evidence bounded and explicit", async () => { + const service = createSqlLanguageService({ + dialects: [postgres], + }); + const text = + "SELECT d. FROM (SELECT original, *) AS d(renamed, extra,)"; + const session = service.openDocument({ + context: { dialect: "postgresql", engine: "local" }, + text, + }); + await expect(session.complete({ + position: text.indexOf(" FROM"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "query-binding-partial" }], + items: [ + { label: "extra" }, + { label: "renamed" }, + ], + }, + }); + service.dispose(); + }); + + it("reuses one inferred CTE output across repeated aliases", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "WITH c AS (SELECT id) SELECT FROM c a, c b, c d"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + await expect(session.complete({ + position: text.indexOf(" FROM"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [ + { detail: "a", label: "id" }, + { detail: "b", label: "id" }, + { detail: "d", label: "id" }, + ], + }, + }); + service.dispose(); + }); + + it("keeps provable local columns while reporting unknown projections partial", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "WITH recent AS (SELECT id, *, upper(name) FROM users) " + + "SELECT recent.i FROM recent"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.indexOf("i FROM") + 1, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ + coverage: "partial", + feature: "query-output", + }], + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "query-binding-partial" }], + items: [{ label: "id" }], + }, + }); + service.dispose(); + }); + + it("merges inferred and physical columns without duplicate issues", async () => { + const service = serviceWithColumns(async (request) => ({ + epoch: { generation: 1, token: "mixed" }, + relations: request.relations.map((relation) => ({ + columns: [{ + columnEntityId: "physical:invoice_id", + identifier: { quoted: false, value: "invoice_id" }, + insertText: "invoice_id", + ordinal: 0, + }], + coverage: "complete", + relationEntityId: "physical", + requestKey: relation.requestKey, + status: "ready", + })), + })); + const text = + "WITH c AS (SELECT id AS local_id, *) " + + "SELECT FROM c JOIN physical p ON true"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:mixed" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + const result = await session.complete({ + position: text.indexOf(" FROM"), + trigger: { kind: "invoked" }, + }); + expect(result).toMatchObject({ + sources: [ + { feature: "query-output" }, + { feature: "column-catalog" }, + ], + status: "ready", + value: { + isIncomplete: true, + items: [ + { label: "local_id" }, + { label: "invoice_id" }, + ], + }, + }); + if (result.status === "ready") { + expect(result.value.issues.map((issue) => issue.reason)).toEqual([ + "query-binding-partial", + ]); + } + service.dispose(); + }); + + it("retains local output when physical column completion is unavailable", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "WITH c AS (SELECT id AS local_id) " + + "SELECT l FROM c JOIN physical p ON true"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.indexOf("l FROM") + 1, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [{ feature: "query-output" }], + status: "ready", + value: { + isIncomplete: true, + issues: [{ reason: "query-binding-partial" }], + items: [{ label: "local_id" }], + }, + }); + service.dispose(); + }); + + it("intersects local query outputs for JOIN USING completion", async () => { + const service = createSqlLanguageService({ + dialects: [duckdb], + }); + const text = + "WITH a AS (SELECT 1 AS id, 2 AS only_a), " + + "b AS (SELECT 1 AS id, 3 AS only_b) " + + "SELECT * FROM a JOIN b USING ()"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + text, + }); + + await expect(session.complete({ + position: text.lastIndexOf(")"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { items: [{ label: "id" }] }, + }); + service.dispose(); + }); + + it("intersects local and physical outputs for JOIN USING completion", async () => { + const service = serviceWithColumns(async (request) => ({ + epoch: { generation: 1, token: "mixed-using" }, + relations: request.relations.map((relation) => ({ + columns: [ + { + columnEntityId: "physical:id", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }, + { + columnEntityId: "physical:only_physical", + identifier: { quoted: false, value: "only_physical" }, + insertText: "only_physical", + ordinal: 1, + }, + ], + coverage: "complete", + relationEntityId: "physical", + requestKey: relation.requestKey, + status: "ready", + })), + })); + const text = + "WITH c AS (SELECT 1 AS id, 2 AS only_local) " + + "SELECT * FROM c JOIN physical p USING ()"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:mixed-using" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position: text.lastIndexOf(")"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [ + { feature: "query-output" }, + { feature: "column-catalog" }, + ], + status: "ready", + value: { items: [{ label: "id" }] }, + }); + service.dispose(); + }); + + it("uses the left physical spelling for mixed JOIN USING completion", async () => { + const service = serviceWithColumns(async (request) => ({ + epoch: { generation: 1, token: "physical-left-using" }, + relations: request.relations.map((relation) => ({ + columns: [ + { + columnEntityId: "physical:id", + identifier: { quoted: false, value: "id" }, + insertText: "id", + ordinal: 0, + }, + { + columnEntityId: "physical:only_physical", + identifier: { quoted: false, value: "only_physical" }, + insertText: "only_physical", + ordinal: 1, + }, + ], + coverage: "complete", + relationEntityId: "physical", + requestKey: relation.requestKey, + status: "ready", + })), + })); + const text = + "WITH c AS (SELECT 1 AS id, 2 AS only_local) " + + "SELECT * FROM physical p JOIN c USING ()"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:physical-left-using" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position: text.lastIndexOf(")"), + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + status: "ready", + value: { + items: [{ + edit: { insert: "id" }, + label: "id", + provenance: { kind: "column-catalog" }, + }], + }, + }); + service.dispose(); + }); + + it("returns local output while a physical column request exceeds its budget", async () => { + const service = createSqlLanguageService({ + catalog: relationCatalog, + columns: { + id: "columns", + loadColumns: () => new Promise(() => {}), + }, + completion: { catalogResponseBudgetMs: 0 }, + dialects: [duckdb], + }); + const text = + "WITH c AS (SELECT id AS local_id) " + + "SELECT l FROM c JOIN physical p ON true"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:mixed-timeout" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + await expect(session.complete({ + position: text.indexOf("l FROM") + 1, + trigger: { kind: "invoked" }, + })).resolves.toMatchObject({ + sources: [ + { feature: "query-output" }, + { feature: "column-catalog", outcome: "loading" }, + ], + status: "ready", + value: { + isIncomplete: true, + items: [{ label: "local_id" }], + }, + }); + service.dispose(); + }); + it("batches and completes a qualified alias through the session", async () => { const requests: Parameters< SqlColumnCatalogProvider["loadColumns"] @@ -1532,7 +2098,14 @@ describe("column completion session integration", () => { position, trigger: { kind: "invoked" }, })).resolves.toMatchObject({ - status: "unavailable", + sources: [{ feature: "query-output" }], + status: "ready", + value: { + items: [{ + edit: { insert: "local_id" }, + label: "local_id", + }], + }, }); expect(calls).toBe(0); service.dispose(); diff --git a/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts index abdfaa8..243ca0c 100644 --- a/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts +++ b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts @@ -210,6 +210,43 @@ test("standard editor can accept the first completion immediately", async () => expect(view.state.doc.toString()).toBe("SELECT * FROM users"); }); +test("standard editor completes and applies a CTE output column", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + autocomplete: { selectOnOpen: true }, + initialContext: { dialect: "duckdb" }, + service, + }); + const documentText = + "WITH c AS (SELECT id AS user_id) SELECT c. FROM c"; + const position = documentText.indexOf("c. FROM") + 2; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: position }, + }); + onTestFinished(() => view.destroy()); + + view.focus(); + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["user_id"]); + await expect.poll(() => selectedCompletionIndex(view.state)).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(acceptCompletion(view)).toBe(true); + expect(view.state.doc.toString()).toBe( + "WITH c AS (SELECT id AS user_id) SELECT c.user_id FROM c", + ); +}); + test("standard editor preserves SQL language completions", async () => { const parent = document.createElement("div"); document.body.append(parent); diff --git a/src/column-completion.ts b/src/column-completion.ts index 8ea49e9..f0b433f 100644 --- a/src/column-completion.ts +++ b/src/column-completion.ts @@ -3,6 +3,7 @@ import type { } from "./column-catalog-batch-coordinator.js"; import { MAX_COLUMN_BATCH_RELATIONS, + MAX_COLUMNS_PER_BATCH, } from "./column-catalog-boundary.js"; import type { SqlColumnCatalogRelationReference, @@ -18,6 +19,8 @@ import type { SqlCompletionIssue, SqlCompletionItem, SqlCompletionList, + SqlCompletionProviderReport, + SqlQueryOutputProviderReport, } from "./relation-completion-types.js"; import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; import type { @@ -40,10 +43,15 @@ export interface SqlPreparedColumnCatalogRelations { } export interface SqlColumnCompletionComposition { - readonly sources: readonly SqlColumnCatalogProviderReport[]; + readonly sources: readonly SqlCompletionProviderReport[]; readonly value: SqlCompletionList; } +const completionIdentifiers = new WeakMap< + SqlCompletionItem, + SqlIdentifierComponent +>(); + function sameIdentifier( dialect: SqlRelationDialectRuntime, left: SqlIdentifierComponent, @@ -94,10 +102,15 @@ export function prepareSqlColumnCatalogRelations( SqlColumnQueryRelation >(); let coverage: "complete" | "partial" = "complete"; - for (let index = 0; index < site.relations.length; index += 1) { + const firstRelation = + site.context === "using" + ? Math.max(0, site.relations.length - 2) + : 0; + for (let index = firstRelation; index < site.relations.length; index += 1) { const relation = site.relations[index]; if ( relation === undefined || + relation.local !== undefined || !relationMatchesQualifier( dialect, relation, @@ -129,6 +142,135 @@ function relationLabel(relation: SqlColumnQueryRelation): string { relation.path.map((component) => component.value).join("."); } +export function composeSqlLocalQueryOutputCompletion( + site: ReadyColumnSite, + dialect: SqlRelationDialectRuntime, +): SqlColumnCompletionComposition | null { + const items: SqlCompletionItem[] = []; + const seen = new Set(); + let found = false; + let partial = site.coverage === "partial"; + const firstRelation = site.context === "using" + ? Math.max(0, site.relations.length - 2) + : 0; + relations: for ( + let index = firstRelation; + index < site.relations.length; + index += 1 + ) { + const relation = site.relations[index]; + if ( + !relation?.local || + !relationMatchesQualifier(dialect, relation, site.qualifier) + ) { + continue; + } + found = true; + const output = relation.local.output; + if (!output || output.status !== "ready") { + partial = true; + continue; + } + if (output.coverage === "partial") partial = true; + for (const column of output.columns) { + if ( + dialect.completion.cteIdentifierMatchesPrefix( + column.identifier, + site.prefix, + ) !== "match" + ) { + continue; + } + const identity = [ + relation.range.from, + relation.range.to, + column.identifier.quoted, + column.identifier.value, + column.definition.from, + column.definition.to, + ].join("\u0000"); + if (seen.has(identity)) continue; + seen.add(identity); + if (items.length === MAX_COLUMNS_PER_BATCH) { + partial = true; + break relations; + } + const item = Object.freeze({ + detail: relationLabel(relation), + edit: Object.freeze({ + from: site.replacementRange.from, + insert: column.insertText, + to: site.replacementRange.to, + }), + kind: "column", + label: column.identifier.value, + provenance: Object.freeze({ + definition: column.definition, + kind: "query-output", + relation: relation.range, + }), + relationRequestKey: `local:${index}`, + }); + completionIdentifiers.set(item, column.identifier); + items.push(item); + } + } + if (!found) return null; + const source: SqlQueryOutputProviderReport = Object.freeze({ + coverage: partial ? "partial" : "complete", + feature: "query-output", + outcome: "ready", + }); + return Object.freeze({ + sources: Object.freeze([source]), + value: list( + Object.freeze(items.sort(compareItems)), + partial ? Object.freeze([issue("query-binding-partial")]) : Object.freeze([]), + ), + }); +} + +export function filterSqlUsingCompletionList( + value: SqlCompletionList, + site: ReadyColumnSite, + dialect: SqlRelationDialectRuntime, +): SqlCompletionList { + if (site.context !== "using" || site.relations.length < 2) return value; + const leftIndex = site.relations.length - 2; + const rightIndex = site.relations.length - 1; + const left = site.relations[leftIndex]; + const right = site.relations[rightIndex]; + if (!left || !right) return value; + const leftKey = left.local ? `local:${leftIndex}` : `binding:${leftIndex}`; + const rightKey = right.local + ? `local:${rightIndex}` + : `binding:${rightIndex}`; + const rightIdentifiers = value.items.flatMap((item) => { + if ( + item.kind !== "column" || + item.relationRequestKey !== rightKey + ) { + return []; + } + const identifier = completionIdentifiers.get(item); + return identifier ? [identifier] : []; + }); + const items = value.items.filter((item) => { + if ( + item.kind !== "column" || + item.relationRequestKey !== leftKey + ) { + return false; + } + const candidate = completionIdentifiers.get(item); + return candidate !== undefined && + rightIdentifiers.some((identifier) => + sameIdentifier(dialect, candidate, identifier) + ); + }); + return list(Object.freeze(items), value.issues); +} + function issue( reason: Exclude, ): SqlCompletionIssue { @@ -239,6 +381,7 @@ export function composeSqlColumnCompletion( let hasLoading = false; let hasFailure = false; const seen = new Set(); + const usingIdentifiers = new Map(); const failures: SqlColumnCatalogFailure[] = []; for (const result of input.outcome.relations) { issues.push(...resultIssues(result)); @@ -263,10 +406,26 @@ export function composeSqlColumnCompletion( issues.push(issue("column-catalog-malformed")); continue; } - for (const column of result.columns) { + if ( + relation.columnAliases?.coverage === "partial" || + (relation.columnAliases?.columns.length ?? 0) > + result.columns.length + ) { + hasPartial = true; + issues.push(issue("query-binding-partial")); + } + for ( + let columnIndex = 0; + columnIndex < result.columns.length; + columnIndex += 1 + ) { + const column = result.columns[columnIndex]!; + const alias = relation.columnAliases?.columns[columnIndex]; + const completionIdentifier = alias?.identifier ?? column.identifier; + const insertText = alias?.insertText ?? column.insertText; if ( input.dialect.completion.cteIdentifierMatchesPrefix( - column.identifier, + completionIdentifier, input.site.prefix, ) !== "match" ) { @@ -277,13 +436,16 @@ export function composeSqlColumnCompletion( column.provenance.scope, column.provenance.relationEntityId, column.provenance.columnEntityId, - column.insertText, + insertText, ].join("\u0000"); + const identifiers = usingIdentifiers.get(result.requestKey) ?? []; + identifiers.push(completionIdentifier); + usingIdentifiers.set(result.requestKey, identifiers); if (seen.has(identity)) continue; seen.add(identity); const relationName = relationLabel(relation); const metadata = column.detail ?? column.dataType; - items.push(Object.freeze({ + const item = Object.freeze({ ...(column.dataType === undefined ? {} : { dataType: column.dataType }), @@ -292,11 +454,11 @@ export function composeSqlColumnCompletion( : `${metadata} — ${relationName}`, edit: Object.freeze({ from: input.site.replacementRange.from, - insert: column.insertText, + insert: insertText, to: input.site.replacementRange.to, }), kind: "column", - label: column.identifier.value, + label: completionIdentifier.value, provenance: Object.freeze({ columnEntityId: column.provenance.columnEntityId, epoch: column.provenance.epoch, @@ -306,7 +468,33 @@ export function composeSqlColumnCompletion( scope: column.provenance.scope, }), relationRequestKey: result.requestKey, - })); + }); + completionIdentifiers.set(item, completionIdentifier); + items.push(item); + } + } + if ( + input.site.context === "using" && + input.prepared.references.length === 2 + ) { + const leftKey = input.prepared.references[0]?.requestKey; + const rightKey = input.prepared.references[1]?.requestKey; + if (leftKey && rightKey) { + const rightIdentifiers = usingIdentifiers.get(rightKey) ?? []; + const shared = items.filter((item) => { + if ( + item.kind !== "column" || + item.relationRequestKey !== leftKey + ) { + return false; + } + const left = completionIdentifiers.get(item); + return left !== undefined && + rightIdentifiers.some((right) => + sameIdentifier(input.dialect, left, right) + ); + }); + items.splice(0, items.length, ...shared); } } const deduplicatedIssues = Array.from( diff --git a/src/column-query-site.ts b/src/column-query-site.ts index dc8e8a6..f0752d5 100644 --- a/src/column-query-site.ts +++ b/src/column-query-site.ts @@ -17,18 +17,118 @@ import type { SqlIdentifierPath, SqlTextRange, } from "./types.js"; +import { + MAX_QUERY_OUTPUT_COLUMNS, + type SqlQueryOutput, + type SqlQueryOutputColumn, +} from "./query-output.js"; export const MAX_COLUMN_QUERY_RELATIONS = 256; export interface SqlColumnQueryRelation { readonly alias: SqlIdentifierComponent | null; + readonly columnAliases?: { + readonly columns: readonly SqlQueryOutputColumn[]; + readonly coverage: "complete" | "partial"; + }; + readonly local?: { + readonly kind: "cte" | "derived"; + readonly output?: SqlQueryOutput; + readonly queryRange: SqlTextRange; + }; readonly path: SqlIdentifierPath; readonly range: SqlTextRange; } +function parseColumnAliases( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + tokens: readonly Token[], + start: number, + depth: number, +): { + readonly aliases: + | SqlColumnQueryRelation["columnAliases"] + | undefined; + readonly next: number; +} { + const open = tokens[start]; + if (!punctuation(source, open, "(") || open?.depth !== depth) { + return { aliases: undefined, next: start }; + } + const columns: SqlQueryOutputColumn[] = []; + let expectIdentifier = true; + let coverage: "complete" | "partial" = "complete"; + let index = start + 1; + for (; index < tokens.length; index += 1) { + const token = tokens[index]!; + if ( + token.kind === "comment" || + token.kind === "line-comment" + ) { + continue; + } + if (punctuation(source, token, ")") && token.depth === depth) { + if (expectIdentifier) coverage = "partial"; + return { + aliases: Object.freeze({ + columns: Object.freeze(columns), + coverage, + }), + next: index + 1, + }; + } + if (token.depth !== depth + 1) { + coverage = "partial"; + continue; + } + if (expectIdentifier && isIdentifier(token)) { + const path = decodePath( + source, + dialect, + token.from, + token.to, + ); + const identifier = path?.length === 1 ? path[0] : undefined; + if (!identifier) { + coverage = "partial"; + } else if (columns.length < MAX_QUERY_OUTPUT_COLUMNS) { + columns.push(Object.freeze({ + definition: Object.freeze({ + from: token.from, + to: token.to, + }), + identifier, + insertText: source.originalText.slice(token.from, token.to), + })); + } else { + coverage = "partial"; + } + expectIdentifier = false; + continue; + } + if ( + !expectIdentifier && + punctuation(source, token, ",") + ) { + expectIdentifier = true; + continue; + } + coverage = "partial"; + } + return { + aliases: Object.freeze({ + columns: Object.freeze(columns), + coverage: "partial", + }), + next: index, + }; +} + export type SqlColumnQuerySiteIssue = | "derived-relation" | "incomplete-relation" + | "local-output-partial" | "nested-query" | "opaque-template-context" | "table-function"; @@ -53,6 +153,7 @@ export type SqlColumnQuerySiteResult = | { readonly status: "ready"; readonly coverage: "complete" | "partial"; + readonly context: SqlColumnCompletionContext; readonly issues: readonly SqlColumnQuerySiteIssue[]; readonly prefix: SqlIdentifierComponent; readonly qualifier: SqlIdentifierPath; @@ -64,7 +165,7 @@ interface Token extends BoundedSqlLexeme { readonly depth: number; } -type Clause = +export type SqlColumnCompletionContext = | "from" | "group" | "having" @@ -73,6 +174,7 @@ type Clause = | "order" | "qualify" | "select-list" + | "using" | "where"; const RELATION_END_WORDS: ReadonlySet = new Set([ @@ -197,6 +299,84 @@ function parseNamedRelation( readonly issue: SqlColumnQuerySiteIssue | null; } { const first = tokens[start]; + if (punctuation(source, first, "(") && first?.depth === depth) { + const closeIndex = tokens.findIndex((token, index) => + index > start && + token.depth === depth && + punctuation(source, token, ")") + ); + const close = tokens[closeIndex]; + const hasSelect = tokens.some((token, index) => + index > start && + index < closeIndex && + token.depth === depth + 1 && + word(source, token) === "select" + ); + if (closeIndex < 0 || !close || !hasSelect) { + return { + issue: "derived-relation" as const, + next: start + 1, + relation: null, + }; + } + let end = closeIndex + 1; + const maybeAs = word(source, tokens[end]); + if (maybeAs === "as") end += 1; + const aliasToken = tokens[end]; + const aliasWord = aliasToken ? word(source, aliasToken) : null; + const aliasPath = + isIdentifier(aliasToken) && + aliasToken?.depth === depth && + (aliasWord === null || !RELATION_END_WORDS.has(aliasWord)) + ? decodePath( + source, + dialect, + aliasToken.from, + aliasToken.to, + ) + : null; + const alias = aliasPath?.length === 1 + ? aliasPath[0] ?? null + : null; + if (alias !== null) end += 1; + const parsedAliases = alias === null + ? { aliases: undefined, next: end } + : parseColumnAliases( + source, + dialect, + tokens, + end, + depth, + ); + end = parsedAliases.next; + return { + issue: + alias === null + ? "derived-relation" as const + : parsedAliases.aliases?.coverage === "partial" + ? "local-output-partial" as const + : null, + next: end, + relation: Object.freeze({ + alias, + ...(parsedAliases.aliases === undefined + ? {} + : { columnAliases: parsedAliases.aliases }), + local: Object.freeze({ + kind: "derived" as const, + queryRange: Object.freeze({ + from: first.to, + to: close.from, + }), + }), + path: Object.freeze([]), + range: Object.freeze({ + from: first.from, + to: close.to, + }), + }), + }; + } if (!isIdentifier(first) || first?.depth !== depth) { return { issue: punctuation(source, first, "(") @@ -258,14 +438,29 @@ function parseNamedRelation( end += 1; } } + const parsedAliases = alias === null + ? { aliases: undefined, next: end } + : parseColumnAliases( + source, + dialect, + tokens, + end, + depth, + ); + end = parsedAliases.next; return { issue: maybeAs === "as" && alias === null ? "incomplete-relation" - : null, + : parsedAliases.aliases?.coverage === "partial" + ? "local-output-partial" + : null, next: end, relation: Object.freeze({ alias, + ...(parsedAliases.aliases === undefined + ? {} + : { columnAliases: parsedAliases.aliases }), path, range: Object.freeze({ from: first.from, @@ -281,8 +476,8 @@ function clauseAt( selectIndex: number, depth: number, position: number, -): Clause { - let clause: Clause = "select-list"; +): SqlColumnCompletionContext { + let clause: SqlColumnCompletionContext = "select-list"; for ( let index = selectIndex + 1; index < tokens.length && tokens[index]!.from < position; @@ -306,9 +501,11 @@ function clauseAt( clause = "limit"; break; case "on": - case "using": clause = "join-condition"; break; + case "using": + clause = "using"; + break; case "order": clause = "order"; break; @@ -488,6 +685,12 @@ function collectRelations( ); if (parsed.issue !== null) issues.add(parsed.issue); if (parsed.relation !== null) { + if ( + precedingOnly && + parsed.relation.range.to >= visibilityPosition + ) { + break; + } relations.push(parsed.relation); if (relations.length > MAX_COLUMN_QUERY_RELATIONS) return false; } @@ -671,6 +874,7 @@ export function recognizeSqlColumnQuerySite( const issueList = Object.freeze(Array.from(issues).sort()); return Object.freeze({ coverage: issueList.length === 0 ? "complete" : "partial", + context: clause, issues: issueList, prefix: path.prefix, qualifier: path.qualifier, diff --git a/src/cte-layout.ts b/src/cte-layout.ts index ac11cdf..3d83544 100644 --- a/src/cte-layout.ts +++ b/src/cte-layout.ts @@ -14,6 +14,7 @@ import { type SqlStatementIndex, } from "./statement-index.js"; import type { SqlIdentifierComponent } from "./types.js"; +import { MAX_QUERY_OUTPUT_COLUMNS } from "./query-output.js"; const cteRangeBrand: unique symbol = Symbol("SqlCteRange"); @@ -41,6 +42,7 @@ export type SqlCteLayoutIssue = export type SqlCteLayoutResource = | "active-statement" + | "cte-column" | "cte-declaration" | "cte-frame" | "identifier-segment" @@ -51,6 +53,11 @@ export interface SqlCteIdentifier { readonly component: SqlIdentifierComponent; } +export interface SqlCteDeclaredColumn { + readonly name: SqlIdentifierComponent; + readonly range: SqlCteRange; +} + export type SqlCteIdentifierResult = | { readonly status: "identifier"; @@ -84,6 +91,7 @@ export interface SqlCteLayoutDialect { export interface SqlCteDeclaration { readonly ambiguous: boolean; readonly bodyRange: SqlCteRange; + readonly declaredColumns: readonly SqlCteDeclaredColumn[]; readonly equivalenceClass: number; readonly frameIndex: number; readonly name: SqlIdentifierComponent; @@ -96,6 +104,7 @@ export interface SqlCteDeclaration { export interface SqlCteDraftDeclaration { readonly ambiguous: boolean; readonly bodyRange: SqlCteRange; + readonly declaredColumns: readonly SqlCteDeclaredColumn[]; readonly equivalenceClass: number; readonly frameIndex: number; readonly name: SqlIdentifierComponent; @@ -247,6 +256,11 @@ interface DraftDeclaration { bodyLead: "select" | "with" | null; bodyLeadFrameIndex: number | null; component: SqlIdentifierComponent; + declaredColumns: { + readonly from: number; + readonly name: SqlIdentifierComponent; + readonly to: number; + }[]; nameFrom: number; nameTo: number; sourceSpelling: string; @@ -256,6 +270,7 @@ interface MutableDeclaration { ambiguous: boolean; bodyFrom: number; bodyTo: number; + declaredColumns: DraftDeclaration["declaredColumns"]; identityIndex: number; frameIndex: number; name: SqlIdentifierComponent; @@ -269,6 +284,7 @@ interface MutableDraftDeclaration { ambiguous: boolean; bodyFrom: number; bodyTo: number; + declaredColumns: DraftDeclaration["declaredColumns"]; identityIndex: number; frameIndex: number; name: SqlIdentifierComponent; @@ -788,6 +804,7 @@ function processName( bodyLead: null, bodyLeadFrameIndex: null, component: identifier.component, + declaredColumns: [], nameFrom: token.from - statementFrom, nameTo: token.to - statementFrom, sourceSpelling: text.slice(token.from, token.to), @@ -817,6 +834,7 @@ function commitDeclaration( ambiguous: false, bodyFrom: current.bodyFrom, bodyTo, + declaredColumns: [...current.declaredColumns], frameIndex: frame.index, identityIndex, name: current.component, @@ -885,6 +903,7 @@ function collectActiveBodyEvidence( ambiguous, bodyFrom: current.bodyFrom, bodyTo: exactThrough, + declaredColumns: [...current.declaredColumns], frameIndex: frame.index, identityIndex, name: current.component, @@ -960,6 +979,14 @@ function freezeLayout( declaration.bodyFrom, declaration.bodyTo, ), + declaredColumns: Object.freeze( + declaration.declaredColumns.map((column) => + Object.freeze({ + name: column.name, + range: createRange(column.from, column.to), + }) + ), + ), equivalenceClass: relationRoot( relations, declaration.identityIndex, @@ -986,6 +1013,14 @@ function freezeLayout( declaration.bodyFrom, declaration.bodyTo, ), + declaredColumns: Object.freeze( + declaration.declaredColumns.map((column) => + Object.freeze({ + name: column.name, + range: createRange(column.from, column.to), + }) + ), + ), equivalenceClass: relationRoot( relations, declaration.identityIndex, @@ -1125,6 +1160,7 @@ export function analyzeSqlCteLayout( const declarationAttempts = { value: 0 }; let depth = 0; let exactThrough = statementLength; + let recoveredThrough: number | null = null; let resource: SqlCteLayoutResource | undefined; scan: while (true) { @@ -1152,6 +1188,16 @@ export function analyzeSqlCteLayout( frame.issues.add("opaque-template-context"); } } + if ( + bodyOwners.size > 0 || + frames.some((frame) => frame.state === "main") + ) { + recoveredThrough = recoveredThrough === null + ? exactThrough + : Math.min(recoveredThrough, exactThrough); + exactThrough = statementLength; + continue; + } break; } const code = punctuation(text, token); @@ -1528,7 +1574,13 @@ export function analyzeSqlCteLayout( } if ( token.kind === "word" && - wordEquals(text, token, "select") + ( + wordEquals(text, token, "select") || + wordEquals(text, token, "insert") || + wordEquals(text, token, "update") || + wordEquals(text, token, "delete") || + wordEquals(text, token, "merge") + ) ) { frame.mainQueryStart = token.from - statementFrom; frame.state = "main"; @@ -1547,14 +1599,13 @@ export function analyzeSqlCteLayout( const columnOwner = columnOwners.get(depth); if (columnOwner) { if (columnOwner.columnExpectIdentifier) { - if ( - !normalizeIdentifier( - validatedDialect, - text, - token, - "cte-column", - ) - ) { + const column = normalizeIdentifier( + validatedDialect, + text, + token, + "cte-column", + ); + if (!column) { exactThrough = token.from - statementFrom; markPartial( columnOwner, @@ -1563,6 +1614,23 @@ export function analyzeSqlCteLayout( ); break; } + if ( + (columnOwner.current?.declaredColumns.length ?? 0) < + MAX_QUERY_OUTPUT_COLUMNS + ) { + columnOwner.current?.declaredColumns.push({ + from: token.from - statementFrom, + name: column.component, + to: token.to - statementFrom, + }); + } else { + resource ??= "cte-column"; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + } columnOwner.columnCount += 1; columnOwner.columnExpectIdentifier = false; continue; @@ -1628,7 +1696,7 @@ export function analyzeSqlCteLayout( relations, exactThrough, ); - const layout = freezeLayout( + const structuralLayout = freezeLayout( frames, declarations, draftDeclarations, @@ -1638,6 +1706,15 @@ export function analyzeSqlCteLayout( issues, resource, ); + const layout = recoveredThrough === null + ? structuralLayout + : Object.freeze({ + ...structuralLayout, + exactThrough: Math.min( + structuralLayout.exactThrough, + recoveredThrough, + ), + }); return registerSqlCteLayout( layout, source, diff --git a/src/index.ts b/src/index.ts index 43d23ed..0439452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,8 @@ export type { SqlColumnCatalogProviderReport, SqlColumnCatalogFailure, SqlColumnCompletionProvenance, + SqlQueryOutputCompletionProvenance, + SqlQueryOutputProviderReport, SqlCompletionProviderReport, SqlCompletionIssue, SqlCompletionRequest, diff --git a/src/local-relation-site.ts b/src/local-relation-site.ts index 27ad6cd..6d8b439 100644 --- a/src/local-relation-site.ts +++ b/src/local-relation-site.ts @@ -6,8 +6,14 @@ import { } from "./cte-layout.js"; import { recognizeSqlColumnQuerySite, + type SqlColumnQueryRelation, type SqlColumnQuerySiteResult, } from "./column-query-site.js"; +import { + inferSqlQueryOutput, + MAX_QUERY_OUTPUT_COLUMNS, + type SqlQueryOutput, +} from "./query-output.js"; import { recognizeSqlRelationQuerySiteWithEntrypoints, type SqlQuerySiteResult, @@ -24,11 +30,44 @@ import { type SqlStatementIndex, type SqlStatementSlot, } from "./statement-index.js"; +import type { SqlTextRange } from "./types.js"; const localRelationStatementBrand: unique symbol = Symbol( "SqlLocalRelationStatement", ); +export function applySqlQueryOutputAliases( + output: SqlQueryOutput, + aliases: NonNullable, +): SqlQueryOutput { + if (output.status !== "ready") { + return Object.freeze({ + columns: aliases.columns, + coverage: "partial" as const, + status: "ready" as const, + }); + } + const columns = output.columns.map((column, index) => + aliases.columns[index] ?? column + ); + if (aliases.columns.length > columns.length) { + columns.push(...aliases.columns.slice(columns.length)); + } + return Object.freeze({ + columns: Object.freeze( + columns.slice(0, MAX_QUERY_OUTPUT_COLUMNS), + ), + coverage: + aliases.coverage === "partial" || + output.coverage === "partial" || + columns.length > MAX_QUERY_OUTPUT_COLUMNS || + aliases.columns.length > output.columns.length + ? "partial" as const + : "complete" as const, + status: "ready" as const, + }); +} + export interface SqlLocalRelationStatement { readonly [localRelationStatementBrand]: "SqlLocalRelationStatement"; } @@ -227,22 +266,116 @@ export function analyzeSqlLocalColumnSite( context.layout, position - context.slot.source.from, ); - const relations = result.relations.filter((relation) => { - const name = relation.path.length === 1 - ? relation.path[0] - : undefined; - return name === undefined || - !visibility.ctes.some((cte) => - context.dialect.completion.compareCteIdentifiers( - name, - cte.name, - ) === "equal" + const outputCache = new Map(); + const inferOutput = (range: SqlTextRange): SqlQueryOutput => { + const key = `${range.from}:${range.to}`; + const cached = outputCache.get(key); + if (cached) return cached; + const output = inferSqlQueryOutput( + context.source, + range, + context.dialect, + ); + outputCache.set(key, output); + return output; + }; + const applyAliases = ( + output: SqlQueryOutput, + relation: SqlColumnQueryRelation, + ): SqlQueryOutput => { + const aliases = relation.columnAliases; + if (!aliases) return output; + return applySqlQueryOutputAliases(output, aliases); + }; + const relations: SqlColumnQueryRelation[] = result.relations.map( + (relation) => { + if (relation.local?.kind === "derived") { + return Object.freeze({ + ...relation, + local: Object.freeze({ + ...relation.local, + output: applyAliases( + inferOutput(relation.local.queryRange), + relation, + ), + }), + }); + } + const name = relation.path.length === 1 + ? relation.path[0] + : undefined; + const visible = name === undefined + ? undefined + : visibility.ctes.find((cte) => + context.dialect.completion.compareCteIdentifiers( + name, + cte.name, + ) === "equal" + ); + if (!visible) return relation; + const declaration = context.layout.declarations.find((candidate) => + candidate.nameRange.from === visible.declarationPosition ); - }); - return relations.length === result.relations.length - ? result - : Object.freeze({ - ...result, - relations: Object.freeze(relations), + if (!declaration) return relation; + const queryRange = Object.freeze({ + from: context.slot.source.from + declaration.bodyRange.from, + to: context.slot.source.from + declaration.bodyRange.to, + }); + const inferredOutput = inferOutput(queryRange); + const output = declaration.declaredColumns.length === 0 + ? inferredOutput + : Object.freeze({ + columns: Object.freeze( + declaration.declaredColumns.map((column) => { + const definition = Object.freeze({ + from: context.slot.source.from + column.range.from, + to: context.slot.source.from + column.range.to, + }); + return Object.freeze({ + definition, + identifier: column.name, + insertText: context.source.originalText.slice( + definition.from, + definition.to, + ), + }); + }), + ), + coverage: + context.layout.status === "partial" + ? "partial" as const + : "complete" as const, + status: "ready" as const, + }); + return Object.freeze({ + ...relation, + local: Object.freeze({ + kind: "cte" as const, + output: applyAliases(output, relation), + queryRange, + }), }); + }, + ); + const localPartial = relations.some((relation) => + relation.local !== undefined && + ( + relation.local.output?.status !== "ready" || + relation.local.output.coverage === "partial" + ) + ) || visibility.shadowing.coverage === "unknown"; + const issues = localPartial && + !result.issues.includes("local-output-partial") + ? Object.freeze([ + ...result.issues, + "local-output-partial" as const, + ].sort()) + : result.issues; + return Object.freeze({ + ...result, + coverage: + result.coverage === "partial" || localPartial ? "partial" : "complete", + issues, + relations: Object.freeze(relations), + }); } diff --git a/src/query-output.ts b/src/query-output.ts new file mode 100644 index 0000000..d730e65 --- /dev/null +++ b/src/query-output.ts @@ -0,0 +1,268 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme, +} from "./bounded-sql-lexer.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import type { SqlSourceSnapshot } from "./source.js"; +import type { + SqlIdentifierComponent, + SqlTextRange, +} from "./types.js"; + +export const MAX_QUERY_OUTPUT_COLUMNS = 256; +export const MAX_QUERY_OUTPUT_LENGTH = 65_536; + +interface Token extends BoundedSqlLexeme { + readonly depth: number; +} + +export interface SqlQueryOutputColumn { + readonly definition: SqlTextRange; + readonly identifier: SqlIdentifierComponent; + readonly insertText: string; +} + +export type SqlQueryOutput = + | { + readonly columns: readonly SqlQueryOutputColumn[]; + readonly coverage: "complete" | "partial"; + readonly status: "ready"; + } + | { + readonly reason: "resource-limit" | "unsupported-query"; + readonly status: "unavailable"; + }; + +function punctuation( + source: SqlSourceSnapshot, + token: Token | undefined, + expected: string, +): boolean { + return token?.kind === "punctuation" && + source.analysisText.slice(token.from, token.to) === expected; +} + +function word( + source: SqlSourceSnapshot, + token: Token | undefined, +): string | null { + return token?.kind === "word" + ? source.analysisText.slice(token.from, token.to).toLowerCase() + : null; +} + +function identifier(token: Token | undefined): boolean { + return token?.kind === "word" || + token?.kind === "quoted-identifier"; +} + +function tokenize( + source: SqlSourceSnapshot, + range: SqlTextRange, + dialect: SqlRelationDialectRuntime, +): readonly Token[] | null { + const lexer = new BoundedSqlLexer( + source, + range.from, + range.to, + dialect.querySite.lexicalProfile, + ); + const tokens: Token[] = []; + let depth = 0; + for (let lexeme = lexer.next(); lexeme; lexeme = lexer.next()) { + if (punctuation(source, { ...lexeme, depth }, ")")) { + depth = Math.max(0, depth - 1); + } + tokens.push(Object.freeze({ ...lexeme, depth })); + if (punctuation(source, { ...lexeme, depth }, "(")) { + depth += 1; + } + } + return lexer.resource === null ? Object.freeze(tokens) : null; +} + +function decodeColumn( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + token: Token, +): SqlQueryOutputColumn | null { + const raw = source.analysisText.slice(token.from, token.to); + const decoded = dialect.querySite.decodeRelationPath(raw, raw.length); + if ( + decoded.status !== "decoded" || + decoded.qualifier.length !== 0 || + decoded.prefix.value.length === 0 + ) { + return null; + } + return Object.freeze({ + definition: Object.freeze({ from: token.from, to: token.to }), + identifier: decoded.prefix, + insertText: source.originalText.slice(token.from, token.to), + }); +} + +function projectionColumn( + source: SqlSourceSnapshot, + dialect: SqlRelationDialectRuntime, + segment: readonly Token[], + depth: number, +): SqlQueryOutputColumn | null { + const significant = segment.filter((token) => + token.kind !== "comment" && token.kind !== "line-comment" + ); + if (significant.length === 0) return null; + for (let index = significant.length - 2; index >= 0; index -= 1) { + const token = significant[index]; + const alias = significant[index + 1]; + if ( + token?.depth === depth && + word(source, token) === "as" && + alias?.depth === depth && + identifier(alias) && + index + 2 === significant.length + ) { + return decodeColumn(source, dialect, alias); + } + } + if (significant.some((token) => token.kind === "barrier")) return null; + const projection = significant.filter((token, index) => + !( + index === 0 && + token.depth === depth && + (word(source, token) === "all" || + word(source, token) === "distinct") + ) + ); + if (projection.length === 0 || projection.some((token) => + token.depth !== depth + )) { + return null; + } + for (let index = 0; index < projection.length; index += 1) { + const token = projection[index]; + if ( + index % 2 === 0 + ? !identifier(token) + : !punctuation(source, token, ".") + ) { + return null; + } + } + if (projection.length % 2 === 0) return null; + const final = projection[projection.length - 1]; + return final ? decodeColumn(source, dialect, final) : null; +} + +export function inferSqlQueryOutput( + source: SqlSourceSnapshot, + range: SqlTextRange, + dialect: SqlRelationDialectRuntime, +): SqlQueryOutput { + if ( + !Number.isSafeInteger(range.from) || + !Number.isSafeInteger(range.to) || + range.from < 0 || + range.from >= range.to || + range.to > source.analysisText.length || + range.to - range.from > MAX_QUERY_OUTPUT_LENGTH + ) { + return Object.freeze({ reason: "resource-limit", status: "unavailable" }); + } + const tokens = tokenize(source, range, dialect); + if (tokens === null) { + return Object.freeze({ reason: "resource-limit", status: "unavailable" }); + } + const selects = tokens + .map((token, index) => ({ index, token })) + .filter(({ token }) => word(source, token) === "select"); + const minimumDepth = Math.min(...selects.map(({ token }) => token.depth)); + const firstTopLevelSet = tokens.findIndex((token) => + token.depth === minimumDepth && + ( + word(source, token) === "union" || + word(source, token) === "intersect" || + word(source, token) === "except" + ) + ); + const precedingArmSelects = firstTopLevelSet < 0 + ? [] + : selects.filter(({ index }) => index < firstTopLevelSet); + const firstArmDepth = Math.min( + ...precedingArmSelects.map(({ token }) => token.depth), + ); + const selected = firstTopLevelSet < 0 + ? selects.find(({ token }) => token.depth === minimumDepth) + : precedingArmSelects.find(({ token }) => + token.depth === firstArmDepth + ); + if (!selected || !Number.isFinite(minimumDepth)) { + return Object.freeze({ + reason: "unsupported-query", + status: "unavailable", + }); + } + const projectionDepth = selected.token.depth; + const segments: Token[][] = [[]]; + let stopped = false; + let complete = !tokens.some((token) => + token.kind === "barrier" || !token.closed + ); + for ( + let index = selected.index + 1; + index < tokens.length; + index += 1 + ) { + const token = tokens[index]!; + const tokenWord = word(source, token); + if ( + ( + token.depth === projectionDepth && + tokenWord === "from" + ) || + ( + token.depth <= projectionDepth && + ( + tokenWord === "union" || + tokenWord === "intersect" || + tokenWord === "except" + ) + ) + ) { + stopped = true; + break; + } + if ( + token.depth === projectionDepth && + punctuation(source, token, ",") + ) { + segments.push([]); + continue; + } + segments[segments.length - 1]!.push(token); + } + const columns: SqlQueryOutputColumn[] = []; + complete &&= stopped || segments.some((segment) => segment.length > 0); + for (const segment of segments) { + const column = projectionColumn( + source, + dialect, + segment, + projectionDepth, + ); + if (column === null) { + complete = false; + continue; + } + if (columns.length === MAX_QUERY_OUTPUT_COLUMNS) { + complete = false; + break; + } + columns.push(column); + } + return Object.freeze({ + columns: Object.freeze(columns), + coverage: complete ? "complete" : "partial", + status: "ready", + }); +} diff --git a/src/query-site.ts b/src/query-site.ts index 33c9480..3ee6ccc 100644 --- a/src/query-site.ts +++ b/src/query-site.ts @@ -126,6 +126,7 @@ export interface SqlQuerySiteDialect { }; readonly lexicalProfile: SqlLexicalProfile; readonly maximumPathDepth: number; + readonly optionalDmlInto?: boolean; readonly supportsNaturalJoin: boolean; readonly decodeRelationPath: ( rawPath: string, @@ -462,10 +463,13 @@ function processFrameWord( text: string, token: Lexeme, ): void { - if (frame.state === "unavailable" || frame.state === "closed") { + if (frame.state === "unavailable") { return; } const word = wordValue(text, token); + if (frame.state === "closed" && !isSetOperation(word)) { + return; + } if (frame.state === "expect-alias") { if (word.length === 0) { markUnavailable(frame, "ambiguous-query-site"); @@ -502,7 +506,13 @@ function processFrameWord( return; } if (isSetOperation(word)) { - markUnavailable(frame, "unsupported-query-site"); + frame.anchor = "from"; + frame.blocksNestedQuery = false; + frame.joinConstraint = null; + frame.joinConstraintAllowed = false; + frame.joinPrefix = null; + frame.selectWords = [null, null, null]; + frame.state = "select-list"; return; } if (word === "lateral" || word === "qualify" || word === "window") { @@ -1073,6 +1083,136 @@ function resultAtGap( return inactive("not-relation-position"); } +function recognizeDmlRelationQuerySite( + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + start: number, + position: number, + dialect: SqlQuerySiteDialect, + lexicalProfile: SqlLexicalProfile, + maximumPathDepth: number, +): SqlQuerySiteResult | "continue-select" | null { + const lexer = new BoundedSqlLexer( + source, + start, + slot.source.to, + lexicalProfile, + ); + let first = lexer.next(); + while (first && isCommentLexeme(first)) { + if (cursorIsInComment(first, position)) { + return inactive("cursor-in-comment"); + } + first = lexer.next(); + } + if (!first || first.kind !== "word") return null; + const leader = wordValue(source.analysisText, first); + let expectedKeyword: "from" | "into" | null; + let keywordOptional = false; + let merge = false; + if (leader === "update") { + expectedKeyword = null; + } else if (leader === "insert") { + expectedKeyword = "into"; + keywordOptional = dialect.optionalDmlInto === true; + } else if (leader === "delete") { + expectedKeyword = "from"; + } else if (leader === "merge") { + expectedKeyword = "into"; + keywordOptional = dialect.optionalDmlInto === true; + merge = true; + } else { + return null; + } + if (first.from <= position && position < first.to) { + return inactive("not-relation-position"); + } + let expectRelation = expectedKeyword === null; + let targetComplete = false; + let anchor: QueryFrame["anchor"] = "from"; + while (true) { + const token = lexer.next(); + if (lexer.resource) { + return unavailable( + "resource-limit", + querySiteLexerResource(lexer.resource), + ); + } + if (!token || token.from > position) { + if (expectRelation) { + const frame = createFrame(0, false); + frame.anchor = anchor; + frame.state = "expect-relation"; + return readyEmpty(slot, frame, position); + } + return inactive("not-relation-position"); + } + if (isCommentLexeme(token)) { + if (cursorIsInComment(token, position)) { + return inactive("cursor-in-comment"); + } + if (!token.closed) return unavailable("ambiguous-query-site"); + continue; + } + if (token.kind === "barrier") { + return token.from <= position && position <= token.to + ? inactive("cursor-in-embedded-region") + : unavailable("ambiguous-query-site"); + } + if (token.from <= position && position < token.to) { + if (token.kind === "string") return inactive("cursor-in-string"); + if (!expectRelation) return inactive("not-relation-position"); + } + if (expectedKeyword !== null) { + const keywordMatches = + token.kind === "word" && + wordValue(source.analysisText, token) === expectedKeyword; + if (!keywordMatches && !keywordOptional) { + return unavailable("ambiguous-query-site"); + } + expectedKeyword = null; + expectRelation = true; + if (keywordMatches) continue; + } + if (expectRelation) { + if ( + token.kind !== "word" && + token.kind !== "quoted-identifier" + ) { + return unavailable("unsupported-query-site"); + } + const frame = createFrame(0, false); + frame.anchor = anchor; + frame.state = "expect-relation"; + const result = recognizePath( + lexer, + source, + slot, + frame, + dialect, + maximumPathDepth, + token, + position, + ); + if (result) return result; + expectRelation = false; + targetComplete = true; + if (!merge || anchor === "join") return "continue-select"; + continue; + } + if ( + merge && + targetComplete && + token.kind === "word" && + wordValue(source.analysisText, token) === "using" + ) { + anchor = "join"; + expectRelation = true; + targetComplete = false; + } + } +} + function recognizeSqlRelationQuerySiteInternal( source: SqlSourceSnapshot, slot: SqlStatementSlot, @@ -1120,6 +1260,29 @@ function recognizeSqlRelationQuerySiteInternal( ) { return unavailable("ambiguous-query-site"); } + const dmlStarts = [ + slot.source.from, + ...(authenticatedEntrypoints ?? []) + .filter((entrypoint) => entrypoint.depth === 0) + .map((entrypoint) => slot.source.from + entrypoint.from), + ]; + let dml: SqlQuerySiteResult | "continue-select" | null = null; + for (let index = dmlStarts.length - 1; index >= 0; index -= 1) { + const start = dmlStarts[index]; + if (start === undefined || start > position) continue; + dml = recognizeDmlRelationQuerySite( + source, + slot, + start, + position, + dialect, + lexicalProfile, + maximumPathDepth, + ); + if (dml !== null) break; + } + const continueAfterDml = dml === "continue-select"; + if (dml !== null && dml !== "continue-select") return dml; const lexer = new BoundedSqlLexer( source, slot.source.from, @@ -1445,11 +1608,11 @@ function recognizeSqlRelationQuerySiteInternal( queryCandidates.has(depth) || isAuthenticatedEntrypoint ) { - queryCandidates.delete(depth); if ( isSelect && !topFrame(frames)?.blocksNestedQuery ) { + queryCandidates.delete(depth); frames.push(createFrame(depth, statementTainted)); sawSelect = true; if (token.to === position) { @@ -1457,6 +1620,9 @@ function recognizeSqlRelationQuerySiteInternal( } continue; } + if (!(continueAfterDml && depth === 0)) { + queryCandidates.delete(depth); + } } const activeFrame = topFrame(frames); let openedRelation = false; diff --git a/src/relation-completion-types.ts b/src/relation-completion-types.ts index c6057fe..03e5448 100644 --- a/src/relation-completion-types.ts +++ b/src/relation-completion-types.ts @@ -3,6 +3,7 @@ import type { SqlIdentifierPath, SqlRevision, SqlTextChange, + SqlTextRange, } from "./types.js"; // Provisional package-private declarations until the vertical slice is proven. @@ -258,6 +259,12 @@ export interface SqlColumnCompletionProvenance { readonly columnEntityId: string; } +export interface SqlQueryOutputCompletionProvenance { + readonly definition: SqlTextRange; + readonly kind: "query-output"; + readonly relation: SqlTextRange; +} + export interface SqlNamespaceCompletionProvenance { readonly containerEntityId: string; readonly epoch: SqlCatalogEpoch; @@ -289,6 +296,11 @@ export type SqlCompletionItem = readonly provenance: SqlColumnCompletionProvenance; readonly relationRequestKey: string; }) + | (SqlCompletionItemBase & { + readonly kind: "column"; + readonly provenance: SqlQueryOutputCompletionProvenance; + readonly relationRequestKey: string; + }) | (SqlCompletionItemBase & { readonly kind: "namespace"; readonly provenance: SqlNamespaceCompletionProvenance; @@ -457,10 +469,17 @@ export type SqlNamespaceCatalogProviderReport = | "provider-failed"; }; +export interface SqlQueryOutputProviderReport { + readonly coverage: "complete" | "partial"; + readonly feature: "query-output"; + readonly outcome: "ready"; +} + export type SqlCompletionProviderReport = | SqlCatalogProviderReport | SqlColumnCatalogProviderReport - | SqlNamespaceCatalogProviderReport; + | SqlNamespaceCatalogProviderReport + | SqlQueryOutputProviderReport; export interface SqlServiceFailure { readonly code: "internal"; diff --git a/src/relation-dialect.ts b/src/relation-dialect.ts index 39c2fe5..f8b4c96 100644 --- a/src/relation-dialect.ts +++ b/src/relation-dialect.ts @@ -1415,6 +1415,7 @@ function createRuntime(spec: RelationDialectSpec): SqlRelationDialectRuntime { decodeRelationPath: createPathDecoder(spec, decodeIdentifier), lexicalProfile: spec.lexicalProfile, maximumPathDepth: spec.maximumPathDepth, + ...(spec.kind === "bigquery" ? { optionalDmlInto: true } : {}), supportsNaturalJoin: spec.supportsNaturalJoin, }); return registerSqlRelationDialectRuntime( diff --git a/src/session.ts b/src/session.ts index 628410a..bd25033 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,6 +18,8 @@ import { } from "./column-catalog-batch-coordinator.js"; import { composeSqlColumnCompletion, + composeSqlLocalQueryOutputCompletion, + filterSqlUsingCompletionList, prepareSqlColumnCatalogRelations, } from "./column-completion.js"; import { @@ -650,7 +652,14 @@ function mergeCompletionLists( right: SqlCompletionList, ): SqlCompletionList { const items = Object.freeze([...left.items, ...right.items]); - const issues = Object.freeze([...left.issues, ...right.issues]); + const issueReasons = new Set(); + const issues = Object.freeze( + [...left.issues, ...right.issues].filter((issue) => { + if (issueReasons.has(issue.reason)) return false; + issueReasons.add(issue.reason); + return true; + }), + ); const first = issues[0]; if (first === undefined) { return Object.freeze({ @@ -670,6 +679,22 @@ function mergeCompletionLists( }); } +function completionListWithIssue( + value: SqlCompletionList, + reason: "query-binding-partial", +): SqlCompletionList { + if (value.issues.some((issue) => issue.reason === reason)) return value; + const issues: [SqlCompletionIssue, ...SqlCompletionIssue[]] = [ + Object.freeze({ reason }), + ...value.issues, + ]; + return Object.freeze({ + isIncomplete: true, + issues: Object.freeze(issues), + items: value.items, + }); +} + function completionListWithLoadingLease( value: SqlCompletionList, reason: @@ -1892,8 +1917,25 @@ export class DefaultSqlDocumentSession columnSite, snapshot.dialect.relationDialect, ); + const localComposition = composeSqlLocalQueryOutputCompletion( + columnSite, + snapshot.dialect.relationDialect, + ); if (preparedRelations.references.length === 0) { cancelPrevious(); + if (localComposition) { + return Object.freeze({ + refreshToken: null, + revision: snapshot.revision, + sources: localComposition.sources, + status: "ready", + value: filterSqlUsingCompletionList( + localComposition.value, + columnSite, + snapshot.dialect.relationDialect, + ), + }); + } return Object.freeze({ reason: unavailableCompletionReason(localSite), retryable: false, @@ -1903,6 +1945,22 @@ export class DefaultSqlDocumentSession } if (!catalog || !columnOwner || !columnCoordinator) { cancelPrevious(); + if (localComposition) { + return Object.freeze({ + refreshToken: null, + revision: snapshot.revision, + sources: localComposition.sources, + status: "ready", + value: completionListWithIssue( + filterSqlUsingCompletionList( + localComposition.value, + columnSite, + snapshot.dialect.relationDialect, + ), + "query-binding-partial", + ), + }); + } return Object.freeze({ reason: "unsupported-query-site", retryable: false, @@ -1939,24 +1997,37 @@ export class DefaultSqlDocumentSession if (raced.kind === "timeout") { const remainingIntentLeaseMs = this.#retainAuxiliaryRefresh(active, ticket.result); + const loadingValue = Object.freeze({ + isIncomplete: true as const, + issues: Object.freeze([Object.freeze({ + reason: "column-catalog-loading" as const, + remainingIntentLeaseMs, + })] as const), + items: Object.freeze([]), + }); return Object.freeze({ refreshToken: active.token, revision: snapshot.revision, - sources: Object.freeze([Object.freeze({ - feature: "column-catalog" as const, - failures: Object.freeze([]), - outcome: "loading" as const, - providerId: columnCoordinator.providerId, - })]), + sources: Object.freeze([ + ...(localComposition?.sources ?? []), + Object.freeze({ + feature: "column-catalog" as const, + failures: Object.freeze([]), + outcome: "loading" as const, + providerId: columnCoordinator.providerId, + }), + ]), status: "ready", - value: Object.freeze({ - isIncomplete: true, - issues: Object.freeze([Object.freeze({ - reason: "column-catalog-loading" as const, - remainingIntentLeaseMs, - })] as const), - items: Object.freeze([]), - }), + value: localComposition + ? filterSqlUsingCompletionList( + mergeCompletionLists( + localComposition.value, + loadingValue, + ), + columnSite, + snapshot.dialect.relationDialect, + ) + : loadingValue, }); } const composition = composeSqlColumnCompletion({ @@ -1995,15 +2066,33 @@ export class DefaultSqlDocumentSession return Object.freeze({ refreshToken: retryLoading ? active.token : null, revision: snapshot.revision, - sources: composition.sources, + sources: Object.freeze([ + ...(localComposition?.sources ?? []), + ...composition.sources, + ]), status: "ready", - value: retryLoading - ? completionListWithLoadingLease( - composition.value, - "column-catalog-loading", - remainingIntentLeaseMs, + value: localComposition + ? filterSqlUsingCompletionList( + mergeCompletionLists( + localComposition.value, + retryLoading + ? completionListWithLoadingLease( + composition.value, + "column-catalog-loading", + remainingIntentLeaseMs, + ) + : composition.value, + ), + columnSite, + snapshot.dialect.relationDialect, ) - : composition.value, + : retryLoading + ? completionListWithLoadingLease( + composition.value, + "column-catalog-loading", + remainingIntentLeaseMs, + ) + : composition.value, }); } cancelPrevious(); diff --git a/test/corpora/semantic-completion/bigquery.ts b/test/corpora/semantic-completion/bigquery.ts new file mode 100644 index 0000000..b3860ac --- /dev/null +++ b/test/corpora/semantic-completion/bigquery.ts @@ -0,0 +1,35 @@ +import type { SemanticCompletionCase } from "./types.js"; + +export const bigQuerySemanticCompletion = [ + { + category: "valid", + expectedIncomplete: false, + expectedLabels: ["Display Name"], + sql: "WITH c AS (SELECT name AS `Display Name`) SELECT c.| FROM c", + }, + { + category: "invalid", + expectedIncomplete: true, + expectedLabels: ["safe_id"], + sql: "WITH c AS (SELECT id AS safe_id, STRUCT() AS) SELECT c.| FROM c", + }, + { + category: "incomplete", + expectedIncomplete: false, + expectedLabels: ["project_id"], + sql: "WITH c AS (SELECT id AS project_id) SELECT c.proj| FROM c", + }, + { + category: "templated", + expectedIncomplete: true, + expectedLabels: ["metric"], + sql: "WITH c AS (SELECT {python} AS metric) SELECT c.| FROM c", + template: "{python}", + }, + { + category: "multi-statement", + expectedIncomplete: false, + expectedLabels: ["event_id"], + sql: "SELECT 1; WITH c AS (SELECT id AS event_id) SELECT c.| FROM c", + }, +] as const satisfies readonly SemanticCompletionCase[]; diff --git a/test/corpora/semantic-completion/duckdb.ts b/test/corpora/semantic-completion/duckdb.ts new file mode 100644 index 0000000..dfbaf9b --- /dev/null +++ b/test/corpora/semantic-completion/duckdb.ts @@ -0,0 +1,35 @@ +import type { SemanticCompletionCase } from "./types.js"; + +export const duckDbSemanticCompletion = [ + { + category: "valid", + expectedIncomplete: false, + expectedLabels: ["first_id"], + sql: "SELECT d.| FROM (SELECT id AS first_id) d", + }, + { + category: "invalid", + expectedIncomplete: true, + expectedLabels: ["known_id"], + sql: "SELECT d.| FROM (SELECT id AS known_id, count() +) d", + }, + { + category: "incomplete", + expectedIncomplete: false, + expectedLabels: ["first_id"], + sql: "SELECT d.fi| FROM (SELECT id AS first_id) d", + }, + { + category: "templated", + expectedIncomplete: true, + expectedLabels: ["dynamic_id"], + sql: "SELECT d.| FROM (SELECT {python} AS dynamic_id) d", + template: "{python}", + }, + { + category: "multi-statement", + expectedIncomplete: false, + expectedLabels: ["first_name"], + sql: "SELECT 1; SELECT d.| FROM (SELECT id AS first_name UNION ALL SELECT id AS later_name) d", + }, +] as const satisfies readonly SemanticCompletionCase[]; diff --git a/test/corpora/semantic-completion/postgresql.ts b/test/corpora/semantic-completion/postgresql.ts new file mode 100644 index 0000000..a5b95ca --- /dev/null +++ b/test/corpora/semantic-completion/postgresql.ts @@ -0,0 +1,35 @@ +import type { SemanticCompletionCase } from "./types.js"; + +export const postgresqlSemanticCompletion = [ + { + category: "valid", + expectedIncomplete: false, + expectedLabels: ["user_id"], + sql: "WITH c AS (SELECT id AS user_id) SELECT c.| FROM c", + }, + { + category: "invalid", + expectedIncomplete: true, + expectedLabels: ["safe_name"], + sql: "WITH c AS (SELECT id AS safe_name, upper(name) FROM) SELECT c.| FROM c", + }, + { + category: "incomplete", + expectedIncomplete: false, + expectedLabels: ["user_id"], + sql: "WITH c AS (SELECT id AS user_id) SELECT c.us| FROM c", + }, + { + category: "templated", + expectedIncomplete: true, + expectedLabels: ["computed"], + sql: "WITH c AS (SELECT {python} AS computed) SELECT c.| FROM c", + template: "{python}", + }, + { + category: "multi-statement", + expectedIncomplete: false, + expectedLabels: ["second_id"], + sql: "SELECT 1; WITH c AS (SELECT id AS second_id) SELECT c.| FROM c", + }, +] as const satisfies readonly SemanticCompletionCase[]; diff --git a/test/corpora/semantic-completion/types.ts b/test/corpora/semantic-completion/types.ts new file mode 100644 index 0000000..611e58a --- /dev/null +++ b/test/corpora/semantic-completion/types.ts @@ -0,0 +1,14 @@ +export type SemanticCompletionCategory = + | "incomplete" + | "invalid" + | "multi-statement" + | "templated" + | "valid"; + +export interface SemanticCompletionCase { + readonly category: SemanticCompletionCategory; + readonly expectedIncomplete: boolean; + readonly expectedLabels: readonly string[]; + readonly sql: string; + readonly template?: string; +} diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index 564c035..d7076e7 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -9,23 +9,23 @@ package's dependency. The fixture workspace pins Vite's floating transitive versions to the exact versions in the root lock, so the nested frozen install can run offline after a clean root CI install. -The minified Vite 8 packed-consumer baseline is 40,121 gzip/143,265 raw +The minified Vite 8 packed-consumer baseline is 53,612 gzip/199,957 raw bytes for the complete parser-free core, 67,573 gzip bytes for the PostgreSQL transitive graph, 50,470 gzip bytes for the BigQuery transitive graph, and -150,985 gzip/669,106 raw bytes for the complete worker build output. The core +164,513 gzip/725,798 raw bytes for the complete worker build output. The core measurement includes the four authenticated relation-dialect runtimes, their reserved-word tables, and relation-completion/session orchestration. The PostgreSQL and BigQuery figures each include their transitive shared chunks; the report also identifies those shared chunks explicitly. -The fail-closed ceilings retain approximately 18% gzip and 22% raw headroom -for the core, 8% gzip and 7% raw headroom for the complete worker output, and +The fail-closed ceilings retain approximately 2–3% headroom for the core and +complete worker output, plus the existing tight dialect-graph headroom: -- Complete parser-free core: 48 KiB gzip and 180 KiB raw +- Complete parser-free core: 54 KiB gzip and 200 KiB raw - PostgreSQL transitive graph: 68 KiB gzip - BigQuery transitive graph: 50 KiB gzip -- Complete worker build output: 160 KiB gzip and 700 KiB raw +- Complete worker build output: 164 KiB gzip and 720 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no From fcd18464611f4fd64610f2c6640d7ffd6f119110 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 13:26:53 +0800 Subject: [PATCH 40/42] feat: add bounded SQL language intelligence --- .github/workflows/test.yml | 19 +- README.md | 5 + ...7-language-feature-provider-composition.md | 46 ++ docs/capability-charter.md | 4 +- docs/language-features.md | 85 ++ implementation.md | 4 +- package.json | 1 + scripts/mutation-pilot.mjs | 43 + scripts/package-smoke.mjs | 45 ++ scripts/worker-placement.mjs | 8 +- src/__tests__/language-feature-fuzz.test.ts | 62 ++ .../language-feature-performance.test.ts | 71 ++ .../language-feature-runtime.test.ts | 389 +++++++++ src/__tests__/language-features.test.ts | 523 ++++++++++++ .../relation-catalog-boundary.test.ts | 12 + src/codemirror/__tests__/sql-editor.test.ts | 81 +- src/codemirror/sql-editor.ts | 150 ++++ src/index.ts | 31 + src/language-feature-runtime.ts | 604 ++++++++++++++ src/language-features.ts | 217 +++++ src/relation-catalog-boundary.ts | 1 + src/relation-completion-types.ts | 1 + src/relation-completion.ts | 7 +- src/session.ts | 762 +++++++++++++++++- src/types.ts | 18 +- test/types/language-features.test-d.ts | 62 ++ test/worker-placement/README.md | 16 +- vitest.browser.config.ts | 11 +- 28 files changed, 3251 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0007-language-feature-provider-composition.md create mode 100644 docs/language-features.md create mode 100644 scripts/mutation-pilot.mjs create mode 100644 src/__tests__/language-feature-fuzz.test.ts create mode 100644 src/__tests__/language-feature-performance.test.ts create mode 100644 src/__tests__/language-feature-runtime.test.ts create mode 100644 src/__tests__/language-features.test.ts create mode 100644 src/language-feature-runtime.ts create mode 100644 src/language-features.ts create mode 100644 test/types/language-features.test-d.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b31b752..3b5b3c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,9 @@ jobs: - name: ⚡ Performance Gates run: pnpm run test:performance + - name: 🧬 Mutation Pilot + run: pnpm run test:mutation + - name: 📈 Changed-Code Coverage if: github.event_name == 'pull_request' || github.event_name == 'push' env: @@ -77,6 +80,13 @@ jobs: browser: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: + - chromium + - firefox + - webkit permissions: contents: read @@ -96,13 +106,16 @@ jobs: - name: 📥 Install dependencies run: pnpm install --ignore-scripts --frozen-lockfile - - name: 🎭 Install Chromium - run: pnpm exec playwright install --with-deps chromium + - name: 🎭 Install ${{ matrix.browser }} + run: pnpm exec playwright install --with-deps "${{ matrix.browser }}" - - name: 🧪 Browser Tests + - name: 🧪 Browser Tests (${{ matrix.browser }}) + env: + VITEST_BROWSER: ${{ matrix.browser }} run: pnpm run test:browser - name: 🧵 Worker Placement Evidence + if: matrix.browser == 'chromium' run: pnpm run test:worker-placement package: diff --git a/README.md b/README.md index 18064e7..862c6b7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Published as [`@marimo-team/codemirror-sql`](https://www.npmjs.com/package/@mari - **Schema-aware completion** — complete relations, namespaces, physical columns, inferred CTE/derived outputs, aliases, correlated scopes, set-operation arms, and DML targets - **CodeMirror integration** — cancellation-safe completion, disposable detail panels, atomic context updates, and an optional virtualized statement gutter - **Composable completion** — package results coexist with SQL keywords, functions, embedded-language sources, and host sources +- **Language intelligence** — bounded diagnostics, hover, navigation, rename, + symbols, folding, formatting, and code-action providers share one revision + and cancellation model - **Isolated parsing** — optional browser-worker parser execution kept off the public API surface ## Installation @@ -64,6 +67,8 @@ service.dispose(); Configure relation, column, and namespace providers on the shared service for schema-aware completion. See the [CodeMirror adapter](./docs/codemirror-adapter.md) and [session primitives](./docs/session-primitives.md) for the full contracts. +See [language feature providers](./docs/language-features.md) for native-engine, +LSP, formatter, and host-validation integration. ## Demo diff --git a/docs/adr/0007-language-feature-provider-composition.md b/docs/adr/0007-language-feature-provider-composition.md new file mode 100644 index 0000000..b2fdfe5 --- /dev/null +++ b/docs/adr/0007-language-feature-provider-composition.md @@ -0,0 +1,46 @@ +# ADR 0007: Bounded language-feature provider composition + +Status: accepted +Date: 2026-07-27 + +## Context + +Diagnostics, hover, navigation, rename, formatting, symbols, folding, and code +actions need the same revision, coordinate, cancellation, failure-isolation, +and result-validation rules. Leaving those rules to each host creates stale +results, leaked provider errors, unsafe edits, and incompatible native-engine +and language-server integrations. + +## Decision + +The framework-independent session owns one generic provider composition +runtime. Providers run concurrently under one absolute request deadline and receive +per-provider abort signals. The runtime validates, bounds, freezes, and +normalizes every public result. Collection features compose in configuration +order; scalar features use the first non-empty successful result. Provider ASTs, +errors, transports, and mutable editor objects never cross the boundary. + +Statement symbols and folding have a parser-free local baseline. DOM rendering, +debouncing, lint panels, and tooltip presentation remain host policy. + +This adds 3,695 gzip bytes (about 3.6 KiB) to the complete +framework-independent core. The measured +artifact is 57,307 gzip bytes and 215,639 raw bytes, so the enforced core +ceilings move from 54 KiB/200 KiB to 57 KiB/212 KiB. Optional parser chunks +remain unchanged and independently chunkable. +The worker-placement page includes the core, so its raw aggregate ceiling moves +from 720 KiB to 725 KiB; its parser chunks do not move, while the aggregate +gzip ceiling moves from 164 KiB to 165 KiB. +The increase is accepted because one shared validator is smaller and safer than +duplicating feature-specific boundaries in every consumer. + +## Consequences + +- Native engines, LSP clients, host validators, and formatters use one stable + plain-data contract. +- A slow or failed provider cannot suppress unrelated provider evidence. +- Cancellation and disposal settle without waiting for provider cooperation. +- Synchronous provider time counts against the deadline but cannot be + preempted; CPU-heavy integrations must run off-thread. +- Core size remains measured in CI with 1,061 gzip bytes and 1,449 raw bytes + of headroom at acceptance. diff --git a/docs/capability-charter.md b/docs/capability-charter.md index 8e1614e..b899dac 100644 --- a/docs/capability-charter.md +++ b/docs/capability-charter.md @@ -238,8 +238,8 @@ count and estimated retained bytes. Provisional compressed bundle budgets: -- Framework-independent core with relation, namespace, column, and local query - output completion: 54 KiB +- Framework-independent core with completion and the validated language-feature + provider runtime: 57 KiB gzip / 212 KiB raw - Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB - Each ordinary dialect module: 25 KiB - Optional `node-sql-parser` chunk: no regression from its recorded baseline diff --git a/docs/language-features.md b/docs/language-features.md new file mode 100644 index 0000000..6157649 --- /dev/null +++ b/docs/language-features.md @@ -0,0 +1,85 @@ +# Language feature providers + +The language service exposes diagnostics, hover, definition, references, +highlights, rename, document symbols, folding, formatting, and code actions +through the same document session used for completion. + +```ts +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ + id: "duckdb", + diagnostics: async ({ document, signal }) => { + const response = await validateSql(document.text, { signal }); + return response.diagnostics; + }, + hover: ({ document, request }) => + describeSqlAt(document.text, request.position), + format: ({ document, request }) => ({ + changes: [{ + from: request.range?.from ?? 0, + to: request.range?.to ?? document.text.length, + insert: formatSql(document.text, request), + }], + }), + }], +}); + +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "select * form users", +}); + +const task = session.diagnostics(); +const result = await task.result; +if (result.status === "ready" && session.isCurrent(result.revision)) { + renderDiagnostics(result.value); +} +``` + +Every provider receives a frozen document snapshot, a normalized request, and +an `AbortSignal`. Public ranges are absolute, half-open UTF-16 ranges in the +original document. Results are copied, bounded, validated, and frozen before +publication. Provider exceptions, rejections, timeouts, malformed ranges, and +malformed edits do not escape the service boundary. + +Providers run concurrently. `featureProviderBudgetMs` is one absolute +per-request deadline, not a budget multiplied by the provider count. Elapsed +synchronous invocation time counts against that deadline, but JavaScript +cannot preempt a provider that blocks the current thread. Expensive or +untrusted integrations must yield immediately and continue asynchronously in a +worker, process, or remote service. Cancellation, session updates, catalog +invalidation, and disposal stop publication promptly once control returns, +even when an underlying provider ignores its signal. + +Collection features compose successful providers in configuration order. +Scalar features such as hover, rename, and format select the first successful +non-empty result. `isIncomplete` explicitly records bounded collection +truncation. Source reports remain available on ready and unavailable results, +preserving which providers were ready, failed, or timed out. Formatting is +explicit; typing never invokes a formatter. + +The built-in local structure provider supplies statement-level document +symbols and multiline folding without loading an optional parser. Parser, +native-engine, language-server, host-policy, and formatter integrations remain +ordinary providers and do not expose their AST or transport types. + +`sqlEditor` exposes the same tasks through its returned support object: + +```ts +const support = sqlEditor({ initialContext, service }); +const diagnostics = support.diagnostics(view); +const hover = support.hover(view, { position: view.state.selection.main.head }); +``` + +The host owns presentation and scheduling. This keeps the core SSR-safe and +allows applications to use CodeMirror lint, tooltip, panel, or custom UI +extensions without the SQL package imposing DOM rendering or debounce policy. + +Function, scalar parameter, and snippet completion use +`autocomplete.externalSources`, which accepts ordinary CodeMirror +`CompletionSource` values and therefore preserves snippet application and +parameter syntax without translating them through a lossy catalog shape. +Table-valued functions can also be returned by relation catalogs with +`relationKind: "table-function"`; the CodeMirror adapter presents them as +functions at relation sites. diff --git a/implementation.md b/implementation.md index f887a83..bb59caa 100644 --- a/implementation.md +++ b/implementation.md @@ -1,6 +1,6 @@ # SQL editor completion plan -Status: active +Status: complete Updated: 2026-07-27 The standard language-service overhaul is delivered by PR #204. Two additional @@ -32,6 +32,8 @@ columns. ## PR 2: language intelligence and release hardening +Status: complete + The final PR adds the remaining feature methods and provider composition: - syntax, semantic, and host diagnostics; diff --git a/package.json b/package.json index e830a86..12bc60b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "test:performance": "vitest run --config vitest.performance.config.ts", + "test:mutation": "node ./scripts/mutation-pilot.mjs", "bench:catalog-boundary": "vitest bench --run src/__tests__/relation-catalog-boundary.bench.ts", "bench:catalog-coordinator": "vitest bench --run src/__tests__/relation-catalog-epoch-coordinator.bench.ts", "bench:editor": "vitest bench --run src/codemirror/__tests__/sql-editor.bench.ts", diff --git a/scripts/mutation-pilot.mjs b/scripts/mutation-pilot.mjs new file mode 100644 index 0000000..582a4fb --- /dev/null +++ b/scripts/mutation-pilot.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = resolve(repository, "src", "language-feature-runtime.ts"); +const original = readFileSync(target, "utf8"); +const before = "value.length > MAX_SQL_FEATURE_RESULTS"; +const after = "value.length >= MAX_SQL_FEATURE_RESULTS"; +if (original.split(before).length !== 2) { + throw new Error("Mutation pilot target is missing or ambiguous"); +} + +let outcome; +try { + writeFileSync(target, original.replace(before, after)); + outcome = spawnSync( + process.execPath, + [ + resolve(repository, "node_modules", "vitest", "vitest.mjs"), + "run", + "--config", + "vitest.config.ts", + "src/__tests__/language-feature-runtime.test.ts", + ], + { + cwd: repository, + encoding: "utf8", + stdio: "pipe", + }, + ); +} finally { + writeFileSync(target, original); +} + +if (outcome.status === 0) { + throw new Error( + "Mutation survived: the feature result limit boundary is not tested", + ); +} +if (outcome.error) throw outcome.error; +process.stdout.write("Mutation killed: feature result limit boundary\n"); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index fbd9a8e..e93614a 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -239,6 +239,18 @@ const cleanup: SqlCatalogSubscriptionCleanup = () => undefined; const service = createSqlLanguageService({ dialects: [duckdbDialect()], + featureProviders: [{ + id: "marimo-duckdb", + diagnostics: ({ document }) => document.context.engine === "remote" + ? [{ + from: 0, + message: "Remote validation", + severity: "information", + source: "duckdb", + to: 6, + }] + : [], + }], }); const editorSupport = sqlEditor({ initialContext: { dialect: "duckdb", engine: "local" }, @@ -262,12 +274,14 @@ const statement: SqlStatementBoundaryAtResult = session.statementBoundaryAt({ affinity: "left", position: 0, }); +const diagnostics = session.diagnostics(); void editorSupport.extension; void cleanup; void range; void session; void statement; +void diagnostics; `, ); @@ -337,6 +351,37 @@ if ( throw new Error("The packaged statement boundary is invalid"); } service.dispose(); + +const marimoService = api.createSqlLanguageService({ + dialects: [api.duckdbDialect()], + featureProviders: [{ + id: "marimo-duckdb", + diagnostics: ({ document }) => [{ + from: 0, + message: \`Validated \${document.context.engine}\`, + severity: "information", + source: "duckdb", + to: 6, + }], + }], +}); +const marimoSession = marimoService.openDocument({ + context: { dialect: "duckdb", engine: "remote" }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", +}); +const diagnostics = await marimoSession.diagnostics().result; +const symbols = await marimoSession.documentSymbols().result; +if ( + diagnostics.status !== "ready" || + diagnostics.value[0]?.message !== "Validated remote" || + symbols.status !== "ready" || + symbols.value[0]?.name !== "SELECT statement" +) { + throw new Error("The packed marimo language-feature fixture failed"); +} +marimoSession.dispose(); +marimoService.dispose(); `, ); diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 353ed8e..4d59f26 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,11 +35,11 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_GZIP_LIMIT = 54 * 1024; -const CORE_TOTAL_RAW_LIMIT = 200 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 57 * 1024; +const CORE_TOTAL_RAW_LIMIT = 212 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 164 * 1024; -const WORKER_TOTAL_RAW_LIMIT = 720 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 165 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 725 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], diff --git a/src/__tests__/language-feature-fuzz.test.ts b/src/__tests__/language-feature-fuzz.test.ts new file mode 100644 index 0000000..8437060 --- /dev/null +++ b/src/__tests__/language-feature-fuzz.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../index.js"; + +function generator(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +describe("deterministic language feature fuzzing", () => { + test("keeps local structure ranges valid across hostile incomplete text", async () => { + const random = generator(0x5eed_2026); + const fragments = [ + "select", " from ", "(", ")", ";", "\n", "/*", "*/", "'", + "\"", "-- comment\n", "{python}", "with x as (", " union all ", + ]; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + for (let iteration = 0; iteration < 250; iteration += 1) { + let text = ""; + const count = 1 + Math.floor(random() * 30); + for (let index = 0; index < count; index += 1) { + text += fragments[Math.floor(random() * fragments.length)] ?? ""; + } + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + const [symbols, folds] = await Promise.all([ + session.documentSymbols().result, + session.foldingRanges().result, + ]); + expect(symbols.status).toBe("ready"); + expect(folds.status).toBe("ready"); + if (symbols.status === "ready") { + for (const symbol of symbols.value) { + expect(symbol.range.from).toBeGreaterThanOrEqual(0); + expect(symbol.range.to).toBeLessThanOrEqual(text.length); + expect(symbol.selectionRange.from).toBeGreaterThanOrEqual( + symbol.range.from, + ); + expect(symbol.selectionRange.to).toBeLessThanOrEqual(symbol.range.to); + } + } + if (folds.status === "ready") { + for (const fold of folds.value) { + expect(fold.from).toBeGreaterThanOrEqual(0); + expect(fold.to).toBeLessThanOrEqual(text.length); + expect(fold.from).toBeLessThanOrEqual(fold.to); + } + } + session.dispose(); + } + service.dispose(); + }); +}); diff --git a/src/__tests__/language-feature-performance.test.ts b/src/__tests__/language-feature-performance.test.ts new file mode 100644 index 0000000..297e175 --- /dev/null +++ b/src/__tests__/language-feature-performance.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../index.js"; + +function percentile95(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? + Number.POSITIVE_INFINITY; +} + +describe("language feature performance gates", () => { + it("keeps warm local structure analysis below 150 ms p95", async () => { + const statement = "select id, name\nfrom users\nwhere active = true;\n"; + const text = statement.repeat(Math.ceil(10_240 / statement.length)) + .slice(0, 10_240); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + await session.documentSymbols().result; + const samples: number[] = []; + for (let index = 0; index < 20; index += 1) { + const started = performance.now(); + const result = await session.documentSymbols().result; + expect(result.status).toBe("ready"); + samples.push(performance.now() - started); + } + expect(percentile95(samples)).toBeLessThan(150); + session.dispose(); + service.dispose(); + }); + + it("aborts feature work for fifty disposed sessions", async () => { + const signals: AbortSignal[] = []; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: 1_000, + featureProviders: [{ + id: "pending-engine", + diagnostics: ({ signal }) => { + signals.push(signal); + return new Promise(() => {}); + }, + }], + }); + const sessions = Array.from({ length: 50 }, () => + service.openDocument({ + context: { dialect: "duckdb" }, + text: "select 1", + })); + const tasks = sessions.map((session) => session.diagnostics().result); + await Promise.resolve(); + for (const session of sessions) session.dispose(); + const results = await Promise.all(tasks); + + expect(signals).toHaveLength(50); + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect( + results.every( + (result) => + result.status === "cancelled" && result.reason === "disposed", + ), + ).toBe(true); + service.dispose(); + }); +}); diff --git a/src/__tests__/language-feature-runtime.test.ts b/src/__tests__/language-feature-runtime.test.ts new file mode 100644 index 0000000..41ecf12 --- /dev/null +++ b/src/__tests__/language-feature-runtime.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from "vitest"; +import { + captureSqlLanguageFeatureProviders, + composeSqlCodeActionResults, + createSqlFeatureDocument, + invokeSqlFeatureProviders, + normalizeSqlCodeActions, + normalizeSqlDiagnostics, + normalizeSqlDocumentEdit, + normalizeSqlDocumentSymbols, + normalizeSqlFoldingRanges, + normalizeSqlHover, + normalizeSqlLocations, + normalizeSqlRanges, +} from "../language-feature-runtime.js"; + +describe("language feature runtime boundaries", () => { + test("captures only bounded, unique, data-only providers", () => { + expect(captureSqlLanguageFeatureProviders(undefined)).toEqual([]); + expect(captureSqlLanguageFeatureProviders([ + { id: "one", hover: () => null }, + ])).toHaveLength(1); + for (const candidate of [ + null, + {}, + { id: "" }, + { id: "x".repeat(257) }, + { diagnostics: () => [] }, + { diagnostics: 1, id: "bad-method" }, + ]) { + expect(() => captureSqlLanguageFeatureProviders([candidate])).toThrow(); + } + expect(() => captureSqlLanguageFeatureProviders([ + { id: "same" }, + { id: "same" }, + ])).toThrow(); + expect(() => captureSqlLanguageFeatureProviders({})).toThrow(); + expect(() => captureSqlLanguageFeatureProviders( + Array.from({ length: 65 }, (_, index) => ({ id: String(index) })), + )).toThrow(); + const accessor = {}; + Object.defineProperty(accessor, "id", { get: () => "unsafe" }); + expect(() => captureSqlLanguageFeatureProviders([accessor])).toThrow(); + }); + + test("normalizes all range-bearing result families", () => { + expect(normalizeSqlDiagnostics([ + { + code: "E1", + from: 0, + message: "error", + severity: "error", + source: "syntax", + to: 1, + }, + { + from: 1, + message: "warning", + severity: "warning", + source: "host", + to: 2, + }, + { + from: 2, + message: "info", + severity: "information", + source: "host", + to: 3, + }, + { + from: 3, + message: "hint", + severity: "hint", + source: "host", + to: 4, + }, + ], 4)).toHaveLength(4); + expect(normalizeSqlLocations([ + { range: { from: 0, to: 1 } }, + { range: { from: 1_000, to: 1_010 }, uri: "file:///query.sql" }, + ], 2)).toHaveLength(2); + expect(normalizeSqlRanges([{ from: 0, to: 0 }], 1)).toEqual([ + { from: 0, to: 0 }, + ]); + expect(normalizeSqlRanges( + Array.from({ length: 1_000 }, () => ({ from: 0, to: 0 })), + 1, + )).toHaveLength(1_000); + for (const candidate of [ + null, + {}, + Array.from({ length: 1_001 }, () => ({})), + ]) { + expect(() => normalizeSqlRanges(candidate, 1)).toThrow(); + } + expect(() => normalizeSqlDiagnostics([{ + from: 0, + message: "bad", + severity: "fatal", + source: "host", + to: 1, + }], 1)).toThrow(); + expect(() => normalizeSqlLocations([ + { range: { from: 0, to: 1 }, uri: "" }, + ], 1)).toThrow(); + }); + + test("normalizes hover, symbols, and folding", () => { + expect(normalizeSqlHover(null, 1)).toBeNull(); + expect(normalizeSqlHover({ + contents: { kind: "plaintext", value: "value" }, + range: { from: 0, to: 1 }, + }, 1)).toMatchObject({ contents: { kind: "plaintext" } }); + expect(normalizeSqlHover({ + contents: { kind: "markdown", value: "**value**" }, + range: { from: 0, to: 1 }, + }, 1)).toMatchObject({ contents: { kind: "markdown" } }); + expect(() => normalizeSqlHover({ + contents: { kind: "html", value: "unsafe" }, + range: { from: 0, to: 1 }, + }, 1)).toThrow(); + expect(() => normalizeSqlHover(1, 1)).toThrow(); + expect(() => normalizeSqlHover({}, 1)).toThrow(); + + const kinds = ["statement", "relation", "column", "function", "parameter"] as const; + expect(normalizeSqlDocumentSymbols(kinds.map((kind) => ({ + detail: `${kind} detail`, + kind, + name: kind, + range: { from: 0, to: 2 }, + selectionRange: { from: 0, to: 1 }, + })), 2)).toHaveLength(5); + expect(() => normalizeSqlDocumentSymbols([{ + kind: "unknown", + name: "x", + range: { from: 0, to: 1 }, + selectionRange: { from: 0, to: 1 }, + }], 1)).toThrow(); + expect(() => normalizeSqlDocumentSymbols([{ + kind: "relation", + name: "x", + range: { from: 1, to: 2 }, + selectionRange: { from: 0, to: 1 }, + }], 2)).toThrow(); + + expect(normalizeSqlFoldingRanges([ + { from: 0, kind: "statement", to: 1 }, + { from: 1, kind: "region", to: 2 }, + { from: 2, kind: "comment", to: 3 }, + { from: 3, to: 4 }, + ], 4)).toHaveLength(4); + expect(() => normalizeSqlFoldingRanges([ + { from: 0, kind: "custom", to: 1 }, + ], 1)).toThrow(); + }); + + test("normalizes edits and code actions atomically", () => { + expect(normalizeSqlDocumentEdit(null, 2)).toBeNull(); + expect(normalizeSqlDocumentEdit({ + changes: [ + { from: 0, insert: "A", to: 1 }, + { from: 1, insert: "", to: 2 }, + ], + }, 2)).toMatchObject({ changes: [{ insert: "A" }, { insert: "" }] }); + expect(() => normalizeSqlDocumentEdit({ + changes: [ + { from: 1, insert: "A", to: 2 }, + { from: 0, insert: "B", to: 1 }, + ], + }, 2)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [{ from: 0, insert: 1, to: 1 }], + }, 1)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [ + { from: 0, insert: "x".repeat(16 * 1024 * 1024), to: 0 }, + { from: 0, insert: "x", to: 0 }, + ], + }, 0)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [{ from: 0, insert: "xx", to: 0 }], + }, 16 * 1024 * 1024 - 1)).toThrow(); + + expect(normalizeSqlCodeActions([ + { kind: "quickfix", title: "Fix" }, + { + diagnostics: ["E1"], + edit: { changes: [] }, + kind: "refactor", + title: "Refactor", + }, + { kind: "source", title: "Source" }, + { edit: null, title: "Null edit" }, + { title: "Other" }, + ], 1)).toHaveLength(5); + expect(() => normalizeSqlCodeActions([ + { kind: "unsafe", title: "Bad" }, + ], 1)).toThrow(); + const nested = Array.from({ length: 11 }, (_, index) => ({ + diagnostics: Array.from( + { length: 1_000 }, + (__, diagnostic) => `${index}:${diagnostic}`, + ), + title: `Action ${index}`, + })); + expect(() => normalizeSqlCodeActions(nested, 1)).toThrow(); + const action = Object.freeze({ + diagnostics: Object.freeze( + Array.from({ length: 1_000 }, (_, index) => `E${index}`), + ), + title: "Nested action", + }); + const composed = composeSqlCodeActionResults([ + Array.from({ length: 6 }, () => action), + Array.from({ length: 6 }, () => action), + ]); + expect(composed.isIncomplete).toBe(true); + expect(composed.value).toHaveLength(10); + }); + + test("isolates concurrent invocation outcomes in provider order", async () => { + let timedOutSignal: AbortSignal | undefined; + const providers = captureSqlLanguageFeatureProviders([ + { diagnostics: () => [1], id: "ready" }, + { diagnostics: () => { + throw new Error("private"); + }, id: "throw" }, + { id: "absent" }, + { + diagnostics: ({ signal }: { readonly signal: AbortSignal }) => { + timedOutSignal = signal; + return new Promise(() => {}); + }, + id: "timeout", + }, + ]); + const document = createSqlFeatureDocument( + "select", + { dialect: "duckdb" }, + [], + ); + const controller = new AbortController(); + const result = await invokeSqlFeatureProviders( + providers, + document, + {}, + controller.signal, + 5, + (provider) => provider.diagnostics !== undefined, + (provider, currentDocument, request, signal) => + provider.diagnostics?.({ + document: currentDocument, + request, + signal, + }), + (value) => value, + ); + + expect(result.reports).toEqual([ + { outcome: "ready", providerId: "ready" }, + { outcome: "failed", providerId: "throw" }, + { outcome: "timed-out", providerId: "timeout" }, + ]); + expect(result.values).toEqual([[1]]); + expect(timedOutSignal?.aborted).toBe(true); + expect(Object.isFrozen(document)).toBe(true); + }); + + test("drains ignored work while aborting publication promptly", async () => { + const providers = captureSqlLanguageFeatureProviders([ + { + diagnostics: () => new Promise(() => {}), + id: "pending", + }, + ]); + const controller = new AbortController(); + const result = invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + controller.signal, + 1_000, + (provider) => provider.diagnostics !== undefined, + (provider, document, request, signal) => + provider.diagnostics?.({ document, request, signal }), + (value) => value, + ); + controller.abort(); + await expect(result).resolves.toEqual({ reports: [], values: [] }); + + const alreadyAborted = new AbortController(); + alreadyAborted.abort(); + await expect(invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + alreadyAborted.signal, + 1_000, + () => true, + () => [], + (value) => value, + )).resolves.toEqual({ reports: [], values: [] }); + + const reentrant = new AbortController(); + await expect(invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + reentrant.signal, + 1_000, + () => true, + () => { + reentrant.abort(); + return new Promise(() => {}); + }, + (value) => value, + )).resolves.toEqual({ reports: [], values: [] }); + }); + + test("accounts for synchronous provider time in one request deadline", async () => { + let laterCalls = 0; + const providers = captureSqlLanguageFeatureProviders([ + { + diagnostics: () => { + const deadline = performance.now() + 10; + while (performance.now() < deadline) { + // Deliberately model a badly behaved synchronous integration. + } + return []; + }, + id: "blocking", + }, + { + diagnostics: () => { + laterCalls += 1; + return []; + }, + id: "later", + }, + ]); + const result = await invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + new AbortController().signal, + 1, + (provider) => provider.diagnostics !== undefined, + (provider, document, request, signal) => + provider.diagnostics?.({ document, request, signal }), + (value) => value, + ); + expect(result).toEqual({ + reports: [ + { outcome: "timed-out", providerId: "blocking" }, + { outcome: "timed-out", providerId: "later" }, + ], + values: [], + }); + expect(laterCalls).toBe(0); + }); + + test("isolates provider mutation during capability inspection", async () => { + const mutable = { + diagnostics: () => [], + id: "mutable", + }; + const providers = captureSqlLanguageFeatureProviders([mutable]); + Object.defineProperty(mutable, "diagnostics", { + configurable: true, + get: () => { + throw new Error("private accessor failure"); + }, + }); + const result = await invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + new AbortController().signal, + 10, + (provider) => provider.diagnostics !== undefined, + () => [], + (value) => value, + ); + expect(result).toEqual({ + reports: [{ outcome: "failed", providerId: "mutable" }], + values: [], + }); + }); +}); diff --git a/src/__tests__/language-features.test.ts b/src/__tests__/language-features.test.ts new file mode 100644 index 0000000..dfdd6c3 --- /dev/null +++ b/src/__tests__/language-features.test.ts @@ -0,0 +1,523 @@ +import { describe, expect, test, vi } from "vitest"; +import { + bigQueryDialect, + createSqlLanguageService, + duckdbDialect, + SqlSessionError, + type SqlDocumentContext, + type SqlLanguageFeatureProvider, +} from "../index.js"; + +function open( + text: string, + providers: readonly SqlLanguageFeatureProvider[] = [], + budget = 150, +) { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + featureProviders: providers, + }); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + return { service, session }; +} + +describe("language feature sessions", () => { + test("provides bounded local statement symbols and folds", async () => { + const { service, session } = open( + "select\n 1;\n\ninsert into t\nvalues (1)", + ); + + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + value: [ + { + kind: "statement", + name: "SELECT statement", + selectionRange: { from: 0, to: 6 }, + }, + { + kind: "statement", + name: "INSERT statement", + }, + ], + }); + await expect(session.foldingRanges().result).resolves.toMatchObject({ + status: "ready", + value: [ + { from: 0, kind: "statement", to: 10 }, + { from: 13, kind: "statement", to: 37 }, + ], + }); + session.dispose(); + service.dispose(); + }); + + test("composes diagnostics while preserving provider evidence", async () => { + const providers: readonly SqlLanguageFeatureProvider[] = [ + { + id: "syntax", + diagnostics: () => [{ + from: 0, + message: "Unexpected token", + severity: "error", + source: "parser", + to: 6, + }], + }, + { + id: "host", + diagnostics: () => [{ + code: "policy", + from: 7, + message: "Use a qualified relation", + severity: "warning", + source: "host", + to: 8, + }], + }, + ]; + const { service, session } = open("select t", providers); + + const result = await session.diagnostics().result; + + expect(result).toMatchObject({ + sources: [ + { outcome: "ready", providerId: "syntax" }, + { outcome: "ready", providerId: "host" }, + ], + status: "ready", + }); + if (result.status === "ready") { + expect(result.value).toHaveLength(2); + expect(result.isIncomplete).toBe(false); + expect(Object.isFrozen(result.value)).toBe(true); + expect(Object.isFrozen(result.value[0])).toBe(true); + } + session.dispose(); + service.dispose(); + }); + + test("supports every scalar and collection feature contract", async () => { + const provider: SqlLanguageFeatureProvider = { + id: "engine", + codeActions: () => [{ + edit: { changes: [{ from: 0, insert: "SELECT", to: 6 }] }, + kind: "quickfix", + title: "Uppercase SELECT", + }], + definitions: () => [{ range: { from: 7, to: 8 } }], + format: () => ({ + changes: [{ from: 0, insert: "SELECT a", to: 8 }], + }), + highlights: () => [{ from: 7, to: 8 }], + hover: () => ({ + contents: { kind: "markdown", value: "`a`: integer" }, + range: { from: 7, to: 8 }, + }), + references: () => [ + { range: { from: 7, to: 8 } }, + { range: { from: 17, to: 18 }, uri: "file:///query.sql" }, + ], + rename: ({ request }) => ({ + changes: [ + { from: 7, insert: request.newName, to: 8 }, + { from: 17, insert: request.newName, to: 18 }, + ], + }), + }; + const { service, session } = open( + "select a from t, a", + [provider], + ); + + await expect(session.hover({ position: 7 }).result).resolves.toMatchObject({ + status: "ready", + value: { range: { from: 7, to: 8 } }, + }); + await expect( + session.definitions({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{ range: { from: 7 } }] }); + await expect( + session.references({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{}, {}] }); + await expect( + session.highlights({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{ from: 7 }] }); + await expect( + session.rename({ newName: "answer", position: 7 }).result, + ).resolves.toMatchObject({ + status: "ready", + value: { changes: [{ insert: "answer" }, { insert: "answer" }] }, + }); + await expect(session.format({ tabSize: 2 }).result).resolves.toMatchObject({ + status: "ready", + value: { changes: [{ insert: "SELECT a" }] }, + }); + await expect( + session.codeActions({ range: { from: 0, to: 6 } }).result, + ).resolves.toMatchObject({ + status: "ready", + value: [{ kind: "quickfix", title: "Uppercase SELECT" }], + }); + session.dispose(); + service.dispose(); + }); + + test("isolates provider failures and malformed results", async () => { + const malformed = { + id: "malformed", + diagnostics: () => [{ + from: -1, + message: "bad", + severity: "error", + source: "bad", + to: 1, + }], + } satisfies SqlLanguageFeatureProvider; + const rejected = { + id: "rejected", + diagnostics: () => Promise.reject(new Error("private detail")), + } satisfies SqlLanguageFeatureProvider; + const { service, session } = open("select 1", [malformed, rejected]); + + await expect(session.diagnostics().result).resolves.toEqual({ + reason: "no-result", + revision: session.revision, + sources: [ + { outcome: "failed", providerId: "malformed" }, + { outcome: "failed", providerId: "rejected" }, + ], + status: "unavailable", + }); + session.dispose(); + service.dispose(); + }); + + test("cancels promptly on callers, updates, and disposal", async () => { + const pending = vi.fn(() => new Promise(() => {})); + const { service, session } = open( + "select 1", + [{ id: "remote", hover: pending }], + 25, + ); + const controller = new AbortController(); + const caller = session.hover({ + position: 1, + signal: controller.signal, + }); + controller.abort(); + await expect(caller.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + + const superseded = session.hover({ position: 1 }); + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "select 2" }, + embeddedRegions: [], + }); + await expect(superseded.result).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + + const disposed = session.hover({ position: 1 }); + session.dispose(); + await expect(disposed.result).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + service.dispose(); + }); + + test("runs independent providers concurrently under one latency envelope", async () => { + const provider = (id: string): SqlLanguageFeatureProvider => ({ + id, + diagnostics: () => new Promise(() => {}), + }); + const { service, session } = open( + "select 1", + [provider("one"), provider("two"), provider("three")], + 20, + ); + const started = performance.now(); + + const result = await session.diagnostics().result; + + expect(performance.now() - started).toBeLessThan(75); + expect(result).toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + session.dispose(); + service.dispose(); + }); + + test("rejects unsafe request and provider shapes atomically", () => { + const { service, session } = open("select 1"); + expect(() => session.hover({ position: -1 })).toThrowError( + SqlSessionError, + ); + expect(() => session.rename({ newName: "", position: 1 })).toThrowError( + SqlSessionError, + ); + expect(() => session.format({ tabSize: 0 })).toThrowError(SqlSessionError); + session.dispose(); + service.dispose(); + + expect(() => createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [ + { id: "same" }, + { id: "same" }, + ], + })).toThrowError(SqlSessionError); + }); + + test("normalizes the complete request surface", async () => { + const controller = new AbortController(); + const { service, session } = open("select 1", [{ + id: "empty", + codeActions: () => [], + diagnostics: () => [], + format: () => null, + hover: () => null, + rename: () => null, + }]); + await expect(session.diagnostics({ + range: undefined, + signal: controller.signal, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.diagnostics({ + range: { from: 0, to: 6 }, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.codeActions({ + range: { from: 0, to: 6 }, + signal: controller.signal, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.format({ + range: { from: 0, to: 6 }, + tabSize: 16, + useTabs: true, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + await expect(session.format({ + tabSize: 1, + useTabs: false, + }).result).resolves.toMatchObject({ status: "unavailable" }); + await expect(session.hover({ + position: 1, + signal: controller.signal, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + await expect(session.rename({ + newName: "x", + position: 1, + signal: controller.signal, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + + for (const invoke of [ + () => Reflect.apply(session.hover, session, [null]), + () => Reflect.apply(session.hover, session, [{ position: Number.NaN }]), + () => Reflect.apply(session.hover, session, [{ position: 9 }]), + () => Reflect.apply(session.hover, session, [{ position: 1, signal: "bad" }]), + () => Reflect.apply(session.codeActions, session, [null]), + () => Reflect.apply(session.codeActions, session, [{}]), + () => Reflect.apply(session.codeActions, session, [{ range: { from: 2, to: 1 } }]), + () => Reflect.apply(session.diagnostics, session, [null]), + () => Reflect.apply(session.diagnostics, session, [{ range: null }]), + () => Reflect.apply(session.rename, session, [{ newName: 1, position: 1 }]), + () => Reflect.apply(session.rename, session, [{ newName: "x".repeat(1_025), position: 1 }]), + () => Reflect.apply(session.format, session, [{ tabSize: Number.NaN }]), + () => Reflect.apply(session.format, session, [{ tabSize: 17 }]), + () => Reflect.apply(session.format, session, [{ tabSize: "2" }]), + () => Reflect.apply(session.format, session, [{ useTabs: "yes" }]), + ]) { + expect(invoke).toThrowError(SqlSessionError); + } + const positionAccessor = {}; + Object.defineProperty(positionAccessor, "position", { + get: () => { + throw new Error("unsafe"); + }, + }); + const rangeAccessor = {}; + Object.defineProperty(rangeAccessor, "range", { + get: () => { + throw new Error("unsafe"); + }, + }); + expect(() => Reflect.apply( + session.hover, + session, + [positionAccessor], + )).toThrowError(SqlSessionError); + expect(() => Reflect.apply( + session.codeActions, + session, + [rangeAccessor], + )).toThrowError(SqlSessionError); + expect(() => Reflect.apply( + session.diagnostics, + session, + [rangeAccessor], + )).toThrowError(SqlSessionError); + session.dispose(); + expect(() => session.diagnostics()).toThrowError(SqlSessionError); + service.dispose(); + }); + + test("clamps composed collection results and masks embedded regions", async () => { + const diagnostics = Array.from({ length: 1_000 }, (_, index) => ({ + from: index % 2, + message: `message ${index}`, + severity: "hint" as const, + source: "host", + to: index % 2, + })); + const { service, session } = open("s{py}\nelect 1", [ + { diagnostics: () => diagnostics, id: "one" }, + { diagnostics: () => diagnostics, id: "two" }, + ]); + const result = await session.diagnostics().result; + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.value).toHaveLength(1_000); + expect(result.isIncomplete).toBe(true); + } + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "s{py}\nelect 2" }, + embeddedRegions: [{ from: 1, language: "python", to: 5 }], + }); + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + }); + session.dispose(); + service.dispose(); + }); + + test("accepts budget boundaries and rejects every invalid budget class", () => { + const defaults = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: undefined, + featureProviders: undefined, + }); + defaults.dispose(); + for (const budget of [0, 5_000]) { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + }); + service.dispose(); + } + for (const budget of [-1, 5_001, Number.POSITIVE_INFINITY, "10"]) { + expect(() => Reflect.apply(createSqlLanguageService, undefined, [{ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + }])).toThrowError(SqlSessionError); + } + expect(() => createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ id: "@marimo/local-structure" }], + })).toThrowError(SqlSessionError); + }); + + test("makes repeated cancellation idempotent", async () => { + const { service, session } = open("select", [{ + id: "pending", + hover: () => new Promise(() => {}), + }], 1_000); + const task = session.hover({ position: 1 }); + task.cancel(); + task.cancel(); + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "select 2" }, + embeddedRegions: [], + }); + await expect(task.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + session.dispose(); + service.dispose(); + }); + + test("bounds local structure output and handles trivia-only statements", async () => { + const statement = "select\n1;"; + const text = `; 123;\n${statement.repeat(1_001)}`; + const { service, session } = open(text); + const symbols = await session.documentSymbols().result; + const folds = await session.foldingRanges().result; + expect(symbols.status).toBe("ready"); + expect(folds.status).toBe("ready"); + if (symbols.status === "ready") { + expect(symbols.value).toHaveLength(1_000); + expect(symbols.value[0]?.name).toBe("SQL statement"); + } + if (folds.status === "ready") { + expect(folds.value).toHaveLength(1_000); + } + session.dispose(); + service.dispose(); + }); + + test("does not invent structure inside opaque procedural blocks", async () => { + const service = createSqlLanguageService({ + dialects: [bigQueryDialect()], + }); + const session = service.openDocument({ + context: { dialect: "bigquery" }, + text: "IF condition THEN SELECT 1; END IF;", + }); + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + value: [], + }); + await expect(session.foldingRanges().result).resolves.toMatchObject({ + status: "ready", + value: [], + }); + session.dispose(); + service.dispose(); + }); + + test("uses one provider-owned cancellation signal at maximum fan-out", async () => { + const providerSignals: AbortSignal[] = []; + const requestHasSignal: boolean[] = []; + const providers = Array.from({ length: 64 }, (_, index) => ({ + id: `provider-${index}`, + hover: ({ request, signal }) => { + requestHasSignal.push("signal" in request); + providerSignals.push(signal); + return new Promise(() => {}); + }, + } satisfies SqlLanguageFeatureProvider)); + const { service, session } = open("select", providers, 1_000); + const task = session.hover({ position: 1 }); + await Promise.resolve(); + task.cancel(); + await expect(task.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + expect(providerSignals).toHaveLength(64); + expect(providerSignals.every((signal) => signal.aborted)).toBe(true); + expect(requestHasSignal.every((present) => !present)).toBe(true); + session.dispose(); + service.dispose(); + }); +}); diff --git a/src/__tests__/relation-catalog-boundary.test.ts b/src/__tests__/relation-catalog-boundary.test.ts index 0e610bc..80b7829 100644 --- a/src/__tests__/relation-catalog-boundary.test.ts +++ b/src/__tests__/relation-catalog-boundary.test.ts @@ -1046,6 +1046,18 @@ describe("catalog response decoding", () => { } }); + it("accepts table-valued functions as relation-site entities", () => { + const value = accepted(decodeSqlCatalogSearchResponse( + readyResponse([{ ...relation(), relationKind: "table-function" }]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + )); + expect(value).toMatchObject({ + relations: [{ relationKind: "table-function" }], + status: "ready", + }); + }); + it("rejects present undefined detail and enforces relation bounds", () => { expectMalformed( decodeSqlCatalogSearchResponse( diff --git a/src/codemirror/__tests__/sql-editor.test.ts b/src/codemirror/__tests__/sql-editor.test.ts index d80cce6..7be6e11 100644 --- a/src/codemirror/__tests__/sql-editor.test.ts +++ b/src/codemirror/__tests__/sql-editor.test.ts @@ -25,6 +25,8 @@ import { type SqlDocumentContext, type SqlDocumentSession, type SqlDocumentUpdate, + type SqlFeatureTask, + type SqlLanguageFeatureMethods, type SqlLanguageService, type SqlRelationCatalogProvider, type SqlRevision, @@ -95,7 +97,7 @@ function relationResponse( function completionItem( from = 14, to = 16, - relationKind: "cte" | "table" = "table", + relationKind: "cte" | "table" | "table-function" = "table", ): SqlCompletionItem { const edit = { from, @@ -151,7 +153,29 @@ function fakeService( openDocument: () => { let disposed = false; let revision = createSqlRevisionToken(); + const unavailable = (): SqlFeatureTask => ({ + cancel: () => undefined, + result: Promise.resolve({ + reason: "no-provider", + revision, + sources: [], + status: "unavailable", + }), + }); + const featureMethods: SqlLanguageFeatureMethods = { + codeActions: () => unavailable(), + definitions: () => unavailable(), + diagnostics: () => unavailable(), + documentSymbols: () => unavailable(), + foldingRanges: () => unavailable(), + format: () => unavailable(), + highlights: () => unavailable(), + hover: () => unavailable(), + references: () => unavailable(), + rename: () => unavailable(), + }; const session: SqlDocumentSession = { + ...featureMethods, complete: (request): SqlCompletionTask => { completionRequests.push(request); completeSignals.push(request.signal ?? new AbortController().signal); @@ -704,6 +728,47 @@ describe("sqlEditor", () => { service.dispose(); }); + it("exposes every language feature through the editor lifecycle", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ + id: "host", + codeActions: () => [], + definitions: () => [], + diagnostics: () => [], + format: () => ({ changes: [] }), + highlights: () => [], + hover: () => null, + references: () => [], + rename: () => ({ changes: [] }), + }], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + }); + const view = createView(support.extension, "select\n1"); + + const tasks = [ + support.codeActions(view, { range: { from: 0, to: 6 } }), + support.definitions(view, { position: 1 }), + support.diagnostics(view), + support.documentSymbols(view), + support.foldingRanges(view), + support.format(view), + support.highlights(view, { position: 1 }), + support.hover(view, { position: 1 }), + support.references(view, { position: 1 }), + support.rename(view, { newName: "value", position: 1 }), + ]; + expect(tasks.every((task) => task !== null)).toBe(true); + await Promise.all(tasks.map((task) => task?.result)); + + view.destroy(); + expect(support.diagnostics(view)).toBeNull(); + service.dispose(); + }); + it("marks internal blank lines but not separator trivia", async () => { const service = createSqlLanguageService({ dialects: [duckdbDialect()], @@ -821,6 +886,20 @@ describe("sqlEditor", () => { service.dispose(); }); + it("presents table-valued relation completions as functions", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem(14, 16, "table-function")]) + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(currentCompletions(view.state)[0]?.type).toBe("function"); + }); + it("owns rich completion info until CodeMirror destroys it", async () => { const item = completionItem(); const destroys: Array> = []; diff --git a/src/codemirror/sql-editor.ts b/src/codemirror/sql-editor.ts index 4ff6188..47627ff 100644 --- a/src/codemirror/sql-editor.ts +++ b/src/codemirror/sql-editor.ts @@ -52,7 +52,23 @@ import type { SqlLanguageService, SqlRevision, SqlTextChange, + SqlTextRange, } from "../types.js"; +import type { + SqlCodeAction, + SqlDiagnostic, + SqlDiagnosticsRequest, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlFeatureTask, + SqlFoldingRange, + SqlFormatRequest, + SqlHover, + SqlLocation, + SqlPositionFeatureRequest, + SqlRangeFeatureRequest, + SqlRenameRequest, +} from "../language-features.js"; import { createSqlStatementGutter, type SqlEditorStatementGutterOptions, @@ -96,6 +112,36 @@ export interface SqlEditorSupport< readonly SqlEmbeddedRegion[] >; readonly extension: Extension; + readonly codeActions: ( + view: EditorView, + request: SqlRangeFeatureRequest, + ) => SqlFeatureTask | null; + readonly definitions: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly diagnostics: ( + view: EditorView, + request?: SqlDiagnosticsRequest, + ) => SqlFeatureTask | null; + readonly documentSymbols: ( + view: EditorView, + ) => SqlFeatureTask | null; + readonly foldingRanges: ( + view: EditorView, + ) => SqlFeatureTask | null; + readonly format: ( + view: EditorView, + request?: SqlFormatRequest, + ) => SqlFeatureTask | null; + readonly highlights: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly hover: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; readonly invalidateCatalog: (view: EditorView) => SqlRevision | null; readonly statementBoundariesIntersecting: ( view: EditorView, @@ -113,6 +159,14 @@ export interface SqlEditorSupport< view: EditorView, regions: readonly SqlEmbeddedRegion[], ) => void; + readonly references: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly rename: ( + view: EditorView, + request: SqlRenameRequest, + ) => SqlFeatureTask | null; } export interface SqlEditorRuntime { @@ -225,6 +279,7 @@ function haveOneEditRange(items: readonly SqlCompletionItem[]): boolean { function completionType(item: SqlCompletionItem): string { if (item.kind === "column") return "property"; if (item.kind === "namespace") return "namespace"; + if (item.relationKind === "table-function") return "function"; return item.relationKind === "cte" ? "type" : "table"; } @@ -314,6 +369,10 @@ export function createSqlEditorInternal< readonly #subscription; readonly #visibilityListener: () => void; + get destroyed(): boolean { + return this.#destroyed; + } + constructor(view: EditorView) { this.#view = view; const initialRegions = view.state.field(embeddedRegionsField); @@ -725,6 +784,54 @@ export function createSqlEditorInternal< this.#clearCompletionState(); }; + readonly codeActions = ( + request: SqlRangeFeatureRequest, + ): SqlFeatureTask => + this.#session.codeActions(request); + + readonly definitions = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.definitions(request); + + readonly diagnostics = ( + request?: SqlDiagnosticsRequest, + ): SqlFeatureTask => + this.#session.diagnostics(request); + + readonly documentSymbols = (): SqlFeatureTask< + readonly SqlDocumentSymbol[] + > => this.#session.documentSymbols(); + + readonly foldingRanges = (): SqlFeatureTask< + readonly SqlFoldingRange[] + > => this.#session.foldingRanges(); + + readonly format = ( + request?: SqlFormatRequest, + ): SqlFeatureTask => + this.#session.format(request); + + readonly highlights = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.highlights(request); + + readonly hover = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.hover(request); + + readonly references = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.references(request); + + readonly rename = ( + request: SqlRenameRequest, + ): SqlFeatureTask => + this.#session.rename(request); + readonly invalidateCatalog = (): SqlRevision | null => { if (this.#destroyed) return null; try { @@ -933,8 +1040,29 @@ export function createSqlEditorInternal< view.state.field(embeddedRegionsField), ], }); + const withPlugin = ( + view: EditorView, + run: (instance: SqlEditorPlugin) => Value, + ): Value | null => { + const instance = view.plugin(plugin); + return instance === null || instance.destroyed ? null : run(instance); + }; return Object.freeze({ + codeActions: ( + view: EditorView, + request: SqlRangeFeatureRequest, + ) => withPlugin(view, (instance) => instance.codeActions(request)), contextEffect, + definitions: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.definitions(request)), + diagnostics: ( + view: EditorView, + request?: SqlDiagnosticsRequest, + ) => withPlugin(view, (instance) => instance.diagnostics(request)), + documentSymbols: (view: EditorView) => + withPlugin(view, (instance) => instance.documentSymbols()), embeddedRegionsEffect, extension: [ contextField, @@ -945,8 +1073,30 @@ export function createSqlEditorInternal< completionLanguageData, autocompletion(autocompleteOptions), ], + foldingRanges: (view: EditorView) => + withPlugin(view, (instance) => instance.foldingRanges()), + format: ( + view: EditorView, + request?: SqlFormatRequest, + ) => withPlugin(view, (instance) => instance.format(request)), + highlights: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.highlights(request)), + hover: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.hover(request)), invalidateCatalog: (view: EditorView): SqlRevision | null => view.plugin(plugin)?.invalidateCatalog() ?? null, + references: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.references(request)), + rename: ( + view: EditorView, + request: SqlRenameRequest, + ) => withPlugin(view, (instance) => instance.rename(request)), statementBoundariesIntersecting: ( view: EditorView, request: SqlStatementBoundariesIntersectingRequest, diff --git a/src/index.ts b/src/index.ts index 0439452..c5afa72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,37 @@ export type { SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; +export { + MAX_SQL_FEATURE_RESULTS, + MAX_SQL_FEATURE_TEXT_LENGTH, +} from "./language-features.js"; +export type { + SqlCodeAction, + SqlDiagnostic, + SqlDiagnosticSeverity, + SqlDiagnosticsRequest, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlDocumentSymbolKind, + SqlFeatureCancelled, + SqlFeatureDocument, + SqlFeatureProviderReport, + SqlFeatureProviderRequest, + SqlFeatureReady, + SqlFeatureResult, + SqlFeatureTask, + SqlFeatureUnavailable, + SqlFoldingRange, + SqlFormatRequest, + SqlHover, + SqlLanguageFeatureMethods, + SqlLanguageFeatureProvider, + SqlLocation, + SqlMarkupContent, + SqlPositionFeatureRequest, + SqlRangeFeatureRequest, + SqlRenameRequest, +} from "./language-features.js"; export type { SqlExactStatementBoundary, SqlOpaqueStatementBoundary, diff --git a/src/language-feature-runtime.ts b/src/language-feature-runtime.ts new file mode 100644 index 0000000..ff318b5 --- /dev/null +++ b/src/language-feature-runtime.ts @@ -0,0 +1,604 @@ +import type { + SqlCodeAction, + SqlDiagnostic, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlFeatureDocument, + SqlFeatureProviderReport, + SqlFoldingRange, + SqlHover, + SqlLanguageFeatureProvider, + SqlLocation, +} from "./language-features.js"; +import { + MAX_SQL_FEATURE_RESULTS, + MAX_SQL_FEATURE_TEXT_LENGTH, +} from "./language-features.js"; +import { + MAX_SQL_SOURCE_LENGTH, + normalizeSqlTextRange, +} from "./source.js"; +import type { + SqlDocumentContext, + SqlTextChange, + SqlTextRange, +} from "./types.js"; + +const PROVIDER_METHODS = [ + "codeActions", + "definitions", + "diagnostics", + "documentSymbols", + "foldingRanges", + "format", + "highlights", + "hover", + "references", + "rename", +] as const; +const MAX_SQL_FEATURE_NESTED_ITEMS = 10_000; +const MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH = MAX_SQL_SOURCE_LENGTH; + +export interface CapturedSqlLanguageFeatureProvider< + Context extends SqlDocumentContext, +> { + readonly provider: SqlLanguageFeatureProvider; + readonly id: string; +} + +function isSqlLanguageFeatureProvider< + Context extends SqlDocumentContext, +>(value: object): value is SqlLanguageFeatureProvider { + const id = ownData(value, "id"); + if (typeof id !== "string") return false; + return PROVIDER_METHODS.every((method) => { + const candidate = ownData(value, method); + return candidate === undefined || typeof candidate === "function"; + }); +} + +function ownData(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + return descriptor.value; +} + +export function captureSqlLanguageFeatureProviders< + Context extends SqlDocumentContext, +>( + candidates: unknown, +): readonly CapturedSqlLanguageFeatureProvider[] { + if (candidates === undefined) return Object.freeze([]); + if (!Array.isArray(candidates) || candidates.length > 64) { + throw new Error("SQL feature providers must be an array of at most 64 providers"); + } + const captured: CapturedSqlLanguageFeatureProvider[] = []; + const ids = new Set(); + for (const candidate of candidates) { + if (candidate === null || typeof candidate !== "object") { + throw new Error("SQL feature providers must be objects"); + } + if (!isSqlLanguageFeatureProvider(candidate)) { + throw new Error("SQL feature provider has an invalid shape"); + } + const id = candidate.id; + if ( + typeof id !== "string" || + id.length === 0 || + id.length > 256 || + ids.has(id) + ) { + throw new Error("SQL feature provider IDs must be unique non-empty strings"); + } + ids.add(id); + captured.push(Object.freeze({ + id, + provider: candidate, + })); + } + return Object.freeze(captured); +} + +export function createSqlFeatureDocument< + Context extends SqlDocumentContext, +>( + text: string, + context: Context, + embeddedRegions: SqlFeatureDocument["embeddedRegions"], +): SqlFeatureDocument { + return Object.freeze({ + context, + dialect: context.dialect, + embeddedRegions, + text, + }); +} + +function text(value: unknown, subject: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_SQL_FEATURE_TEXT_LENGTH + ) { + throw new Error(`${subject} must be a bounded non-empty string`); + } + return value; +} + +function optionalText(value: unknown, subject: string): string | undefined { + return value === undefined ? undefined : text(value, subject); +} + +function range(value: unknown, length: number, subject: string): SqlTextRange { + return normalizeSqlTextRange(value, length, subject); +} + +function array(value: unknown, subject: string): readonly unknown[] { + if (!Array.isArray(value) || value.length > MAX_SQL_FEATURE_RESULTS) { + throw new Error(`${subject} must be a bounded array`); + } + return value; +} + +function object(value: unknown, subject: string): object { + if (value === null || typeof value !== "object") { + throw new Error(`${subject} must be an object`); + } + return value; +} + +function property(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new Error(`Feature result requires ${String(key)}`); + } + return descriptor.value; +} + +export function normalizeSqlDiagnostics( + value: unknown, + length: number, +): readonly SqlDiagnostic[] { + return Object.freeze(array(value, "SQL diagnostics").map((candidate) => { + const item = object(candidate, "SQL diagnostic"); + const location = range(item, length, "SQL diagnostic range"); + const severity = property(item, "severity"); + if ( + severity !== "error" && + severity !== "warning" && + severity !== "information" && + severity !== "hint" + ) { + throw new Error("SQL diagnostic severity is invalid"); + } + return Object.freeze({ + ...location, + code: optionalText(ownData(item, "code"), "SQL diagnostic code"), + message: text(property(item, "message"), "SQL diagnostic message"), + severity, + source: text(property(item, "source"), "SQL diagnostic source"), + }); + })); +} + +export function normalizeSqlHover( + value: unknown, + length: number, +): SqlHover | null { + if (value === null) return null; + const item = object(value, "SQL hover"); + const contents = object(property(item, "contents"), "SQL hover contents"); + const kind = property(contents, "kind"); + if (kind !== "plaintext" && kind !== "markdown") { + throw new Error("SQL hover markup kind is invalid"); + } + return Object.freeze({ + contents: Object.freeze({ + kind, + value: text(property(contents, "value"), "SQL hover contents"), + }), + range: range(property(item, "range"), length, "SQL hover range"), + }); +} + +function normalizeLocation(value: unknown, length: number): SqlLocation { + const item = object(value, "SQL location"); + const uri = ownData(item, "uri"); + return Object.freeze({ + range: range( + property(item, "range"), + uri === undefined ? length : MAX_SQL_SOURCE_LENGTH, + "SQL location range", + ), + uri: uri === undefined ? undefined : text(uri, "SQL location URI"), + }); +} + +export function normalizeSqlLocations( + value: unknown, + length: number, +): readonly SqlLocation[] { + return Object.freeze( + array(value, "SQL locations").map((item) => + normalizeLocation(item, length)), + ); +} + +export function normalizeSqlRanges( + value: unknown, + length: number, +): readonly SqlTextRange[] { + return Object.freeze( + array(value, "SQL ranges").map((item) => + range(item, length, "SQL feature range")), + ); +} + +export function normalizeSqlDocumentSymbols( + value: unknown, + length: number, +): readonly SqlDocumentSymbol[] { + return Object.freeze(array(value, "SQL document symbols").map((candidate) => { + const item = object(candidate, "SQL document symbol"); + const kind = property(item, "kind"); + if ( + kind !== "statement" && + kind !== "relation" && + kind !== "column" && + kind !== "function" && + kind !== "parameter" + ) { + throw new Error("SQL document symbol kind is invalid"); + } + const symbolRange = range(property(item, "range"), length, "SQL symbol range"); + const selectionRange = range( + property(item, "selectionRange"), + length, + "SQL symbol selection range", + ); + if ( + selectionRange.from < symbolRange.from || + selectionRange.to > symbolRange.to + ) { + throw new Error("SQL symbol selection must be inside its range"); + } + return Object.freeze({ + detail: optionalText(ownData(item, "detail"), "SQL symbol detail"), + kind, + name: text(property(item, "name"), "SQL symbol name"), + range: symbolRange, + selectionRange, + }); + })); +} + +export function normalizeSqlFoldingRanges( + value: unknown, + length: number, +): readonly SqlFoldingRange[] { + return Object.freeze(array(value, "SQL folding ranges").map((candidate) => { + const item = object(candidate, "SQL folding range"); + const normalized = range(item, length, "SQL folding range"); + const kind = ownData(item, "kind"); + if ( + kind !== undefined && + kind !== "statement" && + kind !== "region" && + kind !== "comment" + ) { + throw new Error("SQL folding range kind is invalid"); + } + return Object.freeze({ ...normalized, kind }); + })); +} + +function normalizeTextChanges( + value: unknown, + length: number, +): readonly SqlTextChange[] { + let insertedLength = 0; + let removedLength = 0; + const changes = array(value, "SQL text changes").map((candidate) => { + const item = object(candidate, "SQL text change"); + const normalized = range(item, length, "SQL text change range"); + const insert = property(item, "insert"); + if (typeof insert !== "string" || insert.length > 16 * 1024 * 1024) { + throw new Error("SQL text change insertion is invalid"); + } + insertedLength += insert.length; + removedLength += normalized.to - normalized.from; + if (insertedLength > MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH) { + throw new Error("SQL text changes contain too much inserted text"); + } + return Object.freeze({ ...normalized, insert }); + }); + let previousEnd = 0; + for (const change of changes) { + if (change.from < previousEnd) { + throw new Error("SQL text changes must be ordered and non-overlapping"); + } + previousEnd = change.to; + } + if (length - removedLength + insertedLength > MAX_SQL_SOURCE_LENGTH) { + throw new Error("SQL text changes exceed the maximum document length"); + } + return Object.freeze(changes); +} + +export function normalizeSqlDocumentEdit( + value: unknown, + length: number, +): SqlDocumentEditResult | null { + if (value === null) return null; + const item = object(value, "SQL document edit"); + return Object.freeze({ + changes: normalizeTextChanges(property(item, "changes"), length), + }); +} + +export function normalizeSqlCodeActions( + value: unknown, + length: number, +): readonly SqlCodeAction[] { + const actions: SqlCodeAction[] = []; + let nestedItems = 0; + let aggregateTextLength = 0; + for (const candidate of array(value, "SQL code actions")) { + const item = object(candidate, "SQL code action"); + const kind = ownData(item, "kind"); + if ( + kind !== undefined && + kind !== "quickfix" && + kind !== "refactor" && + kind !== "source" + ) { + throw new Error("SQL code action kind is invalid"); + } + const edit = ownData(item, "edit"); + const diagnostics = ownData(item, "diagnostics"); + const normalizedDiagnostics = diagnostics === undefined + ? undefined + : Object.freeze( + array(diagnostics, "SQL code action diagnostics").map( + (code) => text(code, "SQL diagnostic code"), + ), + ); + const normalizedEdit = edit === undefined + ? undefined + : normalizeSqlDocumentEdit(edit, length) ?? undefined; + const title = text(property(item, "title"), "SQL code action title"); + nestedItems += + (normalizedDiagnostics?.length ?? 0) + + (normalizedEdit?.changes.length ?? 0); + aggregateTextLength += title.length; + for (const diagnostic of normalizedDiagnostics ?? []) { + aggregateTextLength += diagnostic.length; + } + for (const change of normalizedEdit?.changes ?? []) { + aggregateTextLength += change.insert.length; + } + if ( + nestedItems > MAX_SQL_FEATURE_NESTED_ITEMS || + aggregateTextLength > MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH + ) { + throw new Error("SQL code actions exceed the aggregate result budget"); + } + actions.push(Object.freeze({ + diagnostics: normalizedDiagnostics, + edit: normalizedEdit, + kind, + title, + })); + } + return Object.freeze(actions); +} + +export function composeSqlCodeActionResults( + values: readonly (readonly SqlCodeAction[])[], +): { + readonly isIncomplete: boolean; + readonly value: readonly SqlCodeAction[]; +} { + const actions: SqlCodeAction[] = []; + let nestedItems = 0; + let aggregateTextLength = 0; + for (const value of values) { + for (const action of value) { + let actionTextLength = action.title.length; + const actionNestedItems = + (action.diagnostics?.length ?? 0) + + (action.edit?.changes.length ?? 0); + for (const diagnostic of action.diagnostics ?? []) { + actionTextLength += diagnostic.length; + } + for (const change of action.edit?.changes ?? []) { + actionTextLength += change.insert.length; + } + if ( + actions.length === MAX_SQL_FEATURE_RESULTS || + nestedItems + actionNestedItems > MAX_SQL_FEATURE_NESTED_ITEMS || + aggregateTextLength + actionTextLength > + MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH + ) { + return Object.freeze({ + isIncomplete: true, + value: Object.freeze(actions), + }); + } + actions.push(action); + nestedItems += actionNestedItems; + aggregateTextLength += actionTextLength; + } + } + return Object.freeze({ + isIncomplete: values.some( + (value) => value.length === MAX_SQL_FEATURE_RESULTS, + ), + value: Object.freeze(actions), + }); +} + +export type SqlFeatureProviderInvocation< + Context extends SqlDocumentContext, + Request, + Value, +> = ( + provider: SqlLanguageFeatureProvider, + document: SqlFeatureDocument, + request: Request, + signal: AbortSignal, +) => PromiseLike | Value | undefined; + +export interface SqlFeatureInvocationResult { + readonly reports: readonly SqlFeatureProviderReport[]; + readonly values: readonly Value[]; +} + +export async function invokeSqlFeatureProviders< + Context extends SqlDocumentContext, + Request, + Value, +>( + providers: readonly CapturedSqlLanguageFeatureProvider[], + document: SqlFeatureDocument, + request: Request, + signal: AbortSignal, + budgetMs: number, + supports: ( + provider: SqlLanguageFeatureProvider, + ) => boolean, + invoke: SqlFeatureProviderInvocation, + normalize: (value: unknown) => Value, +): Promise> { + const timeoutMarker = Symbol("sql-feature-timeout"); + const abortMarker = Symbol("sql-feature-abort"); + const deadline = performance.now() + budgetMs; + const providerControllers = new Set(); + const abortProviders = (): void => { + for (const controller of providerControllers) controller.abort(); + }; + signal.addEventListener("abort", abortProviders, { once: true }); + if (signal.aborted) abortProviders(); + const settled = await Promise.all(providers.map(async (captured) => { + if (signal.aborted) return null; + let supported: boolean; + try { + supported = supports(captured.provider); + } catch { + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } + if (!supported) return null; + if (performance.now() >= deadline) { + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + const providerController = new AbortController(); + providerControllers.add(providerController); + if (signal.aborted) providerController.abort(); + let operation: PromiseLike | unknown | undefined; + try { + operation = invoke( + captured.provider, + document, + request, + providerController.signal, + ); + } catch { + providerControllers.delete(providerController); + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } + if (operation === undefined) { + providerControllers.delete(providerController); + return null; + } + const remainingBudgetMs = deadline - performance.now(); + if (remainingBudgetMs <= 0) { + providerController.abort(); + providerControllers.delete(providerController); + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutMarker), remainingBudgetMs); + }); + const aborted = new Promise((resolve) => { + onAbort = () => resolve(abortMarker); + providerController.signal.addEventListener( + "abort", + onAbort, + { once: true }, + ); + if (providerController.signal.aborted) onAbort(); + }); + try { + const value = await Promise.race([ + Promise.resolve(operation), + timeout, + aborted, + ]); + if (value === abortMarker) return null; + if (value === timeoutMarker) { + providerController.abort(); + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + return Object.freeze({ + report: Object.freeze({ + outcome: "ready" as const, + providerId: captured.id, + }), + value: normalize(value), + }); + } catch { + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (onAbort !== undefined) { + providerController.signal.removeEventListener("abort", onAbort); + } + providerControllers.delete(providerController); + } + })); + signal.removeEventListener("abort", abortProviders); + providerControllers.clear(); + const reports: SqlFeatureProviderReport[] = []; + const values: Value[] = []; + for (const result of settled) { + if (result === null) continue; + reports.push(result.report); + if ("value" in result) values.push(result.value); + } + return Object.freeze({ + reports: Object.freeze(reports), + values: Object.freeze(values), + }); +} diff --git a/src/language-features.ts b/src/language-features.ts new file mode 100644 index 0000000..8fade6f --- /dev/null +++ b/src/language-features.ts @@ -0,0 +1,217 @@ +import type { + SqlDocumentContext, + SqlEmbeddedRegion, + SqlRevision, + SqlTextChange, + SqlTextRange, +} from "./types.js"; + +export const MAX_SQL_FEATURE_RESULTS = 1_000; +export const MAX_SQL_FEATURE_TEXT_LENGTH = 8_192; + +export type SqlDiagnosticSeverity = + | "error" + | "warning" + | "information" + | "hint"; + +export interface SqlDiagnostic extends SqlTextRange { + readonly code?: string | undefined; + readonly message: string; + readonly severity: SqlDiagnosticSeverity; + readonly source: string; +} + +export interface SqlMarkupContent { + readonly kind: "plaintext" | "markdown"; + readonly value: string; +} + +export interface SqlHover { + readonly contents: SqlMarkupContent; + readonly range: SqlTextRange; +} + +export interface SqlLocation { + readonly range: SqlTextRange; + readonly uri?: string | undefined; +} + +export type SqlDocumentSymbolKind = + | "statement" + | "relation" + | "column" + | "function" + | "parameter"; + +export interface SqlDocumentSymbol { + readonly detail?: string | undefined; + readonly kind: SqlDocumentSymbolKind; + readonly name: string; + readonly range: SqlTextRange; + readonly selectionRange: SqlTextRange; +} + +export interface SqlFoldingRange extends SqlTextRange { + readonly kind?: "statement" | "region" | "comment" | undefined; +} + +export interface SqlDocumentEditResult { + readonly changes: readonly SqlTextChange[]; +} + +export interface SqlCodeAction { + readonly diagnostics?: readonly string[] | undefined; + readonly edit?: SqlDocumentEditResult | undefined; + readonly kind?: "quickfix" | "refactor" | "source" | undefined; + readonly title: string; +} + +export interface SqlPositionFeatureRequest { + readonly position: number; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlRangeFeatureRequest { + readonly range: SqlTextRange; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlDiagnosticsRequest { + readonly range?: SqlTextRange | undefined; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlRenameRequest extends SqlPositionFeatureRequest { + readonly newName: string; +} + +export interface SqlFormatRequest { + readonly range?: SqlTextRange | undefined; + readonly signal?: AbortSignal | undefined; + readonly tabSize?: number | undefined; + readonly useTabs?: boolean | undefined; +} + +export interface SqlFeatureDocument< + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly context: Context; + readonly dialect: string; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + readonly text: string; +} + +export interface SqlFeatureProviderRequest< + Request, + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly document: SqlFeatureDocument; + readonly request: Omit; + readonly signal: AbortSignal; +} + +export interface SqlLanguageFeatureProvider< + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly id: string; + readonly codeActions?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlCodeAction[]; + readonly definitions?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlLocation[]; + readonly diagnostics?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlDiagnostic[]; + readonly documentSymbols?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlDocumentSymbol[]; + readonly foldingRanges?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlFoldingRange[]; + readonly format?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlDocumentEditResult | null; + readonly highlights?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlTextRange[]; + readonly hover?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlHover | null; + readonly references?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlLocation[]; + readonly rename?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlDocumentEditResult | null; +} + +export interface SqlFeatureProviderReport { + readonly outcome: "ready" | "failed" | "timed-out"; + readonly providerId: string; +} + +export interface SqlFeatureReady { + readonly isIncomplete: boolean; + readonly revision: SqlRevision; + readonly sources: readonly SqlFeatureProviderReport[]; + readonly status: "ready"; + readonly value: Value; +} + +export interface SqlFeatureCancelled { + readonly reason: "caller" | "disposed" | "superseded"; + readonly revision: SqlRevision; + readonly status: "cancelled"; +} + +export interface SqlFeatureUnavailable { + readonly reason: "no-provider" | "no-result"; + readonly revision: SqlRevision; + readonly sources: readonly SqlFeatureProviderReport[]; + readonly status: "unavailable"; +} + +export type SqlFeatureResult = + | SqlFeatureReady + | SqlFeatureCancelled + | SqlFeatureUnavailable; + +export interface SqlFeatureTask { + readonly cancel: () => void; + readonly result: Promise>; +} + +export interface SqlLanguageFeatureMethods { + readonly codeActions: ( + request: SqlRangeFeatureRequest, + ) => SqlFeatureTask; + readonly definitions: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly diagnostics: ( + request?: SqlDiagnosticsRequest, + ) => SqlFeatureTask; + readonly documentSymbols: () => SqlFeatureTask< + readonly SqlDocumentSymbol[] + >; + readonly foldingRanges: () => SqlFeatureTask< + readonly SqlFoldingRange[] + >; + readonly format: ( + request?: SqlFormatRequest, + ) => SqlFeatureTask; + readonly highlights: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly hover: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly references: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly rename: ( + request: SqlRenameRequest, + ) => SqlFeatureTask; +} diff --git a/src/relation-catalog-boundary.ts b/src/relation-catalog-boundary.ts index 8149513..31b6983 100644 --- a/src/relation-catalog-boundary.ts +++ b/src/relation-catalog-boundary.ts @@ -189,6 +189,7 @@ const RELATION_KINDS: ReadonlySet = new Set([ "external-relation", "materialized-view", "table", + "table-function", "temporary-table", "view", ]); diff --git a/src/relation-completion-types.ts b/src/relation-completion-types.ts index 03e5448..62ba3dc 100644 --- a/src/relation-completion-types.ts +++ b/src/relation-completion-types.ts @@ -44,6 +44,7 @@ export interface SqlCatalogEpoch { export type SqlCatalogRelationKind = | "temporary-table" | "table" + | "table-function" | "view" | "materialized-view" | "external-relation"; diff --git a/src/relation-completion.ts b/src/relation-completion.ts index 87ac3cb..1b34d89 100644 --- a/src/relation-completion.ts +++ b/src/relation-completion.ts @@ -83,9 +83,10 @@ interface RankedCatalogItem { const CATALOG_KIND_ORDER = Object.freeze({ "temporary-table": 0, table: 1, - view: 2, - "materialized-view": 3, - "external-relation": 4, + "table-function": 2, + view: 3, + "materialized-view": 4, + "external-relation": 5, } as const); const ISSUE_ORDER = Object.freeze([ diff --git a/src/session.ts b/src/session.ts index bd25033..4ad200d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ import type { SqlDocumentContext, SqlDocumentSession, SqlDocumentUpdate, + SqlEmbeddedRegion, SqlLanguageService, SqlLanguageServiceOptions, SqlRevision, @@ -114,6 +115,42 @@ import type { SqlStatementBoundaryAtResult, SqlStatementLexicalEnd, } from "./statement-boundary-types.js"; +import { + captureSqlLanguageFeatureProviders, + composeSqlCodeActionResults, + createSqlFeatureDocument, + invokeSqlFeatureProviders, + normalizeSqlCodeActions, + normalizeSqlDiagnostics, + normalizeSqlDocumentEdit, + normalizeSqlDocumentSymbols, + normalizeSqlFoldingRanges, + normalizeSqlHover, + normalizeSqlLocations, + normalizeSqlRanges, + type CapturedSqlLanguageFeatureProvider, + type SqlFeatureProviderInvocation, +} from "./language-feature-runtime.js"; +import { + MAX_SQL_FEATURE_RESULTS, + type SqlCodeAction, + type SqlDiagnostic, + type SqlDiagnosticsRequest, + type SqlDocumentEditResult, + type SqlDocumentSymbol, + type SqlFeatureCancelled, + type SqlFeatureProviderRequest, + type SqlFeatureResult, + type SqlFeatureTask, + type SqlFoldingRange, + type SqlFormatRequest, + type SqlHover, + type SqlLanguageFeatureProvider, + type SqlLocation, + type SqlPositionFeatureRequest, + type SqlRangeFeatureRequest, + type SqlRenameRequest, +} from "./language-features.js"; const MAX_CONTEXT_DEPTH = 100; const MAX_CONTEXT_NODES = 10_000; @@ -123,6 +160,8 @@ const MAX_CONTEXT_STRING_LENGTH = 1_000_000; const MAX_CONTEXT_ARRAY_LENGTH = 50_000; const MAX_CHANGES_PER_UPDATE = 10_000; const MAX_DIALECTS = 1_000; +const DEFAULT_FEATURE_PROVIDER_BUDGET_MS = 150; +const MAX_FEATURE_PROVIDER_BUDGET_MS = 5_000; const DEFAULT_CATALOG_RESPONSE_BUDGET_MS = 40; const MAX_CATALOG_RESPONSE_BUDGET_MS = 50; const TERMINAL_LOADING_INTENT_LEASE_MS = 1_000; @@ -178,6 +217,12 @@ interface CompletionConfiguration { readonly catalogResponseBudgetMs: number; } +interface ActiveFeatureRequest { + cancelReason: SqlFeatureCancelled["reason"] | null; + readonly controller: AbortController; + readonly revision: SqlRevision; +} + interface SqlDialectRuntime { readonly dialect: SqlDialect; readonly lexicalProfile: SqlLexicalProfile; @@ -1121,6 +1166,245 @@ function statementBoundary( }); } +function featureRequestObject(value: unknown, subject: string): object { + if (value === null || typeof value !== "object") { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} must be an object`, + ); + } + return value; +} + +function featureSignal( + request: object, + subject: string, +): AbortSignal | undefined { + const candidate = readOwnDataProperty( + request, + "signal", + "invalid-feature-request", + subject, + ); + if (!candidate.found || candidate.value === undefined) return undefined; + if (!(candidate.value instanceof AbortSignal)) { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} signal must be an AbortSignal`, + ); + } + return candidate.value; +} + +function positionFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlPositionFeatureRequest { + try { + const request = featureRequestObject(value, subject); + const position = readRequiredDataProperty( + request, + "position", + "invalid-feature-request", + subject, + ); + if ( + !Number.isSafeInteger(position) || + Number(position) < 0 || + Number(position) > length + ) { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} position must be in bounds`, + ); + } + return Object.freeze({ + position: Number(position), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +function rangeFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlRangeFeatureRequest { + try { + const request = featureRequestObject(value, subject); + const candidate = readRequiredDataProperty( + request, + "range", + "invalid-feature-request", + subject, + ); + return Object.freeze({ + range: normalizeSqlTextRange(candidate, length, `${subject} range`), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +function optionalRangeFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlDiagnosticsRequest { + try { + const request = value === undefined + ? Object.freeze({}) + : featureRequestObject(value, subject); + const candidate = readOwnDataProperty( + request, + "range", + "invalid-feature-request", + subject, + ); + return Object.freeze({ + range: !candidate.found || candidate.value === undefined + ? undefined + : normalizeSqlTextRange(candidate.value, length, `${subject} range`), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +interface SqlFeatureComposition { + readonly isIncomplete: boolean; + readonly value: Value | null; +} + +function composeFeatureArrays( + values: readonly (readonly Value[])[], +): SqlFeatureComposition { + const merged: Value[] = []; + let isIncomplete = false; + for (const value of values) { + if (value.length === MAX_SQL_FEATURE_RESULTS) isIncomplete = true; + for (const item of value) { + if (merged.length === MAX_SQL_FEATURE_RESULTS) { + return Object.freeze({ + isIncomplete: true, + value: Object.freeze(merged), + }); + } + merged.push(item); + } + } + return Object.freeze({ + isIncomplete, + value: Object.freeze(merged), + }); +} + +function createLocalStructureProvider< + Context extends SqlDocumentContext, +>( + dialects: ReadonlyMap, +): SqlLanguageFeatureProvider { + const analyze = ( + text: string, + embeddedRegions: readonly SqlEmbeddedRegion[], + dialectId: string, + ): { + readonly source: SqlSourceSnapshot; + readonly index: SqlStatementIndex; + } => { + const dialect = dialects.get(dialectId); + if (!dialect) throw new Error("Unknown SQL dialect"); + const source = embeddedRegions.length === 0 + ? createIdentitySqlSource(text) + : createMaskedSqlSource(text, embeddedRegions); + return Object.freeze({ + index: buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ), + source, + }); + }; + return Object.freeze({ + id: "@marimo/local-structure", + documentSymbols: ({ + document, + }: SqlFeatureProviderRequest) => { + const { index, source } = analyze( + document.text, + document.embeddedRegions, + document.dialect, + ); + const symbols: SqlDocumentSymbol[] = []; + for (const slot of index.slots) { + if (slot.boundaryQuality === "opaque") continue; + if (slot.code === null) continue; + const code = statementRange(source, slot.code); + const extent = statementRange(source, slot.extent); + const codeText = document.text.slice(code.from, code.to); + const keyword = /^[\s]*(?[A-Za-z]+)/u.exec(codeText) + ?.groups?.keyword; + const selectionRange = keyword + ? Object.freeze({ + from: code.from + codeText.indexOf(keyword), + to: code.from + codeText.indexOf(keyword) + keyword.length, + }) + : Object.freeze({ from: code.from, to: code.from }); + symbols.push(Object.freeze({ + detail: slot.boundaryQuality, + kind: "statement", + name: keyword + ? `${keyword.toUpperCase()} statement` + : "SQL statement", + range: extent, + selectionRange, + })); + if (symbols.length === MAX_SQL_FEATURE_RESULTS) break; + } + return Object.freeze(symbols); + }, + foldingRanges: ({ + document, + }: SqlFeatureProviderRequest) => { + const { index, source } = analyze( + document.text, + document.embeddedRegions, + document.dialect, + ); + const ranges: SqlFoldingRange[] = []; + for (const slot of index.slots) { + if (slot.boundaryQuality === "opaque") continue; + if (slot.code === null) continue; + const code = statementRange(source, slot.code); + if (!document.text.slice(code.from, code.to).includes("\n")) continue; + ranges.push(Object.freeze({ + ...code, + kind: "statement", + })); + if (ranges.length === MAX_SQL_FEATURE_RESULTS) break; + } + return Object.freeze(ranges); + }, + }); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -1129,7 +1413,11 @@ export class DefaultSqlDocumentSession readonly #columnCoordinator: SqlColumnCatalogBatchCoordinator | null; readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #dialects: ReadonlyMap; + readonly #featureProviderBudgetMs: number; + readonly #featureProviders: + readonly CapturedSqlLanguageFeatureProvider[]; readonly #onDispose: () => void; + readonly #activeFeatures = new Set(); readonly #listeners = new Set(); #activeCompletion: CompletionRequestState | null = null; #columnLoadingRetry: AuxiliaryLoadingRetry | null = null; @@ -1162,6 +1450,9 @@ export class DefaultSqlDocumentSession columnCoordinator: SqlColumnCatalogBatchCoordinator | null, namespaceCoordinator: SqlNamespaceCatalogCoordinator | null, completion: CompletionConfiguration, + featureProviders: + readonly CapturedSqlLanguageFeatureProvider[], + featureProviderBudgetMs: number, onDispose: () => void, ) { this.#catalogCoordinator = catalogCoordinator; @@ -1169,6 +1460,8 @@ export class DefaultSqlDocumentSession this.#namespaceCoordinator = namespaceCoordinator; this.#catalogResponseBudgetMs = completion.catalogResponseBudgetMs; + this.#featureProviders = featureProviders; + this.#featureProviderBudgetMs = featureProviderBudgetMs; this.#dialects = dialects; this.#onDispose = onDispose; const sequence = 0; @@ -1234,6 +1527,14 @@ export class DefaultSqlDocumentSession return this.#getStatementIndex(); } + #supersedeFeatures(): void { + for (const feature of this.#activeFeatures) { + if (feature.cancelReason !== null) continue; + feature.cancelReason = "superseded"; + feature.controller.abort(); + } + } + readonly statementBoundaryAt = ( input: SqlStatementBoundaryAtRequest, ): SqlStatementBoundaryAtResult => { @@ -1435,6 +1736,7 @@ export class DefaultSqlDocumentSession ? activeIntent.token : null; const previous = this.#snapshot; + this.#supersedeFeatures(); const revision = createSqlRevisionToken(); this.#snapshot = Object.freeze({ ...previous, @@ -2728,6 +3030,7 @@ export class DefaultSqlDocumentSession nextSourceSequence !== this.#snapshot.sourceSequence || nextDialect.relationDialect !== this.#snapshot.dialect.relationDialect; + this.#supersedeFeatures(); this.#snapshot = nextSnapshot; this.#statementIndexCache = nextStatementIndexCache; if (invalidatesLocalRelationCache) { @@ -2766,6 +3069,393 @@ export class DefaultSqlDocumentSession return !this.#disposed && revision === this.#snapshot.revision; }; + #featureTask( + request: Request, + callerSignal: AbortSignal | undefined, + supports: ( + provider: SqlLanguageFeatureProvider, + ) => boolean, + invoke: SqlFeatureProviderInvocation, + normalize: (value: unknown, length: number) => ProviderValue, + compose: ( + values: readonly ProviderValue[], + ) => SqlFeatureComposition, + ): SqlFeatureTask { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const snapshot = this.#snapshot; + const active: ActiveFeatureRequest = { + cancelReason: null, + controller: new AbortController(), + revision: snapshot.revision, + }; + this.#activeFeatures.add(active); + const cancel = (): void => { + if (active.cancelReason !== null) return; + active.cancelReason = "caller"; + active.controller.abort(); + }; + const onCallerAbort = (): void => cancel(); + if (callerSignal?.aborted) { + cancel(); + } else { + callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + } + const result = (async (): Promise> => { + try { + await Promise.resolve(); + if (active.cancelReason !== null) { + return Object.freeze({ + reason: active.cancelReason, + revision: snapshot.revision, + status: "cancelled", + }); + } + const document = createSqlFeatureDocument( + snapshot.source.originalText, + snapshot.context, + snapshot.source.embeddedRegions, + ); + const invocation = await invokeSqlFeatureProviders( + this.#featureProviders, + document, + request, + active.controller.signal, + this.#featureProviderBudgetMs, + supports, + invoke, + (value) => normalize(value, snapshot.source.originalText.length), + ); + if ( + active.cancelReason !== null || + this.#disposed || + this.#snapshot.revision !== snapshot.revision + ) { + const reason = active.cancelReason ?? + (this.#disposed ? "disposed" : "superseded"); + return Object.freeze({ + reason, + revision: snapshot.revision, + status: "cancelled", + }); + } + if (invocation.values.length === 0) { + return Object.freeze({ + reason: invocation.reports.length === 0 + ? "no-provider" + : "no-result", + revision: snapshot.revision, + sources: invocation.reports, + status: "unavailable", + }); + } + const composition = compose(invocation.values); + if (composition.value === null) { + return Object.freeze({ + reason: "no-result", + revision: snapshot.revision, + sources: invocation.reports, + status: "unavailable", + }); + } + return Object.freeze({ + isIncomplete: composition.isIncomplete, + revision: snapshot.revision, + sources: invocation.reports, + status: "ready", + value: composition.value, + }); + } finally { + callerSignal?.removeEventListener("abort", onCallerAbort); + this.#activeFeatures.delete(active); + } + })(); + return Object.freeze({ cancel, result }); + } + + readonly diagnostics = ( + input?: SqlDiagnosticsRequest, + ): SqlFeatureTask => { + const request = optionalRangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL diagnostics request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.diagnostics !== undefined, + (provider, document, current, signal) => + provider.diagnostics?.({ + document, + request: Object.freeze({ range: current.range }), + signal, + }), + normalizeSqlDiagnostics, + composeFeatureArrays, + ); + }; + + readonly hover = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL hover request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.hover !== undefined, + (provider, document, current, signal) => + provider.hover?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }), + normalizeSqlHover, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly definitions = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#locationFeature(input, "definitions", "SQL definition request"); + + readonly references = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#locationFeature(input, "references", "SQL references request"); + + #locationFeature( + input: SqlPositionFeatureRequest, + kind: "definitions" | "references", + subject: string, + ): SqlFeatureTask { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + subject, + ); + return this.#featureTask( + request, + request.signal, + (provider) => ( + kind === "definitions" + ? provider.definitions !== undefined + : provider.references !== undefined + ), + (provider, document, current, signal) => { + const method = kind === "definitions" + ? provider.definitions + : provider.references; + return method?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }); + }, + normalizeSqlLocations, + composeFeatureArrays, + ); + } + + readonly highlights = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL highlights request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.highlights !== undefined, + (provider, document, current, signal) => + provider.highlights?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }), + normalizeSqlRanges, + composeFeatureArrays, + ); + }; + + readonly documentSymbols = (): SqlFeatureTask< + readonly SqlDocumentSymbol[] + > => this.#featureTask( + Object.freeze({}), + undefined, + (provider) => provider.documentSymbols !== undefined, + (provider, document, request, signal) => + provider.documentSymbols?.({ document, request, signal }), + normalizeSqlDocumentSymbols, + composeFeatureArrays, + ); + + readonly foldingRanges = (): SqlFeatureTask< + readonly SqlFoldingRange[] + > => this.#featureTask( + Object.freeze({}), + undefined, + (provider) => provider.foldingRanges !== undefined, + (provider, document, request, signal) => + provider.foldingRanges?.({ document, request, signal }), + normalizeSqlFoldingRanges, + composeFeatureArrays, + ); + + readonly rename = ( + input: SqlRenameRequest, + ): SqlFeatureTask => { + const position = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL rename request", + ); + const candidate = featureRequestObject(input, "SQL rename request"); + const newName = readRequiredDataProperty( + candidate, + "newName", + "invalid-feature-request", + "SQL rename request", + ); + if ( + typeof newName !== "string" || + newName.length === 0 || + newName.length > 1_024 + ) { + throw new SqlSessionError( + "invalid-feature-request", + "SQL rename name must be a bounded non-empty string", + ); + } + const request: SqlRenameRequest = Object.freeze({ + ...position, + newName, + }); + return this.#featureTask( + request, + request.signal, + (provider) => provider.rename !== undefined, + (provider, document, current, signal) => + provider.rename?.({ + document, + request: Object.freeze({ + newName: current.newName, + position: current.position, + }), + signal, + }), + normalizeSqlDocumentEdit, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly format = ( + input?: SqlFormatRequest, + ): SqlFeatureTask => { + const base = optionalRangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL format request", + ); + const candidate = input === undefined + ? Object.freeze({}) + : featureRequestObject(input, "SQL format request"); + const tabSizeValue = readOwnDataProperty( + candidate, + "tabSize", + "invalid-feature-request", + "SQL format request", + ); + const useTabsValue = readOwnDataProperty( + candidate, + "useTabs", + "invalid-feature-request", + "SQL format request", + ); + const tabSize = tabSizeValue.found ? tabSizeValue.value : undefined; + const useTabs = useTabsValue.found ? useTabsValue.value : undefined; + if ( + tabSize !== undefined && + (!Number.isSafeInteger(tabSize) || Number(tabSize) < 1 || Number(tabSize) > 16) + ) { + throw new SqlSessionError( + "invalid-feature-request", + "SQL format tab size must be an integer from 1 through 16", + ); + } + if (useTabs !== undefined && typeof useTabs !== "boolean") { + throw new SqlSessionError( + "invalid-feature-request", + "SQL format useTabs must be a boolean", + ); + } + const request: SqlFormatRequest = Object.freeze({ + ...base, + tabSize: tabSize === undefined ? undefined : Number(tabSize), + useTabs, + }); + return this.#featureTask( + request, + request.signal, + (provider) => provider.format !== undefined, + (provider, document, current, signal) => + provider.format?.({ + document, + request: Object.freeze({ + range: current.range, + tabSize: current.tabSize, + useTabs: current.useTabs, + }), + signal, + }), + normalizeSqlDocumentEdit, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly codeActions = ( + input: SqlRangeFeatureRequest, + ): SqlFeatureTask => { + const request = rangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL code actions request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.codeActions !== undefined, + (provider, document, current, signal) => + provider.codeActions?.({ + document, + request: Object.freeze({ range: current.range }), + signal, + }), + normalizeSqlCodeActions, + composeSqlCodeActionResults, + ); + }; + readonly dispose = (): void => { if (this.#disposed) { return; @@ -2786,6 +3476,11 @@ export class DefaultSqlDocumentSession this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); this.#listeners.clear(); + for (const feature of this.#activeFeatures) { + feature.cancelReason = "disposed"; + feature.controller.abort(); + } + this.#activeFeatures.clear(); this.#localRelationStatementCache = null; this.#statementIndexCache = null; if ( @@ -2820,10 +3515,13 @@ export class DefaultSqlLanguageService readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #completion: CompletionConfiguration; readonly #dialects: ReadonlyMap; + readonly #featureProviderBudgetMs: number; + readonly #featureProviders: + readonly CapturedSqlLanguageFeatureProvider[]; readonly #sessions = new Set>(); #disposed = false; - constructor(options: SqlLanguageServiceOptions) { + constructor(options: SqlLanguageServiceOptions) { try { if (options === null || typeof options !== "object") { throw new SqlSessionError( @@ -2923,6 +3621,62 @@ export class DefaultSqlLanguageService catalogResponseBudgetMs, }); + const featureProviders = readOwnDataProperty( + options, + "featureProviders", + "invalid-service-options", + "SQL language service options", + ); + const configuredFeatureProviders = + captureSqlLanguageFeatureProviders( + featureProviders.found ? featureProviders.value : undefined, + ); + const localStructureProviders = + captureSqlLanguageFeatureProviders( + Object.freeze([createLocalStructureProvider(this.#dialects)]), + ); + const localProviderId = localStructureProviders[0]?.id; + if ( + localProviderId !== undefined && + configuredFeatureProviders.some( + (provider) => provider.id === localProviderId, + ) + ) { + throw new SqlSessionError( + "invalid-service-options", + `SQL feature provider ID ${localProviderId} is reserved`, + ); + } + this.#featureProviders = Object.freeze([ + ...localStructureProviders, + ...configuredFeatureProviders, + ]); + const featureBudget = readOwnDataProperty( + options, + "featureProviderBudgetMs", + "invalid-service-options", + "SQL language service options", + ); + if ( + featureBudget.found && + featureBudget.value !== undefined && + ( + typeof featureBudget.value !== "number" || + !Number.isFinite(featureBudget.value) || + featureBudget.value < 0 || + featureBudget.value > MAX_FEATURE_PROVIDER_BUDGET_MS + ) + ) { + throw new SqlSessionError( + "invalid-service-options", + `Feature provider budget must be between 0 and ${MAX_FEATURE_PROVIDER_BUDGET_MS} milliseconds`, + ); + } + this.#featureProviderBudgetMs = + featureBudget.found && featureBudget.value !== undefined + ? featureBudget.value + : DEFAULT_FEATURE_PROVIDER_BUDGET_MS; + const catalog = readOwnDataProperty( options, "catalog", @@ -3076,6 +3830,8 @@ export class DefaultSqlLanguageService this.#columnCoordinator, this.#namespaceCoordinator, this.#completion, + this.#featureProviders, + this.#featureProviderBudgetMs, () => { this.#sessions.delete(session); }, @@ -3124,6 +3880,8 @@ export class DefaultSqlLanguageService /** Creates a framework-independent SQL service with an immutable dialect registry. */ export function createSqlLanguageService< Context extends SqlDocumentContext = SqlDocumentContext, ->(options: SqlLanguageServiceOptions): SqlLanguageService { +>( + options: SqlLanguageServiceOptions, +): SqlLanguageService { return new DefaultSqlLanguageService(options); } diff --git a/src/types.ts b/src/types.ts index a0582b4..9422784 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,8 @@ +import type { + SqlLanguageFeatureMethods, + SqlLanguageFeatureProvider, +} from "./language-features.js"; + const revisionBrand: unique symbol = Symbol("SqlRevision"); export function isDataArray( @@ -143,7 +148,9 @@ export interface OpenSqlDocument { } /** Owns all mutable state for one open SQL document. */ -export interface SqlDocumentSession { +export interface SqlDocumentSession + extends SqlLanguageFeatureMethods +{ readonly revision: SqlRevision; /** Invalidates relation, column, and namespace catalog observations. */ readonly invalidateCatalog: () => SqlRevision; @@ -172,13 +179,19 @@ export interface SqlLanguageService { readonly dispose: () => void; } -export interface SqlLanguageServiceOptions { +export interface SqlLanguageServiceOptions< + Context extends SqlDocumentContext = SqlDocumentContext, +> { readonly catalog?: SqlRelationCatalogProvider | undefined; readonly columns?: SqlColumnCatalogProvider | undefined; readonly completion?: { readonly catalogResponseBudgetMs?: number | undefined; } | undefined; readonly dialects: readonly SqlDialect[]; + readonly featureProviderBudgetMs?: number | undefined; + readonly featureProviders?: + | readonly SqlLanguageFeatureProvider[] + | undefined; readonly namespaces?: SqlNamespaceCatalogProvider | undefined; } @@ -189,6 +202,7 @@ export type SqlSessionErrorCode = | "invalid-completion-request" | "invalid-dialect" | "invalid-document" + | "invalid-feature-request" | "invalid-service-options" | "invalid-statement-boundary-request" | "invalid-update" diff --git a/test/types/language-features.test-d.ts b/test/types/language-features.test-d.ts new file mode 100644 index 0000000..f880cf9 --- /dev/null +++ b/test/types/language-features.test-d.ts @@ -0,0 +1,62 @@ +import { + createSqlLanguageService, + duckdbDialect, + type SqlDiagnostic, + type SqlDocumentContext, + type SqlLanguageFeatureProvider, +} from "../../src/index.js"; + +interface HostContext extends SqlDocumentContext { + readonly connection: string; +} + +const provider: SqlLanguageFeatureProvider = { + id: "host", + diagnostics: ({ document, request, signal }) => { + const connection: string = document.context.connection; + const dialect: string = document.dialect; + const range = request.range; + const aborted: boolean = signal.aborted; + void connection; + void dialect; + void range; + void aborted; + return [{ + from: 0, + message: "message", + severity: "warning", + source: "host", + to: 1, + }]; + }, + rename: ({ request }) => ({ + changes: [{ + from: request.position, + insert: request.newName, + to: request.position, + }], + }), +}; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [provider], +}); +const session = service.openDocument({ + context: { + connection: "local", + dialect: "duckdb", + }, + text: "select 1", +}); + +const diagnostics = await session.diagnostics().result; +if (diagnostics.status === "ready") { + const item: SqlDiagnostic | undefined = diagnostics.value[0]; + void item; +} + +const hoverTask = session.hover({ position: 1 }); +hoverTask.cancel(); +const renameTask = session.rename({ newName: "answer", position: 1 }); +void renameTask.result; diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index d7076e7..33c7143 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -9,23 +9,23 @@ package's dependency. The fixture workspace pins Vite's floating transitive versions to the exact versions in the root lock, so the nested frozen install can run offline after a clean root CI install. -The minified Vite 8 packed-consumer baseline is 53,612 gzip/199,957 raw +The minified Vite 8 packed-consumer baseline is 57,307 gzip/215,639 raw bytes for the complete parser-free core, 67,573 gzip bytes for the PostgreSQL transitive graph, 50,470 gzip bytes for the BigQuery transitive graph, and -164,513 gzip/725,798 raw bytes for the complete worker build output. The core +168,218 gzip/741,480 raw bytes for the complete worker build output. The core measurement includes the four authenticated relation-dialect runtimes, their -reserved-word tables, and relation-completion/session orchestration. The +reserved-word tables, completion/session orchestration, and the validated +language-feature provider runtime. The PostgreSQL and BigQuery figures each include their transitive shared chunks; the report also identifies those shared chunks explicitly. -The fail-closed ceilings retain approximately 2–3% headroom for the core and -complete worker output, plus -the existing tight dialect-graph headroom: +The fail-closed ceilings retain explicit tight headroom for the measured +graphs: -- Complete parser-free core: 54 KiB gzip and 200 KiB raw +- Complete parser-free core: 57 KiB gzip and 212 KiB raw - PostgreSQL transitive graph: 68 KiB gzip - BigQuery transitive graph: 50 KiB gzip -- Complete worker build output: 164 KiB gzip and 720 KiB raw +- Complete worker build output: 165 KiB gzip and 725 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 9e3503f..09e6286 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -1,6 +1,15 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; +const configuredBrowser = process.env.VITEST_BROWSER ?? "chromium"; +if ( + configuredBrowser !== "chromium" && + configuredBrowser !== "firefox" && + configuredBrowser !== "webkit" +) { + throw new Error(`Unsupported browser: ${configuredBrowser}`); +} + export default defineConfig({ optimizeDeps: { include: [ @@ -14,7 +23,7 @@ export default defineConfig({ browser: { enabled: true, headless: true, - instances: [{ browser: "chromium" }], + instances: [{ browser: configuredBrowser }], provider: playwright(), ui: false, }, From 91f6bd1538e370bb1588a5674620c9b205860177 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 13:28:07 +0800 Subject: [PATCH 41/42] docs: link final overhaul pull request --- implementation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/implementation.md b/implementation.md index bb59caa..f88938b 100644 --- a/implementation.md +++ b/implementation.md @@ -33,6 +33,7 @@ columns. ## PR 2: language intelligence and release hardening Status: complete +Delivery: PR #206 The final PR adds the remaining feature methods and provider composition: From e5bee955cef631759bcd6864daaa638ca57e2d58 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 19:02:00 +0800 Subject: [PATCH 42/42] docs: remove ADR trailing whitespace --- docs/adr/0007-language-feature-provider-composition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0007-language-feature-provider-composition.md b/docs/adr/0007-language-feature-provider-composition.md index b2fdfe5..2b2a20d 100644 --- a/docs/adr/0007-language-feature-provider-composition.md +++ b/docs/adr/0007-language-feature-provider-composition.md @@ -1,6 +1,6 @@ # ADR 0007: Bounded language-feature provider composition -Status: accepted +Status: accepted Date: 2026-07-27 ## Context