diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6456b6a..c0d8ab3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,48 @@ jobs:
- run: cargo clippy --all-targets -- -D warnings
- run: cargo fmt --all -- --check
+ emulator:
+ name: BigQuery emulator integration
+ runs-on: ubuntu-latest
+ services:
+ bigquery:
+ image: ghcr.io/goccy/bigquery-emulator:latest
+ env:
+ BIGQUERY_EMULATOR_PROJECT: test
+ BIGQUERY_EMULATOR_DATASET: ds
+ ports:
+ - 9050:9050
+ steps:
+ - uses: actions/checkout@v7
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Wait for the emulator
+ run: |
+ for attempt in $(seq 1 30); do
+ curl -sf http://localhost:9050/bigquery/v2/projects/test/datasets && exit 0
+ sleep 2
+ done
+ exit 1
+ - run: cargo test --test emulator --test emulator_metadata
+ env:
+ BIGQUERY_EMULATOR_HOST: localhost:9050
+
+ explain:
+ name: EXPLAIN parser package
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+ cache-dependency-path: explain/pnpm-lock.yaml
+ - run: pnpm --dir explain install --frozen-lockfile
+ - run: pnpm --dir explain typecheck
+ - run: pnpm --dir explain test
+
validate-manifest:
name: Validate .tabularium manifest
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d968509..de6509f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,14 +18,23 @@ jobs:
with:
node-version: 20
cache: pnpm
- cache-dependency-path: ui/pnpm-lock.yaml
+ cache-dependency-path: |
+ ui/pnpm-lock.yaml
+ explain/pnpm-lock.yaml
- run: pnpm --dir ui install --frozen-lockfile
- run: pnpm --dir ui typecheck
- run: pnpm --dir ui build
+ - run: pnpm --dir explain install --frozen-lockfile
+ - run: pnpm --dir explain typecheck
+ - run: pnpm --dir explain test
- uses: actions/upload-artifact@v7
with:
name: ui-dist
path: ui/dist/
+ - uses: actions/upload-artifact@v7
+ with:
+ name: explain-dist
+ path: explain/dist/index.iife.js
build:
name: ${{ matrix.platform-label }}
@@ -74,22 +83,29 @@ jobs:
with:
name: ui-dist
path: ui/dist/
+ - uses: actions/download-artifact@v8
+ with:
+ name: explain-dist
+ path: explain/dist/
- name: Package on Unix
if: runner.os != 'Windows'
run: |
- mkdir -p staging/ui/dist
+ mkdir -p staging/ui/dist staging/explain/dist
cp target/${{ matrix.target }}/release/bigquery-plugin${{ matrix.binary-suffix }} staging/
cp .tabularium staging/
cp ui/dist/*.js staging/ui/dist/
+ cp explain/dist/index.iife.js staging/explain/dist/
(cd staging && zip -r ../bigquery-plugin-${{ matrix.platform-label }}.zip .)
- name: Package on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path "staging\ui\dist" | Out-Null
+ New-Item -ItemType Directory -Force -Path "staging\explain\dist" | Out-Null
Copy-Item "target\${{ matrix.target }}\release\bigquery-plugin${{ matrix.binary-suffix }}" staging
Copy-Item ".tabularium" staging
Copy-Item "ui\dist\*.js" "staging\ui\dist"
+ Copy-Item "explain\dist\index.iife.js" "staging\explain\dist"
Compress-Archive -Path "staging\*" -DestinationPath "bigquery-plugin-${{ matrix.platform-label }}.zip"
- name: Smoke test on Unix
if: runner.os != 'Windows' && !matrix.cross
diff --git a/.gitignore b/.gitignore
index 603a9e6..46c02ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ ui/dist/
.DS_Store
.idea/
.vscode/
+explain/node_modules/
+explain/dist/
diff --git a/.tabularium b/.tabularium
index 159649e..a1be161 100644
--- a/.tabularium
+++ b/.tabularium
@@ -1,13 +1,27 @@
{
"$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver",
- "name": "bigquery",
- "version": "0.1.0",
- "description": "Google BigQuery driver for Tabularis with GoogleSQL, datasets, views, routines, editable results and cost controls.",
+ "id": "bigquery",
+ "name": "BigQuery",
+ "version": "0.2.0",
+ "description": "Google BigQuery driver for Tabularis with multi-project browsing, cancellable paged queries, sessions and transactions, query plans, GoogleSQL, materialized views, routine management, typed row editing and cost controls.",
+ "category": "database",
+ "tags": ["driver", "bigquery", "google-cloud", "gcp", "sql", "googlesql", "analytics", "data-warehouse", "database-driver"],
"kind": "driver",
"engine": "bigquery",
+ "explain_parsers": [
+ {
+ "engine": "bigquery",
+ "format": "bigquery-job-json",
+ "label": "BigQuery job plan (JSON)",
+ "module": "explain/dist/index.iife.js"
+ }
+ ],
"paradigms": ["relational", "columnar"],
"license": "Apache-2.0",
+ "readme": "README.md",
+ "min_runtime_version": "0.29.0",
"homepage": "https://github.com/TabularisDB/tabularis-bigquery-plugin",
+ "documentation_url": "https://github.com/TabularisDB/tabularis-bigquery-plugin#readme",
"support": {
"issues_url": "https://github.com/TabularisDB/tabularis-bigquery-plugin/issues"
},
@@ -15,7 +29,7 @@
"default_username": "",
"executable": "bigquery-plugin",
"color": "#4285F4",
- "icon": "cloud",
+ "icon": "https://raw.githubusercontent.com/TabularisDB/tabularis-bigquery-plugin/main/bigquery-icon.svg",
"connection_fields": {
"host": {"hidden": true},
"port": {"hidden": true},
@@ -23,7 +37,7 @@
"password": {"hidden": true},
"database": {
"label": "GCP Project ID",
- "placeholder": "billing-project"
+ "placeholder": "my-data-project"
}
},
"ui_extensions": [
@@ -39,14 +53,35 @@
"views": true,
"materialized_views": true,
"routines": true,
- "routine_management": false,
+ "routine_management": true,
"triggers": false,
"file_based": false,
"folder_based": false,
"no_connection_required": false,
"connection_string": false,
"identifier_quote": "`",
- "sql_dialect": "generic",
+ "sql_dialect": "mysql",
+ "sql_syntax": {
+ "extends": "generic",
+ "quotes": [
+ {"open": "'", "kind": "string", "escape": "backslash"},
+ {"open": "\"", "kind": "string", "escape": "backslash"},
+ {"open": "`", "kind": "identifier", "escape": "backslash"}
+ ],
+ "triple_quoted_strings": true,
+ "string_prefixes": ["r", "b", "rb", "br"],
+ "hash_comments": true,
+ "nested_block_comments": false,
+ "line_comment_requires_space": false,
+ "script_blocks": true,
+ "dollar_quoting": false,
+ "string_escape": "backslash",
+ "like_escape": false,
+ "boolean_literals": "keywords",
+ "formatter": "bigquery",
+ "keywords": ["QUALIFY", "PIVOT", "UNPIVOT", "TABLESAMPLE SYSTEM", "UNNEST", "WITH OFFSET", "EXCEPT", "REPLACE", "WINDOW", "OVER", "PARTITION BY", "CLUSTER BY", "OPTIONS", "FOR SYSTEM_TIME AS OF", "IGNORE NULLS", "RESPECT NULLS", "UNION ALL", "UNION DISTINCT", "INTERSECT DISTINCT", "EXCEPT DISTINCT", "MERGE", "WHEN MATCHED", "WHEN NOT MATCHED", "WHEN NOT MATCHED BY SOURCE", "CREATE OR REPLACE TABLE", "CREATE TEMP TABLE", "CREATE TEMP FUNCTION", "CREATE OR REPLACE PROCEDURE", "CREATE SCHEMA", "RETURNS", "LANGUAGE js", "EXPORT DATA", "LOAD DATA", "ASSERT", "DECLARE", "BEGIN", "BEGIN TRANSACTION", "COMMIT TRANSACTION", "ROLLBACK TRANSACTION", "EXCEPTION WHEN ERROR THEN", "ELSEIF", "END IF", "LOOP", "END LOOP", "WHILE", "END WHILE", "REPEAT", "UNTIL", "END REPEAT", "FOR", "DO", "END FOR", "END CASE", "LEAVE", "BREAK", "CONTINUE", "ITERATE", "RETURN", "CALL", "EXECUTE IMMEDIATE", "RAISE USING MESSAGE", "INT64", "FLOAT64", "NUMERIC", "BIGNUMERIC", "BOOL", "STRING", "BYTES", "DATE", "DATETIME", "TIME", "TIMESTAMP", "INTERVAL", "GEOGRAPHY", "JSON", "STRUCT", "ARRAY", "RANGE"],
+ "functions": ["SAFE_CAST", "SAFE.", "SAFE_DIVIDE", "IF", "IFNULL", "COALESCE", "NULLIF", "COUNTIF", "ARRAY_AGG", "STRING_AGG", "LOGICAL_AND", "LOGICAL_OR", "ANY_VALUE", "APPROX_COUNT_DISTINCT", "APPROX_QUANTILES", "APPROX_TOP_COUNT", "APPROX_TOP_SUM", "ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTILE", "PERCENTILE_CONT", "ARRAY_LENGTH", "ARRAY_CONCAT", "ARRAY_TO_STRING", "GENERATE_ARRAY", "GENERATE_DATE_ARRAY", "SPLIT", "FORMAT", "STARTS_WITH", "ENDS_WITH", "CONTAINS_SUBSTR", "REGEXP_CONTAINS", "REGEXP_EXTRACT", "REGEXP_EXTRACT_ALL", "REGEXP_REPLACE", "REGEXP_INSTR", "FARM_FINGERPRINT", "GENERATE_UUID", "JSON_VALUE", "JSON_QUERY", "JSON_VALUE_ARRAY", "JSON_QUERY_ARRAY", "JSON_EXTRACT", "JSON_EXTRACT_SCALAR", "PARSE_JSON", "TO_JSON", "TO_JSON_STRING", "CURRENT_DATE", "CURRENT_DATETIME", "CURRENT_TIMESTAMP", "EXTRACT", "DATE_TRUNC", "DATE_ADD", "DATE_SUB", "DATE_DIFF", "DATETIME_TRUNC", "DATETIME_DIFF", "TIMESTAMP_TRUNC", "TIMESTAMP_ADD", "TIMESTAMP_SUB", "TIMESTAMP_DIFF", "FORMAT_DATE", "FORMAT_TIMESTAMP", "PARSE_DATE", "PARSE_TIMESTAMP", "UNIX_SECONDS", "UNIX_MILLIS", "TIMESTAMP_SECONDS", "TIMESTAMP_MILLIS", "LAST_DAY", "ST_GEOGPOINT", "ST_GEOGFROMTEXT", "ST_ASTEXT", "ST_DISTANCE", "ST_DWITHIN", "ST_CONTAINS", "ST_INTERSECTS", "ST_AREA", "ST_LENGTH", "ST_BUFFER"]
+ },
"alter_primary_key": true,
"alter_column": true,
"create_foreign_keys": true,
@@ -56,20 +91,33 @@
"readonly": false,
"serial_type": "",
"auto_increment_keyword": "",
- "inline_pk": false
+ "inline_pk": false,
+ "column_comments": true,
+ "drop_table_sql": true
},
"type_mappings": {
"INTEGER": "INT64",
+ "BIGINT": "INT64",
+ "SERIAL": "INT64",
"REAL": "FLOAT64",
+ "DOUBLE": "FLOAT64",
+ "DECIMAL": "NUMERIC",
"BOOLEAN": "BOOL",
+ "TEXT": "STRING",
+ "VARCHAR": "STRING",
+ "UUID": "STRING",
+ "BLOB": "BYTES",
+ "JSON": "JSON",
+ "DATE": "DATE",
+ "TIME": "TIME",
"DATETIME": "DATETIME",
- "TEXT": "STRING"
+ "TIMESTAMP": "TIMESTAMP"
},
"data_types": [
{"name": "INT64", "category": "numeric", "requires_length": false, "requires_precision": false},
{"name": "FLOAT64", "category": "numeric", "requires_length": false, "requires_precision": false},
- {"name": "NUMERIC", "category": "numeric", "requires_length": false, "requires_precision": true},
- {"name": "BIGNUMERIC", "category": "numeric", "requires_length": false, "requires_precision": true},
+ {"name": "NUMERIC", "category": "numeric", "requires_length": false, "requires_precision": false},
+ {"name": "BIGNUMERIC", "category": "numeric", "requires_length": false, "requires_precision": false},
{"name": "STRING", "category": "string", "requires_length": false, "requires_precision": false},
{"name": "BYTES", "category": "binary", "requires_length": false, "requires_precision": false},
{"name": "BOOL", "category": "other", "requires_length": false, "requires_precision": false},
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 508b84e..d6334f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,64 @@
All notable changes to this project are documented in this file.
-## Unreleased
+## 0.2.0
+
+Requires Tabularis 0.29.0 or later (`min_runtime_version`): it relies on plugin connection fields and secrets, the DDL connection context, the declarative `sql_syntax` capability and the bundled Visual EXPLAIN parser.
+
+### Query runtime
+
+- Handle JSON-RPC requests concurrently with a single ordered writer, cancel the running BigQuery job when Tabularis sends a `cancel` notification, and answer with an internal error instead of never replying when a request handler fails.
+- Start jobs with a `requestId`, the `client: tabularis` label and a server-side `jobTimeoutMs`; cancel jobs that exceed the query timeout. The default query timeout is now 110 seconds, below the host's 120 second call timeout.
+- Retry HTTP 429, 5xx and 403 `rateLimitExceeded`/`backendError` responses with jittered backoff and refresh the token once after HTTP 401. Judge job failure by `status.errorResult`.
+- Fill result pages across `pageToken` responses, keep the rows returned by `jobs.query`, and read later pages from the finished job instead of running the SQL again.
+- Return TIMESTAMP values with exact microseconds.
+- Serve plain `SELECT * FROM table` table browsing through the free `tabledata.list` API.
+- Support Tabularis editor sessions with BigQuery sessions, transaction tracking (including COMMIT and ROLLBACK inside scripting blocks) and `release_session`; run stateful batches in a short-lived session.
+- Return the last result set and summed DML row count of multi-statement scripts.
+- Return Visual EXPLAIN as a raw `bigquery-job-json` payload with a bundled parser: dry-run bytes and on-demand cost estimates, and executed stage plans for EXPLAIN ANALYZE.
+- Map common BigQuery error reasons to messages with hints.
+- Run metadata queries, DDL, row edits and routine statements through the same job path as editor queries, with typed named query parameters, so they get request IDs, retries, job timeouts, labels, error hints and cancellation.
+- Clear the metadata cache after any editor statement other than a plain query, so DDL and DML run from a query tab show up at once.
+
+### Projects and metadata
+
+- Browse several projects from one connection: `get_databases` lists visible projects plus the `additional_projects` setting, and metadata follows the selected project.
+- Run and bill every job in the optional `billing_project` (default: the GCP Project ID), while unqualified names and table browsing resolve in the selected project.
+- Read routines through the free routines REST API, return host-compatible routine parameters with ordinal positions and a named return row, and add routine management: run, create from templates, edit with rebuilt `CREATE OR REPLACE` scripts (SQL, JavaScript, Python, remote, table and aggregate functions, procedures) and drop.
+- Fetch each table resource once with bounded concurrency for schema snapshots, ER diagram batches and AI context, cache metadata briefly, and return the requested tables from `get_all_columns_batch` and `get_all_foreign_keys_batch`, skipping tables that cannot be read instead of failing the batch.
+- Fill table descriptions, parameterized column types, numeric precision and scale, string lengths and composite foreign-key order.
+- Add `get_table_ddl` and use `INFORMATION_SCHEMA.TABLES.ddl` for table, view and materialized view definitions, with a metadata-based fallback that keeps partitioning, clustering and options.
+- Keep view options when saving a view and refuse to replace legacy SQL views.
+- Refresh materialized views on demand with `BQ.REFRESH_MATERIALIZED_VIEW`.
+
+### DDL and row editing
+
+- Quote every part of generated identifiers and fully qualify objects as `` `project`.`dataset`.`object` ``. Table, column, foreign-key and routine template builders use the browsed project from the connection parameters the host sends with every DDL request, and fall back to dataset-qualified names on older hosts.
+- Declare `drop_table_sql` and implement `get_drop_table_sql`, so the explorer drops tables, external tables, snapshots, views and materialized views with the matching statement.
+- Declare `column_comments`: column descriptions from the column editor are written with `OPTIONS(description = ...)` on create and add, and changed or cleared with `ALTER COLUMN ... SET OPTIONS(description = ...)`.
+- Drop views and materialized views with the matching statement, and reject row edits and column changes on snapshots and external tables.
+- Generate only DDL that BigQuery accepts: no `NOT NULL` on added columns, no `SET NOT NULL`, type changes limited to supported widenings, column descriptions through `SET OPTIONS`, primary keys added and dropped as `NOT ENFORCED`, and optional partitioning, clustering and description on new tables.
+- Edit rows with typed query parameters; support tables without a primary key by matching comparable columns inside a transaction that rolls back when more than one row matches; report real affected-row counts and explain streaming-buffer errors.
+- Declare the GoogleSQL syntax (backtick identifiers, triple-quoted and raw strings, `#` comments, procedural `BEGIN … END` blocks, backslash literals, `TRUE`/`FALSE` boolean literals, keywords and functions) through `sql_syntax`, so Tabularis splits scripts, formats SQL and builds filters the GoogleSQL way. Older hosts fall back to the `mysql` dialect.
+- Write valid GoogleSQL literals for strings with newlines and control characters, NUMERIC, BIGNUMERIC, GEOGRAPHY, INTERVAL, RANGE, JSON, BYTES, ARRAY and STRUCT values.
+
+### Connection and authentication
- Hide unused common connection fields on compatible Tabularis hosts.
- Store plugin-owned credentials in the OS keychain while preserving legacy Password-based connections.
-- Author the connection UI in React and TypeScript and generate its release bundle with Vite.
+- Author the connection UI in React and TypeScript and generate its release bundle with Vite, with advanced fields for the billing project, additional projects, API endpoint and impersonation.
+- Support `impersonated_service_account` ADC files and an optional service account to impersonate, reject service account keys whose `token_uri` is not a googleapis.com endpoint, and tolerate a byte order mark in credentials.
+- Add an API endpoint override for Private Service Connect and emulators, and a `none` authentication method for localhost endpoints.
+- Fix the client cache key to cover the plugin-owned credential and every connection option, including the billing and data project.
+- Use a single `datasets.list` call for `ping`.
+- Fix BigQuery REST URLs that contained a double slash after the API version.
+
+### Packaging and tests
+
+- Fix the GCP Project ID placeholder, which suggested the billing project.
+- Let `pnpm install --frozen-lockfile` run the esbuild install script with pnpm 10 and later.
+- Complete the manifest type mappings, stop requiring precision for NUMERIC and BIGNUMERIC, add registry metadata and declare the Visual EXPLAIN parser.
+- Add mocked HTTP, host-shape conformance and BigQuery emulator integration tests for the query runtime and for metadata, DDL and row editing.
## 0.1.0
diff --git a/Cargo.lock b/Cargo.lock
index 239f304..c0730b7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -11,6 +20,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -42,8 +61,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bigquery-plugin"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
+ "async-trait",
"base64",
"chrono",
"gcp_auth",
@@ -53,6 +73,8 @@ dependencies = [
"sha2",
"tokio",
"url",
+ "uuid",
+ "wiremock",
]
[[package]]
@@ -173,6 +195,24 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "deadpool"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
+dependencies = [
+ "deadpool-runtime",
+ "lazy_static",
+ "num_cpus",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -221,6 +261,21 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -228,6 +283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
+ "futures-sink",
]
[[package]]
@@ -236,6 +292,34 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
[[package]]
name = "futures-sink"
version = "0.3.34"
@@ -254,8 +338,13 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
+ "futures-channel",
"futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
"futures-task",
+ "memchr",
"pin-project-lite",
"slab",
]
@@ -349,6 +438,12 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
[[package]]
name = "http"
version = "1.5.0"
@@ -388,6 +483,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.11.0"
@@ -402,6 +503,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -609,6 +711,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
[[package]]
name = "libc"
version = "0.2.189"
@@ -659,6 +767,16 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -818,6 +936,35 @@ dependencies = [
"rand_core",
]
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -1338,6 +1485,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1584,6 +1742,29 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "wiremock"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
+dependencies = [
+ "assert-json-diff",
+ "base64",
+ "deadpool",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
[[package]]
name = "writeable"
version = "0.6.3"
diff --git a/Cargo.toml b/Cargo.toml
index 532b786..a98c695 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "bigquery-plugin"
-version = "0.1.0"
+version = "0.2.0"
edition = "2021"
description = "Google BigQuery driver plugin for Tabularis"
license = "Apache-2.0"
@@ -20,6 +20,11 @@ serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
url = "2"
+uuid = { version = "1", features = ["v4"] }
+
+[dev-dependencies]
+async-trait = "0.1"
+wiremock = "0.6"
[profile.release]
lto = true
diff --git a/README.md b/README.md
index 0bb43f6..7e58082 100644
--- a/README.md
+++ b/README.md
@@ -6,20 +6,34 @@
A native Google BigQuery driver for [Tabularis](https://github.com/TabularisDB/tabularis). The plugin is a standalone Rust executable that uses the BigQuery REST API v2 and communicates with Tabularis through newline-delimited JSON-RPC 2.0 over standard input and output.
+Requires Tabularis 0.29.0 or later.
+
## Features
-- Application Default Credentials, service account JSON, refreshable OAuth authorized user credentials and direct OAuth access token authentication
-- Dataset, table, external table, snapshot, clone, view and materialized view browsing
+- Application Default Credentials (including `impersonated_service_account` ADC files), service account JSON, refreshable OAuth authorized user credentials and direct OAuth access token authentication
+- Optional service account impersonation for every authentication method
+- Optional API endpoint override for Private Service Connect and local emulators
+- Multi-project browsing: every project the credential can see, plus extra projects such as `bigquery-public-data`, with an optional separate billing project for jobs
+- Dataset, table, external table, snapshot, clone, view and materialized view browsing with descriptions
- Nested `STRUCT`, repeated `ARRAY`, `JSON`, `BYTES`, temporal, numeric and geography value conversion
-- GoogleSQL query execution with result pagination, job polling, retry handling and DML affected-row counts
-- Visual EXPLAIN cost summaries backed by BigQuery dry runs
+- GoogleSQL editor support declared through `sql_syntax`: script splitting with `BEGIN … END` blocks, triple-quoted and raw strings, `#` comments, BigQuery formatting and autocomplete keywords
+- GoogleSQL query execution with idempotent, labelled jobs, server-side job timeouts and cancellation
+- Exact result pagination that reuses the finished job for later pages instead of running the SQL again
+- Free table browsing through `tabledata.list` for plain `SELECT * FROM table` queries
+- Editor sessions: temporary tables, variables and multi-statement transactions survive between runs
+- Multi-statement scripts return the last result set and the total DML affected-row count
+- Visual EXPLAIN with dry-run cost estimates and executed stage plans (`EXPLAIN ANALYZE`)
+- Clear error messages with hints for common BigQuery failures
- Per-connection location, timeout and maximum bytes billed controls
-- Table columns, descriptions, primary keys and foreign keys from BigQuery metadata
-- Routine discovery and routine definition inspection
-- Inline insert, update and delete operations with type-aware GoogleSQL literals
-- Table, column, view and unenforced key DDL generation
+- Table columns, descriptions, parameterized types, primary keys and foreign keys from BigQuery metadata
+- Table, view and materialized view DDL from `INFORMATION_SCHEMA`, with partitioning, clustering and options
+- Routine management for SQL, JavaScript and Python functions, table functions, aggregate functions and procedures: browse, inspect parameters, run, create, edit and drop
+- Manual materialized view refresh
+- Inline insert, update and delete with typed query parameters, including tables without a primary key
+- Table, column, view and unenforced key DDL generation that only emits changes BigQuery accepts, with column descriptions
+- Dropping tables, external tables, snapshots, views and materialized views from the explorer with the matching `DROP` statement
- BLOB preview and export for `BYTES` columns
-- Batch metadata methods for ER diagrams and AI schema context
+- Batch metadata methods for ER diagrams and AI schema context, fetched with bounded concurrency
- Linux, macOS and Windows release packaging
## Connection setup
@@ -28,15 +42,21 @@ On a compatible Tabularis host, the BigQuery plugin hides unused common fields a
| Tabularis field | BigQuery meaning |
| --- | --- |
-| GCP Project ID | Project used for jobs and billing |
+| GCP Project ID | Project that is browsed and, unless a billing project is set, runs jobs |
| Credential | Service account JSON/path, authorized user JSON/path, or access token |
The BigQuery section in the connection form adds:
-- **Authentication**: ADC, service account JSON, OAuth authorized user JSON, or OAuth access token
+- **Authentication**: ADC, service account JSON, OAuth authorized user JSON, OAuth access token, or no authentication (local emulator only)
- **Location**: optional job location such as `US`, `EU`, or `europe-west1`
-- **Query timeout**: 30 to 3600 seconds, default 300
+- **Query timeout**: 30 to 3600 seconds, default 110
- **Maximum bytes billed**: optional per-query cost guard
+- **Advanced options**:
+ - **Billing project** (`billing_project`): optional project that runs and pays for every job. Set it when browsing several projects, so jobs never run in a project you can only read, such as `bigquery-public-data`
+ - **Additional projects** (`additional_projects`): optional comma-separated projects added to the project list, for public or shared projects the credential cannot list
+ - **API endpoint** and **Impersonate service account**, described below
+
+The query timeout is also sent to BigQuery as the job timeout, and the plugin cancels a job that exceeds it. Tabularis waits 120 seconds for a plugin call by default, so the default of 110 seconds lets the plugin report and cancel a slow query itself. To allow longer queries, raise both the connection's query timeout and the Tabularis plugin call timeout (`pluginCallTimeoutSeconds`, or `callTimeoutSeconds` for the `bigquery` plugin). When Tabularis gives up on a call it sends a cancel notification and the plugin cancels the running job.
The Credential field is plugin-owned. Tabularis stores its value in the OS keychain and never writes it to `connections.json`. Existing connections that used the Password field remain supported and do not require migration.
@@ -51,9 +71,19 @@ Leave Credential empty. The plugin tries the standard Google credential chain:
3. A service account attached to the Google Cloud runtime
4. The active `gcloud` CLI account
+If the ADC file (`GOOGLE_APPLICATION_CREDENTIALS` or the gcloud default file) is an `impersonated_service_account` credential, such as one created by `gcloud auth application-default login --impersonate-service-account`, the plugin exchanges its source credential for a token of the target service account through the IAM Credentials API.
+
### Service account
-Select **Service account JSON** and put either the credential file path or the complete JSON document in Credential.
+Select **Service account JSON** and put either the credential file path or the complete JSON document in Credential. The key's `token_uri` must be a `googleapis.com` HTTPS endpoint; keys pointing elsewhere are rejected. A leading byte order mark is tolerated.
+
+### Service account impersonation
+
+Enter a service account email under **Advanced options > Impersonate service account** to run every request as that account. The configured credential must hold the Service Account Token Creator role on it.
+
+### API endpoint and emulators
+
+**Advanced options > API endpoint** replaces `https://bigquery.googleapis.com`, for example with a Private Service Connect endpoint. The REST path `/bigquery/v2/` is appended when missing. HTTPS is required except for `localhost`, `127.0.0.1` and `[::1]`. The **No authentication** method is accepted only together with such a local endpoint, for example `http://localhost:9050` for a BigQuery emulator.
### OAuth access token
@@ -65,7 +95,18 @@ Select **OAuth authorized user JSON** and put either an `authorized_user` creden
## Required Google Cloud permissions
-The exact IAM role depends on the enabled operations. Read-only use generally needs BigQuery Metadata Viewer and BigQuery Job User permissions. Editing and DDL need the corresponding BigQuery Data Editor or Data Owner permissions. Grant the least privilege required by your workflow.
+The exact IAM role depends on the enabled operations. Grant the least privilege required by your workflow.
+
+| Operation | Permissions | Typical role |
+| --- | --- | --- |
+| Run queries, DDL and DML | `bigquery.jobs.create` on the billing project | BigQuery Job User |
+| Browse datasets, tables, views and routines | `bigquery.datasets.get`, `bigquery.tables.list`, `bigquery.tables.get`, `bigquery.routines.list`, `bigquery.routines.get` | BigQuery Metadata Viewer |
+| Read table data and `INFORMATION_SCHEMA` DDL | `bigquery.tables.getData` | BigQuery Data Viewer |
+| List projects for multi-project browsing | `resourcemanager.projects.get` on each listed project | Any role granting BigQuery access to the project |
+| Edit rows, alter tables, create and drop views and routines | `bigquery.tables.update`, `bigquery.tables.updateData`, `bigquery.tables.create`, `bigquery.tables.delete`, `bigquery.routines.create`, `bigquery.routines.update`, `bigquery.routines.delete` | BigQuery Data Editor |
+| Refresh materialized views | `bigquery.tables.updateData` on the view and read access to its base tables | BigQuery Data Editor |
+
+When projects cannot be listed, the project list falls back to the configured, billing and additional projects.
## Installation
@@ -75,6 +116,9 @@ Download the archive for your platform from GitHub Releases, extract it, and cop
bigquery/
├── .tabularium
├── bigquery-plugin
+├── explain/
+│ └── dist/
+│ └── index.iife.js
└── ui/
└── dist/
└── connection-fields.js
@@ -84,24 +128,36 @@ Use `bigquery-plugin.exe` on Windows.
| OS | Plugin directory |
| --- | --- |
-| Linux | `~/.local/share/tabularis/plugins/bigquery/` |
-| macOS | `~/Library/Application Support/com.debba.tabularis/plugins/bigquery/` |
-| Windows | `%APPDATA%\debba\tabularis\data\plugins\bigquery\` |
+| Linux | `~/.local/share/tabularis/plugins/drivers/bigquery/` |
+| macOS | `~/Library/Application Support/tabularis/plugins/drivers/bigquery/` |
+| Windows | `%APPDATA%\tabularis\plugins\drivers\bigquery\` |
## Build and test
-Rust stable, Node.js 20 and pnpm 9 are required. The UI extension is authored in React and TypeScript under `ui/src`; Vite produces the IIFE bundle loaded by Tabularis.
+Rust stable, Node.js 20 and pnpm 9 or later are required. Each package directory is its own pnpm workspace whose `pnpm-workspace.yaml` allows the esbuild install script, so frozen installs also work with pnpm 10 and later. The UI extension is authored in React and TypeScript under `ui/src`; Vite produces the IIFE bundle loaded by Tabularis. The Visual EXPLAIN parser lives under `explain/` and is built with tsup.
```bash
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui typecheck
pnpm --dir ui build
+pnpm --dir explain install --frozen-lockfile
+pnpm --dir explain typecheck
+pnpm --dir explain test
cargo build
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check
```
+Integration tests run against the BigQuery emulator when `BIGQUERY_EMULATOR_HOST` is set and are skipped otherwise. `tests/emulator.rs` covers the query runtime and `tests/emulator_metadata.rs` covers metadata, DDL and row editing; both expect the project `test` and the dataset `ds`:
+
+```bash
+docker run -d -p 9050:9050 ghcr.io/goccy/bigquery-emulator:latest --project=test --dataset=ds
+BIGQUERY_EMULATOR_HOST=localhost:9050 cargo test --all-targets
+```
+
+The emulator does not implement sessions, dry runs, DML row counts, script child jobs, `tabledata.list` paging, `@@row_count`, `INFORMATION_SCHEMA.TABLES.ddl`, informational keys or the routines API; the plugin falls back gracefully and the mocked tests in `tests/runtime_mock.rs` and the host-shape tests in `tests/conformance.rs` and `tests/conformance_metadata.rs` cover those paths.
+
Validate the release manifest with Tabularium:
```bash
@@ -117,16 +173,32 @@ echo '{"jsonrpc":"2.0","method":"initialize","id":1}' \
| cargo run --quiet
```
-The plugin writes protocol responses only to stdout. Diagnostics are written to stderr.
+The plugin writes protocol responses only to stdout. Diagnostics are written to stderr. Requests are handled concurrently, so responses can arrive in a different order than requests; each carries its request `id`. Notifications, such as `cancel`, are never answered.
## BigQuery-specific behavior
-- Datasets are exposed as Tabularis schemas.
-- The selected project is used as the BigQuery job project and billing project.
-- BigQuery primary and foreign keys are informational and emitted as `NOT ENFORCED` in generated DDL.
-- BigQuery materialized views refresh automatically, so manual refresh returns an unsupported-operation error.
-- General-purpose indexes are not available in BigQuery. Search indexes and vector indexes require specialized statements and are not exposed through Tabularis' generic index UI.
-- Visual EXPLAIN uses a BigQuery dry run. It reports estimated bytes processed without executing the query, rather than exposing an operator tree.
+- Projects are exposed as Tabularis databases and datasets as schemas. Select several projects in the connection dialog to browse them side by side.
+- Jobs always run in the billing project: the `billing_project` setting when present, otherwise the GCP Project ID. Unqualified table names in the editor resolve in the project being browsed (`defaultDataset.projectId`), and metadata, DDL and row edits target that project too. Generated SQL is fully qualified as `` `project`.`dataset`.`object` `` with every part quoted separately, so project IDs containing dashes work.
+- Metadata comes from the free REST API (`tables.get`, `routines.get`). Resources are fetched once per object, with at most eight requests in flight for ER diagrams, schema snapshots and AI context, and cached for five seconds; any DDL sent through the plugin, and any statement other than a plain query run from the editor, clears the cache. Table descriptions are loaded for datasets with up to 100 tables or views.
+- Jobs carry a `requestId`, so retried requests never start a second job, and the label `client: tabularis` for cost attribution. Requests are retried on HTTP 429 and 5xx and on 403 `rateLimitExceeded` or `backendError`, with exponential backoff and jitter. A rejected token is refreshed once.
+- Pages after the first are read from the finished job with `getQueryResults` for 10 minutes; the SQL is not executed again. The first page always runs the statement.
+- A query of the exact form `SELECT * FROM table [LIMIT n [OFFSET m]]` on a native table, which is what a Tabularis table tab sends, is served by `tabledata.list` without a query job and without scanning cost. Views, external tables, filters, ordering and any other SQL run as normal jobs.
+- TIMESTAMP values are returned with exact microseconds.
+- When Tabularis pins an editor tab to a session, the plugin creates a BigQuery session for it and runs every statement of that tab inside it. `BEGIN`, `COMMIT` and `ROLLBACK` are tracked for the transaction indicator. Closing the tab aborts the session. A batch without a session that contains `DECLARE`, `SET`, temporary tables or transaction statements runs in a short-lived session. BigQuery has no savepoints; savepoint statements are answered with an error without being sent.
+- Visual EXPLAIN runs a dry run and shows bytes processed and an estimated on-demand cost (US$6.25 per TiB list price, clearly labelled as an estimate) plus the referenced tables. EXPLAIN ANALYZE executes the statement, including DML, and shows each query stage with slot time, wall time, records read and written, shuffle bytes and steps. The plan is parsed by the bundled `explain/dist/index.iife.js` parser.
+- Table, view and materialized view definitions come from `INFORMATION_SCHEMA.TABLES.ddl`, a small billed query run in the dataset location, so options, partitioning and clustering are preserved. When that query is not available the DDL is rebuilt from table metadata.
+- Saving a view keeps its description, friendly name and labels. Legacy SQL views can be read but not saved from the view editor.
+- Routines are listed as functions and procedures. Table functions, aggregate functions, JavaScript and Python UDFs and remote functions are rebuilt as complete `CREATE OR REPLACE` scripts for editing. Running a function previews `SELECT fn(...)` or `SELECT * FROM table_fn(...)`; procedures use `CALL`, with `OUT` and `INOUT` arguments returned through script variables.
+- Materialized views refresh automatically. **Refresh** runs `CALL BQ.REFRESH_MATERIALIZED_VIEW(...)` for an immediate refresh.
+- BigQuery primary and foreign keys are informational and emitted as `NOT ENFORCED`. Foreign keys support no `ON DELETE` / `ON UPDATE` actions.
+- Column changes follow BigQuery limits: new columns cannot be `NOT NULL`, existing columns can drop `NOT NULL` but never gain it, and `SET DATA TYPE` only widens types (`INT64` to `NUMERIC`, `BIGNUMERIC` or `FLOAT64`; `NUMERIC` to `BIGNUMERIC` or `FLOAT64`; larger `STRING`, `BYTES` and decimal parameters). Other changes are rejected with an explanation instead of failing at run time.
+- Snapshots and external tables are read-only for row editing and column changes.
+- Column descriptions entered in the column editor become `OPTIONS(description = ...)` on new tables and columns, and `ALTER COLUMN ... SET OPTIONS(description = ...)` on existing columns; clearing the description sets it to `NULL`.
+- Dropping an object from the explorer looks up its type and runs `DROP TABLE`, `DROP EXTERNAL TABLE`, `DROP SNAPSHOT TABLE`, `DROP VIEW` or `DROP MATERIALIZED VIEW` on the fully qualified name.
+- Row edits use typed query parameters. On tables without a primary key, rows are matched on every comparable column (floating point, JSON, geography, arrays, structs, ranges and bytes are skipped), and the change runs in a transaction that rolls back if more than one row would change.
+- Rows still in the streaming buffer cannot be updated or deleted; the plugin reports this with a clear message.
+- General-purpose indexes are not available in BigQuery. Search and vector indexes use `CREATE SEARCH INDEX` / `CREATE VECTOR INDEX` in a query tab; the index editor only drops the informational primary key.
+- Metadata queries, DDL and row edits run through the same job path as editor queries: `requestId`, retries, `jobTimeoutMs`, labels, error hints and cancellation apply to every statement the plugin sends.
## License
diff --git a/bigquery-icon.svg b/bigquery-icon.svg
new file mode 100644
index 0000000..a04833e
--- /dev/null
+++ b/bigquery-icon.svg
@@ -0,0 +1,23 @@
+
diff --git a/explain/LICENSE b/explain/LICENSE
new file mode 100644
index 0000000..57bc88a
--- /dev/null
+++ b/explain/LICENSE
@@ -0,0 +1,202 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/explain/README.md b/explain/README.md
new file mode 100644
index 0000000..1dcba85
--- /dev/null
+++ b/explain/README.md
@@ -0,0 +1,38 @@
+# `@tabularis/explain-bigquery`
+
+Google BigQuery job plan parser for Tabularis Visual EXPLAIN. It is maintained
+and versioned with the
+[Tabularis BigQuery plugin](https://github.com/TabularisDB/tabularis-bigquery-plugin),
+which ships `dist/index.iife.js` in each release archive.
+
+The parser reads the `bigquery-job-json` payload produced by the plugin's
+`explain_query`:
+
+- `mode: "estimate"`: a dry run. The plan shows bytes processed, an on-demand
+ cost estimate and the referenced tables. Nothing is executed.
+- `mode: "analyze"`: an executed job. Each query stage becomes a node with slot
+ milliseconds as cost, stage wall time, records read and written, shuffle
+ bytes and the stage steps.
+
+`@tabularis/explain` 0.2.0 or newer is required for the parser registry.
+
+```ts
+import "@tabularis/explain-bigquery";
+import { parseRawExplain } from "@tabularis/explain";
+
+const plan = parseRawExplain({
+ engine: "bigquery",
+ format: "bigquery-job-json",
+ payload,
+ original_query: "SELECT * FROM sales.orders",
+});
+```
+
+## Development
+
+```bash
+pnpm install
+pnpm typecheck
+pnpm test
+pnpm build
+```
diff --git a/explain/package.json b/explain/package.json
new file mode 100644
index 0000000..2237200
--- /dev/null
+++ b/explain/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@tabularis/explain-bigquery",
+ "version": "0.2.0",
+ "description": "Google BigQuery job plan parser for Tabularis Visual EXPLAIN",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/TabularisDB/tabularis-bigquery-plugin/tree/main/explain",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/TabularisDB/tabularis-bigquery-plugin.git",
+ "directory": "explain"
+ },
+ "bugs": {
+ "url": "https://github.com/TabularisDB/tabularis-bigquery-plugin/issues"
+ },
+ "keywords": [
+ "explain",
+ "bigquery",
+ "query-plan",
+ "tabularis"
+ ],
+ "type": "module",
+ "sideEffects": [
+ "./dist/index.js"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit",
+ "test": "pnpm run build && vitest run",
+ "prepublishOnly": "pnpm run clean && pnpm run typecheck && pnpm run test"
+ },
+ "peerDependencies": {
+ "@tabularis/explain": "^0.2.0"
+ },
+ "devDependencies": {
+ "@tabularis/explain": "file:tests/host",
+ "@types/node": "^26.4.0",
+ "tsup": "^8.3.5",
+ "typescript": "~5.9.3",
+ "vitest": "^3.2.4"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/explain/pnpm-lock.yaml b/explain/pnpm-lock.yaml
new file mode 100644
index 0000000..c1ddd27
--- /dev/null
+++ b/explain/pnpm-lock.yaml
@@ -0,0 +1,1591 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@tabularis/explain':
+ specifier: file:tests/host
+ version: file:tests/host
+ '@types/node':
+ specifier: ^26.4.0
+ version: 26.6.5
+ tsup:
+ specifier: ^8.3.5
+ version: 8.5.1(postcss@8.5.30)(typescript@5.9.3)
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.7(@types/node@26.6.5)
+
+packages:
+
+ '@esbuild/aix-ppc64@0.27.7':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.7':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.7':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.7':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.7':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.7':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.7':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.7':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.7':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.7':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.7':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.7':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.7':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.7':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.7':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.7':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.7':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.7':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.7':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.7':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.7':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-android-arm-eabi@4.64.7':
+ resolution: {integrity: sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.64.7':
+ resolution: {integrity: sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.64.7':
+ resolution: {integrity: sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.64.7':
+ resolution: {integrity: sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.64.7':
+ resolution: {integrity: sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.64.7':
+ resolution: {integrity: sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.7':
+ resolution: {integrity: sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.7':
+ resolution: {integrity: sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.7':
+ resolution: {integrity: sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.64.7':
+ resolution: {integrity: sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.7':
+ resolution: {integrity: sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.64.7':
+ resolution: {integrity: sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.7':
+ resolution: {integrity: sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.7':
+ resolution: {integrity: sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.7':
+ resolution: {integrity: sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.7':
+ resolution: {integrity: sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.7':
+ resolution: {integrity: sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.64.7':
+ resolution: {integrity: sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.64.7':
+ resolution: {integrity: sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.64.7':
+ resolution: {integrity: sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.64.7':
+ resolution: {integrity: sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.7':
+ resolution: {integrity: sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.7':
+ resolution: {integrity: sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.64.7':
+ resolution: {integrity: sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.64.7':
+ resolution: {integrity: sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tabularis/explain@file:tests/host':
+ resolution: {directory: tests/host, type: directory}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
+
+ '@vitest/expect@3.2.7':
+ resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
+
+ '@vitest/mocker@3.2.7':
+ resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@3.2.7':
+ resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
+
+ '@vitest/runner@3.2.7':
+ resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
+
+ '@vitest/snapshot@3.2.7':
+ resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
+
+ '@vitest/spy@3.2.7':
+ resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
+
+ '@vitest/utils@3.2.7':
+ resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
+
+ acorn@8.19.0:
+ resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ bundle-require@5.1.0:
+ resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ peerDependencies:
+ esbuild: '>=0.18'
+
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ confbox@0.1.8:
+ resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+
+ consola@3.4.2:
+ resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+ esbuild@0.27.7:
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fix-dts-default-cjs-exports@1.0.1:
+ resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ joycon@3.1.1:
+ resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
+ engines: {node: '>=10'}
+
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
+ lilconfig@3.1.4:
+ resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ load-tsconfig@0.2.5:
+ resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ mlly@1.8.2:
+ resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ pkg-types@1.3.1:
+ resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss@8.5.30:
+ resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ resolve-from@5.0.0:
+ resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
+ engines: {node: '>=8'}
+
+ rollup@4.64.7:
+ resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.7.6:
+ resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+ engines: {node: '>= 12'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+ strip-literal@3.1.0:
+ resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
+ tinyspy@4.0.6:
+ resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
+ engines: {node: '>=14.0.0'}
+
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ tsup@8.5.1:
+ resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ '@microsoft/api-extractor': ^7.36.0
+ '@swc/core': ^1
+ postcss: ^8.4.12
+ typescript: '>=4.5.0'
+ peerDependenciesMeta:
+ '@microsoft/api-extractor':
+ optional: true
+ '@swc/core':
+ optional: true
+ postcss:
+ optional: true
+ typescript:
+ optional: true
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ ufo@1.6.4:
+ resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
+
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
+ vite-node@3.2.4:
+ resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+
+ vite@7.3.7:
+ resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.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
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@3.2.7:
+ resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.2.7
+ '@vitest/ui': 3.2.7
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/debug':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+snapshots:
+
+ '@esbuild/aix-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.7':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.7':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.7':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.7':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.64.7':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.64.7':
+ optional: true
+
+ '@tabularis/explain@file:tests/host': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@26.6.5':
+ dependencies:
+ undici-types: 8.9.0
+
+ '@vitest/expect@3.2.7':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
+ '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@26.6.5))':
+ dependencies:
+ '@vitest/spy': 3.2.7
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 7.3.7(@types/node@26.6.5)
+
+ '@vitest/pretty-format@3.2.7':
+ dependencies:
+ tinyrainbow: 2.0.0
+
+ '@vitest/runner@3.2.7':
+ dependencies:
+ '@vitest/utils': 3.2.7
+ pathe: 2.0.3
+ strip-literal: 3.1.0
+
+ '@vitest/snapshot@3.2.7':
+ dependencies:
+ '@vitest/pretty-format': 3.2.7
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@3.2.7':
+ dependencies:
+ tinyspy: 4.0.6
+
+ '@vitest/utils@3.2.7':
+ dependencies:
+ '@vitest/pretty-format': 3.2.7
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
+ acorn@8.19.0: {}
+
+ any-promise@1.3.0: {}
+
+ assertion-error@2.0.1: {}
+
+ bundle-require@5.1.0(esbuild@0.27.7):
+ dependencies:
+ esbuild: 0.27.7
+ load-tsconfig: 0.2.5
+
+ cac@6.7.14: {}
+
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
+ check-error@2.1.3: {}
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ commander@4.1.1: {}
+
+ confbox@0.1.8: {}
+
+ consola@3.4.2: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-eql@5.0.2: {}
+
+ es-module-lexer@1.7.0: {}
+
+ esbuild@0.27.7:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ expect-type@1.4.0: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fix-dts-default-cjs-exports@1.0.1:
+ dependencies:
+ magic-string: 0.30.21
+ mlly: 1.8.2
+ rollup: 4.64.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ joycon@3.1.1: {}
+
+ js-tokens@9.0.1: {}
+
+ lilconfig@3.1.4: {}
+
+ lines-and-columns@1.2.4: {}
+
+ load-tsconfig@0.2.5: {}
+
+ loupe@3.2.1: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ mlly@1.8.2:
+ dependencies:
+ acorn: 8.19.0
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ ufo: 1.6.4
+
+ ms@2.1.3: {}
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ nanoid@3.3.20: {}
+
+ object-assign@4.1.1: {}
+
+ pathe@2.0.3: {}
+
+ pathval@2.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ pirates@4.0.7: {}
+
+ pkg-types@1.3.1:
+ dependencies:
+ confbox: 0.1.8
+ mlly: 1.8.2
+ pathe: 2.0.3
+
+ postcss-load-config@6.0.1(postcss@8.5.30):
+ dependencies:
+ lilconfig: 3.1.4
+ optionalDependencies:
+ postcss: 8.5.30
+
+ postcss@8.5.30:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ readdirp@4.1.2: {}
+
+ resolve-from@5.0.0: {}
+
+ rollup@4.64.7:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.64.7
+ '@rollup/rollup-android-arm64': 4.64.7
+ '@rollup/rollup-darwin-arm64': 4.64.7
+ '@rollup/rollup-darwin-x64': 4.64.7
+ '@rollup/rollup-freebsd-arm64': 4.64.7
+ '@rollup/rollup-freebsd-x64': 4.64.7
+ '@rollup/rollup-linux-arm-gnueabihf': 4.64.7
+ '@rollup/rollup-linux-arm-musleabihf': 4.64.7
+ '@rollup/rollup-linux-arm64-gnu': 4.64.7
+ '@rollup/rollup-linux-arm64-musl': 4.64.7
+ '@rollup/rollup-linux-loong64-gnu': 4.64.7
+ '@rollup/rollup-linux-loong64-musl': 4.64.7
+ '@rollup/rollup-linux-ppc64-gnu': 4.64.7
+ '@rollup/rollup-linux-ppc64-musl': 4.64.7
+ '@rollup/rollup-linux-riscv64-gnu': 4.64.7
+ '@rollup/rollup-linux-riscv64-musl': 4.64.7
+ '@rollup/rollup-linux-s390x-gnu': 4.64.7
+ '@rollup/rollup-linux-x64-gnu': 4.64.7
+ '@rollup/rollup-linux-x64-musl': 4.64.7
+ '@rollup/rollup-openbsd-x64': 4.64.7
+ '@rollup/rollup-openharmony-arm64': 4.64.7
+ '@rollup/rollup-win32-arm64-msvc': 4.64.7
+ '@rollup/rollup-win32-ia32-msvc': 4.64.7
+ '@rollup/rollup-win32-x64-gnu': 4.64.7
+ '@rollup/rollup-win32-x64-msvc': 4.64.7
+ fsevents: 2.3.3
+
+ siginfo@2.0.0: {}
+
+ source-map-js@1.2.2: {}
+
+ source-map@0.7.6: {}
+
+ stackback@0.0.2: {}
+
+ std-env@3.10.0: {}
+
+ strip-literal@3.1.0:
+ dependencies:
+ js-tokens: 9.0.1
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.17
+ ts-interface-checker: 0.1.13
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ tinybench@2.9.0: {}
+
+ tinyexec@0.3.2: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ tinypool@1.1.1: {}
+
+ tinyrainbow@2.0.0: {}
+
+ tinyspy@4.0.6: {}
+
+ tree-kill@1.2.2: {}
+
+ ts-interface-checker@0.1.13: {}
+
+ tsup@8.5.1(postcss@8.5.30)(typescript@5.9.3):
+ dependencies:
+ bundle-require: 5.1.0(esbuild@0.27.7)
+ cac: 6.7.14
+ chokidar: 4.0.3
+ consola: 3.4.2
+ debug: 4.4.3
+ esbuild: 0.27.7
+ fix-dts-default-cjs-exports: 1.0.1
+ joycon: 3.1.1
+ picocolors: 1.1.1
+ postcss-load-config: 6.0.1(postcss@8.5.30)
+ resolve-from: 5.0.0
+ rollup: 4.64.7
+ source-map: 0.7.6
+ sucrase: 3.35.1
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.17
+ tree-kill: 1.2.2
+ optionalDependencies:
+ postcss: 8.5.30
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - jiti
+ - supports-color
+ - tsx
+ - yaml
+
+ typescript@5.9.3: {}
+
+ ufo@1.6.4: {}
+
+ undici-types@8.9.0: {}
+
+ vite-node@3.2.4(@types/node@26.6.5):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 7.3.7(@types/node@26.6.5)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
+ vite@7.3.7(@types/node@26.6.5):
+ dependencies:
+ esbuild: 0.28.2
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.30
+ rollup: 4.64.7
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 26.6.5
+ fsevents: 2.3.3
+
+ vitest@3.2.7(@types/node@26.6.5):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.7
+ '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@26.6.5))
+ '@vitest/pretty-format': 3.2.7
+ '@vitest/runner': 3.2.7
+ '@vitest/snapshot': 3.2.7
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+ picomatch: 4.0.7
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.17
+ tinypool: 1.1.1
+ tinyrainbow: 2.0.0
+ vite: 7.3.7(@types/node@26.6.5)
+ vite-node: 3.2.4(@types/node@26.6.5)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 26.6.5
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
diff --git a/explain/pnpm-workspace.yaml b/explain/pnpm-workspace.yaml
new file mode 100644
index 0000000..3ee2585
--- /dev/null
+++ b/explain/pnpm-workspace.yaml
@@ -0,0 +1,8 @@
+# Own workspace root so pnpm 9 installs this package alone; the build
+# allowlist lets pnpm 10 and later run the esbuild install script.
+packages:
+ - .
+allowBuilds:
+ esbuild: true
+onlyBuiltDependencies:
+ - esbuild
diff --git a/explain/src/iife.ts b/explain/src/iife.ts
new file mode 100644
index 0000000..d775da5
--- /dev/null
+++ b/explain/src/iife.ts
@@ -0,0 +1,5 @@
+import { bigQueryExplainParser } from "./parser";
+
+// The desktop loader registers this descriptor after matching it to the
+// plugin manifest. This entry intentionally has no registration side effect.
+export default bigQueryExplainParser;
diff --git a/explain/src/index.ts b/explain/src/index.ts
new file mode 100644
index 0000000..15c89cb
--- /dev/null
+++ b/explain/src/index.ts
@@ -0,0 +1,9 @@
+import { registerExplainParser } from "@tabularis/explain";
+
+import { bigQueryExplainParser } from "./parser";
+
+registerExplainParser(bigQueryExplainParser);
+
+export { bigQueryExplainParser } from "./parser";
+export { parseBigQueryJob } from "./plan";
+export type { BigQueryJobPayload, BigQueryStage } from "./plan";
diff --git a/explain/src/parser.ts b/explain/src/parser.ts
new file mode 100644
index 0000000..767957f
--- /dev/null
+++ b/explain/src/parser.ts
@@ -0,0 +1,16 @@
+import type { RegisteredExplainParser } from "@tabularis/explain";
+
+import { parseBigQueryJob } from "./plan";
+
+/** BigQuery parser descriptor consumed by both package and plugin loaders. */
+export const bigQueryExplainParser: RegisteredExplainParser = {
+ engine: "bigquery",
+ format: "bigquery-job-json",
+ label: "BigQuery job plan (JSON)",
+ parse: parseBigQueryJob,
+ // Cheap check for the plugin payload; the parser still validates fully.
+ sniff: (payload) => {
+ const head = payload.slice(0, 4096);
+ return /^\s*\{/.test(head) && /"query_plan"\s*:/.test(payload) && /"mode"\s*:\s*"(estimate|analyze)"/.test(head);
+ },
+};
diff --git a/explain/src/plan.ts b/explain/src/plan.ts
new file mode 100644
index 0000000..25de3f2
--- /dev/null
+++ b/explain/src/plan.ts
@@ -0,0 +1,327 @@
+import type { ExplainNode, ExplainPlan } from "@tabularis/explain";
+
+/** One `statistics.query.queryPlan` entry. BigQuery encodes int64 as strings. */
+export interface BigQueryStage {
+ id?: string;
+ name?: string;
+ status?: string;
+ inputStages?: string[];
+ startMs?: string;
+ endMs?: string;
+ slotMs?: string;
+ recordsRead?: string;
+ recordsWritten?: string;
+ parallelInputs?: string;
+ completedParallelInputs?: string;
+ shuffleOutputBytes?: string;
+ shuffleOutputBytesSpilled?: string;
+ waitMsAvg?: string;
+ waitMsMax?: string;
+ readMsAvg?: string;
+ readMsMax?: string;
+ computeMsAvg?: string;
+ computeMsMax?: string;
+ writeMsAvg?: string;
+ writeMsMax?: string;
+ steps?: { kind?: string; substeps?: string[] }[];
+}
+
+/** Payload written by the plugin's `explain_query` (`src/explain.rs`). */
+export interface BigQueryJobPayload {
+ version: number;
+ mode: "estimate" | "analyze";
+ job_id?: string | null;
+ location?: string | null;
+ statement_type?: string | null;
+ total_bytes_processed?: number | null;
+ total_bytes_processed_accuracy?: string | null;
+ total_bytes_billed?: number | null;
+ estimated_cost_usd?: number | null;
+ cost_note?: string | null;
+ cache_hit?: boolean | null;
+ total_slot_ms?: number | null;
+ elapsed_ms?: number | null;
+ billing_tier?: number | null;
+ referenced_tables?: string[];
+ query_plan?: BigQueryStage[];
+}
+
+function int(value: string | number | null | undefined): number | null {
+ if (value === null || value === undefined || value === "") return null;
+ const parsed = typeof value === "number" ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function emptyNode(id: string, nodeType: string): ExplainNode {
+ return {
+ id,
+ node_type: nodeType,
+ relation: null,
+ startup_cost: null,
+ total_cost: null,
+ plan_rows: null,
+ actual_rows: null,
+ actual_time_ms: null,
+ actual_loops: null,
+ buffers_hit: null,
+ buffers_read: null,
+ filter: null,
+ index_condition: null,
+ join_type: null,
+ hash_condition: null,
+ extra: {},
+ children: [],
+ };
+}
+
+function formatBytes(bytes: number): string {
+ const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return unit === 0 ? `${bytes} B` : `${value.toFixed(2)} ${units[unit]}`;
+}
+
+function formatUsd(value: number): string {
+ if (value === 0) return "$0.00";
+ if (value < 0.01) return `$${value.toPrecision(2)}`;
+ return `$${value.toFixed(2)}`;
+}
+
+function substeps(stage: BigQueryStage, kind: string): string[] {
+ return (stage.steps ?? [])
+ .filter((step) => step.kind === kind)
+ .flatMap((step) => step.substeps ?? []);
+}
+
+/** Tables a stage reads, ignoring intermediate stage outputs. */
+function relationOf(stage: BigQueryStage): string | null {
+ const tables = substeps(stage, "READ")
+ .filter((substep) => substep.startsWith("FROM "))
+ .map((substep) => substep.slice(5).trim())
+ .filter((source) => !source.startsWith("__stage") && !source.startsWith("$"));
+ return tables.length > 0 ? tables.join(", ") : null;
+}
+
+function filterOf(stage: BigQueryStage): string | null {
+ const filters = [...substeps(stage, "READ"), ...substeps(stage, "FILTER")]
+ .filter((substep) => substep.startsWith("WHERE "))
+ .map((substep) => substep.slice(6).trim());
+ return filters.length > 0 ? filters.join(" AND ") : null;
+}
+
+function joinOf(stage: BigQueryStage): { type: string | null; condition: string | null } {
+ const join = substeps(stage, "JOIN")[0];
+ if (join === undefined) return { type: null, condition: null };
+ const on = join.indexOf(" ON ");
+ return on === -1
+ ? { type: join, condition: null }
+ : { type: join.slice(0, on).trim(), condition: join.slice(on + 4).trim() };
+}
+
+function stageNode(stage: BigQueryStage, key: string): ExplainNode {
+ const node = emptyNode(`stage-${key}`, stage.name ?? `Stage ${key}`);
+ const start = int(stage.startMs);
+ const end = int(stage.endMs);
+ const join = joinOf(stage);
+ node.relation = relationOf(stage);
+ node.filter = filterOf(stage);
+ node.join_type = join.type;
+ node.hash_condition = join.condition;
+ node.actual_rows = int(stage.recordsWritten);
+ node.actual_time_ms = start !== null && end !== null && end >= start ? end - start : null;
+ node.total_cost = int(stage.slotMs);
+ const shuffle = int(stage.shuffleOutputBytes);
+ const spilled = int(stage.shuffleOutputBytesSpilled);
+ node.extra = {
+ status: stage.status ?? null,
+ slot_ms: int(stage.slotMs),
+ records_read: int(stage.recordsRead),
+ records_written: int(stage.recordsWritten),
+ parallel_inputs: int(stage.parallelInputs),
+ completed_parallel_inputs: int(stage.completedParallelInputs),
+ shuffle_output: shuffle === null ? null : formatBytes(shuffle),
+ shuffle_spilled: spilled === null ? null : formatBytes(spilled),
+ wait_ms_avg: int(stage.waitMsAvg),
+ wait_ms_max: int(stage.waitMsMax),
+ read_ms_avg: int(stage.readMsAvg),
+ read_ms_max: int(stage.readMsMax),
+ compute_ms_avg: int(stage.computeMsAvg),
+ compute_ms_max: int(stage.computeMsMax),
+ write_ms_avg: int(stage.writeMsAvg),
+ write_ms_max: int(stage.writeMsMax),
+ steps: (stage.steps ?? []).map(
+ (step) => `${step.kind ?? "STEP"}: ${(step.substeps ?? []).join("; ")}`,
+ ),
+ };
+ return node;
+}
+
+/**
+ * Turn per-node own values into the inclusive values Visual EXPLAIN expects
+ * (a node's cost and time cover its children, as in PostgreSQL plans).
+ */
+function accumulate(node: ExplainNode): void {
+ let childCost = 0;
+ let childTime = 0;
+ let anyCost = false;
+ let anyTime = false;
+ for (const child of node.children) {
+ accumulate(child);
+ if (child.total_cost !== null) {
+ childCost += child.total_cost;
+ anyCost = true;
+ }
+ if (child.actual_time_ms !== null) {
+ childTime += child.actual_time_ms;
+ anyTime = true;
+ }
+ }
+ if (anyCost) node.total_cost = (node.total_cost ?? 0) + childCost;
+ if (anyTime) node.actual_time_ms = (node.actual_time_ms ?? 0) + childTime;
+}
+
+/**
+ * Build the stage tree. BigQuery stages form a DAG through `inputStages`; a
+ * stage feeding several consumers is attached to the first one and listed in
+ * the others' `extra.also_reads_stages`.
+ */
+function stageTree(stages: BigQueryStage[]): ExplainNode[] {
+ const byId = new Map();
+ stages.forEach((stage, index) => byId.set(stage.id ?? String(index), stage));
+ const parentOf = new Map();
+ const shared = new Map();
+ for (const [id, stage] of byId) {
+ for (const input of stage.inputStages ?? []) {
+ if (!byId.has(input) || input === id) continue;
+ if (parentOf.has(input)) {
+ shared.set(id, [...(shared.get(id) ?? []), input]);
+ } else {
+ parentOf.set(input, id);
+ }
+ }
+ }
+
+ const visited = new Set();
+ const build = (id: string): ExplainNode => {
+ visited.add(id);
+ const node = stageNode(byId.get(id)!, id);
+ const alsoReads = shared.get(id);
+ if (alsoReads) node.extra.also_reads_stages = alsoReads;
+ for (const input of byId.get(id)!.inputStages ?? []) {
+ if (parentOf.get(input) === id && !visited.has(input)) {
+ node.children.push(build(input));
+ }
+ }
+ return node;
+ };
+
+ const roots = [...byId.keys()].filter((id) => !parentOf.has(id));
+ const tree = roots.map(build);
+ // A malformed cycle leaves stages unvisited; keep them visible at the top.
+ for (const id of byId.keys()) {
+ if (!visited.has(id)) tree.push(build(id));
+ }
+ return tree.reverse();
+}
+
+function parsePayload(payload: string): BigQueryJobPayload {
+ let value: unknown;
+ try {
+ value = JSON.parse(payload);
+ } catch (error) {
+ throw new Error(`Invalid BigQuery plan JSON: ${(error as Error).message}`);
+ }
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error("BigQuery plan payload must be a JSON object");
+ }
+ const job = value as BigQueryJobPayload;
+ if (job.version !== 1) {
+ throw new Error(`Unsupported BigQuery plan payload version ${String(job.version)}`);
+ }
+ if (job.mode !== "estimate" && job.mode !== "analyze") {
+ throw new Error("BigQuery plan payload has an unknown mode");
+ }
+ if (job.query_plan !== undefined && !Array.isArray(job.query_plan)) {
+ throw new Error("BigQuery plan payload query_plan must be an array");
+ }
+ job.query_plan?.forEach(validateStage);
+ return job;
+}
+
+function isObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function validateStage(stage: unknown, index: number): void {
+ const at = `BigQuery plan payload query_plan[${index}]`;
+ if (!isObject(stage)) {
+ throw new Error(`${at} must be an object`);
+ }
+ if (stage.inputStages !== undefined && !Array.isArray(stage.inputStages)) {
+ throw new Error(`${at}.inputStages must be an array`);
+ }
+ if (stage.steps === undefined) return;
+ if (!Array.isArray(stage.steps)) {
+ throw new Error(`${at}.steps must be an array`);
+ }
+ stage.steps.forEach((step: unknown, stepIndex: number) => {
+ if (!isObject(step)) {
+ throw new Error(`${at}.steps[${stepIndex}] must be an object`);
+ }
+ if (step.substeps !== undefined && !Array.isArray(step.substeps)) {
+ throw new Error(`${at}.steps[${stepIndex}].substeps must be an array`);
+ }
+ });
+}
+
+/** Parse the `bigquery-job-json` payload into a Visual EXPLAIN plan. */
+export function parseBigQueryJob(payload: string): ExplainPlan {
+ const job = parsePayload(payload);
+ const analyze = job.mode === "analyze";
+ const root = emptyNode("query", analyze ? "BigQuery job" : "Dry run estimate");
+ const processed = int(job.total_bytes_processed);
+ const billed = int(job.total_bytes_billed);
+ const cost = int(job.estimated_cost_usd);
+ root.extra = {
+ statement_type: job.statement_type ?? null,
+ bytes_processed: processed === null ? null : formatBytes(processed),
+ bytes_processed_exact: processed,
+ bytes_processed_accuracy: job.total_bytes_processed_accuracy ?? null,
+ bytes_billed: billed === null ? null : formatBytes(billed),
+ estimated_cost: cost === null ? null : `${formatUsd(cost)} (estimate)`,
+ cost_note: job.cost_note ?? null,
+ cache_hit: job.cache_hit ?? null,
+ slot_ms: int(job.total_slot_ms),
+ billing_tier: int(job.billing_tier),
+ job_id: job.job_id ?? null,
+ location: job.location ?? null,
+ };
+
+ if (analyze) {
+ root.children = stageTree(job.query_plan ?? []);
+ accumulate(root);
+ if (root.children.length === 0 && job.cache_hit) {
+ root.node_type = "BigQuery job (cached result)";
+ }
+ } else {
+ root.children = (job.referenced_tables ?? []).map((table, index) => {
+ const node = emptyNode(`table-${index}`, "Referenced table");
+ node.relation = table;
+ return node;
+ });
+ }
+
+ return {
+ root,
+ planning_time_ms: null,
+ execution_time_ms: analyze ? int(job.elapsed_ms) : null,
+ original_query: "",
+ driver: "bigquery",
+ has_analyze_data: analyze && (job.query_plan ?? []).length > 0,
+ raw_output: JSON.stringify(job, null, 2),
+ };
+}
diff --git a/explain/tests/fixtures/estimate.json b/explain/tests/fixtures/estimate.json
new file mode 100644
index 0000000..806a7a7
--- /dev/null
+++ b/explain/tests/fixtures/estimate.json
@@ -0,0 +1,19 @@
+{
+ "version": 1,
+ "mode": "estimate",
+ "job_id": null,
+ "location": "EU",
+ "statement_type": "SELECT",
+ "total_bytes_processed": 1099511627776,
+ "total_bytes_processed_accuracy": "PRECISE",
+ "total_bytes_billed": null,
+ "estimated_cost_usd": 6.25,
+ "cost_note": "Estimate at the on-demand list price of $6.25 per TiB.",
+ "cache_hit": false,
+ "total_slot_ms": null,
+ "elapsed_ms": null,
+ "billing_tier": null,
+ "referenced_tables": ["p.sales.orders"],
+ "query_plan": [],
+ "timeline": []
+}
diff --git a/explain/tests/fixtures/join-analyzed.json b/explain/tests/fixtures/join-analyzed.json
new file mode 100644
index 0000000..462f9d7
--- /dev/null
+++ b/explain/tests/fixtures/join-analyzed.json
@@ -0,0 +1,56 @@
+{
+ "version": 1,
+ "mode": "analyze",
+ "job_id": "job_join",
+ "location": "US",
+ "statement_type": "SELECT",
+ "total_bytes_processed": 52428800,
+ "total_bytes_billed": 52428800,
+ "estimated_cost_usd": 0.0002980232238769531,
+ "cost_note": "Estimate at the on-demand list price of $6.25 per TiB.",
+ "cache_hit": false,
+ "total_slot_ms": 1450,
+ "elapsed_ms": 812,
+ "billing_tier": 1,
+ "referenced_tables": ["p.sales.orders", "p.sales.customers"],
+ "query_plan": [
+ {
+ "id": "0", "name": "S00: Input", "status": "COMPLETE",
+ "startMs": "1000", "endMs": "1300", "slotMs": "600",
+ "recordsRead": "100000", "recordsWritten": "2500",
+ "parallelInputs": "4", "completedParallelInputs": "4",
+ "shuffleOutputBytes": "40960", "shuffleOutputBytesSpilled": "0",
+ "waitMsAvg": "2", "readMsAvg": "40", "computeMsAvg": "80", "writeMsAvg": "5",
+ "steps": [
+ {"kind": "READ", "substeps": ["$1:customer_id, $2:amount", "FROM p.sales.orders", "WHERE greater($2, 100)"]},
+ {"kind": "WRITE", "substeps": ["$1, $2", "TO __stage00_output"]}
+ ]
+ },
+ {
+ "id": "1", "name": "S01: Input", "status": "COMPLETE",
+ "startMs": "1000", "endMs": "1200", "slotMs": "300",
+ "recordsRead": "5000", "recordsWritten": "5000",
+ "steps": [
+ {"kind": "READ", "substeps": ["$10:id, $11:name", "FROM p.sales.customers"]},
+ {"kind": "WRITE", "substeps": ["$10, $11", "TO __stage01_output"]}
+ ]
+ },
+ {
+ "id": "2", "name": "S02: Join+", "status": "COMPLETE", "inputStages": ["0", "1"],
+ "startMs": "1300", "endMs": "1700", "slotMs": "500",
+ "recordsRead": "7500", "recordsWritten": "2500",
+ "steps": [
+ {"kind": "READ", "substeps": ["FROM __stage00_output"]},
+ {"kind": "JOIN", "substeps": ["INNER HASH JOIN EACH WITH ALL ON $1 = $10"]},
+ {"kind": "WRITE", "substeps": ["TO __stage02_output"]}
+ ]
+ },
+ {
+ "id": "3", "name": "S03: Output", "status": "COMPLETE", "inputStages": ["2"],
+ "startMs": "1700", "endMs": "1800", "slotMs": "50",
+ "recordsRead": "2500", "recordsWritten": "2500",
+ "steps": [{"kind": "WRITE", "substeps": ["TO __stage03_output"]}]
+ }
+ ],
+ "timeline": []
+}
diff --git a/explain/tests/host/index.d.ts b/explain/tests/host/index.d.ts
new file mode 100644
index 0000000..047f79e
--- /dev/null
+++ b/explain/tests/host/index.d.ts
@@ -0,0 +1,40 @@
+export interface ExplainNode {
+ id: string;
+ node_type: string;
+ relation: string | null;
+ startup_cost: number | null;
+ total_cost: number | null;
+ plan_rows: number | null;
+ actual_rows: number | null;
+ actual_time_ms: number | null;
+ actual_loops: number | null;
+ buffers_hit: number | null;
+ buffers_read: number | null;
+ filter: string | null;
+ index_condition: string | null;
+ join_type: string | null;
+ hash_condition: string | null;
+ extra: Record;
+ children: ExplainNode[];
+}
+
+export interface ExplainPlan {
+ root: ExplainNode;
+ planning_time_ms: number | null;
+ execution_time_ms: number | null;
+ original_query: string;
+ driver: string;
+ has_analyze_data: boolean;
+ raw_output: string | null;
+}
+
+export interface RegisteredExplainParser {
+ readonly engine: string;
+ readonly format: string;
+ readonly label?: string;
+ parse(payload: string): ExplainPlan;
+ sniff?(payload: string): boolean;
+}
+
+export const registrations: RegisteredExplainParser[];
+export function registerExplainParser(parser: RegisteredExplainParser): void;
diff --git a/explain/tests/host/index.js b/explain/tests/host/index.js
new file mode 100644
index 0000000..b2b4ce3
--- /dev/null
+++ b/explain/tests/host/index.js
@@ -0,0 +1,5 @@
+export const registrations = [];
+
+export function registerExplainParser(parser) {
+ registrations.push(parser);
+}
diff --git a/explain/tests/host/package.json b/explain/tests/host/package.json
new file mode 100644
index 0000000..7f4b8d9
--- /dev/null
+++ b/explain/tests/host/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@tabularis/explain",
+ "version": "0.2.0",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "import": "./index.js"
+ }
+ }
+}
diff --git a/explain/tests/package.test.ts b/explain/tests/package.test.ts
new file mode 100644
index 0000000..15a8ecf
--- /dev/null
+++ b/explain/tests/package.test.ts
@@ -0,0 +1,44 @@
+import { readFile } from "node:fs/promises";
+
+import {
+ registrations,
+ type RegisteredExplainParser,
+} from "@tabularis/explain";
+import { describe, expect, it } from "vitest";
+
+import { bigQueryExplainParser, parseBigQueryJob } from "../src/index";
+
+const fixtureUrl = new URL("./fixtures/join-analyzed.json", import.meta.url);
+
+describe("package entry points", () => {
+ it("registers on ESM import and exports the direct parser API", async () => {
+ expect(registrations).toEqual([bigQueryExplainParser]);
+ expect(bigQueryExplainParser).toMatchObject({
+ engine: "bigquery",
+ format: "bigquery-job-json",
+ label: "BigQuery job plan (JSON)",
+ parse: parseBigQueryJob,
+ });
+ expect(bigQueryExplainParser.sniff?.(await readFile(fixtureUrl, "utf8"))).toBe(true);
+ expect(bigQueryExplainParser.sniff?.("")).toBe(false);
+ });
+
+ it("builds an isolated IIFE descriptor without self-registration", async () => {
+ const source = await readFile(new URL("../dist/index.iife.js", import.meta.url), "utf8");
+ const registrationsBeforeEvaluation = registrations.length;
+ const evaluate = new Function(
+ "__TABULARIS_EXPLAIN__",
+ `${source}\nreturn typeof __tabularis_explain_parser__ !== "undefined" ? __tabularis_explain_parser__ : null;`,
+ );
+ const raw = evaluate({}) as Record;
+ const descriptor = (raw.default ?? raw) as RegisteredExplainParser;
+
+ expect(descriptor).toMatchObject({
+ engine: "bigquery",
+ format: "bigquery-job-json",
+ label: "BigQuery job plan (JSON)",
+ });
+ expect(descriptor.parse(await readFile(fixtureUrl, "utf8")).driver).toBe("bigquery");
+ expect(registrations).toHaveLength(registrationsBeforeEvaluation);
+ });
+});
diff --git a/explain/tests/plan.test.ts b/explain/tests/plan.test.ts
new file mode 100644
index 0000000..8591ab2
--- /dev/null
+++ b/explain/tests/plan.test.ts
@@ -0,0 +1,106 @@
+import { readFile } from "node:fs/promises";
+
+import type { ExplainNode } from "@tabularis/explain";
+import { describe, expect, it } from "vitest";
+
+import { parseBigQueryJob } from "../src/plan";
+
+async function fixture(name: string): Promise {
+ return readFile(new URL(`./fixtures/${name}.json`, import.meta.url), "utf8");
+}
+
+function flatten(node: ExplainNode): ExplainNode[] {
+ return [node, ...node.children.flatMap(flatten)];
+}
+
+describe("parseBigQueryJob", () => {
+ it("builds the stage tree from inputStages with inclusive values", async () => {
+ const plan = parseBigQueryJob(await fixture("join-analyzed"));
+
+ expect(plan.driver).toBe("bigquery");
+ expect(plan.has_analyze_data).toBe(true);
+ expect(plan.execution_time_ms).toBe(812);
+ expect(plan.root.node_type).toBe("BigQuery job");
+ expect(flatten(plan.root).map((node) => node.id)).toEqual([
+ "query",
+ "stage-3",
+ "stage-2",
+ "stage-0",
+ "stage-1",
+ ]);
+
+ const [output] = plan.root.children;
+ const join = output.children[0];
+ expect(join.join_type).toBe("INNER HASH JOIN EACH WITH ALL");
+ expect(join.hash_condition).toBe("$1 = $10");
+ // Inclusive: own slot ms plus both inputs.
+ expect(join.total_cost).toBe(500 + 600 + 300);
+ expect(output.total_cost).toBe(1450);
+ expect(plan.root.total_cost).toBe(1450);
+ expect(join.actual_time_ms).toBe(400 + 300 + 200);
+
+ const orders = join.children[0];
+ expect(orders.relation).toBe("p.sales.orders");
+ expect(orders.filter).toBe("greater($2, 100)");
+ expect(orders.actual_rows).toBe(2500);
+ expect(orders.extra.records_read).toBe(100000);
+ expect(orders.extra.shuffle_output).toBe("40.00 KiB");
+ });
+
+ it("labels the dry-run cost as an estimate", async () => {
+ const plan = parseBigQueryJob(await fixture("estimate"));
+
+ expect(plan.has_analyze_data).toBe(false);
+ expect(plan.root.node_type).toBe("Dry run estimate");
+ expect(plan.root.extra.bytes_processed).toBe("1.00 TiB");
+ expect(plan.root.extra.estimated_cost).toBe("$6.25 (estimate)");
+ expect(plan.root.children.map((node) => node.relation)).toEqual(["p.sales.orders"]);
+ });
+
+ it("marks cached results that have no stages", () => {
+ const plan = parseBigQueryJob(
+ JSON.stringify({ version: 1, mode: "analyze", cache_hit: true, query_plan: [] }),
+ );
+ expect(plan.root.node_type).toBe("BigQuery job (cached result)");
+ expect(plan.has_analyze_data).toBe(false);
+ });
+
+ it("keeps stages of a malformed cyclic plan", () => {
+ const plan = parseBigQueryJob(
+ JSON.stringify({
+ version: 1,
+ mode: "analyze",
+ query_plan: [
+ { id: "a", name: "A", inputStages: ["b"] },
+ { id: "b", name: "B", inputStages: ["a"] },
+ ],
+ }),
+ );
+ expect(flatten(plan.root).map((node) => node.node_type).sort()).toEqual([
+ "A",
+ "B",
+ "BigQuery job",
+ ]);
+ });
+
+ it("rejects payloads it does not understand", () => {
+ expect(() => parseBigQueryJob("not json")).toThrow(/Invalid BigQuery plan JSON/);
+ expect(() => parseBigQueryJob("[]")).toThrow(/JSON object/);
+ expect(() => parseBigQueryJob('{"version":2,"mode":"analyze"}')).toThrow(/version 2/);
+ expect(() => parseBigQueryJob('{"version":1,"mode":"other"}')).toThrow(/unknown mode/);
+ });
+
+ it("rejects malformed stages and steps with clear errors", () => {
+ const plan = (query_plan: unknown) =>
+ parseBigQueryJob(JSON.stringify({ version: 1, mode: "analyze", query_plan }));
+ expect(() => plan([null])).toThrow(/query_plan\[0\] must be an object/);
+ expect(() => plan([{ id: "0" }, 7])).toThrow(/query_plan\[1\] must be an object/);
+ expect(() => plan([["stage"]])).toThrow(/query_plan\[0\] must be an object/);
+ expect(() => plan([{ id: "0", inputStages: "1" }])).toThrow(/inputStages must be an array/);
+ expect(() => plan([{ id: "0", steps: {} }])).toThrow(/steps must be an array/);
+ expect(() => plan([{ id: "0", steps: [null] }])).toThrow(/steps\[0\] must be an object/);
+ expect(() => plan([{ id: "0", steps: [{ kind: "READ", substeps: "x" }] }])).toThrow(
+ /substeps must be an array/,
+ );
+ });
+});
diff --git a/explain/tsconfig.json b/explain/tsconfig.json
new file mode 100644
index 0000000..e20274f
--- /dev/null
+++ b/explain/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noImplicitAny": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "declaration": true,
+ "declarationMap": false,
+ "sourceMap": false,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*", "tests/**/*.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/explain/tsup.config.ts b/explain/tsup.config.ts
new file mode 100644
index 0000000..39d8d46
--- /dev/null
+++ b/explain/tsup.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig } from "tsup";
+
+const common = {
+ platform: "browser" as const,
+ target: "es2022" as const,
+ sourcemap: false,
+ minify: false,
+ splitting: false,
+ treeshake: true,
+ external: ["@tabularis/explain"],
+};
+
+export default defineConfig([
+ {
+ ...common,
+ entry: { index: "src/index.ts" },
+ format: ["esm"],
+ dts: true,
+ clean: true,
+ },
+ {
+ ...common,
+ entry: { "index.iife": "src/iife.ts" },
+ format: ["iife"],
+ globalName: "__tabularis_explain_parser__",
+ dts: false,
+ clean: false,
+ outExtension: () => ({ js: ".js" }),
+ },
+]);
diff --git a/justfile b/justfile
index 04a4069..f064a94 100644
--- a/justfile
+++ b/justfile
@@ -13,6 +13,9 @@ ui-install:
ui-build:
pnpm --dir ui build
+explain-build:
+ pnpm --dir explain build
+
ui-typecheck:
pnpm --dir ui typecheck
@@ -30,15 +33,17 @@ check-ui:
pnpm --dir ui build
[linux]
-dev-install: build ui-build
- mkdir -p ~/.local/share/tabularis/plugins/bigquery/ui/dist
- cp target/debug/bigquery-plugin ~/.local/share/tabularis/plugins/bigquery/
- cp .tabularium ~/.local/share/tabularis/plugins/bigquery/
- cp ui/dist/*.js ~/.local/share/tabularis/plugins/bigquery/ui/dist/
+dev-install: build ui-build explain-build
+ mkdir -p ~/.local/share/tabularis/plugins/drivers/bigquery/ui/dist ~/.local/share/tabularis/plugins/drivers/bigquery/explain/dist
+ cp target/debug/bigquery-plugin ~/.local/share/tabularis/plugins/drivers/bigquery/
+ cp .tabularium ~/.local/share/tabularis/plugins/drivers/bigquery/
+ cp ui/dist/*.js ~/.local/share/tabularis/plugins/drivers/bigquery/ui/dist/
+ cp explain/dist/index.iife.js ~/.local/share/tabularis/plugins/drivers/bigquery/explain/dist/
[macos]
-dev-install: build ui-build
- mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui/dist"
- cp target/debug/bigquery-plugin "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/"
- cp .tabularium "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/"
- cp ui/dist/*.js "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui/dist/"
+dev-install: build ui-build explain-build
+ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/ui/dist" "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/explain/dist"
+ cp target/debug/bigquery-plugin "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/"
+ cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/"
+ cp ui/dist/*.js "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/ui/dist/"
+ cp explain/dist/index.iife.js "$HOME/Library/Application Support/tabularis/plugins/drivers/bigquery/explain/dist/"
diff --git a/src/auth.rs b/src/auth.rs
index 57da9b1..59b4653 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,23 +1,76 @@
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use gcp_auth::{CustomServiceAccount, TokenProvider};
+use reqwest::Url;
use serde::Deserialize;
+use serde_json::{json, Value};
use tokio::sync::Mutex;
+use crate::endpoint::ApiEndpoint;
use crate::models::ConnectionParams;
const BIGQUERY_SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/bigquery",
"https://www.googleapis.com/auth/cloud-platform",
];
+const IAM_CREDENTIALS_BASE: &str = "https://iamcredentials.googleapis.com/v1/";
+
+type ProviderFuture<'a> =
+ std::pin::Pin> + Send + 'a>>;
+
+type GoogleFuture = std::pin::Pin<
+ Box, String>> + Send>,
+>;
+/// Builds a fresh gcp_auth provider, so a rejected token is not served again
+/// from the old provider's internal cache.
+type GoogleBuild = Arc GoogleFuture + Send + Sync>;
#[derive(Clone)]
pub enum AuthProvider {
- Google(Arc),
+ Google(Arc),
AuthorizedUser(Arc),
+ Impersonated(Arc),
AccessToken(String),
+ /// No Authorization header; only allowed for loopback emulator endpoints.
+ None,
+}
+
+/// A gcp_auth provider (service account key, metadata server, gcloud CLI).
+/// gcp_auth caches tokens internally without a way to drop them, so after a
+/// 401 the provider is rebuilt instead.
+pub struct GoogleProvider {
+ current: Mutex>,
+ build: GoogleBuild,
+}
+
+impl GoogleProvider {
+ async fn new(build: GoogleBuild) -> Result {
+ let current = build().await?;
+ Ok(Self {
+ current: Mutex::new(current),
+ build,
+ })
+ }
+
+ async fn access_token(&self) -> Result {
+ let provider = self.current.lock().await.clone();
+ provider
+ .token(BIGQUERY_SCOPES)
+ .await
+ .map(|token| token.as_str().to_string())
+ .map_err(|error| format!("Unable to obtain a Google access token: {error}"))
+ }
+
+ async fn rebuild(&self) {
+ match (self.build)().await {
+ Ok(provider) => *self.current.lock().await = provider,
+ Err(error) => {
+ eprintln!("bigquery-plugin: unable to refresh Google credentials: {error}")
+ }
+ }
+ }
}
pub struct AuthorizedUserProvider {
@@ -26,10 +79,18 @@ pub struct AuthorizedUserProvider {
cached: Mutex