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/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..a121854 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 @@ -66,7 +114,6 @@ jobs: name: package path: | dist/ - src/data/ package.json README.md LICENSE @@ -76,7 +123,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..3b5b3c1 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,123 @@ 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: โšก 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: + 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 + strategy: + fail-fast: false + matrix: + browser: + - chromium + - firefox + - webkit + + 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 ${{ matrix.browser }} + run: pnpm exec playwright install --with-deps "${{ matrix.browser }}" + + - 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: + 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/.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..862c6b7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # 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 +- **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 @@ -24,144 +26,61 @@ npm install @marimo-team/codemirror-sql 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"; +The framework-independent root API needs no CodeMirror packages. For the +`/codemirror` adapter and the example below, also install: -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"), -}); +```bash +npm install @codemirror/autocomplete @codemirror/lang-sql @codemirror/state @codemirror/view ``` -### 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. +## CodeMirror usage ```ts -import { EditorView } from "codemirror"; -import { sqlSemanticLinter } from "@marimo-team/codemirror-sql"; - -const editor = new EditorView({ +import { sql, StandardSQL } from "@codemirror/lang-sql"; +import { EditorView } from "@codemirror/view"; +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql"; +import { sqlEditor } from "@marimo-team/codemirror-sql/codemirror"; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], +}); +const support = sqlEditor({ + initialContext: { dialect: "duckdb" }, + service, + statementGutter: { showInactive: true }, +}); +const view = new EditorView({ + doc: "SELECT * FROM users", extensions: [ - sqlSemanticLinter({ - schema: { users: ["id", "name"], posts: ["id", "user_id"] }, - severity: { - unknownTable: "error", // "error" | "warning" | "off" (default: "warning") - unknownColumn: "warning", - ambiguousColumn: "warning", - }, - }), + sql({ dialect: StandardSQL }), + support.extension, ], - parent: document.querySelector("#editor"), }); -``` - -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), - }, -}); - -// Rename programmatically: rewrites the definition and all references -// in a single undo step -await renameSqlIdentifier(view, { prompt: () => "new_name" }); +view.destroy(); +service.dispose(); ``` -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 - -This extension adds support for additional dialects: - -- **DuckDB** -- **BigQuery** -- **Dremio** - -## Keyword Completion - -The extension includes keyword documentation for common **SQL keywords** including used in hover and completion, -which can be found in the `src/data` directory. +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 -See the [demo](https://marimo-team.github.io/codemirror-sql/) for a full example. +```bash +pnpm install +pnpm dev +``` + +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 @@ -172,6 +91,9 @@ pnpm install # Run tests pnpm test +# Typecheck +pnpm run typecheck + # Run demo pnpm dev ``` diff --git a/_typos.toml b/_typos.toml index f03eaed..47238a2 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,12 +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 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", -] \ No newline at end of file +extend-exclude = [] diff --git a/demo/custom-renderers.ts b/demo/custom-renderers.ts deleted file mode 100644 index 207d9a6..0000000 --- a/demo/custom-renderers.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { NamespaceTooltipData } from "../src/sql/hover.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 columns = data.item.namespace?.[table] ?? []; - - // 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 4ac457b..92817bd 100644 --- a/demo/data.ts +++ b/demo/data.ts @@ -1,92 +1,75 @@ -import type { Schema } from "./custom-renderers"; +export interface DemoTable { + readonly columns: readonly { + readonly name: string; + readonly type: string; + }[]; + readonly description: string; + readonly name: string; + readonly schema: string; +} -// 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 +export const demoTables: readonly DemoTable[] = [ + { + columns: [ + { name: "id", type: "BIGINT" }, + { name: "name", type: "VARCHAR" }, + { name: "email", type: "VARCHAR" }, + { name: "active", type: "BOOLEAN" }, + { name: "created_at", type: "TIMESTAMP" }, + ], + description: "Application users", + name: "users", + schema: "main", + }, + { + columns: [ + { name: "id", type: "BIGINT" }, + { name: "user_id", type: "BIGINT" }, + { name: "title", type: "VARCHAR" }, + { name: "published", type: "BOOLEAN" }, + { name: "created_at", type: "TIMESTAMP" }, + ], + description: "Published and draft posts", + name: "posts", + schema: "main", + }, + { + columns: [ + { name: "id", type: "BIGINT" }, + { name: "customer_id", type: "BIGINT" }, + { name: "order_date", type: "DATE" }, + { name: "total_amount", type: "DECIMAL(18, 2)" }, + { name: "status", type: "VARCHAR" }, + ], + description: "Customer orders", + name: "orders", + schema: "sales", + }, + { + columns: [ + { name: "id", type: "BIGINT" }, + { name: "first_name", type: "VARCHAR" }, + { name: "last_name", type: "VARCHAR" }, + { name: "email", type: "VARCHAR" }, + { name: "country", type: "VARCHAR" }, + ], + description: "Customer directory", + name: "customers", + schema: "sales", + }, +] as const; --- 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' -), -top_customers AS ( - SELECT customer_id, SUM(total_amount) AS total_spent - FROM recent_orders - GROUP BY customer_id -) -SELECT c.first_name, t.total_spent AS amount -FROM customers c -JOIN top_customers t ON t.customer_id = c.id -ORDER BY amount DESC; +export const defaultSqlDoc = `-- codemirror-sql playground +-- Type after a dot or press Ctrl-Space for completion. --- Valid queries (no errors): -SELECT id, name, email -FROM users -WHERE active = true -ORDER BY created_at DESC; +SELECT u. +FROM main.users AS u +WHERE EXISTS ( + SELECT 1 + FROM sales.orders AS o + WHERE o.customer_id = u.id +); -SELECT - u.name, - p.title, - p.created_at -FROM users u -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; +SELECT * +FROM sales. `; - -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..0c7fd90 100644 --- a/demo/index.html +++ b/demo/index.html @@ -3,134 +3,165 @@ - codemirror-sql demo - + codemirror-sql playground + - + + - -
-

- codemirror-sql -

-

by marimo

-

- A CodeMirror extension for SQL with real-time syntax validation and error diagnostics using - node-sql-parser. -

- -
-
-

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 -

-

- 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. + +

+
+
+

+ codemirror-sql +

+

+ The SQL language service running against a live in-memory catalog.

+
+ + Standard API + +
-
- -
- -
-
+
+
+
+

Interactive editor

+

+ Start typing or press Ctrl-Space for completion. + The gutter tracks statement boundaries. +

-
- -
-

Example Queries

-

Click any example to load it into the editor:

-
-
-

Valid Queries

- - - - - -
-
-

Invalid Queries (will show errors)

- - - -
+
+ + +
-
+
+
+
+
+
+
-
-

Features

-
-
-

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".

-
-
-

CTE & Alias Navigation

-

- Go-to-definition (Cmd/Ctrl-click or F12), reference highlighting, and rename (F2) for CTE names, - table aliases, and select aliases. -

-
-
-

CTE Autocomplete

-

- Statement-scoped completion of CTE names and their output columns, including after - my_cte.. -

-
-
-

TypeScript Support

-

Full TypeScript support with comprehensive type definitions.

-
-
-
-
+
+

Completion scenarios

+

+ The | marks where completion opens. +

+
+ + + + + + + + + + +
+
diff --git a/demo/index.ts b/demo/index.ts index 90cca90..07133cf 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -1,325 +1,298 @@ -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 { + acceptCompletion, + closeCompletion, + startCompletion, +} from "@codemirror/autocomplete"; +import { + PostgreSQL, + sql, + StandardSQL, + type SQLDialect, +} from "@codemirror/lang-sql"; +import { Compartment } from "@codemirror/state"; import { basicSetup, EditorView } from "codemirror"; import { - DefaultSqlTooltipRenders, - defaultSqlHoverTheme, - NodeSqlParser, - QueryContextAnalyzer, - type SupportedDialects, - sqlCompletion, - sqlExtension, + bigQueryDialect, + createSqlLanguageService, + dremioDialect, + duckdbDialect, + postgresDialect, + type SqlCatalogEpoch, + type SqlCatalogRelation, + type SqlColumnCatalogProvider, + type SqlDocumentContext, + type SqlNamespaceCatalogProvider, + type SqlRelationCatalogProvider, } from "../src/index.js"; -import { tableTooltipRenderer } from "./custom-renderers.js"; -import { defaultSqlDoc, schema } from "./data.js"; -import { guessSqlDialect } from "./utils.js"; +import { sqlEditor } from "../src/codemirror/index.js"; +import { defaultSqlDoc, demoTables } from "./data.js"; -let editor: EditorView; +type DemoDialect = "bigquery" | "dremio" | "duckdb" | "postgresql"; + +interface DemoContext extends SqlDocumentContext { + readonly environment: "demo"; +} -const completionKindStyles = { - borderRadius: "4px", - padding: "2px 4px", - marginRight: "4px", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: "12px", - height: "12px", +const epoch: SqlCatalogEpoch = { + generation: 1, + token: "demo-catalog-v1", }; +let latencyMs = 0; +let catalogRequests = 0; +let columnRequests = 0; +let namespaceRequests = 0; -const defaultDialect = PostgreSQL; +function identifier(value: string) { + return { quoted: false as const, value }; +} -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; - }, +async function delay(signal: AbortSignal): Promise { + if (latencyMs === 0) return; + await new Promise((resolve, reject) => { + const handle = setTimeout(resolve, latencyMs); + signal.addEventListener("abort", () => { + clearTimeout(handle); + reject(signal.reason); + }, { once: true }); + }); +} + +function updateStats(): void { + const node = document.querySelector("#provider-stats"); + if (node) { + node.textContent = + `${catalogRequests} relation ยท ${columnRequests} column ยท ` + + `${namespaceRequests} namespace requests`; + } +} + +const catalog: SqlRelationCatalogProvider = { + id: "demo-relations", + search: async (request, signal) => { + catalogRequests += 1; + updateStats(); + await delay(signal); + signal.throwIfAborted(); + const prefix = request.prefix.value.toLocaleLowerCase(); + const qualifier = request.qualifier.map((part) => + part.value.toLocaleLowerCase() + ); + return { + coverage: { kind: "complete" }, + epoch, + relations: demoTables + .filter((table) => + table.name.toLocaleLowerCase().startsWith(prefix) && + (qualifier.length === 0 || + qualifier.at(-1) === table.schema.toLocaleLowerCase()) + ) + .slice(0, request.limit) + .map((table): SqlCatalogRelation => ({ + canonicalPath: [ + { ...identifier(table.schema), role: "schema" }, + { ...identifier(table.name), role: "relation" }, + ], + completionPathStart: qualifier.length === 0 ? 1 : 0, + detail: table.description, + entityId: `${table.schema}.${table.name}`, + matchQuality: "exact", + relationKind: "table", + })), + status: "ready", + }; }, -]; +}; -// e.g. lazily load keyword docs -const getKeywordDocs = async () => { - 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, - }; +const columns: SqlColumnCatalogProvider = { + id: "demo-columns", + loadColumns: async (request, signal) => { + columnRequests += 1; + updateStats(); + await delay(signal); + signal.throwIfAborted(); + return { + epoch, + relations: request.relations.map((relation) => { + const tableName = relation.path.at(-1)?.value.toLocaleLowerCase(); + const table = demoTables.find((candidate) => + candidate.name.toLocaleLowerCase() === tableName + ); + if (!table) { + return { + code: "unknown" as const, + requestKey: relation.requestKey, + retry: "after-invalidation" as const, + status: "failed" as const, + }; + } + return { + columns: table.columns.map((column, ordinal) => ({ + columnEntityId: `${table.schema}.${table.name}.${column.name}`, + dataType: column.type, + detail: `${column.type} ยท ${table.schema}.${table.name}`, + identifier: identifier(column.name), + insertText: column.name, + ordinal, + })), + coverage: "complete" as const, + relationEntityId: `${table.schema}.${table.name}`, + requestKey: relation.requestKey, + status: "ready" as const, + }; + }), + }; + }, }; -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; - } - } - return prevValue; +const namespaces: SqlNamespaceCatalogProvider = { + id: "demo-namespaces", + search: async (request, signal) => { + namespaceRequests += 1; + updateStats(); + await delay(signal); + signal.throwIfAborted(); + const prefix = request.prefix.value.toLocaleLowerCase(); + return { + containers: ["main", "sales"] + .filter((name) => name.startsWith(prefix)) + .map((name) => ({ + canonicalPath: [{ ...identifier(name), role: "schema" as const }], + containerEntityId: `schema:${name}`, + detail: `Demo ${name} schema`, + insertText: name, + matchQuality: "exact" as const, + })), + coverage: "complete", + epoch, + status: "ready", + }; }, +}; + +const service = createSqlLanguageService({ + catalog, + columns, + completion: { catalogResponseBudgetMs: 40 }, + dialects: [ + bigQueryDialect(), + dremioDialect(), + duckdbDialect(), + postgresDialect(), + ], + namespaces, }); -// Allows us to reconfigure the base sql extension without reloading the editor -const baseSqlCompartment = new Compartment(); +let dialect: DemoDialect = "duckdb"; +const context = (): DemoContext => ({ + catalog: { + scope: `demo-connection:${dialect}`, + searchPath: [[identifier("main")]], + }, + dialect, + environment: "demo", +}); -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; - }, - }; +const support = sqlEditor({ + autocomplete: { + activateOnTyping: true, + activateOnTypingDelay: 75, + infoResolver: (item) => { + const dom = document.createElement("div"); + dom.className = "sql-completion-info"; + const title = document.createElement("strong"); + title.textContent = item.label; + const detail = document.createElement("div"); + detail.textContent = item.detail ?? `${item.kind} completion`; + dom.append(title, detail); + return { destroy: () => undefined, dom }; }, - }); -}; + maxRenderedOptions: 100, + selectOnOpen: true, + }, + initialContext: context(), + service, + statementGutter: { + hideWhenNotFocused: false, + showInactive: true, + }, +}); -// Initialize the SQL editor -function initializeEditor() { - // Use the same parser - const parser = new NodeSqlParser({ - getParserOptions: (state: EditorState) => { - return { - database: getDatabase(state), - }; - }, - }); - // Shared between the completion sources so each edit is analyzed once - const contextAnalyzer = new QueryContextAnalyzer(parser); +const syntax = new Compartment(); +function syntaxDialect(value: DemoDialect): SQLDialect { + return value === "postgresql" ? PostgreSQL : StandardSQL; +} - const extensions = [ +const editor = new EditorView({ + doc: defaultSqlDoc, + 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, - }, - 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, - }, - }), - // 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 + syntax.of(sql({ dialect: syntaxDialect(dialect) })), + support.extension, EditorView.theme({ "&": { - fontSize: "14px", fontFamily: '"JetBrains Mono", monospace', + fontSize: "14px", }, - ".cm-content": { - minHeight: "400px", - }, - ".cm-focused": { - outline: "none", - }, - ".cm-editor": { - borderRadius: "8px", - }, - ".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, - }, + ".cm-content": { minHeight: "390px" }, + ".cm-focused": { outline: "none" }, }), - ]; + EditorView.domEventHandlers({ + keydown: (event, view) => + event.key === "Tab" && acceptCompletion(view), + }), + ], + parent: document.querySelector("#sql-editor") ?? undefined, +}); - editor = new EditorView({ - doc: defaultSqlDoc, - extensions, - parent: document.querySelector("#sql-editor") ?? undefined, +function loadExample(markedSql: string): void { + const cursor = markedSql.indexOf("|"); + const text = + cursor < 0 + ? markedSql + : markedSql.slice(0, cursor) + markedSql.slice(cursor + 1); + const position = cursor < 0 ? text.length : cursor; + closeCompletion(editor); + editor.dispatch({ + changes: { from: 0, insert: text, to: editor.state.doc.length }, + selection: { anchor: position }, }); - - return editor; + editor.focus(); + queueMicrotask(() => startCompletion(editor)); } -// Handle example button clicks -function setupExampleButtons() { - const buttons = document.querySelectorAll(".example-btn"); - - buttons.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(); - } - }); +for (const button of document.querySelectorAll(".example-btn")) { + button.addEventListener("click", () => { + loadExample(button.dataset.sql ?? ""); }); } -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)], - }); +document.querySelector("#database-select") + ?.addEventListener("change", (event) => { + dialect = (event.target as HTMLSelectElement).value as DemoDialect; + support.setContext(editor, context()); + editor.dispatch({ + effects: syntax.reconfigure(sql({ dialect: syntaxDialect(dialect) })), }); - } -} + editor.focus(); + }); -function updateSqlDialect(view: EditorView, dialect: SQLDialect) { - view.dispatch({ - effects: [baseSqlCompartment.reconfigure(baseSqlExtension(dialect))], +document.querySelector("#latency-select") + ?.addEventListener("change", (event) => { + latencyMs = Number((event.target as HTMLSelectElement).value); + support.invalidateCatalog(editor); }); -} -// Initialize everything when the page loads -document.addEventListener("DOMContentLoaded", () => { - initializeEditor(); - setupExampleButtons(); - setupDatabaseSelect(); +document.querySelector("#invalidate-catalog") + ?.addEventListener("click", () => { + support.invalidateCatalog(editor); + editor.focus(); + startCompletion(editor); + }); - 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"); +window.addEventListener("beforeunload", () => { + editor.destroy(); + service.dispose(); }); + +updateStats(); 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 new file mode 100644 index 0000000..cc93480 --- /dev/null +++ b/docs/adr/0001-language-service-and-session.md @@ -0,0 +1,684 @@ +# ADR 0001: Shared Language Service and Per-Document Sessions + +Status: accepted +Date: 2026-07-24 + +## Context + +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: + +- 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 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 public API therefore models the actual lifecycle with a shared service and +per-document sessions. + +## Decision + +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 +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. + +## 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: + +```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", + embeddedRegions: [], + 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. The first internal boundary is specified by +[ADR 0002](./0002-normalized-syntax-contract.md). + +## 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 (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 +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 +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, context, and embedded regions atomically: + +```ts +session.update({ + 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. 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. +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. + +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 session snapshot associated with the revision tracks: + +- Monotonic sequence +- Document revision +- Context and template revision + +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 +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. + +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. + +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 + +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; + + readonly update: (update: SqlDocumentUpdate) => SqlRevision; + + readonly complete: ( + request: SqlCompletionRequest, + ) => Promise>; + + readonly diagnostics: ( + request?: SqlDiagnosticsRequest, + ) => Promise>; + + readonly hover: ( + request: SqlPositionRequest, + ) => Promise>; + + readonly definition: ( + request: SqlPositionRequest, + ) => Promise>; + + readonly references: ( + request: SqlPositionRequest, + ) => Promise>; + + readonly format: ( + 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. + +## Provider rules + +Providers are narrow and async-only. Pure range, change, identifier, and +statement-index primitives remain synchronous. + +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 +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 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. +- Formatting normally selects one provider. + +Provider completion order cannot affect final ordering. + +## Parser-neutral artifacts + +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. + +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 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: + +```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. + +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 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. + +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. + +## 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. + +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. + +`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. diff --git a/docs/adr/0002-normalized-syntax-contract.md b/docs/adr/0002-normalized-syntax-contract.md new file mode 100644 index 0000000..2d9d7d7 --- /dev/null +++ b/docs/adr/0002-normalized-syntax-contract.md @@ -0,0 +1,201 @@ +# ADR 0002: Internal Normalized Syntax Contract + +Status: accepted +Date: 2026-07-24 + +## Context + +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 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 public 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. + +[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: + +- 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/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md new file mode 100644 index 0000000..c739757 --- /dev/null +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -0,0 +1,207 @@ +# 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 +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 +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 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. + +### 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 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 + +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. + +[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 or another installed package 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 +- 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 new file mode 100644 index 0000000..c107f28 --- /dev/null +++ b/docs/adr/0004-isolated-parser-execution.md @@ -0,0 +1,359 @@ +# 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 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. +- Worker construction is lazy and the executor owns listeners, timers, + termination, and disposal. +- 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 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 +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. 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 +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 the complete backend operation: dialect loading, module decoding, +parser construction, parsing, and output normalization. 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. + +Protocol v2 contains only one closed outcome: + +- 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 + +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 wire does not transport an independently supplied retryability boolean. +The host treats only `module-load` as retryable; `backend` and +`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. +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 +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. + +The first relation-completion slice remains parser-independent under +[ADR 0005](0005-parser-independent-relation-completion.md). Protocol v1 remains +historical and protocol v2 adds the internal query-binding DTO. Incomplete +relation completion must not wait for worker startup, +parser acceptance, timeout, or recovery. + +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 +invalidation, and release semantics. + +### Packaging boundary + +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. + +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. + +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. +- 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 private bounded single-lane executor. +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. + +## 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/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md new file mode 100644 index 0000000..f26b7d0 --- /dev/null +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -0,0 +1,1097 @@ +# 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 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 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`, 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`, +`ON`, malformed `USING`, 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 +``` + +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: + +- 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 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 +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. 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 +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: + +- 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 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 +`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. 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 +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. 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 +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. + +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. +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 +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 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. +The installed callback closure supplies authenticated provider and scope +identity; the untrusted invalidation payload therefore contains only an epoch. +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 +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 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. 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 +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: + +```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; +- 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; +- 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 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 +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. + +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: + +- 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. 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 + +The initial checked limits are: + +| Resource | Limit | +| --- | ---: | +| 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 | +| 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 | +| 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 | +| 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 | 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 | +| 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 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 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 + performance and leak evidence. +10. Validate both the in-memory/notebook and hierarchical remote provider + shapes, then stabilize the declarations and public export surface. +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 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 +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/adr/0006-query-binding-model.md b/docs/adr/0006-query-binding-model.md new file mode 100644 index 0000000..1392d92 --- /dev/null +++ b/docs/adr/0006-query-binding-model.md @@ -0,0 +1,103 @@ +# ADR 0006: Internal query binding model + +## Status + +Accepted for the standard 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/adr/0007-language-feature-provider-composition.md b/docs/adr/0007-language-feature-provider-composition.md new file mode 100644 index 0000000..2b2a20d --- /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 new file mode 100644 index 0000000..b899dac --- /dev/null +++ b/docs/capability-charter.md @@ -0,0 +1,289 @@ +# Capability Charter + +Status: accepted +Date: 2026-07-24 + +## Product boundary + +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. + +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 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 + +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. A complete-empty completion search cannot either; only a distinct +authoritative resolution contract with complete resolution coverage may prove +absence. + +## 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. + +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 +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. + +Packed-consumer and bundle evidence established two stable entry points: + +- An SSR-safe, framework-independent core entry +- An explicit CodeMirror entry +- 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 + +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: + +- 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 + 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 + +The language service 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 wrapper around mutable parser or analyzer classes + +Migration helpers may exist for host apps, but they do not constrain the +architecture. + +## Release evidence + +Before the language service is stable: + +- Every conformance target has an explicit feature matrix and corpus. +- 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 `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/codemirror-adapter.md b/docs/codemirror-adapter.md new file mode 100644 index 0000000..06329c0 --- /dev/null +++ b/docs/codemirror-adapter.md @@ -0,0 +1,167 @@ +# CodeMirror Adapter + +Status: stable + +Import: `@marimo-team/codemirror-sql/codemirror` + +`sqlEditor` connects one caller-owned SQL 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"; +import { + sqlEditor, +} from "@marimo-team/codemirror-sql/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 and registers +its source through CodeMirror language data. Existing language sources, such as +SQL keywords and functions from `@codemirror/lang-sql`, remain active. +Additional global 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, +}); +``` + +## 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: + +```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 deliberately does not use `validFor` or result mapping because +schema and scope changes can invalidate options without changing the typed +prefix. 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/column-catalog-authority.md b/docs/column-catalog-authority.md new file mode 100644 index 0000000..a6a3a50 --- /dev/null +++ b/docs/column-catalog-authority.md @@ -0,0 +1,65 @@ +# standard 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/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/docs/marimo-sql-migration.md b/docs/marimo-sql-migration.md new file mode 100644 index 0000000..9808d75 --- /dev/null +++ b/docs/marimo-sql-migration.md @@ -0,0 +1,203 @@ +# Marimo SQL Completion Migration + +Status: implementation fixture; relation, column, and namespace completion + +The compile-only fixture +[`marimo-sql-migration.test-d.ts`](../test/types/marimo-sql-migration.test-d.ts) +models the intended marimo integration exclusively through public standard +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 standard 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 sources through `autocomplete.externalSources`. SQL keyword +and function sources registered by the active CodeMirror SQL language compose +automatically with the library source. +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 standard 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 standard-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. 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 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, +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 standard 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 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. + +## 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 standard 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, 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: + +- 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/namespace-catalog-authority.md b/docs/namespace-catalog-authority.md new file mode 100644 index 0000000..df12834 --- /dev/null +++ b/docs/namespace-catalog-authority.md @@ -0,0 +1,45 @@ +# standard 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/docs/node-sql-parser-adapter.md b/docs/node-sql-parser-adapter.md new file mode 100644 index 0000000..f1d9921 --- /dev/null +++ b/docs/node-sql-parser-adapter.md @@ -0,0 +1,176 @@ +# 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 +the package 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. + +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 +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. 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 the public API. +There is no public worker constructor, executor, queue, language-service +module, or session integration. + +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. 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 package, 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, 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: + +| 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. + +[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/docs/session-primitives.md b/docs/session-primitives.md new file mode 100644 index 0000000..4fa51a0 --- /dev/null +++ b/docs/session-primitives.md @@ -0,0 +1,248 @@ +# Session Primitives + +Status: walking skeleton +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 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). + +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 +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], +}); + +const session = service.openDocument({ + text: "SELECT * FROM users", + context: { dialect: dialect.id, engine: "local" }, +}); + +const revision = session.update({ + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 14, to: 19, insert: "customers" }], + }, + embeddedRegions: [], +}); + +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 +`{ 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. + +## 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 ", +}); + +let completionRefreshToken: SqlCompletionRefreshToken | null = null; +const subscription = session.onDidChange(({ refreshToken }) => { + if ( + refreshToken !== null && + refreshToken === completionRefreshToken + ) { + // Ask the editor adapter to request completion again. + } +}); + +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. + } +} + +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, 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 + +`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. +- `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, 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, + 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 internal statement index is built lazily per session. Context-only updates +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 +depth, size, properties, and string data. These are defensive walking-skeleton +limits, not release performance claims. diff --git a/docs/source-coordinates.md b/docs/source-coordinates.md new file mode 100644 index 0000000..0008936 --- /dev/null +++ b/docs/source-coordinates.md @@ -0,0 +1,86 @@ +# Source Coordinates + +Status: stable 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 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 + +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. + +`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, 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. 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/docs/statement-index.md b/docs/statement-index.md new file mode 100644 index 0000000..fe0a36b --- /dev/null +++ b/docs/statement-index.md @@ -0,0 +1,114 @@ +# 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 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. 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 + [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. + +## 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 +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`. + +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/implementation.md b/implementation.md new file mode 100644 index 0000000..f88938b --- /dev/null +++ b/implementation.md @@ -0,0 +1,62 @@ +# SQL editor completion plan + +Status: complete +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 + +Status: complete +Delivery: PR #206 + +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/package.json b/package.json index 3290ca3..12bc60b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "publishConfig": { "access": "public" }, - "description": "CodeMirror plugin for SQL", + "description": "Fast, robust SQL language services and schema-aware completion for CodeMirror 6", "sideEffects": false, "repository": { "type": "git", @@ -13,32 +13,68 @@ }, "scripts": { "dev": "vite", - "typecheck": "tsc --noEmit", + "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:tests:loose-optional": "tsc --project tsconfig.tests.loose-optional.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: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", + "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:session-completion": "vitest bench --run src/__tests__/session-completion.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": "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", + "autocomplete", "codemirror-plugin", - "sql" + "editor", + "sql", + "language-service", + "codemirror" ], "license": "Apache-2.0", "packageManager": "pnpm@11.4.0", "peerDependencies": { "@codemirror/autocomplete": "^6", - "@codemirror/lint": "^6", "@codemirror/state": "^6", "@codemirror/view": "^6" }, + "peerDependenciesMeta": { + "@codemirror/autocomplete": { + "optional": true + }, + "@codemirror/state": { + "optional": true + }, + "@codemirror/view": { + "optional": true + } + }, "devDependencies": { + "@codemirror/autocomplete": "6.20.3", "@codemirror/lang-sql": "6.10.0", + "@codemirror/state": "6.7.1", "@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", @@ -50,8 +86,7 @@ "vitest": "4.1.6" }, "files": [ - "dist", - "src/data/*.json" + "dist" ], "exports": { "./package.json": "./package.json", @@ -61,22 +96,20 @@ "default": "./dist/index.js" } }, - "./dialects": { + "./codemirror": { "import": { - "types": "./dist/dialects/index.d.ts", - "default": "./dist/dialects/index.js" + "types": "./dist/codemirror/index.d.ts", + "default": "./dist/codemirror/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", "engines": { - "node": "*" + "node": ">=20.19" }, "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 0c64485..4a88fb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,31 +8,31 @@ 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.3.13 + specifier: 5.4.0 version: 5.4.0 devDependencies: + '@codemirror/autocomplete': + specifier: 6.20.3 + version: 6.20.3 '@codemirror/lang-sql': specifier: 6.10.0 version: 6.10.0 + '@codemirror/state': + specifier: 6.7.1 + version: 6.7.1 '@codemirror/view': specifier: 6.43.0 version: 6.43.0 '@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 +53,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 +465,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 +1025,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 +1476,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 +1542,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 +1584,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 +1597,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 +2004,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 +2016,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 +2039,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..961badb --- /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/node-sql-parser-browser-worker.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/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 new file mode 100644 index 0000000..e93614a --- /dev/null +++ b/scripts/package-smoke.mjs @@ -0,0 +1,420 @@ +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + 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); +} + +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"], + { + 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"); + } + const privateWorkerArtifacts = [ + "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))) { + throw new Error( + `Packed archive omitted private worker artifact ${artifact}`, + ); + } + } + + writeFileSync( + join(temporaryDirectory, "session-consumer.mjs"), + `import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql"; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", +}); +session.update({ + embeddedRegions: [{ from: 14, language: "python", to: 23 }], + 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 statement boundary was unavailable"); +} +service.dispose(); +`, + ); + + writeFileSync( + join(temporaryDirectory, "consumer.mts"), + `import { + createSqlLanguageService, + duckdbDialect, + type SqlDocumentContext, + type SqlEmbeddedRegion, + type SqlCatalogSubscriptionCleanup, + type SqlStatementBoundaryAtResult, + type SqlTextRange, +} from "@marimo-team/codemirror-sql"; +import { sqlEditor } from "@marimo-team/codemirror-sql/codemirror"; + +interface HostContext extends SqlDocumentContext { + readonly engine: string; +} + +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" }, + service, +}); +const embeddedRegions: readonly SqlEmbeddedRegion[] = [ + { from: 14, language: "python", to: 18 }, +]; +const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions, + text: "SELECT * FROM {df}", +}); +const range: SqlTextRange = { from: 0, to: 6 }; +session.update({ + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + baseRevision: session.revision, + document: { kind: "changes", changes: [] }, +}); +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; +`, + ); + + writeFileSync( + join(temporaryDirectory, "consumer.mjs"), + `import * as api from "@marimo-team/codemirror-sql"; +import * as codeMirror from "@marimo-team/codemirror-sql/codemirror"; + +if ( + typeof api.createSqlLanguageService !== "function" || + typeof api.bigQueryDialect !== "function" || + typeof api.dremioDialect !== "function" || + typeof api.duckdbDialect !== "function" || + typeof api.postgresDialect !== "function" +) { + throw new Error("Package exports are incomplete"); +} +if (typeof codeMirror.sqlEditor !== "function") { + throw new Error("CodeMirror package exports are incomplete"); +} +for (const dialect of [ + api.bigQueryDialect(), + api.dremioDialect(), + api.duckdbDialect(), + api.postgresDialect(), +]) { + if ( + Object.keys(dialect).join(",") !== "displayName,id" || + "decodeIdentifier" in dialect || + "grammar" in dialect || + "relationDialect" in dialect || + "renderRelationPath" in dialect + ) { + throw new Error("Dialect implementation policy leaked publicly"); + } +} + +const service = api.createSqlLanguageService({ + dialects: [api.duckdbDialect()], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", +}); +const originalRevision = session.revision; +const updatedRevision = session.update({ + embeddedRegions: [{ from: 14, language: "python", to: 23 }], + baseRevision: originalRevision, + document: { kind: "replace", text: "SELECT * FROM {next_df}" }, +}); +if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { + throw new Error("The packaged 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 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(); +`, + ); + + 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); + const isolatedDependencies = [ + join(temporaryDirectory, "node_modules", "node-sql-parser"), + join(temporaryDirectory, "node_modules", "@codemirror"), + ]; + withRenamedPaths(isolatedDependencies, () => { + run(process.execPath, ["session-consumer.mjs"], temporaryDirectory); + }); + run(process.execPath, ["consumer.mjs"], temporaryDirectory); +} finally { + if (basename(temporaryDirectory).startsWith("codemirror-sql-package-")) { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs new file mode 100644 index 0000000..4d59f26 --- /dev/null +++ b/scripts/worker-placement.mjs @@ -0,0 +1,831 @@ +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 CORE_TOTAL_GZIP_LIMIT = 57 * 1024; +const CORE_TOTAL_RAW_LIMIT = 212 * 1024; +const POSTGRESQL_GZIP_LIMIT = 68 * 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"], + [".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 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", { + 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"); +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 coreBundle = verifyCoreSize(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: coreBundle, + 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/src/__tests__/bounded-sql-lexer.test.ts b/src/__tests__/bounded-sql-lexer.test.ts new file mode 100644 index 0000000..70837af --- /dev/null +++ b/src/__tests__/bounded-sql-lexer.test.ts @@ -0,0 +1,185 @@ +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(); + 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", + ).join(" "); + 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", () => { + const source = createIdentitySqlSource( + `$${"a".repeat(257)}$unterminated`, + ); + expect(lex(source)).toEqual({ + 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/__tests__/column-catalog-batch-coordinator.bench.ts b/src/__tests__/column-catalog-batch-coordinator.bench.ts new file mode 100644 index 0000000..e585907 --- /dev/null +++ b/src/__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/__tests__/column-catalog-batch-coordinator.test.ts b/src/__tests__/column-catalog-batch-coordinator.test.ts new file mode 100644 index 0000000..97fb1c4 --- /dev/null +++ b/src/__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/__tests__/column-catalog-boundary.test.ts b/src/__tests__/column-catalog-boundary.test.ts new file mode 100644 index 0000000..2ab1f9e --- /dev/null +++ b/src/__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/__tests__/column-completion.test.ts b/src/__tests__/column-completion.test.ts new file mode 100644 index 0000000..391a4fd --- /dev/null +++ b/src/__tests__/column-completion.test.ts @@ -0,0 +1,976 @@ +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"; +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", + context: "select-list", + 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("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 }, + (_, 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("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( + 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("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(), + 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/__tests__/column-query-site.test.ts b/src/__tests__/column-query-site.test.ts new file mode 100644 index 0000000..f46c2fb --- /dev/null +++ b/src/__tests__/column-query-site.test.ts @@ -0,0 +1,619 @@ +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("represents derived relations and marks table functions partial", () => { + expect( + ready( + analyze( + "SELECT x.| FROM (SELECT 1) x JOIN UNNEST(items) AS item ON true", + ), + ), + ).toMatchObject({ + coverage: "partial", + 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|", + "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/__tests__/cte-layout.bench.ts b/src/__tests__/cte-layout.bench.ts new file mode 100644 index 0000000..945223b --- /dev/null +++ b/src/__tests__/cte-layout.bench.ts @@ -0,0 +1,127 @@ +import { bench, describe } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + visibleSqlCtesAt, +} from "../cte-layout.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 = DUCKDB_SQL_RELATION_DIALECT.cteLayout; + +function fixture(text: string): { + readonly source: ReturnType; + readonly index: ReturnType; + readonly slot: ExactSqlStatementSlot; +} { + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ); + const slot = index.slots[0]; + if (!slot || slot.boundaryQuality !== "exact") { + throw new Error("CTE benchmark fixture requires an exact statement"); + } + return { index, 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.index, + depthHeavyFixture.slot, + dialect, +); +if (depthLayout.status !== "ready") { + throw new Error("Depth benchmark requires a ready CTE layout"); +} +const projectedLayout = analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.index, + 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.index, + ordinaryFixture.slot, + dialect, + ); + }); + + bench("256 declarations", () => { + analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.index, + declarationHeavyFixture.slot, + dialect, + ); + }); + + bench("128-depth nested CTE", () => { + analyzeSqlCteLayout( + depthHeavyFixture.source, + depthHeavyFixture.index, + depthHeavyFixture.slot, + dialect, + ); + }); + + bench("256 sequential incomplete frames", () => { + analyzeSqlCteLayout( + bareFrameHeavyFixture.source, + bareFrameHeavyFixture.index, + bareFrameHeavyFixture.slot, + dialect, + ); + }); + + bench("cached 256-declaration projection", () => { + visibleSqlCtesAt( + projectedLayout, + declarationHeavyText.length - 1, + ); + }); +}); diff --git a/src/__tests__/cte-layout.test.ts b/src/__tests__/cte-layout.test.ts new file mode 100644 index 0000000..b7838d6 --- /dev/null +++ b/src/__tests__/cte-layout.test.ts @@ -0,0 +1,1209 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + MAX_CTE_IDENTIFIER_LENGTH, + MAX_CTE_QUOTED_IDENTIFIER_LENGTH, + MAX_CTE_STATEMENT_LENGTH, + resolveAuthenticatedSqlCteEntrypoints, + type SqlCteLayout, + type SqlCteLayoutDialect, + type SqlCteLayoutIssue, + type SqlCteIdentifierResult, + visibleSqlCtesAt, +} from "../cte-layout.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, + 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; +const bigquery = BIGQUERY_SQL_RELATION_DIALECT.cteLayout; +const dremio = DREMIO_SQL_RELATION_DIALECT.cteLayout; + +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, + index, + slot as ExactSqlStatementSlot, + dialect, + ); + expect(result.status).not.toBe("unavailable"); + return result as Exclude< + SqlCteLayout, + { status: "unavailable" } + >; +} + +function analyzeRaw( + text: string, + dialect: SqlCteLayoutDialect = postgres, + indexDialect: SqlCteLayoutDialect = dialect, +): SqlCteLayout { + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + indexDialect.lexicalProfile, + ); + const slot = index.slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + return analyzeSqlCteLayout( + source, + index, + 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, + index, + 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),", "ambiguous-cte-header"], + ["WITH a AS (SELECT 1) + SELECT 1", "unsupported-cte-extension"], + [ + "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("keeps structural coverage around an embedded query expression", () => { + 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.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: [ + { name: { value: "a" } }, + { name: { value: "b" } }, + ], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect(visibleSqlCtesAt(layout, from)).toMatchObject({ + ctes: [], + quality: "recovered", + 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"; + 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_QUOTED_IDENTIFIER_LENGTH, + )}"`; + 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 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})`, + ).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, 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"; + 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/__tests__/hostile-data.test.ts b/src/__tests__/hostile-data.test.ts new file mode 100644 index 0000000..f5194d2 --- /dev/null +++ b/src/__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/__tests__/incremental-statement-index.test.ts b/src/__tests__/incremental-statement-index.test.ts new file mode 100644 index 0000000..4e7e879 --- /dev/null +++ b/src/__tests__/incremental-statement-index.test.ts @@ -0,0 +1,550 @@ +import { describe, expect, it } from "vitest"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + isExactSqlStatementSlotSnapshotFor, + 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("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, 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), + ); + 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/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index a784b86..0000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { readdir } from "node:fs/promises"; -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", 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")); - - for (const file of keywordFiles) { - const keywords = await import(`../data/${file}`); - expect(typeof keywords.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/__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__/lexical.test.ts b/src/__tests__/lexical.test.ts new file mode 100644 index 0000000..8e2900d --- /dev/null +++ b/src/__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/__tests__/local-relation-site.bench.ts b/src/__tests__/local-relation-site.bench.ts new file mode 100644 index 0000000..9c10844 --- /dev/null +++ b/src/__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/__tests__/local-relation-site.test.ts b/src/__tests__/local-relation-site.test.ts new file mode 100644 index 0000000..a0e21c9 --- /dev/null +++ b/src/__tests__/local-relation-site.test.ts @@ -0,0 +1,611 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlLocalColumnSite, + analyzeSqlLocalRelationSite, + applySqlQueryOutputAliases, + 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"; + +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, + 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("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( + "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", + }); + 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", () => { + 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/__tests__/namespace-catalog-boundary.test.ts b/src/__tests__/namespace-catalog-boundary.test.ts new file mode 100644 index 0000000..bf2cae6 --- /dev/null +++ b/src/__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/__tests__/namespace-catalog-coordinator.bench.ts b/src/__tests__/namespace-catalog-coordinator.bench.ts new file mode 100644 index 0000000..47489ee --- /dev/null +++ b/src/__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/__tests__/namespace-catalog-coordinator.test.ts b/src/__tests__/namespace-catalog-coordinator.test.ts new file mode 100644 index 0000000..3760d80 --- /dev/null +++ b/src/__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/__tests__/node-sql-parser-adapter.bench.ts b/src/__tests__/node-sql-parser-adapter.bench.ts new file mode 100644 index 0000000..fada3a2 --- /dev/null +++ b/src/__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/__tests__/node-sql-parser-adapter.test.ts b/src/__tests__/node-sql-parser-adapter.test.ts new file mode 100644 index 0000000..e951f1f --- /dev/null +++ b/src/__tests__/node-sql-parser-adapter.test.ts @@ -0,0 +1,1215 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + exportedForTesting, + getBigQueryNodeSqlStatementParser, + getDuckDbCompatibilityNodeSqlStatementParser, + getNodeSqlParserQueryBindingModel, + 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", + }); + if ( + expectedKind === "query" && + state.analysis.status === "parsed" + ) { + expect( + getNodeSqlParserQueryBindingModel(state.analysis.artifact), + ).toMatchObject({ + coverage: { + queryBlocks: "complete", + relationBindings: "complete", + visibility: "complete", + }, + }); + } + }, + ); + + 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", + }); + 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 () => { + 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 only normalized bindings 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(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"); + + const structuralCopy = { + kind: artifact.kind, + range: artifact.range, + }; + expect( + invokeWithUnknown( + exportedForTesting.hasBackendPayload, + structuralCopy, + ), + ).toBe(false); + expect( + invokeWithUnknown( + getNodeSqlParserQueryBindingModel, + structuralCopy, + ), + ).toBeNull(); + }); + + 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/__tests__/node-sql-parser-backend.test.ts b/src/__tests__/node-sql-parser-backend.test.ts new file mode 100644 index 0000000..42db5ba --- /dev/null +++ b/src/__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/__tests__/node-sql-parser-browser-executor.test.ts b/src/__tests__/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..20fc812 --- /dev/null +++ b/src/__tests__/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,2586 @@ +// @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 queryBindings: null; + 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" + ? { + kind: outcome.kind, + root: Object.freeze({}), + statementKind: outcome.statementKind, + } + : outcome.kind === "failed" + ? { + ...outcome, + retryable: outcome.code === "module-load", + } + : outcome; + worker.emit( + encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker, index).requestId, + backendOutcome, + outcome.kind === "parsed" ? outcome.queryBindings : null, + ), + ); +} + +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: 2, + requestId: 1, + text: "SELECT 1", + }); + expect(Object.isFrozen(request)).toBe(true); + + 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); + 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", + queryBindings: null, + statementKind: "query", + }, 2); + expect(await outcome(third)).toStrictEqual({ + kind: "parsed", + queryBindings: null, + 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: 1 } }, + { + data: { + extra: true, + kind: "ready", + protocolVersion: 2, + }, + }, + ])("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: 2, + } 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: 2, + 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/__tests__/node-sql-parser-browser-worker-endpoint.test.ts b/src/__tests__/node-sql-parser-browser-worker-endpoint.test.ts new file mode 100644 index 0000000..f4a516e --- /dev/null +++ b/src/__tests__/node-sql-parser-browser-worker-endpoint.test.ts @@ -0,0 +1,920 @@ +// @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: 2 }, + ]); + expect(evaluations).toStrictEqual({ + bigquery: 0, + postgresql: 0, + }); + + scope.dispatch(request(1, "bigquery")); + await waitForMessageCount(scope, 2); + expect(scope.messages[1]).toStrictEqual({ + kind: "parsed", + queryBindings: expect.any(Object), + protocolVersion: 2, + 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", + queryBindings: expect.any(Object), + protocolVersion: 2, + 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", + queryBindings: expect.any(Object), + protocolVersion: 2, + 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: 2, + 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: 2, + 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: 2, + 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: 2, + 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: 2, + 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: 2, + 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: 2, + }); + 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: 2 }, + { + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 2, + }, + ]); + 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", + queryBindings: expect.any(Object), + protocolVersion: 2, + 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: 2, + requestId: 4, + }, + moduleValue: parserModule(() => { + throw { + location: { + end: { offset: 8 }, + start: { offset: 7 }, + }, + message: "syntax rejected", + name: "SyntaxError", + }; + }), + }, + { + code: undefined, + expected: { + kind: "unsupported", + protocolVersion: 2, + reason: "multiple-statements", + requestId: 4, + }, + moduleValue: parserModule(() => [ + { type: "select" }, + { type: "select" }, + ]), + }, + { + code: "backend", + expected: { + code: "backend", + kind: "failed", + protocolVersion: 2, + requestId: 4, + }, + moduleValue: parserModule(() => { + throw new Error("ordinary backend failure"); + }), + }, + { + code: "malformed-output", + expected: { + code: "malformed-output", + kind: "failed", + protocolVersion: 2, + 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: 2 }, + { + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 2, + }, + ]); + 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/__tests__/node-sql-parser-query-bindings.bench.ts b/src/__tests__/node-sql-parser-query-bindings.bench.ts new file mode 100644 index 0000000..ca3c991 --- /dev/null +++ b/src/__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/__tests__/node-sql-parser-query-bindings.test.ts b/src/__tests__/node-sql-parser-query-bindings.test.ts new file mode 100644 index 0000000..24b2c7f --- /dev/null +++ b/src/__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/__tests__/node-sql-parser-wire.test.ts b/src/__tests__/node-sql-parser-wire.test.ts new file mode 100644 index 0000000..11a5eb3 --- /dev/null +++ b/src/__tests__/node-sql-parser-wire.test.ts @@ -0,0 +1,742 @@ +// @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", + queryBindings: null, + 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: 2, + 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, 1, 3, "2", 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", + queryBindings: null, + protocolVersion: 2, + requestId: 7, + statementKind, + }); + }, + ); + + it("encodes every backend outcome and ready state", () => { + const cases: readonly [ + NodeSqlParserBackendOutcome, + NodeSqlParserWireMessage, + ][] = [ + [ + { kind: "syntax-rejected" }, + { + kind: "syntax-rejected", + protocolVersion: 2, + requestId: 3, + }, + ], + [ + { kind: "unsupported", reason: "multiple-statements" }, + { + kind: "unsupported", + protocolVersion: 2, + reason: "multiple-statements", + requestId: 3, + }, + ], + [ + { kind: "unsupported", reason: "resource-limit" }, + { + kind: "unsupported", + protocolVersion: 2, + reason: "resource-limit", + requestId: 3, + }, + ], + [ + { code: "backend", kind: "failed", retryable: false }, + { + code: "backend", + kind: "failed", + protocolVersion: 2, + requestId: 3, + }, + ], + [ + { + code: "malformed-output", + kind: "failed", + retryable: false, + }, + { + code: "malformed-output", + kind: "failed", + protocolVersion: 2, + requestId: 3, + }, + ], + [ + { code: "module-load", kind: "failed", retryable: true }, + { + code: "module-load", + kind: "failed", + protocolVersion: 2, + requestId: 3, + }, + ], + ]; + + const ready = encodeNodeSqlParserWireReady(); + expect(ready).toStrictEqual({ + kind: "ready", + protocolVersion: 2, + }); + 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", + "queryBindings", + "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: 2, + }); + 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: 2, + requestId, + }), + ).toBeNull(); + }); + + it.each([ + "select", + "QUERY", + "", + null, + 1, + ])("rejects statement kind %#", (statementKind) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "parsed", + queryBindings: null, + protocolVersion: 2, + requestId: 1, + statementKind, + }), + ).toBeNull(); + }); + + it.each([ + "timeout", + "multiple-statement", + "", + null, + ])("rejects unsupported reason %#", (reason) => { + expect( + decodeNodeSqlParserWireMessage({ + kind: "unsupported", + protocolVersion: 2, + reason, + requestId: 1, + }), + ).toBeNull(); + }); + + it.each([ + "timeout", + "syntax", + "", + null, + ])("rejects failure code %#", (code) => { + expect( + decodeNodeSqlParserWireMessage({ + code, + kind: "failed", + protocolVersion: 2, + requestId: 1, + }), + ).toBeNull(); + }); + + it.each([ + "parse", + "unknown", + "", + null, + ])("rejects response kind %#", (kind) => { + expect( + decodeNodeSqlParserWireMessage({ + kind, + protocolVersion: 2, + }), + ).toBeNull(); + }); + + it.each([0, 1, 3, "2", 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: 2, + requestId: 1, + retryable: true, + }), + ).toBeNull(); + expect( + decodeNodeSqlParserWireMessage({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 2, + 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/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index c33d7de..cc88f0a 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 @@ -26,27 +30,34 @@ 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 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")); - // 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("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); + it("publishes package.json and declares a dist-only export map", () => { + expect(packedPaths).toContain("package.json"); + expect(pkg.files).toEqual(["dist"]); + + 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/__tests__/query-binding-model.bench.ts b/src/__tests__/query-binding-model.bench.ts new file mode 100644 index 0000000..ae742fa --- /dev/null +++ b/src/__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/__tests__/query-binding-model.test.ts b/src/__tests__/query-binding-model.test.ts new file mode 100644 index 0000000..7126094 --- /dev/null +++ b/src/__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/__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.bench.ts b/src/__tests__/query-site.bench.ts new file mode 100644 index 0000000..6a8193d --- /dev/null +++ b/src/__tests__/query-site.bench.ts @@ -0,0 +1,223 @@ +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, + findSqlStatementSlot, +} from "../statement-index.js"; + +const dialect = DUCKDB_SQL_RELATION_DIALECT.querySite; +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", +); +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", () => { + 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, + ); + }); + + 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/__tests__/query-site.test.ts b/src/__tests__/query-site.test.ts new file mode 100644 index 0000000..027133d --- /dev/null +++ b/src/__tests__/query-site.test.ts @@ -0,0 +1,2253 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + type SqlCteLayout, +} from "../cte-layout.js"; +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 { + 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, + createMaskedSqlSource, + type SqlSourceSnapshot, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, + POSTGRESQL_SQL_LEXICAL_PROFILE, + type ExactSqlStatementSlot, + updateSqlStatementIndex, +} from "../statement-index.js"; + +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; + 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; +} + +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"], + ["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("applies each built-in identifier and path policy", () => { + 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 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([ + "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("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("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"], + ["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"], + ["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"], + ["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(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"], + ["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 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 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", + ])("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 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.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", + ); + }); + + 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, role); + }, + }; + 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 public.users|").status).toBe( + "ready", + ); + expect(recognize("SELECT * FROM a.b.c|")).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: "ambiguous-query-site", + status: "unavailable", + }); + + const globalPath = Array.from( + { length: MAX_QUERY_SITE_PATH_COMPONENTS }, + () => "a", + ).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( + 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/__tests__/relation-catalog-boundary.bench.ts b/src/__tests__/relation-catalog-boundary.bench.ts new file mode 100644 index 0000000..f8acb10 --- /dev/null +++ b/src/__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/__tests__/relation-catalog-boundary.test.ts b/src/__tests__/relation-catalog-boundary.test.ts new file mode 100644 index 0000000..80b7829 --- /dev/null +++ b/src/__tests__/relation-catalog-boundary.test.ts @@ -0,0 +1,1424 @@ +import { describe, expect, it } from "vitest"; +import { + captureSqlRelationCatalogProvider, + compareSqlCatalogEpoch, + createSqlCatalogSearchRequest, + decodeSqlCatalogInvalidation, + decodeSqlCatalogSearchResponse, + isValidSqlCatalogScope, + 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 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(); + 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("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( + 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/__tests__/relation-catalog-epoch-coordinator.bench.ts b/src/__tests__/relation-catalog-epoch-coordinator.bench.ts new file mode 100644 index 0000000..e8b327e --- /dev/null +++ b/src/__tests__/relation-catalog-epoch-coordinator.bench.ts @@ -0,0 +1,474 @@ +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, + SqlCatalogEpochTransitionTarget, + 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, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, +): 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, + prepareEpochTransition, + ); + 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( + "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 ( + 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/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/__tests__/relation-catalog-epoch-coordinator.test.ts new file mode 100644 index 0000000..fddbbab --- /dev/null +++ b/src/__tests__/relation-catalog-epoch-coordinator.test.ts @@ -0,0 +1,2696 @@ +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 SqlCatalogEpochTransitionTarget, + 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", + 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") { + 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("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, 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( + 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("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(); + 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("quarantines 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); + expect( + owner.prepareScopeMembership("replacement", target.target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + 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; + 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"); + }, + }); + const hostileCleanup = new Proxy(() => hostileResult, { + apply(target, receiver, argumentsList) { + applyCalls += 1; + expect(receiver).toBeUndefined(); + return Reflect.apply(target, receiver, argumentsList); + }, + }); + 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 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( + {}, + 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(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", () => { + 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/__tests__/relation-catalog-search-policy-store.test.ts b/src/__tests__/relation-catalog-search-policy-store.test.ts new file mode 100644 index 0000000..9f5d414 --- /dev/null +++ b/src/__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/__tests__/relation-catalog-search-work.bench.ts b/src/__tests__/relation-catalog-search-work.bench.ts new file mode 100644 index 0000000..1591d84 --- /dev/null +++ b/src/__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/__tests__/relation-catalog-search-work.test.ts b/src/__tests__/relation-catalog-search-work.test.ts new file mode 100644 index 0000000..f6dbbff --- /dev/null +++ b/src/__tests__/relation-catalog-search-work.test.ts @@ -0,0 +1,4038 @@ +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 { MAX_CATALOG_POLICY_STORE_ENTRIES } from "../relation-catalog-search-policy-store.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 }, + { refreshLeaseMs: 0 }, + { refreshLeaseMs: 5_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); + }); + + 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, { + deadlineScheduler: new ManualDeadlineScheduler(), + }); + 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", () => { + 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 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(); + 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/__tests__/relation-completion.test.ts b/src/__tests__/relation-completion.test.ts new file mode 100644 index 0000000..b33efba --- /dev/null +++ b/src/__tests__/relation-completion.test.ts @@ -0,0 +1,657 @@ +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) => { + 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"], + ["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/__tests__/relation-dialect-boundaries.test.ts b/src/__tests__/relation-dialect-boundaries.test.ts new file mode 100644 index 0000000..2cbb508 --- /dev/null +++ b/src/__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/__tests__/relation-dialect.test.ts b/src/__tests__/relation-dialect.test.ts new file mode 100644 index 0000000..667b416 --- /dev/null +++ b/src/__tests__/relation-dialect.test.ts @@ -0,0 +1,1062 @@ +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 { + isSqlRelationDialectRuntime, +} from "../relation-runtime-auth.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 index = buildSqlStatementIndex( + source.analysisText, + runtime.querySite.lexicalProfile, + ); + 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, + index, + 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("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 [id, runtime] of Object.entries(RUNTIMES)) { + expectDeepFrozenRuntime(runtime); + expect(runtime.id).toBe(id); + 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/__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-completion.bench.ts b/src/__tests__/session-completion.bench.ts new file mode 100644 index 0000000..b1b2739 --- /dev/null +++ b/src/__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/__tests__/session.test.ts b/src/__tests__/session.test.ts new file mode 100644 index 0000000..2e5056d --- /dev/null +++ b/src/__tests__/session.test.ts @@ -0,0 +1,7174 @@ +import { describe, expect, it, vi } from "vitest"; +import { + bigQueryDialect, + createSqlLanguageService, + dremioDialect, + duckdbDialect, + postgresDialect, + SqlSessionError, + type SqlColumnCatalogProvider, + type SqlNamespaceCatalogProvider, +} from "../index.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, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "../statement-index.js"; +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; + readonly settings?: { + readonly flags: readonly boolean[]; + }; +} + +const duckdb = duckdbDialect(); +const postgres = postgresDialect(); + +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 singleton definitions", () => { + expect(duckdb).toEqual({ + displayName: "DuckDB", + id: "duckdb", + }); + expect(Object.isFrozen(duckdb)).toBe(true); + expect(duckdbDialect()).toBe(duckdb); + expect(postgresDialect()).toBe(postgres); + expect(bigQueryDialect()).toBe(bigQueryDialect()); + 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({ + dialects: [duckdb, duckdbDialect()], + }); + }); + }); + + it("rejects copied and fabricated definitions without invoking traps", () => { + let invoked = false; + expectSessionError("invalid-dialect", () => { + createSqlLanguageService({ + dialects: [{ ...duckdb }], + }); + }); + expectSessionError("invalid-dialect", () => { + createSqlLanguageService({ + dialects: [{ displayName: "DuckDB", id: "duckdb" } as never], + }); + }); + expectSessionError("invalid-dialect", () => { + createSqlLanguageService({ dialects: [null as never] }); + }); + expectSessionError("invalid-dialect", () => { + 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, + ], + }); + }); + expect(invoked).toBe(false); + }); +}); + +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({ + refreshToken: null, + status: "ready", + value: { + items: [ + { + edit: { from: 45, insert: "source_data", to: 48 }, + label: "source_data", + relationKind: "cte", + }, + ], + }, + }); + 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", + }, + 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.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 () => ({ + 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", + engine: "local", + }, + 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", + }, + ], + }, + }); + expect(result).toMatchObject({ refreshToken: null }); + service.dispose(); + }); + + 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("reports terminal loading with a bounded completion intent", async () => { + const service = createSqlLanguageService({ + catalog: catalogProvider(async () => ({ + epoch: { generation: 0, token: "loading" }, + status: "loading", + })), + dialects: [duckdb], + }); + 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, + }, + ], + }, + }); + 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(); + }); + + 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], + }); + 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: [{ 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" }, + relations: [], + status: "ready", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + 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({ shouldAdvanceTime: true }); + 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(); + }); + + 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], + }); + const session = service.openDocument({ + context: { + catalog: { scope: "connection:1" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + 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("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(); + }); + + 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; + }, + ), + 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 = 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([ + expect.objectContaining({ + reason: "catalog", + refreshToken: result.refreshToken, + }), + ]); + service.dispose(); + }); + + 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", + }, + }); + 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({ + baseRevision: session.revision, + context: { + catalog: { scope: "connection:2" }, + dialect: "duckdb", + engine: "local", + }, + }); + 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("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("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"] + >[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({ + sources: [{ feature: "query-output" }], + status: "ready", + value: { + items: [{ + edit: { insert: "local_id" }, + label: "local_id", + }], + }, + }); + 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({ + 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", + }), + }, + }); + const text = "SELECT * FROM m"; + const session = service.openDocument({ + context: { + catalog: { scope: "connection:namespace-only" }, + dialect: "duckdb", + engine: "local", + }, + text, + }); + + 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:namespaces-loading" }, + dialect: "duckdb", + 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, + }], + }); + + await vi.advanceTimersByTimeAsync(100); + expect(events).toMatchObject([{ + reason: "catalog-availability", + refreshToken: result.refreshToken, + }]); + + 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("retains auxiliary owners across unrelated context changes", () => { + const service = createSqlLanguageService({ + columns: { + id: "columns", + loadColumns: async () => ({ + epoch: { generation: 1, token: "epoch-1" }, + relations: [], + }), + }, + 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:stable" }, + dialect: "duckdb", + engine: "before", + }, + text: "SELECT 1", + }); + const revision = session.update({ + baseRevision: session.revision, + context: { + catalog: { scope: "connection:stable" }, + dialect: "duckdb", + engine: "after", + }, + }); + + expect(session.revision).toBe(revision); + service.dispose(); + }); + + 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: { + id: "relations", + search: async () => ({ + coverage: { kind: "complete" }, + epoch: { generation, token: `epoch-${generation}` }, + relations: [], + status: "ready", + }), + 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], + namespaces: { + id: "namespaces", + search: async () => { + namespaceCalls += 1; + return { + containers: [], + coverage: "complete", + epoch: { generation, token: `epoch-${generation}` }, + status: "ready", + }; + }, + }, + }); + 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" }, + }); + + await completeColumns(); + await completeNamespaces(); + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 1, + namespaceCalls: 1, + }); + + generation = 2; + invalidate?.({ + epoch: { generation, token: `epoch-${generation}` }, + }); + await completeColumns(); + await completeNamespaces(); + expect({ columnCalls, namespaceCalls }).toEqual({ + columnCalls: 2, + namespaceCalls: 2, + }); + service.dispose(); + }); + + 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], + namespaces: { + id: "namespaces", + search: async () => { + namespaceCalls += 1; + return { + containers: [], + coverage: "complete", + epoch: { generation, token: `epoch-${generation}` }, + status: "ready", + }; + }, + }, + }); + const columns = service.openDocument({ + context: { + catalog: { scope: "connection:manual-invalidate" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT u. FROM users u", + }); + const namespaces = service.openDocument({ + context: { + catalog: { scope: "connection:manual-invalidate" }, + dialect: "duckdb", + engine: "local", + }, + text: "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" }, + }); + + 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(); + }); +}); + +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", () => { + const { session } = openSession(); + session.getStatementIndexForTesting(); + session.dispose(); + expect(session.cachedStatementIndexForTesting).toBeNull(); + expectSessionError("session-disposed", () => { + session.getStatementIndexForTesting(); + }); + }); +}); + +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(); + 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({ + embeddedRegions: [], + baseRevision: first, + document: { kind: "replace", text: "B" }, + }); + const third = session.update({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + baseRevision: first, + document: { kind: "replace", text: "SELECT 1" }, + }); + expect(second).not.toBe(first); + }); + + 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({ + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "remote" }, + }); + expect(session.snapshotForTesting.source).toBe(initialSource); + + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "changes", changes: [] }, + }); + 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({ + embeddedRegions: [], + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 7, insert: "customers", to: 12 }, + { from: 21, insert: "customers", to: 26 }, + ], + }, + }); + + expect(session.snapshotForTesting.source.originalText).toBe( + "SELECT customers.id FROM customers", + ); + }); + + it("uses JavaScript UTF-16 offsets", () => { + const { session } = openSession("A๐Ÿ˜€B"); + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { + kind: "changes", + changes: [{ from: 1, insert: "X", to: 3 }], + }, + }); + expect(session.snapshotForTesting.source.originalText).toBe("AXB"); + }); + + it("keeps same-position insertions ordered", () => { + const { session } = openSession("AB"); + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { from: 1, insert: "1", to: 1 }, + { from: 1, insert: "2", to: 1 }, + ], + }, + }); + expect(session.snapshotForTesting.source.originalText).toBe("A12B"); + }); + + it("supports adjacent replacements and document boundaries", () => { + const { session } = openSession("ABCDE"); + session.update({ + embeddedRegions: [], + 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.source.originalText).toBe("XYZ!"); + }); + + it("deletes the entire document", () => { + const { session } = openSession("SELECT 1"); + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "changes", changes: [{ from: 0, insert: "", to: 8 }] }, + }); + expect(session.snapshotForTesting.source.originalText).toBe(""); + }); + + it("allows edits at every UTF-16 boundary", () => { + const { session } = openSession("A๐Ÿ˜€e\u0301\r\nZ\uD800"); + session.update({ + embeddedRegions: [], + 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.source.originalText).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({ + embeddedRegions: [], + baseRevision: revision, + document: { kind: "changes", changes: [change] }, + }); + }); + + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); + }); + + it("rejects unordered and overlapping changes atomically", () => { + const { session } = openSession("ABCDE"); + const revision = session.revision; + expectSessionError("invalid-change", () => { + session.update({ + embeddedRegions: [], + baseRevision: revision, + document: { + kind: "changes", + changes: [ + { from: 2, insert: "", to: 4 }, + { from: 1, insert: "", to: 2 }, + ], + }, + }); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.source.originalText).toBe("ABCDE"); + }); + + it("rejects non-string inserts from JavaScript callers", () => { + const { session } = openSession("ABC"); + expectSessionError("invalid-change", () => { + session.update({ + embeddedRegions: [], + 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({ embeddedRegions: [], baseRevision: revision, document } as never); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.source.originalText).toBe("ABC"); + }); + + it("rejects an empty JavaScript update", () => { + const { session } = openSession("ABC"); + expectSessionError("invalid-update", () => { + session.update({ 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({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "unknown" }, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "replace", text: "ABC", changes: [] }, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + baseRevision: session.revision, + context: undefined, + } as never); + }); + expectSessionError("invalid-update", () => { + session.update({ + 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({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "replace", text: "recovered" }, + }); + expect(session.snapshotForTesting.source.originalText).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.source.originalText).toBe("ABC"); + }); + + it("rejects accessor optional update fields", () => { + const { session } = openSession("ABC"); + const revision = session.revision; + let invoked = false; + const update = { + baseRevision: session.revision, + document: { kind: "replace", text: "changed" }, + embeddedRegions: [], + get context() { + invoked = true; + return { dialect: "postgresql", engine: "warehouse" }; + }, + }; + expectSessionError("invalid-update", () => { + session.update(update as never); + }); + expect(invoked).toBe(false); + expect(session.revision).toBe(revision); + 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; + const document: SqlDocumentReplacement = { + kind: "replace", + get text() { + invoked = true; + return "changed"; + }, + }; + expectSessionError("invalid-update", () => { + session.update({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + baseRevision: originalRevision, + document: { kind: "replace", text: "nested" }, + }); + } catch (error) { + if (error instanceof SqlSessionError) { + nestedError = error; + } + } + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + + const revision = session.update({ + embeddedRegions: [], + baseRevision: originalRevision, + document, + }); + + expect(nestedError?.code).toBe("reentrant-update"); + expect(session.isCurrent(revision)).toBe(true); + expect(session.snapshotForTesting.source.originalText).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({ + embeddedRegions: [], + baseRevision: revision, + document, + }); + }); + expect(session.isCurrent(revision)).toBe(false); + expect(session.snapshotForTesting.source.originalText).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({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "changes", changes: tooManyChanges }, + }); + }); + + expectSessionError("invalid-document", () => { + session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { + kind: "changes", + changes: [ + { + from: 0, + insert: "x".repeat(16 * 1024 * 1024 + 1), + to: 0, + }, + ], + }, + }); + }); + expect(session.snapshotForTesting.source.originalText).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({ + embeddedRegions: [], + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + document: { kind: "replace", text: "SELECT 2" }, + }); + + expect(session.snapshotForTesting).toMatchObject({ + context: { dialect: "postgresql", engine: "warehouse" }, + source: { originalText: "SELECT 2" }, + }); + + const snapshot = session.snapshotForTesting; + expectSessionError("invalid-update", () => { + session.update({ + embeddedRegions: [], + 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({ + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + }); + expect(session.snapshotForTesting.source.originalText).toBe("SELECT 1"); + expect(session.snapshotForTesting.context.dialect).toBe("postgresql"); + }); + + it("retains the owned context for document-only updates", () => { + const { session } = openSession(); + const context = session.snapshotForTesting.context; + session.update({ + embeddedRegions: [], + 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({ baseRevision: session.revision, context }); + const firstOwned = session.snapshotForTesting.context; + session.update({ 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({ + embeddedRegions: [], + baseRevision: revision, + context, + document: { kind: "replace", text: "SELECT 2" }, + } as never); + }); + expect(session.revision).toBe(revision); + expect(session.snapshotForTesting.source.originalText).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("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", + 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({ embeddedRegions: [], 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({ 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({ + embeddedRegions: [], + 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({ + embeddedRegions: [], + 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: "postgresql", 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, + ); + }); + }); +}); + +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([ + { 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", + 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], + namespaces: { + id: "retained-intent-namespaces", + search: async () => ({ + containers: [], + coverage: "complete", + epoch: { generation: 0, token: "ready" }, + status: "ready", + }), + }, + }); + 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, + }); + const events: SqlSessionChangeEvent[] = []; + session.onDidChange((event) => { + events.push(event); + }); + await session.complete({ + position: text.indexOf("a;") + 1, + trigger: { kind: "invoked" }, + }); + 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(); + }); + + 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({ + 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("handles synchronous soft-intent timer completion", async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const cleared: unknown[] = []; + let responseTimerSeen = false; + let positiveTimersAfterResponse = 0; + let synchronousCalls = 0; + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: ( + callback: TimerHandler, + delay?: number, + ...arguments_: unknown[] + ) => { + 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; + } + } + 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 = 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:synchronous-soft-timer" }, + dialect: "duckdb", + engine: "local", + }, + text: "SELECT * FROM ", + }); + 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" }, + }); + 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: "superseded", + status: "cancelled", + }); + service.dispose(); + await expect(nested).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + }); + + 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) + | 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/__tests__/source.test.ts b/src/__tests__/source.test.ts new file mode 100644 index 0000000..62178d9 --- /dev/null +++ b/src/__tests__/source.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vitest"; +import { + createIdentitySqlSource, + createMaskedSqlSource, + findSqlEmbeddedRegionAtOrAfter, + isSqlSourceSnapshot, + 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); + expect(isSqlSourceSnapshot(source)).toBe(true); + expect(isSqlSourceSnapshot({ ...source })).toBe(false); + expect(isSqlSourceSnapshot(null)).toBe(false); + }); + + 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("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, + {}, + [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 and oversized 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); + }); + }); + + it("copies declared region fields and ignores structural extras", () => { + const extended = [{ from: 0, language: "python", to: 1 }]; + 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", () => { + 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( + [], + { + getOwnPropertyDescriptor() { + 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( + [], + { + getOwnPropertyDescriptor() { + 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/__tests__/statement-index.bench.ts b/src/__tests__/statement-index.bench.ts new file mode 100644 index 0000000..42fceb3 --- /dev/null +++ b/src/__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/__tests__/statement-index.test.ts b/src/__tests__/statement-index.test.ts new file mode 100644 index 0000000..26bc41f --- /dev/null +++ b/src/__tests__/statement-index.test.ts @@ -0,0 +1,789 @@ +import { describe, expect, it } from "vitest"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + buildSqlStatementIndex, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + findSqlStatementSlot, + isExactSqlStatementSlotSnapshot, + isExactSqlStatementSlotSnapshotFor, + isSqlStatementSlotSnapshot, + 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("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([ + [ + "", + [ + { + 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/__tests__/syntax.test.ts b/src/__tests__/syntax.test.ts new file mode 100644 index 0000000..97bcaf5 --- /dev/null +++ b/src/__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/bounded-sql-lexer.ts b/src/bounded-sql-lexer.ts new file mode 100644 index 0000000..5996b9c --- /dev/null +++ b/src/bounded-sql-lexer.ts @@ -0,0 +1,272 @@ +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; + resourceAt: number | 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"; + this.resourceAt = from; + 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"; + this.resourceAt = lexeme.from; + return null; + } + return lexeme; + } +} diff --git a/src/browser_tests/fixtures/node-sql-parser-crash-worker.js b/src/browser_tests/fixtures/node-sql-parser-crash-worker.js new file mode 100644 index 0000000..1ec2a4f --- /dev/null +++ b/src/browser_tests/fixtures/node-sql-parser-crash-worker.js @@ -0,0 +1,7 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 2, +}); +globalThis.addEventListener("message", () => { + throw new Error("Intentional parser executor crash fixture"); +}); diff --git a/src/browser_tests/fixtures/node-sql-parser-silent-worker.js b/src/browser_tests/fixtures/node-sql-parser-silent-worker.js new file mode 100644 index 0000000..7ae4382 --- /dev/null +++ b/src/browser_tests/fixtures/node-sql-parser-silent-worker.js @@ -0,0 +1,5 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 2, +}); +globalThis.addEventListener("message", () => {}); diff --git a/src/browser_tests/node-sql-parser-adapter.test.ts b/src/browser_tests/node-sql-parser-adapter.test.ts new file mode 100644 index 0000000..f691f76 --- /dev/null +++ b/src/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/browser_tests/node-sql-parser-browser-executor.test.ts b/src/browser_tests/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..eac837f --- /dev/null +++ b/src/browser_tests/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,283 @@ +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", + queryBindings: expect.any(Object), + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "postgresql", + "SELECT 2 AS warm_value", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + queryBindings: expect.any(Object), + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "bigquery", + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + queryBindings: expect.any(Object), + 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", + queryBindings: expect.any(Object), + 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", + queryBindings: expect.any(Object), + statementKind: "query", + }); + expect(generation).toBe(2); + } finally { + executor.dispose(); + } + }, +); diff --git a/src/browser_tests/node-sql-parser-browser-worker.test.ts b/src/browser_tests/node-sql-parser-browser-worker.test.ts new file mode 100644 index 0000000..968dcf6 --- /dev/null +++ b/src/browser_tests/node-sql-parser-browser-worker.test.ts @@ -0,0 +1,272 @@ +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, + queryBindings: expect.any(Object), + 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, + queryBindings: expect.any(Object), + 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, + queryBindings: expect.any(Object), + 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/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/codemirror/__tests__/sql-editor-performance.test.ts b/src/codemirror/__tests__/sql-editor-performance.test.ts new file mode 100644 index 0000000..8a456c8 --- /dev/null +++ b/src/codemirror/__tests__/sql-editor-performance.test.ts @@ -0,0 +1,166 @@ +import { currentCompletions } from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } 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(); +}); + +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 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 20 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(20); + }); + + 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: { activateOnTypingDelay: 0 }, + initialContext: { + catalog: { scope: "performance" }, + dialect: "duckdb", + }, + service, + }); + const initialText = "SELECT * FROM "; + const view = new EditorView({ + doc: initialText, + extensions: support.extension, + parent: document.body, + selection: { anchor: initialText.length }, + }); + views.push(view); + view.focus(); + const startedAt = performance.now(); + + for (const character of "users") { + const callsBefore = providerCalls; + view.dispatch({ + changes: { + from: view.state.doc.length, + insert: character, + }, + selection: { anchor: view.state.doc.length + 1 }, + userEvent: "input.type", + }); + await vi.waitFor(() => { + expect(providerCalls).toBeGreaterThan(callsBefore); + }, { interval: 1, timeout: 100 }); + } + + 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(); + }); +}); diff --git a/src/codemirror/__tests__/sql-editor.bench.ts b/src/codemirror/__tests__/sql-editor.bench.ts new file mode 100644 index 0000000..b2e52eb --- /dev/null +++ b/src/codemirror/__tests__/sql-editor.bench.ts @@ -0,0 +1,56 @@ +import { EditorView } from "@codemirror/view"; +import { afterAll, bench, describe } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../../index.js"; +import { sqlEditor } from "../index.js"; + +const statement = "SELECT id, name FROM users WHERE active = true;\n"; +const documentText = 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 parent = document.createElement("div"); +document.body.append(parent); +const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, +}); +const position = documentText.indexOf("active"); +support.statementBoundaryAt(view, { + affinity: "left", + position, +}); + +afterAll(() => { + view.destroy(); + service.dispose(); + parent.remove(); +}); + +describe("CodeMirror editor", () => { + bench("1 MiB warmed single-character replacement", () => { + const current = view.state.sliceDoc(position, position + 1); + view.dispatch({ + changes: { + from: position, + insert: current === "a" ? "A" : "a", + to: position + 1, + }, + userEvent: "input.type", + }); + }); +}); diff --git a/src/codemirror/__tests__/sql-editor.test.ts b/src/codemirror/__tests__/sql-editor.test.ts new file mode 100644 index 0000000..7be6e11 --- /dev/null +++ b/src/codemirror/__tests__/sql-editor.test.ts @@ -0,0 +1,2405 @@ +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"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bigQueryDialect, + createSqlLanguageService, + duckdbDialect, + type SqlCatalogSearchRequest, + type SqlCatalogSearchResponse, + type SqlCompletionItem, + type SqlCompletionRequest, + type SqlCompletionRefreshToken, + type SqlCompletionResult, + type SqlCompletionTask, + type SqlContextInput, + type SqlDocumentContext, + type SqlDocumentSession, + type SqlDocumentUpdate, + type SqlFeatureTask, + type SqlLanguageFeatureMethods, + type SqlLanguageService, + type SqlRelationCatalogProvider, + type SqlRevision, + type SqlSessionChangeEvent, + type SqlTextRange, +} 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 completionRequests: readonly SqlCompletionRequest[]; + 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[]; +} + +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-function" = "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, + statementCode: SqlTextRange | null = null, +): FakeServiceHarness { + const completeSignals: AbortSignal[] = []; + const completionRequests: SqlCompletionRequest[] = []; + const updates: SqlDocumentUpdate[] = []; + let listener: + | ((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: () => { + 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); + const token = createSqlCompletionRefreshToken(); + lastToken = token; + return Object.assign( + Promise.resolve(result(revision, token)), + { refreshToken: token }, + ); + }, + dispose: () => { + if (disposed) return; + disposed = true; + sessionDisposalCount += 1; + }, + invalidateCatalog: () => { + invalidationCount += 1; + revision = createSqlRevisionToken(); + return revision; + }, + isCurrent: (candidate) => !disposed && candidate === revision, + onDidChange: (nextListener) => { + listener = nextListener; + return { + dispose: () => { + if (listener === nextListener) listener = null; + }, + }; + }, + get revision() { + return 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) { + throw new Error("update rejected"); + } + revision = createSqlRevisionToken(); + return revision; + }, + }; + return session; + }, + }; + return { + completionRequests, + completeSignals, + emit: (event) => listener?.(event), + getLastToken: () => lastToken, + invalidations: () => invalidationCount, + service, + sessionDisposals: () => sessionDisposalCount, + statementBoundaryCalls: () => statementBoundaryCallCount, + statementIntersectionCalls: () => + statementIntersectionCallCount, + 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"); + }); +} + +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("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()], + }); + 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("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()], + }); + 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: { + 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("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> = []; + 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)]) + ); + 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("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({ + 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("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", + "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/codemirror/browser_tests/sql-editor-completion-gate.test.ts b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts new file mode 100644 index 0000000..243ca0c --- /dev/null +++ b/src/codemirror/browser_tests/sql-editor-completion-gate.test.ts @@ -0,0 +1,588 @@ +import { + acceptCompletion, + currentCompletions, + selectedCompletionIndex, + startCompletion, +} from "@codemirror/autocomplete"; +import { sql, StandardSQL } from "@codemirror/lang-sql"; +import { EditorView } from "@codemirror/view"; +import { expect, onTestFinished, test } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../../index.js"; +import { sqlEditor } from "../index.js"; + +test("standard 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()); + view.focus(); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["python_variable"]); + expect(catalogSearches).toBe(0); +}); + +test("standard 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()); + view.focus(); + + expect(startCompletion(view)).toBe(true); + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).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 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); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { + dialect: "duckdb", + }, + service, + }); + const documentText = "SEL"; + const view = new EditorView({ + doc: documentText, + extensions: [ + sql({ dialect: StandardSQL }), + 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("select"); +}); + +test("standard editor refreshes the first completion after a slow provider", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + catalog: { + id: "browser-slow-catalog", + search: async (_request, signal) => { + await new Promise((resolve, reject) => { + const handle = setTimeout(resolve, 60); + signal.addEventListener("abort", () => { + 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()], + }); + onTestFinished(() => service.dispose()); + const support = sqlEditor({ + initialContext: { + catalog: { scope: "browser-slow" }, + 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"]); +}); + +test("standard editor activates catalog completion while typing", async () => { + const parent = document.createElement("div"); + document.body.append(parent); + onTestFinished(() => parent.remove()); + const service = createSqlLanguageService({ + catalog: { + id: "browser-typing-catalog", + 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: { activateOnTypingDelay: 0 }, + initialContext: { + catalog: { scope: "browser-typing" }, + dialect: "duckdb", + }, + service, + }); + const documentText = "SELECT * FROM u"; + const view = new EditorView({ + doc: documentText, + extensions: support.extension, + parent, + selection: { anchor: documentText.length }, + }); + onTestFinished(() => view.destroy()); + view.focus(); + view.dispatch({ + changes: { from: documentText.length, insert: "s" }, + userEvent: "input.type", + }); + + await expect.poll(() => + currentCompletions(view.state).map((item) => item.label) + ).toEqual(["users"]); +}); + +test("standard 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("standard 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("standard 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/codemirror/browser_tests/statement-gutter.test.ts b/src/codemirror/browser_tests/statement-gutter.test.ts new file mode 100644 index 0000000..7dd3144 --- /dev/null +++ b/src/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("standard 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("standard 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/codemirror/index.ts b/src/codemirror/index.ts new file mode 100644 index 0000000..16e2265 --- /dev/null +++ b/src/codemirror/index.ts @@ -0,0 +1,14 @@ +export { + sqlEditor, + type SqlEditorAutocompleteOptions, + type SqlEditorOptions, + type SqlEditorSupport, +} from "./sql-editor.js"; +export type { + SqlEditorStatementGutterOptions, +} from "./statement-gutter.js"; +export type { + SqlCompletionInfoResolver, + SqlCompletionInfoResolverContext, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; diff --git a/src/codemirror/relation-completion-types.ts b/src/codemirror/relation-completion-types.ts new file mode 100644 index 0000000..53aac2f --- /dev/null +++ b/src/codemirror/relation-completion-types.ts @@ -0,0 +1,19 @@ +import type { SqlCompletionItem } 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: SqlCompletionItem, + context: SqlCompletionInfoResolverContext, +) => + | SqlDisposableCompletionInfo + | null + | Promise; diff --git a/src/codemirror/sql-editor.ts b/src/codemirror/sql-editor.ts new file mode 100644 index 0000000..47627ff --- /dev/null +++ b/src/codemirror/sql-editor.ts @@ -0,0 +1,1134 @@ +import { + autocompletion, + closeCompletion, + completionStatus, + pickedCompletion, + selectedCompletion, + selectedCompletionIndex, + startCompletion, + type Completion, + type CompletionContext, + type CompletionResult, + type CompletionSource, +} from "@codemirror/autocomplete"; +import { + EditorState, + 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 { + SqlCompletionInfoResolver, + SqlDisposableCompletionInfo, +} from "./relation-completion-types.js"; +import type { + SqlCompletionItem, + SqlCompletionRefreshToken, + SqlCompletionTrigger, + SqlCompletionResult, + SqlCompletionTask, +} from "../relation-completion-types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, +} from "../statement-boundary-types.js"; +import type { + SqlContextInput, + SqlDocumentContext, + SqlDocumentSession, + SqlEmbeddedRegion, + 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, +} from "./statement-gutter.js"; + +export interface SqlEditorAutocompleteOptions { + readonly activateOnTyping?: boolean; + readonly activateOnTypingDelay?: number; + readonly closeOnBlur?: boolean; + 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; +} + +export interface SqlEditorOptions< + Context extends SqlDocumentContext, +> { + readonly autocomplete?: SqlEditorAutocompleteOptions; + readonly initialContext: SqlContextInput; + readonly initialEmbeddedRegions?: + | readonly SqlEmbeddedRegion[] + | undefined; + readonly service: SqlLanguageService; + readonly statementGutter?: + | false + | SqlEditorStatementGutterOptions; +} + +export interface SqlEditorSupport< + Context extends SqlDocumentContext, +> { + readonly contextEffect: StateEffectType>; + readonly embeddedRegionsEffect: StateEffectType< + 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, + request: SqlStatementBoundariesIntersectingRequest, + ) => SqlStatementBoundariesIntersectingResult | null; + readonly statementBoundaryAt: ( + view: EditorView, + request: SqlStatementBoundaryAtRequest, + ) => SqlStatementBoundaryAtResult | null; + readonly setContext: ( + view: EditorView, + context: SqlContextInput, + ) => void; + readonly setEmbeddedRegions: ( + 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 { + 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 embeddedRegions: readonly SqlEmbeddedRegion[]; + 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; +} + +interface ActiveCompletionInfo { + readonly controller: AbortController; + disposed: boolean; + resource: SqlDisposableCompletionInfo | null; +} + +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( + 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[] { + 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" || + issue.reason === "column-catalog-loading" || + issue.reason === "namespace-catalog-loading") && + typeof issue.remainingIntentLeaseMs === "number" + ) { + 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 { + 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"; +} + +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; + }, + }); + const autocomplete = options.autocomplete ?? {}; + const { + externalSources = [], + infoResolver, + isCompletionPositionAllowed, + ...autocompleteOptions + } = autocomplete; + + let plugin: ViewPlugin; + let completionSource: CompletionSource; + + class SqlEditorPlugin { + readonly #view: EditorView; + readonly #session: SqlDocumentSession; + #active: ActiveCompletion | null = null; + #contextGeneration = 0; + #destroyed = false; + #disposingInfo = false; + #completionPositionAllowed: boolean; + #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; + readonly #visibilityListener: () => void; + + get destroyed(): boolean { + return this.#destroyed; + } + + 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.#completionPositionAllowed = + this.#completionPositionIsAllowed( + view.state, + view.state.selection.main.head, + ); + 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(); + 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; + } + + #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); + this.#intentTimer = null; + } + this.#intent = null; + } + + #clearCompletionState(): void { + this.#sequence += 1; + this.#abortActive(); + this.#clearInfo(); + 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.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(() => { + if ( + this.#destroyed || + sequence !== this.#sequence + ) { + return; + } + runtime.closeCompletion(this.#view); + }); + } + + #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 || + !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), + }; + 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 }; + } + + readonly complete = async ( + context: CompletionContext, + ): Promise => { + this.#clearCompletionState(); + if ( + !this.#completionPositionIsAllowed( + context.state, + context.pos, + ) + ) { + return null; + } + const capture = this.#capture(); + const controller = new AbortController(); + let task: SqlCompletionTask; + try { + task = this.#session.complete({ + position: context.pos, + signal: controller.signal, + trigger: completionTrigger(context), + }); + } 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 ( + !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 || + 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 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 { + 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) { + 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 ( + completionPositionBecameDenied && + nextCompletionStatus !== null + ) { + this.#scheduleCompletionGateClose(this.#capture()); + } + 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" || + (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 completionLanguageData = EditorState.languageData.of(() => [ + { autocomplete: completionSource }, + ...externalSources.map((autocomplete) => ({ autocomplete })), + ]); + const escapeKeymap = Prec.high( + keymap.of([{ + key: "Escape", + run: (view) => { + view.plugin(plugin)?.cancelCompletion(); + return false; + }, + }]), + ); + 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), + ], + }); + 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, + embeddedRegionsField, + plugin, + escapeKeymap, + statementGutter, + 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, + ) => + view.plugin(plugin)?.statementBoundariesIntersecting(request) ?? + null, + statementBoundaryAt: ( + view: EditorView, + request: SqlStatementBoundaryAtRequest, + ) => + view.plugin(plugin)?.statementBoundaryAt(request) ?? null, + 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); +} diff --git a/src/codemirror/statement-gutter.ts b/src/codemirror/statement-gutter.ts new file mode 100644 index 0000000..392adee --- /dev/null +++ b/src/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/column-catalog-batch-coordinator.ts b/src/column-catalog-batch-coordinator.ts new file mode 100644 index 0000000..38ba9d7 --- /dev/null +++ b/src/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/column-catalog-boundary.ts b/src/column-catalog-boundary.ts new file mode 100644 index 0000000..3c1f573 --- /dev/null +++ b/src/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/column-catalog-types.ts b/src/column-catalog-types.ts new file mode 100644 index 0000000..f2a4690 --- /dev/null +++ b/src/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/column-completion.ts b/src/column-completion.ts new file mode 100644 index 0000000..f0b433f --- /dev/null +++ b/src/column-completion.ts @@ -0,0 +1,538 @@ +import type { + SqlColumnCatalogBatchOutcome, +} from "./column-catalog-batch-coordinator.js"; +import { + MAX_COLUMN_BATCH_RELATIONS, + MAX_COLUMNS_PER_BATCH, +} 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, + SqlCompletionProviderReport, + SqlQueryOutputProviderReport, +} 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 SqlCompletionProviderReport[]; + readonly value: SqlCompletionList; +} + +const completionIdentifiers = new WeakMap< + SqlCompletionItem, + SqlIdentifierComponent +>(); + +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"; + 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, + 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("."); +} + +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 { + 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 usingIdentifiers = new Map(); + 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; + } + 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( + completionIdentifier, + input.site.prefix, + ) !== "match" + ) { + continue; + } + const identity = [ + column.provenance.providerId, + column.provenance.scope, + column.provenance.relationEntityId, + column.provenance.columnEntityId, + 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; + const item = Object.freeze({ + ...(column.dataType === undefined + ? {} + : { dataType: column.dataType }), + detail: metadata === undefined + ? relationName + : `${metadata} โ€” ${relationName}`, + edit: Object.freeze({ + from: input.site.replacementRange.from, + insert: insertText, + to: input.site.replacementRange.to, + }), + kind: "column", + label: completionIdentifier.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, + }); + 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( + 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/column-query-site.ts b/src/column-query-site.ts new file mode 100644 index 0000000..f0752d5 --- /dev/null +++ b/src/column-query-site.ts @@ -0,0 +1,885 @@ +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"; +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"; + +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 context: SqlColumnCompletionContext; + readonly issues: readonly SqlColumnQuerySiteIssue[]; + readonly prefix: SqlIdentifierComponent; + readonly qualifier: SqlIdentifierPath; + readonly relations: readonly SqlColumnQueryRelation[]; + readonly replacementRange: SqlTextRange; + }; + +interface Token extends BoundedSqlLexeme { + readonly depth: number; +} + +export type SqlColumnCompletionContext = + | "from" + | "group" + | "having" + | "join-condition" + | "limit" + | "order" + | "qualify" + | "select-list" + | "using" + | "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 (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, "(") + ? "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; + } + } + 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" + : 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, + to: lastPathToken.to, + }), + }), + }; +} + +function clauseAt( + source: SqlSourceSnapshot, + tokens: readonly Token[], + selectIndex: number, + depth: number, + position: number, +): SqlColumnCompletionContext { + let clause: SqlColumnCompletionContext = "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": + clause = "join-condition"; + break; + case "using": + clause = "using"; + 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) { + if ( + precedingOnly && + parsed.relation.range.to >= visibilityPosition + ) { + break; + } + 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", + context: clause, + issues: issueList, + prefix: path.prefix, + qualifier: path.qualifier, + relations: Object.freeze(relations), + replacementRange: path.replacementRange, + status: "ready", + }); +} diff --git a/src/cte-layout.ts b/src/cte-layout.ts new file mode 100644 index 0000000..3d83544 --- /dev/null +++ b/src/cte-layout.ts @@ -0,0 +1,1991 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme as Lexeme, + type BoundedSqlLexerResource, +} from "./bounded-sql-lexer.js"; +import type { SqlLexicalProfile } from "./lexical.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isExactSqlStatementSlotSnapshotFor, + type ExactSqlStatementSlot, + 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"); + +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 const MAX_CTE_QUOTED_IDENTIFIER_LENGTH = + MAX_CTE_IDENTIFIER_LENGTH * 10 + 2; + +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-column" + | "cte-declaration" + | "cte-frame" + | "identifier-segment" + | "lexical-token" + | "parenthesis-depth"; + +export interface SqlCteIdentifier { + readonly component: SqlIdentifierComponent; +} + +export interface SqlCteDeclaredColumn { + readonly name: SqlIdentifierComponent; + readonly range: SqlCteRange; +} + +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 declaredColumns: readonly SqlCteDeclaredColumn[]; + 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 declaredColumns: readonly SqlCteDeclaredColumn[]; + 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 AuthenticatedSqlCteLayout = + | Exclude + | { + readonly reason: "resource-limit"; + readonly resource: "active-statement"; + readonly status: "unavailable"; + }; + +interface SqlCteLayoutProvenance { + readonly dialect: SqlCteLayoutDialect; + readonly index: SqlStatementIndex; + readonly lexicalProfile: SqlLexicalProfile; + readonly layout: AuthenticatedSqlCteLayout; + readonly slot: ExactSqlStatementSlot; + readonly source: SqlSourceSnapshot; +} + +const sqlCteLayoutProvenance = new WeakMap< + object, + 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" + | "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; + declaredColumns: { + readonly from: number; + readonly name: SqlIdentifierComponent; + readonly to: number; + }[]; + nameFrom: number; + nameTo: number; + sourceSpelling: string; +} + +interface MutableDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + declaredColumns: DraftDeclaration["declaredColumns"]; + identityIndex: number; + frameIndex: number; + name: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + ordinal: number; + sourceSpelling: string; +} + +interface MutableDraftDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + declaredColumns: DraftDeclaration["declaredColumns"]; + 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_QUOTED_IDENTIFIER_LENGTH + : 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", +): SqlCteLayout { + 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( + 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, + declaredColumns: [], + 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, + declaredColumns: [...current.declaredColumns], + 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, + declaredColumns: [...current.declaredColumns], + 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, +): Exclude { + 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, + ), + declaredColumns: Object.freeze( + declaration.declaredColumns.map((column) => + Object.freeze({ + name: column.name, + range: createRange(column.from, column.to), + }) + ), + ), + 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, + ), + declaredColumns: Object.freeze( + declaration.declaredColumns.map((column) => + Object.freeze({ + name: column.name, + range: createRange(column.from, column.to), + }) + ), + ), + 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, + index: SqlStatementIndex, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): SqlCteLayout { + const statementLength = slot.source.to - slot.source.from; + 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( + 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 recoveredThrough: number | null = null; + 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"); + } + } + 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); + + 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") || + wordEquals(text, token, "insert") || + wordEquals(text, token, "update") || + wordEquals(text, token, "delete") || + wordEquals(text, token, "merge") + ) + ) { + 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) { + const column = normalizeIdentifier( + validatedDialect, + text, + token, + "cte-column", + ); + if (!column) { + exactThrough = token.from - statementFrom; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + 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; + } + 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, + ); + const structuralLayout = freezeLayout( + frames, + declarations, + draftDeclarations, + relations, + statementLength, + exactThrough, + issues, + resource, + ); + const layout = recoveredThrough === null + ? structuralLayout + : Object.freeze({ + ...structuralLayout, + exactThrough: Math.min( + structuralLayout.exactThrough, + recoveredThrough, + ), + }); + return registerSqlCteLayout( + layout, + source, + index, + slot, + dialect, + lexicalProfile, + ); +} + +export function resolveAuthenticatedSqlCteLayout( + candidate: unknown, + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): AuthenticatedSqlCteLayout | 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.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( + 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/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/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..c5afa72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,68 +1,144 @@ 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"; + 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, + 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"; 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"; + 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, + SqlStatementAffinity, + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, + SqlStatementLexicalEnd, + SqlStatementOpaqueReason, + SqlStatementUnterminatedConstruct, +} from "./statement-boundary-types.js"; +export type { + SqlCanonicalRelationPath, + SqlCatalogCompletionProvenance, + SqlCatalogContainerComponent, + SqlCatalogContainerRole, + SqlCatalogEpoch, + SqlCatalogFailureCode, + SqlCatalogInvalidation, + SqlCatalogMatchQuality, + SqlCatalogProviderReport, + SqlCatalogProviderUnavailableReason, + SqlCatalogReadyCoverage, + SqlCatalogRelation, + SqlCatalogRelationComponent, + SqlCatalogRelationKind, + SqlCatalogRetryPolicy, + SqlCatalogSearchRequest, + SqlCatalogSearchResponse, + SqlCatalogSubscriptionCleanup, + SqlCompletionCancellationReason, + SqlCompletionUnavailableReason, + SqlCteCompletionProvenance, + SqlColumnCatalogProviderReport, + SqlColumnCatalogFailure, + SqlColumnCompletionProvenance, + SqlQueryOutputCompletionProvenance, + SqlQueryOutputProviderReport, + SqlCompletionProviderReport, + SqlCompletionIssue, + SqlCompletionRequest, + SqlCompletionRefreshToken, + SqlNamespaceCatalogProviderReport, + SqlNamespaceCompletionProvenance, + SqlCompletionTrigger, + SqlDisposable, + SqlRelationCatalogProvider, + SqlCompletionItem, + SqlCompletionList, + SqlCompletionResult, + SqlCompletionTask, + SqlSessionChangeEvent, + SqlSessionChangeReason, + SqlServiceFailure, +} from "./relation-completion-types.js"; 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/lexical.ts b/src/lexical.ts new file mode 100644 index 0000000..a27ba85 --- /dev/null +++ b/src/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/local-relation-site.ts b/src/local-relation-site.ts new file mode 100644 index 0000000..6d8b439 --- /dev/null +++ b/src/local-relation-site.ts @@ -0,0 +1,381 @@ +import { + analyzeSqlCteLayout, + type SqlCteLayoutResource, + type SqlCteVisibility, + visibleSqlCtesAt, +} 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, +} 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"; +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"; +} + +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; + return Object.freeze({ + local: Object.freeze({ + cteVisibility: visibleSqlCtesAt( + context.layout, + relativePosition, + ), + kind: "unqualified" as const, + }), + querySite, + 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 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 + ); + 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/namespace-catalog-boundary.ts b/src/namespace-catalog-boundary.ts new file mode 100644 index 0000000..efe718b --- /dev/null +++ b/src/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/namespace-catalog-coordinator.ts b/src/namespace-catalog-coordinator.ts new file mode 100644 index 0000000..ca9e731 --- /dev/null +++ b/src/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/namespace-catalog-types.ts b/src/namespace-catalog-types.ts new file mode 100644 index 0000000..bcf62f1 --- /dev/null +++ b/src/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/namespace-completion.ts b/src/namespace-completion.ts new file mode 100644 index 0000000..d77215e --- /dev/null +++ b/src/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/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/node-sql-parser-adapter.ts b/src/node-sql-parser-adapter.ts new file mode 100644 index 0000000..197aa4f --- /dev/null +++ b/src/node-sql-parser-adapter.ts @@ -0,0 +1,490 @@ +import { + createNodeSqlParserBackend, + type NodeSqlParserBackend, + type NodeSqlParserBackendOutcome, + 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, + createSqlDialectSyntaxIdentity, + createSqlParserAuthority, + createSqlStatementParser, + createSqlSyntaxArtifact, + createSqlSyntaxBackendIdentity, + createSqlSyntaxConfigurationIdentity, + createUnsupportedParserAnalysis, + type SqlCompatibilityLimitation, + type SqlParserAuthority, + type SqlStatementParser, + type SqlSyntaxArtifact, + type SqlSyntaxBackendIdentity, +} from "./syntax.js"; + +export { MAX_NODE_SQL_PARSER_STATEMENT_LENGTH } from "./node-sql-parser-backend.js"; + +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 NodeSqlParserRuntime { + readonly authority: SqlParserAuthority; + readonly backend: NodeSqlParserBackend; + readonly grammar: NodeSqlParserQueryBindingGrammar; + readonly limitations: readonly [ + SqlCompatibilityLimitation, + ...SqlCompatibilityLimitation[], + ]; + readonly policy: NodeSqlParserPolicy; +} + +type OwnDataProperty = + | { + readonly kind: "missing"; + } + | { + readonly kind: "invalid"; + } + | { + readonly kind: "value"; + readonly value: unknown; + }; + +class NodeSqlParserGlobalCleanupError extends Error {} +class NodeSqlParserExecutionRealmError extends Error {} + +const queryBindingModels = + new WeakMap(); + +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" }; +} + +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 { + 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 { + return loadNodeSqlParserBuild( + "node-sql-parser/build/postgresql.js", + ); +} + +function importBigQueryBuild(): Promise { + return loadNodeSqlParserBuild( + "node-sql-parser/build/bigquery.js", + ); +} + +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 materializeBackendOutcome( + runtime: NodeSqlParserRuntime, + statementText: string, + outcome: NodeSqlParserBackendOutcome, +) { + switch (outcome.kind) { + case "failed": + if (outcome.code === "malformed-output") { + return malformedOutput(runtime.authority, statementText); + } + return backendFailure( + runtime.authority, + statementText, + outcome.code === "module-load" + ? "node-sql-parser failed to load" + : "node-sql-parser backend failed", + outcome.retryable, + ); + case "parsed": { + const artifact = createSqlSyntaxArtifact( + outcome.statementKind, + statementText, + runtime.authority, + ); + 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, + ); + } + 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, + backend: NodeSqlParserBackend, + grammar: NodeSqlParserQueryBindingGrammar, + policy: NodeSqlParserPolicy, +): NodeSqlParserRuntime { + const authority = createSqlParserAuthority( + backendIdentity, + createSqlSyntaxConfigurationIdentity(), + createSqlDialectSyntaxIdentity(), + ); + return Object.freeze({ + authority, + backend, + grammar, + limitations: + policy === "dialect-compatibility" + ? DIALECT_COMPATIBILITY_LIMITATIONS + : TARGET_GRAMMAR_LIMITATIONS, + policy, + }); +} + +const postgresqlBackendIdentity = createSqlSyntaxBackendIdentity(); +const bigQueryBackendIdentity = createSqlSyntaxBackendIdentity(); +const postgresqlBackend = createNodeSqlParserBackend( + importPostgresqlBuild, +); +const bigQueryBackend = createNodeSqlParserBackend(importBigQueryBuild); + +const postgresqlParser = createAdapter( + createRuntime( + postgresqlBackendIdentity, + postgresqlBackend, + "postgresql", + "target-grammar", + ), +); +const bigQueryParser = createAdapter( + createRuntime( + bigQueryBackendIdentity, + bigQueryBackend, + "bigquery", + "target-grammar", + ), +); +const duckDbParser = createAdapter( + createRuntime( + postgresqlBackendIdentity, + postgresqlBackend, + "postgresql", + "dialect-compatibility", + ), +); + +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; +} + +export function getBigQueryNodeSqlStatementParser(): SqlStatementParser { + return bigQueryParser; +} + +export function getDuckDbCompatibilityNodeSqlStatementParser(): SqlStatementParser { + return duckDbParser; +} + +export const exportedForTesting = Object.freeze({ + createParser( + policy: NodeSqlParserPolicy, + loadModule: () => Promise, + ): SqlStatementParser { + const backendIdentity = createSqlSyntaxBackendIdentity(); + return createAdapter( + createRuntime( + backendIdentity, + createNodeSqlParserBackend( + adaptModuleLoaderForTesting(loadModule), + ), + "postgresql", + policy, + ), + ); + }, + hasBackendPayload(_artifact: SqlSyntaxArtifact): boolean { + return false; + }, + createSynchronousModuleLoader, +}); + +export function getNodeSqlParserQueryBindingModel( + artifact: SqlSyntaxArtifact, +): SqlQueryBindingModel | null { + return queryBindingModels.get(artifact) ?? null; +} diff --git a/src/node-sql-parser-backend.ts b/src/node-sql-parser-backend.ts new file mode 100644 index 0000000..87f24eb --- /dev/null +++ b/src/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); + }, + }); +} diff --git a/src/node-sql-parser-browser-executor.ts b/src/node-sql-parser-browser-executor.ts new file mode 100644 index 0000000..0c02e35 --- /dev/null +++ b/src/node-sql-parser-browser-executor.ts @@ -0,0 +1,1135 @@ +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 { SqlQueryBindingModelData } from "./query-binding-model.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 queryBindings: SqlQueryBindingModelData | null; + 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", + queryBindings: message.queryBindings, + 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 }); +} diff --git a/src/node-sql-parser-browser-worker-endpoint.ts b/src/node-sql-parser-browser-worker-endpoint.ts new file mode 100644 index 0000000..8af377f --- /dev/null +++ b/src/node-sql-parser-browser-worker-endpoint.ts @@ -0,0 +1,367 @@ +import { + createNodeSqlParserBackend, + 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, + 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); + const bindingAuthorities = Object.freeze({ + bigquery: Object.freeze({}), + postgresql: Object.freeze({}), + } 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"; + 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 { + 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/node-sql-parser-browser-worker.ts b/src/node-sql-parser-browser-worker.ts new file mode 100644 index 0000000..e4a6d79 --- /dev/null +++ b/src/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/node-sql-parser-query-bindings.ts b/src/node-sql-parser-query-bindings.ts new file mode 100644 index 0000000..786849e --- /dev/null +++ b/src/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/node-sql-parser-wire.ts b/src/node-sql-parser-wire.ts new file mode 100644 index 0000000..40613c0 --- /dev/null +++ b/src/node-sql-parser-wire.ts @@ -0,0 +1,536 @@ +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 = 2 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: 2; + readonly kind: "parse"; + readonly requestId: number; + readonly grammar: NodeSqlParserWireGrammar; + readonly text: string; +} + +export type NodeSqlParserWireMessage = + | { + readonly protocolVersion: 2; + readonly kind: "ready"; + } + | { + readonly protocolVersion: 2; + readonly kind: "parsed"; + readonly queryBindings: SqlQueryBindingModelData | null; + readonly requestId: number; + readonly statementKind: SqlStatementKind; + } + | { + readonly protocolVersion: 2; + readonly kind: "syntax-rejected"; + readonly requestId: number; + } + | { + readonly protocolVersion: 2; + readonly kind: "unsupported"; + readonly requestId: number; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly protocolVersion: 2; + readonly kind: "failed"; + readonly requestId: number; + readonly code: NodeSqlParserWireFailureCode; + } + | { + readonly protocolVersion: 2; + 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 + ); +} + +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( + "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, + queryBindings: SqlQueryBindingModel | null = null, +): Exclude< + NodeSqlParserWireMessage, + { readonly kind: "protocol-error" | "ready" } +> { + requireRequestId(requestId); + 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, + }); + 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"); + const queryBindings = decodeWireBindings( + record.values.get("queryBindings"), + ); + if ( + !hasExactKeys(record, [ + "protocolVersion", + "kind", + "queryBindings", + "requestId", + "statementKind", + ]) || + !isRequestId(requestId) || + !isStatementKind(statementKind) || + !queryBindings.valid + ) { + return null; + } + return Object.freeze({ + kind: "parsed", + protocolVersion: NODE_SQL_PARSER_WIRE_PROTOCOL_VERSION, + queryBindings: queryBindings.model, + 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/src/query-binding-model.ts b/src/query-binding-model.ts new file mode 100644 index 0000000..8b3537b --- /dev/null +++ b/src/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/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 new file mode 100644 index 0000000..3ee6ccc --- /dev/null +++ b/src/query-site.ts @@ -0,0 +1,1746 @@ +import { + 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, +} 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 = 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; + +export interface SqlQuerySiteRange { + readonly [querySiteRangeBrand]: "SqlQuerySiteRange"; + readonly from: number; + readonly to: number; +} + +export interface SqlQuerySiteEntrypoint { + readonly depth: number; + readonly from: 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 optionalDmlInto?: boolean; + readonly supportsNaturalJoin: boolean; + readonly decodeRelationPath: ( + rawPath: string, + cursorOffset: number, + ) => SqlDecodedQueryPath; +} + +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 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 { + 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() + : ""; +} + +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; +} + +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") { + 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"); + 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)) { + 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") { + 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[ + findSqlEmbeddedRegionAtOrAfter(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: BoundedSqlLexer, + 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", + querySiteLexerResource(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", + querySiteLexerResource(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"); +} + +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, + position: number, + dialect: SqlQuerySiteDialect, + authenticatedEntrypoints: + | readonly SqlQuerySiteEntrypoint[] + | null, +): 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 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, + slot.source.to, + lexicalProfile, + ); + const frames: QueryFrame[] = []; + const queryCandidates = new Set([0]); + let entrypointIndex = 0; + let depth = 0; + let sawSelect = false; + let statementTainted = false; + + while (true) { + const token = lexer.next(); + if (lexer.resource) { + return unavailable( + "resource-limit", + querySiteLexerResource(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"); + 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 + ) { + if ( + isSelect && + !topFrame(frames)?.blocksNestedQuery + ) { + queryCandidates.delete(depth); + frames.push(createFrame(depth, statementTainted)); + sawSelect = true; + if (token.to === position) { + return inactive("not-relation-position"); + } + continue; + } + if (!(continueAfterDml && depth === 0)) { + queryCandidates.delete(depth); + } + } + 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); + } + } +} + +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/relation-catalog-boundary.ts b/src/relation-catalog-boundary.ts new file mode 100644 index 0000000..31b6983 --- /dev/null +++ b/src/relation-catalog-boundary.ts @@ -0,0 +1,1282 @@ +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", + "table-function", + "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; +} + +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, + 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/relation-catalog-epoch-coordinator.ts b/src/relation-catalog-epoch-coordinator.ts new file mode 100644 index 0000000..b567d3c --- /dev/null +++ b/src/relation-catalog-epoch-coordinator.ts @@ -0,0 +1,1363 @@ +import { + compareSqlCatalogEpoch, + decodeSqlCatalogInvalidation, + isValidSqlCatalogScope, + 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; +} + +export type SqlCatalogEpochTransitionTarget = ( + this: void, + scope: string, + epoch: SqlCatalogEpoch, +) => + | ((this: void) => undefined) + | 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 hasLiveSubscription: ( + this: void, + scope: string, + ) => boolean; + 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-disposal-target" + | "invalid-provider" + | "invalid-transition-target"; + }; + +interface CoordinatorState { + cleanupAdmissions: number; + cleanupDeferralDepth: number; + cleanupOverloaded: boolean; + disposed: boolean; + draining: boolean; + readonly captures: WeakMap; + readonly commands: EpochCommand[]; + readonly deferredCleanup: Set; + readonly memberships: Set; + onDispose: ((this: void) => undefined) | null; + prepareEpochTransition: Function | null; + 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_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", +); + +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 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 drainDetachedSettlement(result: unknown): void { + if ( + result === null || + (typeof result !== "object" && + typeof result !== "function") + ) { + return; + } + try { + const settlement = Reflect.apply( + INTRINSIC_PROMISE_RESOLVE, + INTRINSIC_PROMISE, + [result], + ); + Reflect.apply(INTRINSIC_PROMISE_THEN, settlement, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); + } catch { + // The detached value is hostile and cannot be observed safely. + } +} + +function cleanupSubscription( + state: CoordinatorState, + 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, []); + if (result !== undefined) { + disposeCoordinator(state); + drainDetachedSettlement(result); + } + } catch { + disposeCoordinator(state); + } +} + +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(state, 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; +} + +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, +): 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 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); + } +} + +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 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)) { + settleDecision( + command.onDecision, + Object.freeze({ + epoch: comparison.epoch, + status: "superseded", + }), + ); + } else { + settleDecision(command.onDecision, discarded("retired")); + } + dispatchEpochTransition(state, transition); + 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 (!isValidSqlCatalogScope(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; + const onDispose = state.onDispose; + state.onDispose = null; + state.prepareEpochTransition = null; + 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); + } + 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); + } + if (!state.draining) drainCommands(state); +} + +function createCoordinatorHandle( + state: CoordinatorState, +): SqlCatalogEpochCoordinator { + return Object.freeze({ + 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, + ): 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, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, + onDispose?: (this: void) => undefined, +): SqlCatalogEpochCoordinatorResult { + const provider = resolveSqlRelationCatalogProvider( + capturedProvider, + ); + if (!provider) { + return Object.freeze({ + reason: "invalid-provider", + status: "unavailable", + }); + } + if ( + prepareEpochTransition !== undefined && + typeof prepareEpochTransition !== "function" + ) { + return Object.freeze({ + reason: "invalid-transition-target", + 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 = { + cleanupAdmissions: 0, + cleanupDeferralDepth: 0, + cleanupOverloaded: false, + captures: new WeakMap(), + commands: [], + deferredCleanup: new Set(), + disposed: false, + draining: false, + memberships: new Set(), + onDispose: onDispose ?? null, + prepareEpochTransition: + prepareEpochTransition ?? null, + providerId, + scopes: new Map(), + subscribe, + }; + const coordinator = createCoordinatorHandle(state); + return Object.freeze({ coordinator, status: "created" }); +} diff --git a/src/relation-catalog-search-policy-store.ts b/src/relation-catalog-search-policy-store.ts new file mode 100644 index 0000000..2f155d7 --- /dev/null +++ b/src/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/relation-catalog-search-work.ts b/src/relation-catalog-search-work.ts new file mode 100644 index 0000000..ca1034e --- /dev/null +++ b/src/relation-catalog-search-work.ts @@ -0,0 +1,2465 @@ +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 { + 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"; +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 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: ( + 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 refreshLeaseMs?: 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 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; +} + +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 refreshLeaseMs: number; + readonly synchronousBudgetMs: number; +} + +interface OwnerState { + current: ConsumerState | null; + readonly dialect: SqlRelationDialectRuntime; + disposed: boolean; + readonly membership: SqlCatalogScopeMembership; + owner: CoordinatorState | null; + refreshObserver: RefreshObserverState | null; + requestToken: object | null; + readonly scope: string; +} + +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; + dialect: SqlRelationDialectRuntime | null; + decisionCell: ResponseDecisionCell | null; + executionDeadline: number | null; + executionTimer: DeadlineCell | null; + joinable: boolean; + readonly observers: Set; + 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 notifications: Set; + readonly options: NormalizedOptions; + readonly owners: Set; + readonly policyStore: SqlCatalogSearchPolicyStore; + 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 ResponseCandidate[]; + index: number; + pending: SqlCatalogResponseEpochDecision | 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: ( + outcome: SqlCatalogSearchWorkOutcome, + ) => 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[]; +} + +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: [], + availabilityPreparations: [], + 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 refreshLeaseMs = + candidate?.refreshLeaseMs ?? + DEFAULT_CATALOG_REFRESH_LEASE_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, + ) || + !isDuration( + refreshLeaseMs, + MIN_CATALOG_REFRESH_LEASE_MS, + MAX_CATALOG_REFRESH_LEASE_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, + refreshLeaseMs, + 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; + consumer.retention.consumer = null; + 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 { + 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); + } + for (const controller of pending.aborts) { + try { + controller.abort(); + } catch { + // 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, +): 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; +} + +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 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, + 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); + detachObservers(work, 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); + 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, + outcome: SqlCatalogSearchWorkOutcome, + pending: Effects, +): void { + const work = consumer.work; + if (work) { + work.owners.delete(consumer); + } + settleDetached(pending.settlements, consumer, outcome); + if (work && !hasWorkOwners(work)) { + retireUnownedWork(state, work, pending); + } +} + +function detachConsumer( + state: CoordinatorState, + consumer: ConsumerState, + outcome: SqlCatalogSearchWorkOutcome, +): void { + const pending = effects(); + detachConsumerInto( + state, + consumer, + outcome, + pending, + ); + 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, +): 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 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, + { 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; + 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); + } + finishUsableDecision( + 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.kind === "observer" + ? candidate.work !== null + : !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" || + !hasWorkOwners(work) + ) { + return; + } + const afterDecode = readNow(state); + if ( + state.disposed || + work.phase !== "deciding" || + !hasWorkOwners(work) || + 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, + ...work.observers, + ], + dialect, + index: 0, + pending: null, + request, + 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" || + !hasWorkOwners(work) + ) { + 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 => {}, + retainForRefresh: + (): SqlCatalogSearchRefreshRetentionResult => + Object.freeze({ + reason: + outcome.status === "unavailable" && + outcome.reason === "disposed" + ? "disposed" + : "not-retainable", + status: "unavailable", + }), + result: new INTRINSIC_PROMISE( + (resolve) => { + resolve(outcome); + }, + ), + }); +} + +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, +): { + readonly consumer: ConsumerState; + readonly ticket: SqlCatalogSearchWorkTicket; +} { + let resolve: (outcome: SqlCatalogSearchWorkOutcome) => void = + IGNORE_DETACHED_REJECTION; + const result = + new INTRINSIC_PROMISE( + (settle) => { + 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) 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( + state, + consumer, + CANCELLED_OUTCOME, + ); + } + }, + 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, + }), + }; +} + +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; + const previousObserver = owner.refreshObserver; + work.owners.add(consumer); + consumer.work = work; + owner.current = consumer; + if (previous && previous !== consumer) { + detachConsumerInto( + state, + previous, + SUPERSEDED_OUTCOME, + 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, + 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, + ); + } + if (owner.refreshObserver) { + detachRefreshObserverInto( + state, + owner.refreshObserver, + 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) { + 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, + request, + owner.dialect, + ); + if (existing) { + replaceOwnerConsumer( + state, + owner, + created.consumer, + existing, + ); + return created.ticket; + } + 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, + ); + } + 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, + observers: new Set(), + 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, + epoch: SqlCatalogEpoch, +): (() => undefined) | null { + retireNotifications( + state, + (notification) => notification.scope === scope, + ); + state.policyStore.advanceScope(scope, epoch); + 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; + if (state) { + retireNotifications( + state, + (notification) => notification.owner === owner, + ); + state.owners.delete(owner); + } + const current = owner.current; + const observer = owner.refreshObserver; + owner.current = null; + 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(); +} + +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, + refreshObserver: null, + 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; + retireNotifications(state, () => true); + 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.refreshObserver = null; + owner.requestToken = null; + } + state.owners.clear(); + state.policyStore.dispose(); + 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, epoch): (() => undefined) | null => + state ? prepareTransition(state, scope, epoch) : 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, + notifications: new Set(), + options: normalized.options, + owners: new Set(), + policyStore: createSqlCatalogSearchPolicyStore(), + 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/relation-completion-types.ts b/src/relation-completion-types.ts new file mode 100644 index 0000000..62ba3dc --- /dev/null +++ b/src/relation-completion-types.ts @@ -0,0 +1,522 @@ +import type { + SqlIdentifierComponent, + SqlIdentifierPath, + SqlRevision, + SqlTextChange, + SqlTextRange, +} from "./types.js"; + +// Provisional package-private declarations until the vertical slice is proven. +export interface SqlDisposable { + readonly dispose: (this: void) => void; +} + +export type SqlCatalogSubscriptionCleanup = ( + this: void, +) => undefined; + +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" + | "table-function" + | "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: ( + this: void, + request: SqlCatalogSearchRequest, + signal: AbortSignal, + ) => Promise; + readonly subscribe?: ( + this: void, + scope: string, + onInvalidation: (event: SqlCatalogInvalidation) => void, + ) => SqlCatalogSubscriptionCleanup; +} + +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 type SqlCteIdentifierComparison = + | "distinct" + | "equal" + | "unknown"; + +export type SqlCteIdentifierPrefixMatch = + | "match" + | "no-match" + | "unknown"; + +export interface SqlRelationCompletionDialectRuntime { + readonly decodeIdentifier: ( + token: string, + mode: "complete" | "completion-prefix", + ) => SqlIdentifierDecodeResult; + readonly renderRelationPath: ( + path: SqlCanonicalRelationPath, + ) => SqlRenderedRelationPath; + readonly compareCteIdentifiers: ( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, + ) => SqlCteIdentifierComparison; + readonly cteIdentifierMatchesPrefix: ( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, + ) => SqlCteIdentifierPrefixMatch; +} + +export type SqlSessionChangeReason = + | "catalog" + | "catalog-availability" + | "provider-configuration"; + +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; + readonly kind: "invoked"; + } + | { + readonly kind: "trigger-character"; + readonly character: string; + }; + +export interface SqlCompletionRequest { + readonly position: number; + readonly trigger: SqlCompletionTrigger; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlCteCompletionProvenance { + readonly kind: "cte"; + readonly declarationPosition: number; +} + +export interface SqlCatalogCompletionProvenance { + readonly kind: "catalog"; + readonly providerId: string; + 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 SqlQueryOutputCompletionProvenance { + readonly definition: SqlTextRange; + readonly kind: "query-output"; + readonly relation: SqlTextRange; +} + +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; + readonly detail?: string; +} + +export type SqlCompletionItem = + | (SqlCompletionItemBase & { + readonly kind: "relation"; + readonly relationKind: "cte"; + readonly provenance: SqlCteCompletionProvenance; + }) + | (SqlCompletionItemBase & { + 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: "column"; + readonly provenance: SqlQueryOutputCompletionProvenance; + readonly relationRequestKey: string; + }) + | (SqlCompletionItemBase & { + readonly kind: "namespace"; + readonly provenance: SqlNamespaceCompletionProvenance; + readonly role: SqlCatalogContainerRole; + }); + +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" + | "catalog-paginated" + | "catalog-failed" + | "catalog-malformed" + | "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" + | "result-limit"; + }; + +export type SqlCompletionList = + | { + readonly items: readonly SqlCompletionItem[]; + readonly isIncomplete: false; + readonly issues: readonly []; + } + | { + readonly items: readonly SqlCompletionItem[]; + 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" + | "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 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 interface SqlQueryOutputProviderReport { + readonly coverage: "complete" | "partial"; + readonly feature: "query-output"; + readonly outcome: "ready"; +} + +export type SqlCompletionProviderReport = + | SqlCatalogProviderReport + | SqlColumnCatalogProviderReport + | SqlNamespaceCatalogProviderReport + | SqlQueryOutputProviderReport; + +export interface SqlServiceFailure { + readonly code: "internal"; + readonly retryable: boolean; +} + +export type SqlCompletionResult = + | { + readonly status: "ready"; + readonly revision: SqlRevision; + readonly refreshToken: SqlCompletionRefreshToken | null; + readonly value: SqlCompletionList; + readonly sources: readonly SqlCompletionProviderReport[]; + } + | { + 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; + }; + +/** + * A completion invocation whose identity is available before provider work + * starts. + */ +export interface SqlCompletionTask + extends Promise { + readonly refreshToken: SqlCompletionRefreshToken; +} diff --git a/src/relation-completion.ts b/src/relation-completion.ts new file mode 100644 index 0000000..1b34d89 --- /dev/null +++ b/src/relation-completion.ts @@ -0,0 +1,523 @@ +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< + Extract, + { readonly relationKind: "cte" } + >; + readonly label: string; + readonly matchQuality: "exact" | "equivalent"; + readonly path: string; +} + +const CATALOG_KIND_ORDER = Object.freeze({ + "temporary-table": 0, + table: 1, + "table-function": 2, + view: 3, + "materialized-view": 4, + "external-relation": 5, +} 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/relation-dialect.ts b/src/relation-dialect.ts new file mode 100644 index 0000000..f8b4c96 --- /dev/null +++ b/src/relation-dialect.ts @@ -0,0 +1,1494 @@ +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 { + registerSqlRelationDialectRuntime, +} from "./relation-runtime-auth.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 id: SqlRelationDialectId; + readonly querySite: SqlQuerySiteDialect; +} + +export type SqlRelationDialectId = + | "bigquery" + | "dremio" + | "duckdb" + | "postgresql"; + +interface RelationDialectSpec { + readonly cteGrammar: SqlCteLayoutDialect["grammar"]; + readonly kind: SqlRelationDialectId; + 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: SqlRelationDialectId, +): 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: SqlRelationDialectId, + 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: SqlRelationDialectId, + 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, + ...(spec.kind === "bigquery" ? { optionalDmlInto: true } : {}), + supportsNaturalJoin: spec.supportsNaturalJoin, + }); + return registerSqlRelationDialectRuntime( + Object.freeze({ + completion, + cteLayout, + id: spec.kind, + 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/relation-query-site.ts b/src/relation-query-site.ts new file mode 100644 index 0000000..f739715 --- /dev/null +++ b/src/relation-query-site.ts @@ -0,0 +1,73 @@ +import { + resolveAuthenticatedSqlCteLayout, + 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" + | "resource-limit", + resource?: "active-statement", +): SqlQuerySiteResult { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, 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 authenticatedLayout = resolveAuthenticatedSqlCteLayout( + layout, + source, + slot, + dialect.cteLayout, + ); + if (!authenticatedLayout) { + return unavailable("ambiguous-query-site"); + } + if (authenticatedLayout.status === "unavailable") { + return unavailable( + authenticatedLayout.reason, + authenticatedLayout.resource, + ); + } + return recognizeSqlRelationQuerySiteWithEntrypoints( + source, + slot, + position, + dialect.querySite, + authenticatedLayout.mainQueryEntrypoints, + ); +} diff --git a/src/dialects/dremio.ts b/src/relation-reserved-words.ts similarity index 56% rename from src/dialects/dremio.ts rename to src/relation-reserved-words.ts index bca209f..9f820a7 100644 --- a/src/dialects/dremio.ts +++ b/src/relation-reserved-words.ts @@ -1,21 +1,99 @@ -import { SQLDialect, type SQLDialectSpec } from "@codemirror/lang-sql"; +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/ -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"; +// 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://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"; +// 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( + " ", + ), +); -const DremioDialectSpec: SQLDialectSpec = { - doubleQuotedStrings: false, - hashComments: false, - spaceAfterDashes: false, - identifierQuotes: '"', - caseInsensitiveIdentifiers: true, - keywords: dremioKeywords, - types: dremioTypes, -}; +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 const DremioDialect = SQLDialect.define(DremioDialectSpec); +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/relation-runtime-auth.ts b/src/relation-runtime-auth.ts new file mode 100644 index 0000000..0bcf73b --- /dev/null +++ b/src/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/session.ts b/src/session.ts new file mode 100644 index 0000000..4ad200d --- /dev/null +++ b/src/session.ts @@ -0,0 +1,3887 @@ +import type { + OpenSqlDocument, + SqlDocumentContext, + SqlDocumentSession, + SqlDocumentUpdate, + SqlEmbeddedRegion, + SqlLanguageService, + SqlLanguageServiceOptions, + SqlRevision, + SqlIdentifierPath, + SqlTextChange, + SqlTextRange, +} from "./types.js"; +import { + createSqlColumnCatalogBatchCoordinator, + type SqlColumnCatalogBatchCoordinator, + type SqlColumnCatalogBatchOwner, + type SqlColumnCatalogBatchTicket, +} from "./column-catalog-batch-coordinator.js"; +import { + composeSqlColumnCompletion, + composeSqlLocalQueryOutputCompletion, + filterSqlUsingCompletionList, + 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, + findSqlStatementSlotsIntersecting, + 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 { + analyzeSqlLocalColumnSite, + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationStatementPreparation, + type SqlLocalRelationSiteResult, +} from "./local-relation-site.js"; +import { + composeSqlRelationCompletion, + MAX_RELATION_COMPLETION_RESULTS, + type SqlComposableCatalogOutcome, +} from "./relation-completion.js"; +import { + createSqlCompletionRefreshToken, + type SqlCompletionRequest, + type SqlCompletionRefreshToken, + type SqlCompletionIssue, + type SqlCompletionItem, + type SqlCompletionList, + type SqlDisposable, + type SqlCompletionResult, + type SqlCompletionTask, + type SqlSessionChangeEvent, +} from "./relation-completion-types.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, + isSqlSourceError, + mapAnalysisRangeToOriginal, + MAX_SQL_SOURCE_LENGTH, + normalizeSqlTextRange, + type SqlSourceSnapshot, +} from "./source.js"; +import { + createSqlDialect, + createSqlRevisionToken, + type SqlDialect, + SqlSessionError, +} from "./types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + 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; +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_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; +const AUXILIARY_LOADING_RETRY_DELAY_MS = 100; + +interface ResolvedCatalogContext { + readonly scope: string; + readonly searchPaths: readonly SqlIdentifierPath[]; +} + +interface CompletionRequestState { + cancelReason: "caller" | "disposed" | "superseded" | null; + readonly revision: SqlRevision; + readonly tickets: Set< + | SqlCatalogSearchWorkTicket + | SqlColumnCatalogBatchTicket + | SqlNamespaceCatalogSearchTicket + >; + readonly token: SqlCompletionRefreshToken; +} + +type ServiceChange = + | { + readonly reason: "catalog-availability"; + readonly expected: CompletionRequestState; + } + | { + readonly reason: "catalog"; + }; + +interface SessionChangeSubscription { + active: boolean; + readonly listener: (event: SqlSessionChangeEvent) => void; +} + +interface SessionTimerCell { + active: boolean; + handle: ReturnType | undefined; +} + +interface TerminalRefreshIntent { + readonly timer: SessionTimerCell; + readonly token: SqlCompletionRefreshToken; +} + +interface AuxiliaryLoadingRetry { + readonly context: Context; + readonly position: number; + readonly source: SqlSourceSnapshot; +} + +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; + readonly relationDialect: SqlRelationDialectRuntime; +} + +const sqlDialectRuntimes = new WeakMap(); + +function createBuiltinSqlDialect( + id: string, + displayName: string, + 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: relationDialect.querySite.lexicalProfile, + relationDialect, + }), + ); + return dialect; +} + +const BIGQUERY_DIALECT = createBuiltinSqlDialect( + "bigquery", + "BigQuery", + BIGQUERY_SQL_RELATION_DIALECT, +); +const DREMIO_DIALECT = createBuiltinSqlDialect( + "dremio", + "Dremio", + DREMIO_SQL_RELATION_DIALECT, +); +const DUCKDB_DIALECT = createBuiltinSqlDialect( + "duckdb", + "DuckDB", + DUCKDB_SQL_RELATION_DIALECT, +); +const POSTGRES_DIALECT = createBuiltinSqlDialect( + "postgresql", + "PostgreSQL", + POSTGRESQL_SQL_RELATION_DIALECT, +); + +/** 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; +} + +export function getSqlRelationDialectRuntime( + candidate: unknown, +): SqlRelationDialectRuntime | null { + return getSqlDialectRuntime(candidate)?.relationDialect ?? null; +} + +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", + ); + } + if (!descriptor.enumerable) { + throw new SqlSessionError( + "invalid-context", + "SQL document context cannot contain non-enumerable properties", + ); + } + 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 resolveDialectRuntime( + context: SqlDocumentContext, + dialects: ReadonlyMap, +): SqlDialectRuntime { + const dialect = readRequiredDataProperty( + context, + "dialect", + "invalid-dialect", + "SQL document context", + ); + const runtime = + typeof dialect === "string" ? dialects.get(dialect) : undefined; + if (!runtime) { + throw new SqlSessionError( + "invalid-dialect", + typeof dialect === "string" + ? `Unknown SQL dialect: ${dialect}` + : "SQL document context dialect must be a string", + ); + } + 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" }); +} + +function completionCancellationReason( + request: CompletionRequestState, +): "caller" | "disposed" | "superseded" { + 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 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({ + 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 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: + | "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; +} + +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 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( + "invalid-document", + `SQL documents cannot exceed ${MAX_SQL_SOURCE_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`, + ); + } + 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 (range.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 - (range.to - range.from); + if (nextLength > MAX_SQL_SOURCE_LENGTH) { + throw new SqlSessionError( + "invalid-document", + `SQL documents cannot exceed ${MAX_SQL_SOURCE_LENGTH} UTF-16 code units`, + ); + } + normalized.push( + Object.freeze({ + from: range.from, + insert, + to: range.to, + }), + ); + previousEnd = range.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(""); +} + +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; + readonly dialect: SqlDialectRuntime; + readonly documentSequence: number; + readonly revision: SqlRevision; + readonly sequence: number; + readonly source: SqlSourceSnapshot; + readonly sourceSequence: number; +} + +interface StatementIndexCache { + readonly index: SqlStatementIndex; + readonly lexicalProfile: SqlLexicalProfile; + readonly sourceSequence: number; +} + +interface LocalRelationStatementCache { + readonly dialect: SqlRelationDialectRuntime; + readonly index: SqlStatementIndex; + readonly preparation: SqlLocalRelationStatementPreparation; + readonly slot: SqlStatementSlot; + readonly sourceSequence: number; +} + +function createCompletionTask( + refreshToken: SqlCompletionRefreshToken, + result: Promise, +): SqlCompletionTask { + 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, + }); +} + +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 +{ + readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; + readonly #catalogResponseBudgetMs: number; + 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; + #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 + | null = null; + #refreshIntent: CompletionRequestState | null = null; + #snapshot: SessionSnapshot; + #softRefreshIntentTimer: SessionTimerCell | null = null; + #statementIndexCache: StatementIndexCache | null = null; + #terminalRefreshIntent: TerminalRefreshIntent | null = null; + #updating = false; + + constructor( + source: SqlSourceSnapshot, + context: Context, + dialects: ReadonlyMap, + catalogCoordinator: SqlCatalogSearchWorkCoordinator | null, + columnCoordinator: SqlColumnCatalogBatchCoordinator | null, + namespaceCoordinator: SqlNamespaceCatalogCoordinator | null, + completion: CompletionConfiguration, + featureProviders: + readonly CapturedSqlLanguageFeatureProvider[], + featureProviderBudgetMs: number, + onDispose: () => void, + ) { + this.#catalogCoordinator = catalogCoordinator; + this.#columnCoordinator = columnCoordinator; + this.#namespaceCoordinator = namespaceCoordinator; + this.#catalogResponseBudgetMs = + completion.catalogResponseBudgetMs; + this.#featureProviders = featureProviders; + this.#featureProviderBudgetMs = featureProviderBudgetMs; + this.#dialects = dialects; + this.#onDispose = onDispose; + const sequence = 0; + const contextSequence = 0; + const documentSequence = 0; + const sourceSequence = 0; + const dialect = resolveDialectRuntime(context, dialects); + this.#snapshot = Object.freeze({ + contextSequence, + context, + dialect, + documentSequence, + revision: createSqlRevisionToken(), + sequence, + source, + sourceSequence, + }); + this.#replaceCatalogOwner(); + this.#replaceColumnOwner(); + this.#replaceNamespaceOwner(); + } + + get revision(): SqlRevision { + return this.#snapshot.revision; + } + + get snapshotForTesting(): SessionSnapshot { + return this.#snapshot; + } + + get cachedStatementIndexForTesting(): SqlStatementIndex | null { + return this.#statementIndexCache?.index ?? null; + } + + #getStatementIndex(): SqlStatementIndex { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const cached = this.#statementIndexCache; + if ( + cached && + cached.sourceSequence === this.#snapshot.sourceSequence && + cached.lexicalProfile === this.#snapshot.dialect.lexicalProfile + ) { + return cached.index; + } + const index = buildSqlStatementIndex( + this.#snapshot.source.analysisText, + this.#snapshot.dialect.lexicalProfile, + ); + this.#statementIndexCache = Object.freeze({ + index, + lexicalProfile: this.#snapshot.dialect.lexicalProfile, + sourceSequence: this.#snapshot.sourceSequence, + }); + return index; + } + + getStatementIndexForTesting(): SqlStatementIndex { + 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 => { + 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; + 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); + } + + #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 || + event.revision !== this.#snapshot.revision + ) { + return; + } + for (const subscription of Array.from(this.#listeners)) { + if ( + this.#disposed || + event.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( + change: ServiceChange, + ): (() => undefined) | null { + const expected = + change.reason === "catalog-availability" + ? change.expected + : undefined; + if ( + this.#disposed || + (expected !== undefined && + (this.#activeCompletion !== expected && + this.#refreshIntent !== expected || + expected.cancelReason !== null || + expected.revision !== this.#snapshot.revision)) + ) { + 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; + this.#supersedeFeatures(); + const revision = createSqlRevisionToken(); + this.#snapshot = Object.freeze({ + ...previous, + 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"; + } + if (intent && intent !== active) { + if (intent.cancelReason === null) { + intent.cancelReason = "superseded"; + } + } + return (): undefined => { + 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; + }; + } + + #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({ reason: "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; + } + } + } + + #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 => { + 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 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 => { + 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( + "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 => { + cancelCompletionTickets(previousActive); + if (previousIntent !== previousActive) { + cancelCompletionTickets(previousIntent); + } + 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, + 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 (isCurrent()) 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 && + active.cancelReason === null + ) { + active.cancelReason = "caller"; + cancelCompletionTickets(active); + } + }; + 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.#getStatementIndex(); + 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") { + 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, + ); + 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, + revision: snapshot.revision, + status: "unavailable", + }); + } + 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, + 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); + 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([ + ...(localComposition?.sources ?? []), + Object.freeze({ + feature: "column-catalog" as const, + failures: Object.freeze([]), + outcome: "loading" as const, + providerId: columnCoordinator.providerId, + }), + ]), + status: "ready", + value: localComposition + ? filterSqlUsingCompletionList( + mergeCompletionLists( + localComposition.value, + loadingValue, + ), + columnSite, + snapshot.dialect.relationDialect, + ) + : loadingValue, + }); + } + 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: Object.freeze([ + ...(localComposition?.sources ?? []), + ...composition.sources, + ]), + status: "ready", + value: localComposition + ? filterSqlUsingCompletionList( + mergeCompletionLists( + localComposition.value, + retryLoading + ? completionListWithLoadingLease( + composition.value, + "column-catalog-loading", + remainingIntentLeaseMs, + ) + : composition.value, + ), + columnSite, + snapshot.dialect.relationDialect, + ) + : retryLoading + ? completionListWithLoadingLease( + composition.value, + "column-catalog-loading", + remainingIntentLeaseMs, + ) + : composition.value, + }); + } + cancelPrevious(); + return Object.freeze({ + reason: unavailableCompletionReason(localSite), + retryable: false, + revision: snapshot.revision, + status: "unavailable", + }); + } + if (!isCurrent()) { + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + + 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 { + if (!isCurrent()) { + cancelPrevious(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + 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.tickets.add(ticket); + const raced = await raceCatalogResponse( + ticket.result, + remainingCatalogBudget( + completionStartedAt, + this.#catalogResponseBudgetMs, + ), + ); + if (!isCurrent()) { + ticket.cancel(); + return completionCancellation( + snapshot.revision, + completionCancellationReason(active), + ); + } + let providerOutcome: SqlCatalogSearchWorkOutcome | null = + raced.kind === "outcome" ? raced.outcome : null; + if (raced.kind === "timeout") { + const retained = ticket.retainForRefresh( + (): (() => undefined) | null => + this.#prepareServiceChange( + { + 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", + }); + } 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, + }; + const intent: TerminalRefreshIntent = { + timer: cell, + token: active.token, + }; + this.#terminalRefreshIntent = intent; + const handle = setTimeout(() => { + if (!cell.active) return; + cell.active = false; + if (this.#terminalRefreshIntent === intent) { + this.#terminalRefreshIntent = null; + } + }, remainingIntentLeaseMs); + cell.handle = handle; + if ( + !cell.active || + this.#terminalRefreshIntent !== intent + ) { + clearTimeout(handle); + } + if (this.#terminalRefreshIntent !== intent) { + remainingIntentLeaseMs = 0; + } + } + } + } + } + } else { + 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, + localSite, + providerId: + catalog && this.#catalogCoordinator + ? this.#catalogCoordinator.providerId + : null, + remainingIntentLeaseMs, + replacementRange, + statementOffset, + }); + 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: + (namespaceLoadingLeaseMs > 0 || + 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, + status: "ready", + value, + }); + } finally { + 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) { + 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 (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session was disposed during the update", + ); + } + 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 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 document = readOwnDataProperty( + update, + "document", + "invalid-update", + "SQL document update", + ); + 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 (hasDocument && !hasEmbeddedRegions) { + throw new SqlSessionError( + "invalid-update", + "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; + + 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.value, + "kind", + "invalid-update", + "SQL document mutation", + ); + if (documentKind === "replace") { + rejectOwnProperty( + document.value, + "changes", + "invalid-update", + "SQL document replacement", + ); + const text = readRequiredDataProperty( + document.value, + "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; + documentMutation = "replace"; + } else if (documentKind === "changes") { + rejectOwnProperty( + document.value, + "text", + "invalid-update", + "SQL document changes", + ); + const changes = readRequiredDataProperty( + document.value, + "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( + this.#snapshot.source.originalText, + changes, + ); + trustedAnalysisChanges = normalizedChanges; + nextText = applyChanges( + this.#snapshot.source.originalText, + normalizedChanges, + ); + documentMutation = "changes"; + } else { + throw new SqlSessionError( + "invalid-update", + "SQL document mutation kind must be replace or changes", + ); + } + 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, + ); + const nextCatalog = resolveCatalogContext(nextContext); + if ( + nextCatalog && + !this.#catalogCoordinator && + !this.#columnCoordinator && + !this.#namespaceCoordinator + ) { + throw new SqlSessionError( + "invalid-context", + "SQL catalog context requires a configured catalog provider", + ); + } + const nextLexicalProfile = nextDialect.lexicalProfile; + 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(); + const nextSnapshot = Object.freeze({ + contextSequence: nextContextSequence, + context: nextContext, + dialect: nextDialect, + documentSequence: nextDocumentSequence, + revision, + sequence, + source: nextSource, + sourceSequence: nextSourceSequence, + }); + let nextStatementIndexCache = this.#statementIndexCache; + if ( + nextStatementIndexCache && + nextLexicalProfile !== this.#snapshot.dialect.lexicalProfile + ) { + nextStatementIndexCache = null; + } else if ( + nextStatementIndexCache && + nextSourceSequence !== this.#snapshot.sourceSequence + ) { + 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({ + index: nextIndex, + lexicalProfile: nextLexicalProfile, + sourceSequence: nextSourceSequence, + }) + : null; + } + const activeCompletion = this.#activeCompletion; + const refreshIntent = this.#refreshIntent; + const invalidatesLocalRelationCache = + nextSourceSequence !== this.#snapshot.sourceSequence || + nextDialect.relationDialect !== + this.#snapshot.dialect.relationDialect; + this.#supersedeFeatures(); + this.#snapshot = nextSnapshot; + this.#statementIndexCache = nextStatementIndexCache; + if (invalidatesLocalRelationCache) { + this.#localRelationStatementCache = null; + } + this.#activeCompletion = null; + this.#columnLoadingRetry = null; + this.#namespaceLoadingRetry = null; + this.#refreshIntent = null; + this.#clearSoftRefreshIntentTimer(); + this.#clearTerminalIntent(); + if ( + activeCompletion && + activeCompletion.cancelReason === null + ) { + activeCompletion.cancelReason = "superseded"; + } + if ( + refreshIntent && + refreshIntent !== activeCompletion && + refreshIntent.cancelReason === null + ) { + refreshIntent.cancelReason = "superseded"; + } + cancelCompletionTickets(activeCompletion); + if (refreshIntent !== activeCompletion) { + cancelCompletionTickets(refreshIntent); + } + this.#replaceCatalogOwner(); + this.#replaceColumnOwner(); + this.#replaceNamespaceOwner(); + return this.#snapshot.revision; + } + + readonly isCurrent = (revision: SqlRevision): boolean => { + 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; + } + this.#disposed = true; + 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(); + for (const feature of this.#activeFeatures) { + feature.cancelReason = "disposed"; + feature.controller.abort(); + } + this.#activeFeatures.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"; + } + cancelCompletionTickets(activeCompletion); + if (refreshIntent !== activeCompletion) { + cancelCompletionTickets(refreshIntent); + } + catalogOwner?.dispose(); + columnOwner?.dispose(); + namespaceOwner?.dispose(); + this.#onDispose(); + }; +} + +export class DefaultSqlLanguageService + implements SqlLanguageService +{ + readonly #catalogCoordinator: SqlCatalogSearchWorkCoordinator | null; + readonly #columnCoordinator: SqlColumnCatalogBatchCoordinator | null; + 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) { + 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 Map(); + for (let index = 0; index < dialectCount; index += 1) { + const dialect = readRequiredDataProperty( + configuredDialects, + index, + "invalid-service-options", + "SQL language service dialects", + ); + 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: ${runtime.dialect.id}`, + ); + } + 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 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", + "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; + } + + 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; + } + 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 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", + "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); + resolveDialectRuntime(context, this.#dialects); + const catalogContext = resolveCatalogContext(context); + if ( + catalogContext && + !this.#catalogCoordinator && + !this.#columnCoordinator && + !this.#namespaceCoordinator + ) { + 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.#columnCoordinator, + this.#namespaceCoordinator, + this.#completion, + this.#featureProviders, + this.#featureProviderBudgetMs, + () => { + 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(); + this.#catalogCoordinator?.dispose(); + this.#columnCoordinator?.dispose(); + this.#namespaceCoordinator?.dispose(); + }; +} + +/** 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/source.ts b/src/source.ts new file mode 100644 index 0000000..24760ef --- /dev/null +++ b/src/source.ts @@ -0,0 +1,368 @@ +import type { SqlEmbeddedRegion, 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(); +const sqlSourceSnapshots = 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 SqlSourceSnapshot { + readonly analysisText: string; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + 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, +): 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; +} + +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 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`, + ); + } + 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); + const source = Object.freeze({ + analysisText: originalText, + embeddedRegions: EMPTY_EMBEDDED_REGIONS, + originalText, + }); + sqlSourceSnapshots.add(source); + return source; +} + +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(""); + const source = Object.freeze({ + analysisText, + embeddedRegions, + originalText, + }); + sqlSourceSnapshots.add(source); + return source; +} + +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/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 ea4741a..0000000 --- a/src/sql/__tests__/completion-extension.test.ts +++ /dev/null @@ -1,56 +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. */ -function autocompleteSources(...extensions: Parameters[0]["extensions"][]) { - 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 b7bed12..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("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/statement-boundary-types.ts b/src/statement-boundary-types.ts new file mode 100644 index 0000000..f415bfa --- /dev/null +++ b/src/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/statement-index.ts b/src/statement-index.ts new file mode 100644 index 0000000..edf4081 --- /dev/null +++ b/src/statement-index.ts @@ -0,0 +1,1115 @@ +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"); +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; + +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 code: SqlAnalysisRange | null; + 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[]; +} + +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 { + 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" || + !isSqlStatementSlotSnapshot(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" } +> = 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, + 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: codeFrom !== null, + source: createAnalysisRange(from, sourceTo), + terminator: + sourceTo === extentTo ? null : createAnalysisRange(sourceTo, extentTo), + }); + exactSqlStatementSlots.add(slot); + sqlStatementSlots.add(slot); + return slot; +} + +function createOpaqueSlot( + from: number, + to: number, + reason: SqlOpaqueBoundaryReason, + detectedAt: number, +): OpaqueSqlStatementSlot { + const slot = Object.freeze({ + boundaryQuality: "opaque", + endState: createOpaqueEndState(reason, detectedAt), + extent: createAnalysisRange(from, to), + }); + sqlStatementSlots.add(slot); + return slot; +} + +class SqlPrefixGuard { + readonly #mode: SqlLexicalProfile["proceduralGuards"]; + 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: SqlLexicalProfile["proceduralGuards"]) { + 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"; +} + +interface SqlStatementScanOptions { + readonly from: number; + readonly prefixSlots: readonly SqlStatementSlot[]; + readonly tryReuseSuffix?: ( + boundary: number, + scannedSlots: readonly SqlStatementSlot[], + ) => SqlStatementIndex | null; +} + +function createStatementIndex( + slots: SqlStatementSlot[], + analysisText: string, + profile: SqlLexicalProfile, +): SqlStatementIndex { + const finalSlot = getStatementSlot(slots, slots.length - 1); + 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( + analysisText: string, + profile: SqlLexicalProfile, + options: SqlStatementScanOptions, +): SqlStatementIndex { + const slots: SqlStatementSlot[] = [...options.prefixSlots]; + const prefixGuard = new SqlPrefixGuard(profile.proceduralGuards); + let slotFrom = options.from; + 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, + detectedAt: number, + ): SqlStatementIndex => { + const slot = createOpaqueSlot( + slotFrom, + analysisText.length, + reason, + detectedAt, + ); + slots.push(slot); + return createStatementIndex(slots, analysisText, profile); + }; + + 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 = scanSqlBlockComment( + analysisText, + cursor, + analysisText.length, + profile.nestedBlockComments, + ); + if (!comment.closed) { + finalEndState = createUnterminatedEndState("block-comment", cursor); + } + cursor = comment.to; + continue; + } + + if ( + profile.dollarQuotedStrings && + code === 36 + ) { + const dollarQuote = scanSqlDollarQuote( + analysisText, + cursor, + analysisText.length, + ); + if (dollarQuote) { + const codeFrom_ = cursor; + prefixGuard.recordNonWord(code); + if (dollarQuote.delimiterTooLong) { + return finishOpaque("resource-limit", cursor); + } + if (!dollarQuote.closed) { + finalEndState = createUnterminatedEndState( + "dollar-quoted-string", + cursor, + ); + } + cursor = dollarQuote.to; + recordCode(codeFrom_, cursor); + continue; + } + } + + if ( + code === 39 || + code === 34 || + (profile.backtickQuotedIdentifiers && code === 96) + ) { + const codeFrom_ = cursor; + if (code === 96) { + prefixGuard.recordQuotedIdentifier(); + prefixGuard.recordNonWord(code); + const quote = scanSqlQuoted( + analysisText, + cursor, + analysisText.length, + code, + 1, + true, + false, + false, + ); + if (!quote.closed) { + finalEndState = createUnterminatedEndState( + "backtick-quoted-identifier", + cursor, + ); + } + cursor = quote.to; + recordCode(codeFrom_, cursor); + 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 = scanSqlQuoted( + analysisText, + cursor, + analysisText.length, + code, + quoteLength, + backslashEscapes, + doubledQuoteEscapes, + profile.bigQueryStrings && quoteLength === 1, + ); + if (!quote.closed) { + finalEndState = createUnterminatedEndState( + quoteConstruct(code, quoteLength, profile.bigQueryStrings), + cursor, + ); + } + cursor = quote.to; + recordCode(codeFrom_, cursor); + continue; + } + + const wordStartLength = sqlIdentifierStartLengthAt(analysisText, cursor); + if (wordStartLength > 0) { + 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, + ); + if (continueLength === 0) { + break; + } + cursor += continueLength; + } + prefixGuard.recordWord(analysisText, wordFrom, cursor); + recordCode(wordFrom, cursor); + 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, + codeFrom, + codeTo, + NORMAL_END_STATE, + ), + ); + slotFrom = cursor + 1; + codeFrom = null; + codeTo = slotFrom; + finalEndState = NORMAL_END_STATE; + prefixGuard.reset(); + cursor += 1; + const reused = options.tryReuseSuffix?.(slotFrom, slots); + if (reused) { + return reused; + } + continue; + } + + recordCode(cursor, cursor + 1); + cursor += 1; + } + + slots.push( + createExactSlot( + slotFrom, + analysisText.length, + analysisText.length, + codeFrom, + codeTo, + finalEndState, + ), + ); + return createStatementIndex(slots, analysisText, profile); +} + +/** Builds the bounded, parser-free statement partition for one analysis text. */ +export function buildSqlStatementIndex( + analysisText: string, + profile: SqlLexicalProfile, +): SqlStatementIndex { + return scanSqlStatementIndex( + analysisText, + snapshotLexicalProfile(profile), + { + from: 0, + prefixSlots: [], + }, + ); +} + +function statementSlotIndexAt( + slots: readonly SqlStatementSlot[], + position: number, + affinity: SqlCursorAffinity, +): number { + 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 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") { + const shifted = Object.freeze({ + boundaryQuality: "opaque", + endState: shiftEndState(slot.endState, delta), + extent: createAnalysisRange( + slot.extent.from + delta, + slot.extent.to + delta, + ), + }); + sqlStatementSlots.add(shifted); + return shifted; + } + 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, + 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, + }); + exactSqlStatementSlots.add(shifted); + sqlStatementSlots.add(shifted); + return shifted; +} + +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 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) { + const unchanged = + previousLength === nextAnalysisText.length && + previousProvenance.analysisText === nextAnalysisText; + return unchanged + ? previousIndex + : scanSqlStatementIndex(nextAnalysisText, nextProfile, { + from: 0, + prefixSlots: [], + }); + } + const normalized = normalizeTrustedChanges( + changes, + previousLength, + nextAnalysisText.length, + ); + if (!normalized) { + return scanSqlStatementIndex(nextAnalysisText, nextProfile, { + from: 0, + prefixSlots: [], + }); + } + + 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, nextProfile, { + 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], + nextAnalysisText, + nextProfile, + ); + }, + }); +} + +/** 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), + ); +} + +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, +): SqlStatementSlot { + const slot = slots[index]; + if (!slot) { + throw new Error("SQL statement index must contain at least one slot"); + } + return slot; +} diff --git a/src/syntax.ts b/src/syntax.ts new file mode 100644 index 0000000..10c2c89 --- /dev/null +++ b/src/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/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9422784 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,241 @@ +import type { + SqlLanguageFeatureMethods, + SqlLanguageFeatureProvider, +} from "./language-features.js"; + +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"; +} + +export function createSqlRevisionToken(): SqlRevision { + const revision: SqlRevision = { + [revisionBrand]: "SqlRevision", + }; + Object.freeze(revision); + 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 SqlIdentifierPath[]; +} + +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; + +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; + readonly to: number; +} + +/** One half-open UTF-16 edit in pre-update document coordinates. */ +export interface SqlTextChange extends SqlTextRange { + readonly insert: string; +} + +export interface SqlDocumentReplacement { + readonly changes?: never; + readonly kind: "replace"; + readonly text: string; +} + +export interface SqlDocumentChanges { + readonly kind: "changes"; + readonly changes: readonly SqlTextChange[]; + readonly text?: never; +} + +export type SqlDocumentEdit = SqlDocumentReplacement | SqlDocumentChanges; + +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 = + | 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. */ +export interface SqlDocumentSession + extends SqlLanguageFeatureMethods +{ + readonly revision: SqlRevision; + /** Invalidates relation, column, and namespace catalog observations. */ + readonly invalidateCatalog: () => SqlRevision; + readonly statementBoundaryAt: ( + request: SqlStatementBoundaryAtRequest, + ) => SqlStatementBoundaryAtResult; + readonly statementBoundariesIntersecting: ( + request: SqlStatementBoundariesIntersectingRequest, + ) => SqlStatementBoundariesIntersectingResult; + readonly update: (update: SqlDocumentUpdate) => SqlRevision; + readonly complete: ( + request: SqlCompletionRequest, + ) => SqlCompletionTask; + readonly onDidChange: ( + listener: (event: SqlSessionChangeEvent) => void, + ) => SqlDisposable; + 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< + 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; +} + +export type SqlSessionErrorCode = + | "duplicate-dialect" + | "invalid-change" + | "invalid-context" + | "invalid-completion-request" + | "invalid-dialect" + | "invalid-document" + | "invalid-feature-request" + | "invalid-service-options" + | "invalid-statement-boundary-request" + | "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; + } +} +import type { + SqlColumnCatalogProvider, +} from "./column-catalog-types.js"; +import type { + SqlNamespaceCatalogProvider, +} from "./namespace-catalog-types.js"; +import type { + SqlCompletionRequest, + SqlCompletionTask, + SqlDisposable, + SqlRelationCatalogProvider, + SqlSessionChangeEvent, +} from "./relation-completion-types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, +} from "./statement-boundary-types.js"; 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/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. 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/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. 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/known-failures.json b/test/known-failures.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/known-failures.json @@ -0,0 +1 @@ +{} 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/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/types/marimo-relation-completion-codemirror.test-d.ts b/test/types/marimo-relation-completion-codemirror.test-d.ts new file mode 100644 index 0000000..2b12866 --- /dev/null +++ b/test/types/marimo-relation-completion-codemirror.test-d.ts @@ -0,0 +1,80 @@ +import type { EditorView } from "@codemirror/view"; + +import type { + SqlCompletionInfoResolver, + SqlCompletionInfoResolverContext, + SqlEditorStatementGutterOptions, +} from "../../src/codemirror/index.js"; +import type { SqlCompletionItem } from "../../src/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: 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"; +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: SqlCompletionItem, + _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 gutter; +void invalidGutter; +void resolverReturningNode; +void resolverReturningNumber; +void resolverReturningReactData; +void resolverWithoutDestroy; +void resolverWithView; diff --git a/test/types/marimo-relation-completion.test-d.ts b/test/types/marimo-relation-completion.test-d.ts new file mode 100644 index 0000000..274a6d5 --- /dev/null +++ b/test/types/marimo-relation-completion.test-d.ts @@ -0,0 +1,425 @@ +import type { + SqlCatalogContext, + SqlDocumentContext, + SqlDocumentSession, + SqlDocumentEdit, + SqlDocumentUpdate, + SqlEmbeddedRegion, + SqlIdentifierComponent, + SqlCompletionRefreshToken, + SqlCompletionTask, + OpenSqlDocument, +} from "../../src/index.js"; +import type { + SqlCanonicalRelationPath, + SqlCatalogProviderReport, + SqlCatalogReadyCoverage, + SqlCatalogRelation, + SqlCatalogSearchRequest, + SqlCatalogSearchResponse, + SqlCatalogSubscriptionCleanup, + SqlCompletionCancellationReason, + SqlCompletionIssue, + SqlDisposable, + SqlCompletionItem, + SqlCompletionList, + SqlRelationCatalogProvider, + SqlSessionChangeEvent, +} from "../../src/relation-completion-types.js"; +import type { + SqlCatalogEpochTransitionTarget, +} from "../../src/relation-catalog-epoch-coordinator.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", +}; +declare const maybeContext: MarimoSqlContext | undefined; +declare const maybeDocument: SqlDocumentEdit | undefined; +const regions: readonly SqlEmbeddedRegion[] = [ + { from: 14, language: "python", to: 18 }, +]; +const openWithoutRegions: OpenSqlDocument = { + context, + text: "SELECT * FROM users", +}; +const openWithRegions: OpenSqlDocument = { + context, + embeddedRegions: regions, + text: "SELECT * FROM {df}", +}; + +declare const session: SqlDocumentSession; +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, +}); +session.update({ + baseRevision: session.revision, + context: maybeContext, + document: maybeDocument, + embeddedRegions: regions, +}); + +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(); +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) => { + 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 () => undefined; + }, +}; +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 receiverDependentDispose = function ( + this: { readonly id: string }, +): undefined { + void this.id; + return undefined; +}; +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; + +// @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" }, + { quoted: false, role: "relation", value: "users" }, +] satisfies SqlCanonicalRelationPath; +const relation: SqlCatalogRelation = { + canonicalPath: relationPath, + completionPathStart: 1, + entityId: "users", + matchQuality: "exact", + relationKind: "table", +}; +void relation; + +// @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 undefined omission leaves an empty transaction +session.update({ baseRevision: session.revision, context: undefined }); +const missingEngine: OpenSqlDocument = { + // @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 SqlCompletionItem; +const contradictoryCompleteList = { + isIncomplete: false, + // @ts-expect-error complete lists cannot carry incomplete issues + issues: [{ reason: "catalog-partial" }], + items: [], +} satisfies SqlCompletionList; +const contradictoryIncompleteList = { + isIncomplete: true, + issues: [], + items: [], + // @ts-expect-error incomplete lists require at least one issue +} satisfies SqlCompletionList; +// @ts-expect-error timeouts are unavailable evidence, not cancellation +const invalidCancellation: SqlCompletionCancellationReason = "timeout"; +const undefinedContext: SqlDocumentUpdate = { + baseRevision: session.revision, + context: undefined, + document: 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 receiverDependentProvider; +void receiverDependentDisposable; +void synchronousProvider; +void undefinedContext; diff --git a/test/types/marimo-sql-migration.test-d.ts b/test/types/marimo-sql-migration.test-d.ts new file mode 100644 index 0000000..5151007 --- /dev/null +++ b/test/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/index.js"; +import { + sqlEditor, + type SqlCompletionInfoResolver, + type SqlEditorSupport, +} from "../../src/codemirror/index.js"; + +type SqlDialectId = + | "bigquery" + | "dremio" + | "duckdb" + | "postgres"; + +type MarimoBackendDialect = + | SqlDialectId + | "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: SqlCompletionRoute, +): 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 SqlCompletionRoute { + readonly connection: MarimoConnection; + readonly dialect: SqlDialectId; + readonly kind: "standard"; +} + +type CompletionRoute = + | { + readonly kind: "legacy"; + readonly source: CompletionSource; + } + | SqlCompletionRoute; + +function completionRoute( + connection: MarimoConnection, +): CompletionRoute { + switch (connection.dialect) { + case "bigquery": + return { connection, dialect: "bigquery", kind: "standard" }; + case "dremio": + return { connection, dialect: "dremio", kind: "standard" }; + case "duckdb": + return { connection, dialect: "duckdb", kind: "standard" }; + case "postgres": + case "postgresql": + return { connection, dialect: "postgres", kind: "standard" }; + case "mysql": + case "oracle": + case "sqlite": + case "snowflake": + return { kind: "legacy", source: legacySchemaCompletionSource }; + } +} + +function createSqlSupport( + route: SqlCompletionRoute, + 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: SqlCompletionRoute; +} + +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: SqlCompletionRoute; +declare const secondDuckdbRoute: SqlCompletionRoute; +declare const mysqlConnection: MarimoConnection & { + readonly dialect: "mysql"; +}; + +const firstSupport = createSqlSupport( + duckdbRoute, + "SELECT * FROM {df}", +); +const secondSupport = createSqlSupport( + 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(); diff --git a/test/types/relation-catalog-search-work.test-d.ts b/test/types/relation-catalog-search-work.test-d.ts new file mode 100644 index 0000000..e5af07a --- /dev/null +++ b/test/types/relation-catalog-search-work.test-d.ts @@ -0,0 +1,286 @@ +import type { + CapturedSqlRelationCatalogProvider, + SqlValidatedCatalogSearchResponse, +} from "../../src/relation-catalog-boundary.js"; +import type { + SqlCatalogRevisionTarget, +} from "../../src/relation-catalog-epoch-coordinator.js"; +import { + createSqlCatalogEpochCoordinator, +} from "../../src/relation-catalog-epoch-coordinator.js"; +import type { + SqlCatalogSearchAvailabilityTarget, + SqlCatalogSearchWorkCoordinator, + SqlCatalogSearchWorkInput, + SqlCatalogSearchWorkOutcome, + SqlCatalogSearchWorkOwner, + SqlCatalogSearchWorkOwnerResult, + SqlCatalogSearchWorkTicket, +} from "../../src/relation-catalog-search-work.js"; +import { + createSqlCatalogSearchWorkCoordinator, +} from "../../src/relation-catalog-search-work.js"; +import type { + SqlRelationDialectRuntime, +} from "../../src/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 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 }, + _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 thisFreeRetainForRefresh; +void invalidPrepareOwnerReceiver; +void invalidActivateReceiver; +void invalidRequestReceiver; +void invalidCancelReceiver; +void invalidAsyncAvailabilityTarget; +void invalidAvailabilityReceiver; +void scopeLeakingInput; +void providerLeakingInput; +void providerHandleLeakingInput; +void epochLeakingInput; +void epochAliasLeakingInput; +void dialectLeakingInput; +void runtimeLeakingInput; +void extraOutcomeField; +void unknownOutcomeStatus; +void foreignCapturedProvider; diff --git a/test/types/session.test-d.ts b/test/types/session.test-d.ts new file mode 100644 index 0000000..21fd419 --- /dev/null +++ b/test/types/session.test-d.ts @@ -0,0 +1,247 @@ +import { + createSqlLanguageService, + duckdbDialect, + type SqlDialect, + type SqlDocumentContext, + type SqlDocumentEdit, + type SqlDocumentSession, + type SqlEmbeddedRegion, + type SqlLanguageService, + type SqlRelationCatalogProvider, + type SqlRevision, + type SqlStatementBoundariesIntersectingResult, + type SqlStatementBoundaryAtResult, + type SqlStatementBoundary, + type SqlTextChange, + type SqlTextRange, +} from "../../src/index.js"; + +interface HostContext extends SqlDocumentContext { + readonly engine: string; +} + +interface DateContext extends HostContext { + readonly lastUsed: Date; +} + +interface HostEmbeddedRegion extends SqlEmbeddedRegion { + readonly hostNodeId: string; +} + +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 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 }], + 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; +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; + +// @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({ + baseRevision: revision, + context: { dialect: "duckdb", engine: "remote" }, +}); +session.update({ + embeddedRegions: [], + baseRevision: session.revision, + document: { kind: "replace", text: "SELECT 1" }, +}); +session.update({ + embeddedRegions: [], + baseRevision: session.revision, + context: { dialect: "duckdb", engine: "local" }, + document: { kind: "changes", changes: [{ from: 0, insert: "SELECT 1", to: 0 }] }, +}); +session.update({ + 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; + +// @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 a non-empty state change +session.update({ baseRevision: revision }); +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(forbiddenUpdate); +// @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({ 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] }); +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 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; +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 +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 statement; +void statementBoundary; +void statementResult; +void statements; +void embeddedRegion; +void identitySession; +void typedDialect; +void widenedService; +void widenedSession; diff --git a/test/types/syntax.test-d.ts b/test/types/syntax.test-d.ts new file mode 100644 index 0000000..5fb04ae --- /dev/null +++ b/test/types/syntax.test-d.ts @@ -0,0 +1,182 @@ +import type { SqlTextRange } from "../../src/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/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; diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md new file mode 100644 index 0000000..33c7143 --- /dev/null +++ b/test/worker-placement/README.md @@ -0,0 +1,33 @@ +# 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 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 +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, 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 explicit tight headroom for the measured +graphs: + +- 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: 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 +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..7d7897b --- /dev/null +++ b/test/worker-placement/src/core.js @@ -0,0 +1,20 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql"; + +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..3103296 --- /dev/null +++ b/test/worker-placement/src/workers.js @@ -0,0 +1,284 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql"; + +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
+ + + 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.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 new file mode 100644 index 0000000..b834e01 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "exactOptionalPropertyTypes": true, + "inlineSources": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "rootDir": ".", + "sourceMap": false, + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*.ts", + "./test/types/**/*.ts", + "./vitest.browser.config.ts", + "./vitest.config.ts" + ], + "exclude": [ + "./dist", + "./node_modules" + ] +} diff --git a/tsconfig.tests.loose-optional.json b/tsconfig.tests.loose-optional.json new file mode 100644 index 0000000..3f21aab --- /dev/null +++ b/tsconfig.tests.loose-optional.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.tests.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false + } +} 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..09e6286 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,35 @@ +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: [ + "@codemirror/lang-sql", + "node-sql-parser/build/bigquery.js", + "node-sql-parser/build/postgresql.js", + ], + }, + test: { + allowOnly: false, + browser: { + enabled: true, + headless: true, + instances: [{ browser: configuredBrowser }], + 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..51469ef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "vitest/config"; + +const performanceTests = "src/**/*-performance.test.ts"; + +export default defineConfig({ + test: { + allowOnly: false, + coverage: { + enabled: false, + exclude: [ + "src/**/*.test.ts", + "src/**/__tests__/**", + "src/**/browser_tests/**", + "src/node-sql-parser-browser-worker.ts", + ], + excludeAfterRemap: true, + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json", "html", "json-summary"], + reportOnFailure: true, + thresholds: { + branches: 96, + functions: 99, + lines: 97, + statements: 97, + }, + }, + environment: "jsdom", + exclude: [ + "src/**/browser_tests/**/*.test.ts", + performanceTests, + ], + include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], + passWithNoTests: false, + watch: 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, + }, +});