Release Query Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:28:20 +02:00
parent 2730ce08b2
commit f44d0598da
34 changed files with 5153 additions and 300 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add raw-input inference evidence, a visible bounded query AST and a local
DuckDB-WASM adapter for broader analytical SQL without remote execution.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines. - Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines.
+19 -2
View File
@@ -2,9 +2,17 @@
Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV, Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV,
NDJSON and static XML. It provides an intentionally small SQL-like row language NDJSON and static XML. It provides an intentionally small SQL-like row language
and JSONPath-like value language, result table/tree/JSON views, explain plans, and JSONPath-like value language, parsed SQL/path syntax trees, result
table/tree/JSON views, explain plans,
local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no
query is evaluated as code. query is evaluated as application JavaScript.
CSV import has explicit `none`, `safe`, and `aggressive` inference modes and
retains every raw cell beside inferred-value evidence. JSON import records raw
unsafe-integer and precision-risk number lexemes. A separately selected
DuckDB-WASM mode runs bounded, read-only `SELECT` queries in a disposable local
worker using same-origin bundled MVP WASM assets; external access and automatic
extension loading/installing are disabled.
SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`, SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`,
`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset:
@@ -13,6 +21,15 @@ not compatibility with full SQL, JSONPath, or JMESPath.
Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured
nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected. nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected.
DuckDB mode accepts at most 10,000 rows, returns at most 1,000 rows, uses a 128
MiB database memory limit, and has a 30-second startup ceiling followed by a
ten-second query deadline. It is not a database connection and does not expose
files, network resources, extensions, DDL, DML, `COPY`, or `PRAGMA`.
Static hosting must serve `.wasm` files as `application/wasm`, cache hashed
assets immutably, permit `'wasm-unsafe-eval'` for same-origin scripts, and allow
same-origin/blob workers while retaining `connect-src 'self'`. The production
browser test verifies those headers against the locally bundled DuckDB asset.
Run `npm ci`, then `npm run check`, `npm run test:browser`, and Run `npm ci`, then `npm run check`, `npm run test:browser`, and
`npm run package:release -- --force`. Copyright © 2026 Albrecht Degering. `npm run package:release -- --force`. Copyright © 2026 Albrecht Degering.
+16 -1
View File
@@ -1,3 +1,18 @@
# Security # Security
Report vulnerabilities privately through the Gitea repository owner. Never attach confidential datasets publicly. Inputs are untrusted, bounded, rendered inertly, and never executed; XML DTD/entities and dangerous JSON keys are rejected. Report vulnerabilities privately through the Gitea repository owner. Never
attach confidential datasets publicly. Inputs are untrusted, bounded, rendered
inertly, and never evaluated as application code; XML DTD/entities and dangerous
JSON keys are rejected.
The built-in query languages use fixed parsers and interpreters without `eval`
or dynamic function construction. DuckDB mode accepts one parsed `SELECT`
statement, rejects semicolons and mutation, extension, attachment, file and
configuration keywords, then executes against a bounded in-memory table in a
disposable locally bundled WASM worker. External access and extension
installation/loading are disabled; startup/query deadlines terminate the worker
and stale results cannot replace newer state.
Deployment must serve the bundled WASM with `application/wasm` and a CSP that
allows same-origin WASM and workers while keeping `connect-src 'self'`. Browser
release tests verify those headers and fail on third-party requests.
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity # Source identity
- Project: Query Tools - Project: Query Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/query-tools - Repository: https://git.add-ideas.de/lotobo/query-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Toolbox id: `de.add-ideas.query-tools` - Toolbox id: `de.add-ideas.query-tools`
+5 -1
View File
@@ -1,3 +1,7 @@
# Third-party notices # Third-party notices
Runtime dependencies are bundled locally: React/React DOM (MIT), @xmldom/xmldom (MIT), and add·ideas Toolbox Contract/Shell/Helpers (GPL-3.0-or-later). Exact texts are generated into `LICENSES/npm-runtime-licenses.txt`. Runtime dependencies are bundled locally: React/React DOM (MIT),
@xmldom/xmldom (MIT), DuckDB-WASM and Apache Arrow JavaScript (MIT), add·ideas
Toolbox Contract/Shell 0.3.0 (Apache-2.0), and Toolbox Helpers 0.2.0
(GPL-3.0-or-later). No DuckDB asset is fetched at runtime. Exact texts are
generated into `LICENSES/npm-runtime-licenses.txt`.
+19 -1
View File
@@ -1,3 +1,21 @@
# Architecture # Architecture
`core/data.ts` performs bounded static parsing into JSON-compatible values. `core/query.ts` tokenizes a fixed grammar, interprets field paths and returns an explain plan; it never compiles or evaluates code. React retains the last valid dataset/result after errors. Relative assets and a same-origin service worker support nested offline deployment. `core/data.ts` performs bounded static parsing into JSON-compatible values while
retaining raw numeric and cell-inference evidence. `core/query.ts` tokenizes the
fixed SQL-like and path grammars, exposes their bounded syntax trees, interprets
field paths and returns an explain plan; it never compiles or evaluates code.
`core/duckdb.ts` is a separately selected adapter for the locally bundled
single-threaded DuckDB-WASM MVP build. A lexical security gate accepts exactly
one `SELECT` statement and rejects semicolons plus mutation, attachment,
extension, file and configuration keywords before DuckDB performs its complete
SQL parse. The adapter serializes at most 10,000 bounded rows into an in-memory
table, disables external access and extension auto-install/auto-load, caps
database memory at 128 MiB, limits results to 1,000 rows and terminates the
disposable worker after completion, cancellation or a hard deadline.
React retains the last valid dataset/result after errors and suppresses stale
asynchronous results. Relative assets and a same-origin service worker support
nested offline deployment. The static host serves hashed WASM as
`application/wasm` with immutable caching and grants only the CSP allowances
needed for same-origin WASM and workers.
+11 -1
View File
@@ -1,3 +1,13 @@
# Privacy and security # Privacy and security
Data stays in browser memory. Saved query text uses localStorage only. There is no telemetry, remote schema resolution, query execution endpoint, URL fetch, eval or dynamic function construction. Exports require an explicit action. Limits are documented in README. Data stays in browser memory. Saved query text uses localStorage only. There is
no telemetry, remote schema resolution, query execution endpoint, URL fetch,
`eval` or dynamic function construction. Exports require an explicit action.
DuckDB mode loads the bounded dataset into an in-memory table inside a
disposable worker using same-origin bundled JavaScript and WASM. External access,
extension installation and extension auto-loading are disabled. The app exposes
no database connection or file/network query surface; completion, cancellation
and hard deadlines terminate the worker. The host CSP retains
`connect-src 'self'`, and browser tests fail on third-party requests. Input,
result, memory and time limits are documented in README.
+308 -21
View File
@@ -1,23 +1,24 @@
{ {
"name": "query-tools", "name": "query-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "query-tools", "name": "query-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@duckdb/duckdb-wasm": "^1.32.0",
"@xmldom/xmldom": "0.9.12", "@xmldom/xmldom": "0.9.12",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -43,24 +44,25 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "license": "GPL-3.0-or-later",
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "engines": {
"license": "GPL-3.0-or-later" "node": ">=22"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -68,13 +70,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
@@ -541,6 +543,15 @@
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@duckdb/duckdb-wasm": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.32.0.tgz",
"integrity": "sha512-IewXTNYEjsZCPE9weUWgtjGxUlMRo7qhX0GF6tq/KjK8bnY+RAl4cyUdYUfcdzbyb4b9ZxPC+FOsCcxgaKFWMg==",
"license": "MIT",
"dependencies": {
"apache-arrow": "^17.0.0"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1", "version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
@@ -1116,6 +1127,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -1225,6 +1245,18 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/command-line-args": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz",
"integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==",
"license": "MIT"
},
"node_modules/@types/command-line-usage": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz",
"integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1739,6 +1771,41 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/apache-arrow": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz",
"integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.11",
"@types/command-line-args": "^5.2.3",
"@types/command-line-usage": "^5.0.4",
"@types/node": "^20.13.0",
"command-line-args": "^5.2.1",
"command-line-usage": "^7.0.1",
"flatbuffers": "^24.3.25",
"json-bignum": "^0.0.3",
"tslib": "^2.6.2"
},
"bin": {
"arrow2csv": "bin/arrow2csv.cjs"
}
},
"node_modules/apache-arrow/node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/apache-arrow/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -1749,6 +1816,15 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1870,6 +1946,118 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk-template": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/chalk/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/command-line-args": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
"integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
"license": "MIT",
"dependencies": {
"array-back": "^3.1.0",
"find-replace": "^3.0.0",
"lodash.camelcase": "^4.3.0",
"typical": "^4.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/command-line-usage": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz",
"integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"chalk-template": "^0.4.0",
"table-layout": "^4.1.1",
"typical": "^7.3.0"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/command-line-usage/node_modules/array-back": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/command-line-usage/node_modules/typical": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
"integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2301,6 +2489,18 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/find-replace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
"license": "MIT",
"dependencies": {
"array-back": "^3.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2332,6 +2532,12 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/flatbuffers": {
"version": "24.12.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz",
"integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==",
"license": "Apache-2.0"
},
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.4", "version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
@@ -2390,6 +2596,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2558,6 +2773,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/json-bignum": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
"integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -2905,6 +3128,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3432,6 +3661,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -3439,6 +3680,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/table-layout": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
"integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"wordwrapjs": "^5.1.0"
},
"engines": {
"node": ">=12.17"
}
},
"node_modules/table-layout/node_modules/array-back": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3542,6 +3805,12 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3593,6 +3862,15 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/typical": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/undici": { "node_modules/undici": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
@@ -3925,6 +4203,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wordwrapjs": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
"integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+6 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "query-tools", "name": "query-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Query structured local data safely in the browser.", "description": "Query structured local data safely in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,15 +39,16 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@duckdb/duckdb-wasm": "^1.32.0",
"@xmldom/xmldom": "0.9.12", "@xmldom/xmldom": "0.9.12",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -12,7 +12,25 @@ export default defineConfig({
timeout: 180000, timeout: 180000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add raw-input inference evidence, a visible bounded query AST and a local
DuckDB-WASM adapter for broader analytical SQL without remote execution.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines. - Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines.
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -2,9 +2,17 @@
Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV, Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV,
NDJSON and static XML. It provides an intentionally small SQL-like row language NDJSON and static XML. It provides an intentionally small SQL-like row language
and JSONPath-like value language, result table/tree/JSON views, explain plans, and JSONPath-like value language, parsed SQL/path syntax trees, result
table/tree/JSON views, explain plans,
local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no
query is evaluated as code. query is evaluated as application JavaScript.
CSV import has explicit `none`, `safe`, and `aggressive` inference modes and
retains every raw cell beside inferred-value evidence. JSON import records raw
unsafe-integer and precision-risk number lexemes. A separately selected
DuckDB-WASM mode runs bounded, read-only `SELECT` queries in a disposable local
worker using same-origin bundled MVP WASM assets; external access and automatic
extension loading/installing are disabled.
SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`, SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`,
`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset:
@@ -13,6 +21,15 @@ not compatibility with full SQL, JSONPath, or JMESPath.
Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured
nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected. nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected.
DuckDB mode accepts at most 10,000 rows, returns at most 1,000 rows, uses a 128
MiB database memory limit, and has a 30-second startup ceiling followed by a
ten-second query deadline. It is not a database connection and does not expose
files, network resources, extensions, DDL, DML, `COPY`, or `PRAGMA`.
Static hosting must serve `.wasm` files as `application/wasm`, cache hashed
assets immutably, permit `'wasm-unsafe-eval'` for same-origin scripts, and allow
same-origin/blob workers while retaining `connect-src 'self'`. The production
browser test verifies those headers against the locally bundled DuckDB asset.
Run `npm ci`, then `npm run check`, `npm run test:browser`, and Run `npm ci`, then `npm run check`, `npm run test:browser`, and
`npm run package:release -- --force`. Copyright © 2026 Albrecht Degering. `npm run package:release -- --force`. Copyright © 2026 Albrecht Degering.
+16 -1
View File
@@ -1,3 +1,18 @@
# Security # Security
Report vulnerabilities privately through the Gitea repository owner. Never attach confidential datasets publicly. Inputs are untrusted, bounded, rendered inertly, and never executed; XML DTD/entities and dangerous JSON keys are rejected. Report vulnerabilities privately through the Gitea repository owner. Never
attach confidential datasets publicly. Inputs are untrusted, bounded, rendered
inertly, and never evaluated as application code; XML DTD/entities and dangerous
JSON keys are rejected.
The built-in query languages use fixed parsers and interpreters without `eval`
or dynamic function construction. DuckDB mode accepts one parsed `SELECT`
statement, rejects semicolons and mutation, extension, attachment, file and
configuration keywords, then executes against a bounded in-memory table in a
disposable locally bundled WASM worker. External access and extension
installation/loading are disabled; startup/query deadlines terminate the worker
and stale results cannot replace newer state.
Deployment must serve the bundled WASM with `application/wasm` and a CSP that
allows same-origin WASM and workers while keeping `connect-src 'self'`. Browser
release tests verify those headers and fail on third-party requests.
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity # Source identity
- Project: Query Tools - Project: Query Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/query-tools - Repository: https://git.add-ideas.de/lotobo/query-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Toolbox id: `de.add-ideas.query-tools` - Toolbox id: `de.add-ideas.query-tools`
+5 -1
View File
@@ -1,3 +1,7 @@
# Third-party notices # Third-party notices
Runtime dependencies are bundled locally: React/React DOM (MIT), @xmldom/xmldom (MIT), and add·ideas Toolbox Contract/Shell/Helpers (GPL-3.0-or-later). Exact texts are generated into `LICENSES/npm-runtime-licenses.txt`. Runtime dependencies are bundled locally: React/React DOM (MIT),
@xmldom/xmldom (MIT), DuckDB-WASM and Apache Arrow JavaScript (MIT), add·ideas
Toolbox Contract/Shell 0.3.0 (Apache-2.0), and Toolbox Helpers 0.2.0
(GPL-3.0-or-later). No DuckDB asset is fetched at runtime. Exact texts are
generated into `LICENSES/npm-runtime-licenses.txt`.
+19 -1
View File
@@ -1,3 +1,21 @@
# Architecture # Architecture
`core/data.ts` performs bounded static parsing into JSON-compatible values. `core/query.ts` tokenizes a fixed grammar, interprets field paths and returns an explain plan; it never compiles or evaluates code. React retains the last valid dataset/result after errors. Relative assets and a same-origin service worker support nested offline deployment. `core/data.ts` performs bounded static parsing into JSON-compatible values while
retaining raw numeric and cell-inference evidence. `core/query.ts` tokenizes the
fixed SQL-like and path grammars, exposes their bounded syntax trees, interprets
field paths and returns an explain plan; it never compiles or evaluates code.
`core/duckdb.ts` is a separately selected adapter for the locally bundled
single-threaded DuckDB-WASM MVP build. A lexical security gate accepts exactly
one `SELECT` statement and rejects semicolons plus mutation, attachment,
extension, file and configuration keywords before DuckDB performs its complete
SQL parse. The adapter serializes at most 10,000 bounded rows into an in-memory
table, disables external access and extension auto-install/auto-load, caps
database memory at 128 MiB, limits results to 1,000 rows and terminates the
disposable worker after completion, cancellation or a hard deadline.
React retains the last valid dataset/result after errors and suppresses stale
asynchronous results. Relative assets and a same-origin service worker support
nested offline deployment. The static host serves hashed WASM as
`application/wasm` with immutable caching and grants only the CSP allowances
needed for same-origin WASM and workers.
+11 -1
View File
@@ -1,3 +1,13 @@
# Privacy and security # Privacy and security
Data stays in browser memory. Saved query text uses localStorage only. There is no telemetry, remote schema resolution, query execution endpoint, URL fetch, eval or dynamic function construction. Exports require an explicit action. Limits are documented in README. Data stays in browser memory. Saved query text uses localStorage only. There is
no telemetry, remote schema resolution, query execution endpoint, URL fetch,
`eval` or dynamic function construction. Exports require an explicit action.
DuckDB mode loads the bounded dataset into an in-memory table inside a
disposable worker using same-origin bundled JavaScript and WASM. External access,
extension installation and extension auto-loading are disabled. The app exposes
no database connection or file/network query surface; completion, cancellation
and hard deadlines terminate the worker. The host CSP retains
`connect-src 'self'`, and browser tests fail on third-party requests. Input,
result, memory and time limits are documented in README.
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "query-tools-v0.1.0", const CACHE = "query-tools-v0.2.0",
APP = [ APP = [
"./", "./",
"./index.html", "./index.html",
+18 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.query-tools", "id": "de.add-ideas.query-tools",
"name": "Query Tools", "name": "Query Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Query structured data locally.", "description": "Query structured data locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,23 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "application/json", "extensions": [".json"] },
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson", ".jsonl"]
},
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/xml", "extensions": [".xml"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
{ "mediaType": "text/csv", "extensions": [".csv"] }
]
},
"capabilities": { "required": [], "optional": ["workers", "webassembly"] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+3 -1
View File
@@ -42,7 +42,9 @@ for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
"--- " + "--- " +
candidate + candidate +
" ---\n" + " ---\n" +
(await readFile(path.join(packageDirectory, candidate), "utf8")), (
await readFile(path.join(packageDirectory, candidate), "utf8")
).replace(/\r\n?/gu, "\n"),
); );
} catch { } catch {
/* directory */ /* directory */
+2 -1
View File
@@ -14,13 +14,14 @@ const root = path.resolve(
[".js", "text/javascript; charset=utf-8"], [".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"], [".json", "application/json; charset=utf-8"],
[".webmanifest", "application/manifest+json; charset=utf-8"], [".webmanifest", "application/manifest+json; charset=utf-8"],
[".wasm", "application/wasm"],
[".svg", "image/svg+xml"], [".svg", "image/svg+xml"],
[".md", "text/markdown; charset=utf-8"], [".md", "text/markdown; charset=utf-8"],
[".txt", "text/plain; charset=utf-8"], [".txt", "text/plain; charset=utf-8"],
]), ]),
headers = { headers = {
"Content-Security-Policy": "Content-Security-Policy":
"default-src 'self';base-uri 'self';object-src 'none';frame-ancestors 'none';form-action 'self';script-src 'self';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;connect-src 'self';worker-src 'self' blob:;manifest-src 'self'", "default-src 'self';base-uri 'self';object-src 'none';frame-ancestors 'none';form-action 'self';script-src 'self' 'wasm-unsafe-eval';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;connect-src 'self';worker-src 'self' blob:;manifest-src 'self'",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
+108 -8
View File
@@ -8,9 +8,11 @@ import {
import { import {
isObject, isObject,
parseData, parseData,
type CsvInferenceMode,
type DataDocument, type DataDocument,
type DataFormat, type DataFormat,
} from "../core/data"; } from "../core/data";
import { runDuckDbQuery } from "../core/duckdb";
import { runQuery, type QueryResult } from "../core/query"; import { runQuery, type QueryResult } from "../core/query";
const SAMPLE = JSON.stringify( const SAMPLE = JSON.stringify(
[ [
@@ -26,22 +28,31 @@ const SQL =
export function Workbench() { export function Workbench() {
const [source, setSource] = useState(SAMPLE), const [source, setSource] = useState(SAMPLE),
[format, setFormat] = useState<DataFormat>("json"), [format, setFormat] = useState<DataFormat>("json"),
[csvInference, setCsvInference] = useState<CsvInferenceMode>("safe"),
[inferNulls, setInferNulls] = useState(false),
[rejectUnsafeNumbers, setRejectUnsafeNumbers] = useState(false),
[document, setDocument] = useState<DataDocument>(() => [document, setDocument] = useState<DataDocument>(() =>
parseData(SAMPLE, "json"), parseData(SAMPLE, "json"),
), ),
[language, setLanguage] = useState<"sql" | "path">("sql"), [language, setLanguage] = useState<"sql" | "path">("sql"),
[engine, setEngine] = useState<"focused" | "duckdb-wasm">("focused"),
[query, setQuery] = useState(SQL), [query, setQuery] = useState(SQL),
[result, setResult] = useState<QueryResult>(() => [result, setResult] = useState<QueryResult>(() =>
runQuery(parseData(SAMPLE, "json").root, SQL, "sql"), runQuery(parseData(SAMPLE, "json").root, SQL, "sql"),
), ),
[status, setStatus] = useState("Sample data parsed and queried locally."), [status, setStatus] = useState("Sample data parsed and queried locally."),
[view, setView] = useState<"table" | "tree" | "json">("table"), [view, setView] = useState<"table" | "tree" | "json">("table"),
[running, setRunning] = useState(false),
[saved, setSaved] = useState< [saved, setSaved] = useState<
{ name: string; language: "sql" | "path"; query: string }[] { name: string; language: "sql" | "path"; query: string }[]
>(() => loadSaved()); >(() => loadSaved());
function parse() { function parse() {
try { try {
const next = parseData(source, format); const next = parseData(source, format, {
csvInference,
inferNulls,
rejectUnsafeJsonNumbers: rejectUnsafeNumbers,
});
setDocument(next); setDocument(next);
setStatus( setStatus(
`Parsed ${next.rows.length.toLocaleString()} rows and ${next.fields.length} fields locally. Existing result remains until Run.`, `Parsed ${next.rows.length.toLocaleString()} rows and ${next.fields.length} fields locally. Existing result remains until Run.`,
@@ -50,15 +61,24 @@ export function Workbench() {
setStatus(`${message(error)} The last valid dataset remains active.`); setStatus(`${message(error)} The last valid dataset remains active.`);
} }
} }
function run() { async function run() {
if (running) return;
setRunning(true);
try { try {
const next = runQuery(document.root, query, language); const next =
language === "sql" && engine === "duckdb-wasm"
? await runDuckDbQuery(document.rows, query, {
onProgress: setStatus,
})
: runQuery(document.root, query, language);
setResult(next); setResult(next);
setStatus( setStatus(
`Query scanned ${next.scanned.toLocaleString()} values and returned ${next.rows.length.toLocaleString()} result rows.`, `Query scanned ${next.scanned.toLocaleString()} values and returned ${next.rows.length.toLocaleString()} result rows.`,
); );
} catch (error) { } catch (error) {
setStatus(`${message(error)} The last valid result remains visible.`); setStatus(`${message(error)} The last valid result remains visible.`);
} finally {
setRunning(false);
} }
} }
async function open(file: File) { async function open(file: File) {
@@ -170,6 +190,42 @@ export function Workbench() {
<option value="xml">XML</option> <option value="xml">XML</option>
</select> </select>
</label> </label>
<div className="actions inference-controls">
<label>
CSV inference
<select
value={csvInference}
disabled={format !== "csv"}
onChange={(event) =>
setCsvInference(event.target.value as CsvInferenceMode)
}
>
<option value="none">Text only</option>
<option value="safe">Safe numbers + booleans</option>
<option value="aggressive">Aggressive numeric</option>
</select>
</label>
<label className="check-label">
<input
type="checkbox"
checked={inferNulls}
disabled={format !== "csv"}
onChange={(event) => setInferNulls(event.target.checked)}
/>
Infer literal null
</label>
<label className="check-label">
<input
type="checkbox"
checked={rejectUnsafeNumbers}
disabled={format !== "json" && format !== "ndjson"}
onChange={(event) =>
setRejectUnsafeNumbers(event.target.checked)
}
/>
Reject risky JSON numbers
</label>
</div>
<label> <label>
Source Source
<textarea <textarea
@@ -186,6 +242,22 @@ export function Workbench() {
<summary>Source fields</summary> <summary>Source fields</summary>
<p>{document.fields.join(" · ") || "Scalar input"}</p> <p>{document.fields.join(" · ") || "Scalar input"}</p>
</details> </details>
<details>
<summary>Loss and inference evidence</summary>
<p>
{document.evidence.csv
? `${document.evidence.csv.cells.filter((cell) => cell.inferred !== "string").length} inferred cells; all ${document.evidence.csv.cells.length} raw cells retained in memory.`
: "No CSV cell inference was applied."}
</p>
<p>
{document.evidence.jsonNumbers.length
? `${document.evidence.jsonNumbers.length} risky JSON number token(s): ${document.evidence.jsonNumbers
.slice(0, 6)
.map((item) => item.raw)
.join(" · ")}`
: "No unsafe-integer or high-precision JSON number lexemes detected."}
</p>
</details>
</section> </section>
<section className="panel editor"> <section className="panel editor">
<div className="heading"> <div className="heading">
@@ -201,6 +273,7 @@ export function Workbench() {
onChange={(e) => { onChange={(e) => {
const next = e.target.value as "sql" | "path"; const next = e.target.value as "sql" | "path";
setLanguage(next); setLanguage(next);
if (next === "path") setEngine("focused");
setQuery(next === "sql" ? SQL : "$[*][?(@.score >= 80)].name"); setQuery(next === "sql" ? SQL : "$[*][?(@.score >= 80)].name");
}} }}
> >
@@ -208,6 +281,23 @@ export function Workbench() {
<option value="path">JSONPath-like values</option> <option value="path">JSONPath-like values</option>
</select> </select>
</label> </label>
{language === "sql" && (
<label>
Execution engine
<select
value={engine}
onChange={(event) => {
const next = event.target.value as typeof engine;
setEngine(next);
if (next === "duckdb-wasm")
setQuery("SELECT * FROM data LIMIT 100");
}}
>
<option value="focused">Focused parsed subset</option>
<option value="duckdb-wasm">DuckDB-WASM (local worker)</option>
</select>
</label>
)}
<label> <label>
Query Query
<textarea <textarea
@@ -218,8 +308,12 @@ export function Workbench() {
/> />
</label> </label>
<div className="actions"> <div className="actions">
<button className="primary" onClick={run}> <button
Run query className="primary"
disabled={running}
onClick={() => void run()}
>
{running ? "Running…" : "Run query"}
</button> </button>
<button onClick={save}>Save locally</button> <button onClick={save}>Save locally</button>
</div> </div>
@@ -251,10 +345,16 @@ export function Workbench() {
))} ))}
</ol> </ol>
</details> </details>
<details>
<summary>Parsed query evidence</summary>
<pre>{stableStringify(result.ast, 2)}</pre>
</details>
<p className="boundary"> <p className="boundary">
This is an intentionally documented subset, not full SQL, JSONPath Focused mode is an intentionally documented subset, not full SQL,
or JMESPath. It has no joins, recursion, functions beyond the five JSONPath or JMESPath. DuckDB mode is a locally bundled, disposable
aggregates, regex, eval or implicit network access. MVP worker: one read-only SELECT, no extension loading or external
access, 128 MiB database memory, 1,000 result rows, a 30-second
startup ceiling and a 10-second query deadline.
</p> </p>
</section> </section>
</section> </section>
+179 -37
View File
@@ -6,19 +6,66 @@ import {
import { DOMParser, type Element as XmlElement } from "@xmldom/xmldom"; import { DOMParser, type Element as XmlElement } from "@xmldom/xmldom";
export type DataFormat = "json" | "csv" | "ndjson" | "xml"; export type DataFormat = "json" | "csv" | "ndjson" | "xml";
export type CsvInferenceMode = "none" | "safe" | "aggressive";
export interface ParseDataOptions {
csvInference?: CsvInferenceMode;
inferNulls?: boolean;
trimBeforeInference?: boolean;
rejectUnsafeJsonNumbers?: boolean;
}
export interface CsvCellEvidence {
row: number;
column: number;
field: string;
raw: string;
value: JsonValue;
inferred: "string" | "number" | "boolean" | "null";
}
export interface JsonNumberEvidence {
offset: number;
raw: string;
parsed: number;
risk: "unsafe-integer" | "precision-risk";
}
export interface DataEvidence {
csv?: {
headers: string[];
rawRows: string[][];
cells: CsvCellEvidence[];
inference: {
csvInference: CsvInferenceMode;
inferNulls: boolean;
trimBeforeInference: boolean;
};
};
jsonNumbers: JsonNumberEvidence[];
}
export interface DataDocument { export interface DataDocument {
format: DataFormat; format: DataFormat;
root: JsonValue; root: JsonValue;
rows: JsonValue[]; rows: JsonValue[];
fields: string[]; fields: string[];
evidence: DataEvidence;
} }
const MAX_CHARS = 2 * 1024 * 1024, const MAX_CHARS = 2 * 1024 * 1024,
MAX_ROWS = 10_000, MAX_ROWS = 10_000,
MAX_FIELDS = 200; MAX_FIELDS = 200;
export function parseData(source: string, format: DataFormat): DataDocument { export function parseData(
source: string,
format: DataFormat,
options: ParseDataOptions = {},
): DataDocument {
if (source.length > MAX_CHARS) if (source.length > MAX_CHARS)
throw new Error("Input exceeds the 2 MiB limit."); throw new Error("Input exceeds the 2 MiB limit.");
const evidence: DataEvidence = {
jsonNumbers:
format === "json" || format === "ndjson" ? scanJsonNumbers(source) : [],
};
if (options.rejectUnsafeJsonNumbers && evidence.jsonNumbers.length)
throw new RangeError(
`JSON contains ${evidence.jsonNumbers.length} number token${evidence.jsonNumbers.length === 1 ? "" : "s"} that cannot be accepted without precision risk.`,
);
let root: JsonValue; let root: JsonValue;
if (format === "json") if (format === "json")
root = safeJsonParse(source, { root = safeJsonParse(source, {
@@ -27,8 +74,11 @@ export function parseData(source: string, format: DataFormat): DataDocument {
maxNodes: 200_000, maxNodes: 200_000,
}); });
else if (format === "ndjson") root = parseNdjson(source); else if (format === "ndjson") root = parseNdjson(source);
else if (format === "csv") root = parseCsvData(source); else if (format === "csv") {
else root = parseXml(source); const csv = parseCsvData(source, options);
root = csv.root;
evidence.csv = csv.evidence;
} else root = parseXml(source);
const rows = normalizeRows(root); const rows = normalizeRows(root);
if (rows.length > MAX_ROWS) if (rows.length > MAX_ROWS)
throw new Error("Dataset exceeds 10,000 query rows."); throw new Error("Dataset exceeds 10,000 query rows.");
@@ -39,29 +89,137 @@ export function parseData(source: string, format: DataFormat): DataDocument {
]; ];
if (fields.length > MAX_FIELDS) if (fields.length > MAX_FIELDS)
throw new Error("Dataset exposes more than 200 top-level fields."); throw new Error("Dataset exposes more than 200 top-level fields.");
return { format, root, rows, fields: fields.sort() }; return { format, root, rows, fields: fields.sort(), evidence };
} }
function parseCsvData(source: string): JsonValue { function parseCsvData(
source: string,
options: ParseDataOptions,
): { root: JsonValue; evidence: NonNullable<DataEvidence["csv"]> } {
const table = parseCsv(source, { const table = parseCsv(source, {
maxRows: MAX_ROWS + 1, maxRows: MAX_ROWS + 1,
maxColumns: MAX_FIELDS, maxColumns: MAX_FIELDS,
maxFieldChars: 100_000, maxFieldChars: 100_000,
}); });
if (!table.length) return []; const inference = {
csvInference: options.csvInference ?? "safe",
inferNulls: options.inferNulls ?? false,
trimBeforeInference: options.trimBeforeInference ?? true,
};
if (!table.length)
return {
root: [],
evidence: { headers: [], rawRows: [], cells: [], inference },
};
const headers = table[0]!.map( const headers = table[0]!.map(
(value, index) => value.trim() || `column_${index + 1}`, (value, index) => value.trim() || `column_${index + 1}`,
); );
if (new Set(headers).size !== headers.length) if (new Set(headers).size !== headers.length)
throw new Error("CSV headers must be unique."); throw new Error("CSV headers must be unique.");
return table const rawRows = table.slice(1).filter((row) => row.some(Boolean));
.slice(1) const cells: CsvCellEvidence[] = [];
.filter((row) => row.some(Boolean)) const root = rawRows.map((row, rowIndex) =>
.map((row) =>
Object.fromEntries( Object.fromEntries(
headers.map((header, index) => [header, inferScalar(row[index] ?? "")]), headers.map((field, column) => {
const raw = row[column] ?? "";
const interpreted = inferCsvScalar(raw, inference);
cells.push({
row: rowIndex + 1,
column: column + 1,
field,
raw,
value: interpreted.value,
inferred: interpreted.kind,
});
return [field, interpreted.value];
}),
), ),
) as JsonValue; ) as JsonValue;
return {
root,
evidence: {
headers: [...headers],
rawRows: rawRows.map((row) => [...row]),
cells,
inference,
},
};
}
function inferCsvScalar(
raw: string,
options: {
csvInference: CsvInferenceMode;
inferNulls: boolean;
trimBeforeInference: boolean;
},
): { value: JsonValue; kind: CsvCellEvidence["inferred"] } {
if (options.csvInference === "none") return { value: raw, kind: "string" };
const candidate = options.trimBeforeInference ? raw.trim() : raw;
if (/^(?:true|false)$/iu.test(candidate))
return { value: candidate.toLowerCase() === "true", kind: "boolean" };
if (options.inferNulls && /^null$/iu.test(candidate))
return { value: null, kind: "null" };
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu.test(candidate)) {
const number = Number(candidate);
const integerToken = /^-?\d+$/u.test(candidate);
if (
Number.isFinite(number) &&
(options.csvInference === "aggressive" ||
!integerToken ||
Number.isSafeInteger(number))
)
return { value: number, kind: "number" };
}
return { value: raw, kind: "string" };
}
/** Identifies JSON number lexemes whose browser Number conversion loses evidence. */
export function scanJsonNumbers(source: string): JsonNumberEvidence[] {
const results: JsonNumberEvidence[] = [];
let index = 0;
let inString = false;
while (index < source.length) {
const char = source[index]!;
if (inString) {
if (char === "\\") index += 2;
else {
if (char === '"') inString = false;
index += 1;
}
continue;
}
if (char === '"') {
inString = true;
index += 1;
continue;
}
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
source.slice(index),
);
if (!match) {
index += 1;
continue;
}
const raw = match[0];
const parsed = Number(raw);
const integer = !/[.eE]/u.test(raw);
const mantissa = raw.split(/[eE]/u, 1)[0] ?? raw;
const significantDigits = mantissa
.replace(/^-|\./gu, "")
.replace(/^0+/u, "")
.replace(/0+$/u, "").length;
const risk = integer
? Number.isSafeInteger(parsed)
? undefined
: "unsafe-integer"
: !Number.isFinite(parsed) || significantDigits > 15
? "precision-risk"
: undefined;
if (risk) results.push({ offset: index, raw, parsed, risk });
index += raw.length;
}
return results;
} }
function parseNdjson(source: string): JsonValue { function parseNdjson(source: string): JsonValue {
@@ -108,34 +266,29 @@ function parseXml(source: string): JsonValue {
const attribute = element.attributes.item(index); const attribute = element.attributes.item(index);
if (attribute) output[`@${attribute.name}`] = attribute.value; if (attribute) output[`@${attribute.name}`] = attribute.value;
} }
const children = [ const allChildren = Array.from(
...Array.from({ length: element.childNodes.length }, (_, index) => { length: element.childNodes.length },
element.childNodes.item(index), (_, index) => element.childNodes.item(index),
), );
].filter((node): node is XmlElement => const children = allChildren.filter((node): node is XmlElement =>
Boolean(node && node.nodeType === 1), Boolean(node && node.nodeType === 1),
); );
for (const child of children) { for (const child of children) {
const value = convert(child, depth + 1), const value = convert(child, depth + 1);
key = child.nodeName; const existing = output[child.nodeName];
const existing = output[key]; output[child.nodeName] =
output[key] =
existing === undefined existing === undefined
? value ? value
: Array.isArray(existing) : Array.isArray(existing)
? [...existing, value] ? [...existing, value]
: [existing, value]; : [existing, value];
} }
const text = [ const text = allChildren
...Array.from({ length: element.childNodes.length }, (_, index) =>
element.childNodes.item(index),
),
]
.filter((node) => node?.nodeType === 3 || node?.nodeType === 4) .filter((node) => node?.nodeType === 3 || node?.nodeType === 4)
.map((node) => node?.nodeValue ?? "") .map((node) => node?.nodeValue ?? "")
.join("") .join("")
.trim(); .trim();
if (!Object.keys(output).length) return inferScalar(text); if (!Object.keys(output).length) return text;
if (text) output["#text"] = text; if (text) output["#text"] = text;
return output; return output;
}; };
@@ -155,14 +308,3 @@ export function normalizeRows(value: JsonValue): JsonValue[] {
export function isObject(value: JsonValue): value is Record<string, JsonValue> { export function isObject(value: JsonValue): value is Record<string, JsonValue> {
return Boolean(value && typeof value === "object" && !Array.isArray(value)); return Boolean(value && typeof value === "object" && !Array.isArray(value));
} }
function inferScalar(value: string): JsonValue {
const trimmed = value.trim();
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu.test(trimmed)) {
const number = Number(trimmed);
if (Number.isFinite(number)) return number;
}
if (/^(?:true|false)$/iu.test(trimmed))
return trimmed.toLowerCase() === "true";
if (/^null$/iu.test(trimmed)) return null;
return value;
}
+277
View File
@@ -0,0 +1,277 @@
import { stableStringify, type JsonValue } from "@add-ideas/toolbox-helpers";
import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
import type { DuckDbQueryAst, QueryResult } from "./query";
export const DUCKDB_RESULT_LIMIT = 1_000;
export const DUCKDB_DEADLINE_MS = 10_000;
export const DUCKDB_STARTUP_DEADLINE_MS = 30_000;
export const DUCKDB_MEMORY_LIMIT = "128MB";
const forbidden = new Set([
"ALTER",
"ATTACH",
"CALL",
"COPY",
"CREATE",
"DELETE",
"DETACH",
"DROP",
"EXPORT",
"IMPORT",
"INSERT",
"INSTALL",
"LOAD",
"PRAGMA",
"RESET",
"SET",
"TRUNCATE",
"UPDATE",
"VACUUM",
]);
export interface DuckDbOptions {
signal?: AbortSignal;
timeoutMs?: number;
startupTimeoutMs?: number;
onProgress?: (message: string) => void;
}
/**
* Parses the security-relevant shell of a DuckDB query. The database performs
* the complete SQL parse; this gate guarantees a single read-only SELECT and
* records every token used for that decision as evidence.
*/
export function parseDuckDbQuery(source: string): DuckDbQueryAst {
if (!source.trim()) throw new SyntaxError("DuckDB SQL cannot be empty.");
if (source.length > 20_000)
throw new RangeError("DuckDB SQL exceeds 20,000 characters.");
const tokens: DuckDbQueryAst["tokens"] = [];
let index = 0;
while (index < source.length) {
const char = source[index]!;
if (/\s/u.test(char)) {
index += 1;
continue;
}
if (source.startsWith("--", index)) {
const end = source.indexOf("\n", index + 2);
index = end < 0 ? source.length : end + 1;
continue;
}
if (source.startsWith("/*", index)) {
const end = source.indexOf("*/", index + 2);
if (end < 0) throw new SyntaxError("Unclosed DuckDB SQL comment.");
index = end + 2;
continue;
}
const offset = index;
if (char === "'" || char === '"') {
const quote = char;
index += 1;
let value = quote;
let closed = false;
while (index < source.length) {
const next = source[index++]!;
value += next;
if (next === quote) {
if (source[index] === quote) value += source[index++]!;
else {
closed = true;
break;
}
}
}
if (!closed) throw new SyntaxError("Unclosed DuckDB SQL quote.");
tokens.push({ kind: "quoted", value, offset });
continue;
}
const word = /^[A-Za-z_][A-Za-z0-9_$]*/u.exec(source.slice(index));
if (word) {
tokens.push({ kind: "word", value: word[0], offset });
index += word[0].length;
continue;
}
if (char === ";")
throw new SyntaxError(
"DuckDB mode accepts exactly one statement without a semicolon.",
);
tokens.push({ kind: "symbol", value: char, offset });
index += 1;
}
const words = tokens
.filter((token) => token.kind === "word")
.map((token) => token.value.toUpperCase());
if (words[0] !== "SELECT")
throw new SyntaxError(
"DuckDB mode accepts a single SELECT statement only.",
);
const blocked = words.find((word) => forbidden.has(word));
if (blocked)
throw new SyntaxError(
`DuckDB keyword ${blocked} is disabled in local read-only mode.`,
);
return { type: "duckdb-query", statement: "SELECT", tokens };
}
export async function runDuckDbQuery(
rows: readonly JsonValue[],
source: string,
options: DuckDbOptions = {},
): Promise<QueryResult> {
if (rows.length > 10_000)
throw new RangeError("DuckDB input exceeds 10,000 rows.");
const ast = parseDuckDbQuery(source);
const timeoutMs = options.timeoutMs ?? DUCKDB_DEADLINE_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000)
throw new RangeError("DuckDB deadline must be 10030,000 milliseconds.");
const startupTimeoutMs =
options.startupTimeoutMs ?? DUCKDB_STARTUP_DEADLINE_MS;
if (
!Number.isFinite(startupTimeoutMs) ||
startupTimeoutMs < 1_000 ||
startupTimeoutMs > 60_000
)
throw new RangeError(
"DuckDB startup deadline must be 1,00060,000 milliseconds.",
);
const text = stableStringify(rows, undefined, {
maxTextChars: 2 * 1024 * 1024,
maxDepth: 32,
maxNodes: 200_000,
});
options.signal?.throwIfAborted();
options.onProgress?.("Starting the local DuckDB worker…");
const [duckdb, moduleAsset, workerAsset] = await Promise.all([
import("@duckdb/duckdb-wasm"),
import("@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url"),
import("@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url"),
]);
options.signal?.throwIfAborted();
const duckdbMvpModule = moduleAsset.default;
const duckdbMvpWorker = workerAsset.default;
const worker = new Worker(duckdbMvpWorker);
const database = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);
let connection: AsyncDuckDBConnection | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
let rejectDeadline: ((reason: Error) => void) | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
rejectDeadline = reject;
});
const armDeadline = (phase: "startup" | "query", milliseconds: number) => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
void connection?.cancelSent().catch(() => undefined);
void database.terminate();
rejectDeadline?.(
new DOMException(
`DuckDB ${phase} exceeded its ${milliseconds.toLocaleString()} ms deadline.`,
"TimeoutError",
),
);
}, milliseconds);
};
armDeadline("startup", startupTimeoutMs);
const abort = () => {
void connection?.cancelSent().catch(() => undefined);
void database.terminate();
};
options.signal?.addEventListener("abort", abort, { once: true });
try {
const execute = async (): Promise<QueryResult> => {
await database.instantiate(duckdbMvpModule);
await database.open({
path: ":memory:",
maximumThreads: 1,
allowUnsignedExtensions: false,
arrowLosslessConversion: true,
query: {
castBigIntToDouble: false,
castDecimalToDouble: false,
castTimestampToDate: true,
queryPollingInterval: 20,
},
});
options.signal?.throwIfAborted();
options.onProgress?.("Loading the bounded dataset into DuckDB…");
await database.registerFileText("toolbox-input.json", text);
connection = await database.connect();
await connection.insertJSONFromPath("toolbox-input.json", {
name: "data",
create: true,
});
await connection.query(`SET memory_limit='${DUCKDB_MEMORY_LIMIT}'`);
await connection.query("SET enable_external_access=false");
await connection.query("SET autoinstall_known_extensions=false");
await connection.query("SET autoload_known_extensions=false");
options.onProgress?.("Running one read-only SELECT…");
armDeadline("query", timeoutMs);
const result = await connection.query(
`SELECT * FROM (${source}) AS toolbox_result LIMIT ${DUCKDB_RESULT_LIMIT + 1}`,
);
const rawRows = result.toArray();
if (rawRows.length > DUCKDB_RESULT_LIMIT)
throw new RangeError(
`DuckDB result exceeds ${DUCKDB_RESULT_LIMIT.toLocaleString()} rows; add a LIMIT or a narrower filter.`,
);
const evidence: string[] = [];
const output = rawRows.map((row: unknown) =>
jsonValue(
typeof (row as { toJSON?: () => unknown }).toJSON === "function"
? (row as { toJSON: () => unknown }).toJSON()
: row,
evidence,
),
);
return {
value: output,
rows: output,
scanned: rows.length,
ast,
engine: "duckdb-wasm",
explain: [
"Instantiate the locally bundled single-threaded DuckDB-WASM MVP worker.",
`Load ${rows.length.toLocaleString()} bounded JSON row${rows.length === 1 ? "" : "s"} into table data.`,
`Disable external access and extension auto-install/auto-load; cap memory at ${DUCKDB_MEMORY_LIMIT}.`,
`Initialize within ${startupTimeoutMs.toLocaleString()} ms, then execute one parsed read-only SELECT with a ${timeoutMs.toLocaleString()} ms hard worker deadline.`,
`Materialize at most ${DUCKDB_RESULT_LIMIT.toLocaleString()} rows${evidence.length ? `; ${[...new Set(evidence)].join(" ")}` : "."}`,
],
};
};
return await Promise.race([execute(), deadline]);
} finally {
if (timer !== undefined) clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
await connection?.close().catch(() => undefined);
await database.terminate().catch(() => undefined);
worker.terminate();
}
}
function jsonValue(value: unknown, evidence: string[], depth = 0): JsonValue {
if (depth > 32) throw new RangeError("DuckDB result exceeds depth 32.");
if (value === null || typeof value === "string" || typeof value === "boolean")
return value;
if (typeof value === "number")
return Number.isFinite(value) ? value : String(value);
if (typeof value === "bigint") {
evidence.push("64-bit integers were rendered as exact decimal strings.");
return value.toString();
}
if (value instanceof Date) return value.toISOString();
if (value instanceof Uint8Array) {
evidence.push("Binary values were rendered as hexadecimal strings.");
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
if (Array.isArray(value))
return value.map((item) => jsonValue(item, evidence, depth + 1));
if (value && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
jsonValue(item, evidence, depth + 1),
]),
);
return String(value);
}
+458 -196
View File
@@ -6,18 +6,75 @@ export interface QueryResult {
rows: JsonValue[]; rows: JsonValue[];
explain: string[]; explain: string[];
scanned: number; scanned: number;
ast: QueryAst;
engine: "focused" | "duckdb-wasm";
} }
type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "CONTAINS";
interface Condition { export type QueryAst = SqlQueryAst | PathQueryAst | DuckDbQueryAst;
path: string; export type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "CONTAINS";
export interface ConditionAst {
type: "condition";
path: string[];
operator: Operator; operator: Operator;
value: JsonValue; value: JsonValue;
} }
interface SelectItem {
kind: "field" | "aggregate" | "all"; export type SelectAst =
path?: string; | { type: "all"; alias: "*" }
fn?: "COUNT" | "SUM" | "AVG" | "MIN" | "MAX"; | { type: "field"; path: string[]; alias: string }
| {
type: "aggregate";
fn: "COUNT" | "SUM" | "AVG" | "MIN" | "MAX";
path?: string[];
alias: string; alias: string;
};
export interface SqlQueryAst {
type: "sql-query";
select: SelectAst[];
where: ConditionAst[];
groupBy: string[][];
orderBy?: { path: string[]; direction: "ASC" | "DESC" };
limit: number;
}
export type PathSegmentAst =
| { type: "property"; key: string; quoted: boolean }
| { type: "index"; index: number }
| { type: "wildcard" }
| { type: "filter"; condition: ConditionAst };
export interface PathQueryAst {
type: "path-query";
segments: PathSegmentAst[];
}
export interface DuckDbQueryAst {
type: "duckdb-query";
statement: "SELECT";
tokens: Array<{
kind: "word" | "quoted" | "symbol";
value: string;
offset: number;
}>;
}
type TokenKind =
| "identifier"
| "number"
| "string"
| "comma"
| "dot"
| "left"
| "right"
| "star"
| "operator"
| "eof";
interface Token {
kind: TokenKind;
value: string;
offset: number;
} }
export function runQuery( export function runQuery(
@@ -27,111 +84,397 @@ export function runQuery(
): QueryResult { ): QueryResult {
if (query.length > 20_000) if (query.length > 20_000)
throw new Error("Query exceeds 20,000 characters."); throw new Error("Query exceeds 20,000 characters.");
return language === "sql" const ast = language === "sql" ? parseSqlQuery(query) : parsePathQuery(query);
? runSql(normalizeRows(root), query) return ast.type === "sql-query"
: runPath(root, query); ? executeSql(normalizeRows(root), ast)
: executePath(root, ast);
} }
function runSql(input: JsonValue[], source: string): QueryResult { export function parseSqlQuery(source: string): SqlQueryAst {
const match = if (source.length > 20_000)
/^\s*SELECT\s+([\s\S]+?)(?:\s+WHERE\s+([\s\S]+?))?(?:\s+GROUP\s+BY\s+([\s\S]+?))?(?:\s+ORDER\s+BY\s+([A-Za-z_$][\w$.-]*)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?\s*$/iu.exec( throw new Error("Query exceeds 20,000 characters.");
source, return new SqlParser(tokenizeSql(source)).parse();
); }
if (!match)
throw new Error( class SqlParser {
"Expected SELECT fields [WHERE …] [GROUP BY …] [ORDER BY field ASC|DESC] [LIMIT n].", #index = 0;
); readonly #tokens: Token[];
const selects = splitComma(match[1]!).map(parseSelect), constructor(tokens: Token[]) {
conditions = match[2] this.#tokens = tokens;
? match[2].split(/\s+AND\s+/iu).map(parseCondition) }
: [];
const groups = match[3] ? splitComma(match[3]).map(validatePath) : []; parse(): SqlQueryAst {
const orderPath = match[4], this.#keyword("SELECT");
descending = match[5]?.toUpperCase() === "DESC"; const select: SelectAst[] = [];
const limit = match[6] ? Number(match[6]) : 1_000; do select.push(this.#selectItem());
while (this.#take("comma"));
const where: ConditionAst[] = [];
if (this.#takeKeyword("WHERE")) {
do where.push(this.#condition());
while (this.#takeKeyword("AND"));
}
const groupBy: string[][] = [];
if (this.#takeKeyword("GROUP")) {
this.#keyword("BY");
do groupBy.push(this.#path());
while (this.#take("comma"));
}
let orderBy: SqlQueryAst["orderBy"];
if (this.#takeKeyword("ORDER")) {
this.#keyword("BY");
const path = this.#path();
const direction = this.#takeKeyword("DESC") ? "DESC" : "ASC";
if (direction === "ASC") this.#takeKeyword("ASC");
orderBy = { path, direction };
}
let limit = 1_000;
if (this.#takeKeyword("LIMIT")) {
const token = this.#expect("number", "LIMIT requires an integer");
if (!/^\d+$/u.test(token.value)) this.#fail("LIMIT requires an integer");
limit = Number(token.value);
if (!Number.isInteger(limit) || limit < 0 || limit > 10_000) if (!Number.isInteger(limit) || limit < 0 || limit > 10_000)
throw new Error("LIMIT must be between 0 and 10,000."); this.#fail("LIMIT must be between 0 and 10,000");
let rows = input.filter((row) => }
conditions.every((condition) => evaluateCondition(row, condition)), this.#expect("eof", "Unexpected SQL after the supported query");
return {
type: "sql-query",
select,
where,
groupBy,
...(orderBy ? { orderBy } : {}),
limit,
};
}
#selectItem(): SelectAst {
if (this.#take("star")) return { type: "all", alias: "*" };
const first = this.#expect("identifier", "Invalid SELECT expression");
if (this.#take("left")) {
const fn = first.value.toUpperCase();
if (!["COUNT", "SUM", "AVG", "MIN", "MAX"].includes(fn))
this.#fail(
`Invalid SELECT expression: unsupported aggregate ${first.value}`,
); );
const aggregate = selects.some((item) => item.kind === "aggregate"); const path = this.#take("star") ? undefined : this.#path();
if (aggregate || groups.length) { this.#expect("right", "Aggregate call needs a closing parenthesis");
const alias = this.#takeKeyword("AS")
? this.#expect("identifier", "AS requires an alias").value
: `${fn.toLowerCase()}_${path?.join(".") ?? "all"}`;
return {
type: "aggregate",
fn: fn as Extract<SelectAst, { type: "aggregate" }>["fn"],
...(path ? { path } : {}),
alias,
};
}
const path = this.#path(first);
const alias = this.#takeKeyword("AS")
? this.#expect("identifier", "AS requires an alias").value
: path.join(".");
return { type: "field", path, alias };
}
#condition(): ConditionAst {
const path = this.#path();
const token = this.#peek();
let operator: Operator;
if (token.kind === "operator") {
operator = token.value as Operator;
this.#index += 1;
} else if (
token.kind === "identifier" &&
token.value.toUpperCase() === "CONTAINS"
) {
operator = "CONTAINS";
this.#index += 1;
} else this.#fail("WHERE requires a supported comparison operator");
return { type: "condition", path, operator, value: this.#literal() };
}
#literal(): JsonValue {
const token = this.#peek();
if (token.kind === "string") {
this.#index += 1;
return token.value;
}
if (token.kind === "number") {
this.#index += 1;
const number = Number(token.value);
if (!Number.isFinite(number)) this.#fail("Number literal is not finite");
return number;
}
if (token.kind === "identifier") {
const keyword = token.value.toUpperCase();
if (keyword === "TRUE" || keyword === "FALSE" || keyword === "NULL") {
this.#index += 1;
return keyword === "NULL" ? null : keyword === "TRUE";
}
}
this.#fail("Literal must be quoted text, number, boolean or null");
}
#path(first?: Token): string[] {
const path = [
(first ?? this.#expect("identifier", "Expected a field path")).value,
];
while (this.#take("dot"))
path.push(this.#expect("identifier", "Expected a name after dot").value);
return path;
}
#keyword(value: string): void {
if (!this.#takeKeyword(value)) this.#fail(`Expected ${value}`);
}
#takeKeyword(value: string): boolean {
const token = this.#peek();
if (token.kind !== "identifier" || token.value.toUpperCase() !== value)
return false;
this.#index += 1;
return true;
}
#take(kind: TokenKind): boolean {
if (this.#peek().kind !== kind) return false;
this.#index += 1;
return true;
}
#expect(kind: TokenKind, message: string): Token {
const token = this.#peek();
if (token.kind !== kind) this.#fail(message);
this.#index += 1;
return token;
}
#peek(): Token {
return this.#tokens[this.#index] ?? this.#tokens.at(-1)!;
}
#fail(message: string): never {
const token = this.#peek();
throw new SyntaxError(`${message} at character ${token.offset + 1}.`);
}
}
function tokenizeSql(source: string): Token[] {
const tokens: Token[] = [];
let index = 0;
const push = (kind: TokenKind, value: string, offset = index) =>
tokens.push({ kind, value, offset });
while (index < source.length) {
if (/\s/u.test(source[index]!)) {
index += 1;
continue;
}
const offset = index;
const char = source[index]!;
const punctuation: Partial<Record<string, TokenKind>> = {
",": "comma",
".": "dot",
"(": "left",
")": "right",
"*": "star",
};
if (punctuation[char]) {
push(punctuation[char]!, char, offset);
index += 1;
continue;
}
const operator = /^(?:>=|<=|!=|=|>|<)/u.exec(source.slice(index));
if (operator) {
push("operator", operator[0], offset);
index += operator[0].length;
continue;
}
const number = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/iu.exec(
source.slice(index),
);
if (number) {
push("number", number[0], offset);
index += number[0].length;
continue;
}
const identifier = /^[A-Za-z_$][\w$-]*/u.exec(source.slice(index));
if (identifier) {
push("identifier", identifier[0], offset);
index += identifier[0].length;
continue;
}
if (char === "'" || char === '"') {
const quote = char;
index += 1;
let value = "";
let closed = false;
while (index < source.length) {
const next = source[index++]!;
if (next === quote) {
if (source[index] === quote) {
value += quote;
index += 1;
} else {
closed = true;
break;
}
} else if (next === "\\") {
const escaped = source[index++];
if (escaped === undefined || !["\\", "'", '"'].includes(escaped))
throw new SyntaxError(
`Unsupported string escape at character ${index}.`,
);
value += escaped;
} else value += next;
}
if (!closed)
throw new SyntaxError(`Unclosed string at character ${offset + 1}.`);
push("string", value, offset);
continue;
}
throw new SyntaxError(
`Unsupported SQL token “${char}” at character ${offset + 1}.`,
);
}
tokens.push({ kind: "eof", value: "", offset: source.length });
return tokens;
}
export function parsePathQuery(source: string): PathQueryAst {
if (source.length > 20_000)
throw new Error("Query exceeds 20,000 characters.");
let index = 0;
const fail = (message: string): never => {
throw new SyntaxError(`${message} at character ${index + 1}.`);
};
if (source[index] !== "$") fail("Path queries start with $");
index += 1;
const segments: PathSegmentAst[] = [];
while (index < source.length) {
if (source[index] === ".") {
index += 1;
const match = /^[A-Za-z_$][\w$-]*/u.exec(source.slice(index));
if (!match)
throw new SyntaxError(
`Unsupported path: expected a property name after dot at character ${index + 1}.`,
);
segments.push({ type: "property", key: match[0], quoted: false });
index += match[0].length;
continue;
}
const rest = source.slice(index);
const quoted = /^\[['"]([^'"\]]+)['"]\]/u.exec(rest);
if (quoted) {
segments.push({ type: "property", key: quoted[1]!, quoted: true });
index += quoted[0].length;
continue;
}
const arrayIndex = /^\[(\d+)\]/u.exec(rest);
if (arrayIndex) {
const value = Number(arrayIndex[1]);
if (!Number.isSafeInteger(value)) fail("Array index is too large");
segments.push({ type: "index", index: value });
index += arrayIndex[0].length;
continue;
}
if (rest.startsWith("[*]")) {
segments.push({ type: "wildcard" });
index += 3;
continue;
}
const filter =
/^\[\?\(@\.([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<)\s*((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|true|false|null))\s*\)\]/iu.exec(
rest,
);
if (filter) {
const literalAst = parseSqlQuery(`SELECT x WHERE x = ${filter[3]}`);
segments.push({
type: "filter",
condition: {
type: "condition",
path: validatePath(filter[1]!),
operator: filter[2]! as Operator,
value: literalAst.where[0]!.value,
},
});
index += filter[0].length;
continue;
}
fail(`Unsupported path token near “${rest.slice(0, 30)}`);
}
return { type: "path-query", segments };
}
function executeSql(input: JsonValue[], ast: SqlQueryAst): QueryResult {
const aggregate = ast.select.some((item) => item.type === "aggregate");
if ( if (
selects.some( ast.select.some(
(item) => item.kind === "field" && !groups.includes(item.path!), (item) =>
) item.type === "field" &&
!ast.groupBy.some((path) => samePath(path, item.path)),
) &&
(aggregate || ast.groupBy.length)
) )
throw new Error("Non-aggregate SELECT fields must appear in GROUP BY."); throw new Error("Non-aggregate SELECT fields must appear in GROUP BY.");
let rows = input.filter((row) =>
ast.where.every((condition) => evaluateCondition(row, condition)),
);
if (aggregate || ast.groupBy.length) {
const buckets = new Map<string, JsonValue[]>(); const buckets = new Map<string, JsonValue[]>();
for (const row of rows) { for (const row of rows) {
const key = stableStringify(groups.map((path) => getPath(row, path))); const key = stableStringify(
ast.groupBy.map((path) => getPath(row, path)),
);
const bucket = buckets.get(key) ?? []; const bucket = buckets.get(key) ?? [];
bucket.push(row); bucket.push(row);
buckets.set(key, bucket); buckets.set(key, bucket);
} }
rows = [...buckets.values()].map((bucket) => rows = [...buckets.values()].map((bucket) =>
projectAggregate(bucket, selects), projectAggregate(bucket, ast.select),
); );
} else rows = rows.map((row) => project(row, selects)); } else rows = rows.map((row) => project(row, ast.select));
if (orderPath) if (ast.orderBy)
rows.sort( rows.sort(
(a, b) => (a, b) =>
compare(getPath(a, orderPath), getPath(b, orderPath)) * compare(getPath(a, ast.orderBy!.path), getPath(b, ast.orderBy!.path)) *
(descending ? -1 : 1), (ast.orderBy!.direction === "DESC" ? -1 : 1),
); );
rows = rows.slice(0, limit); rows = rows.slice(0, ast.limit);
return { return {
value: rows, value: rows,
rows, rows,
scanned: input.length, scanned: input.length,
ast,
engine: "focused",
explain: [ explain: [
`Scan at most ${input.length.toLocaleString()} in-memory rows.`, `Scan at most ${input.length.toLocaleString()} in-memory rows.`,
conditions.length ast.where.length
? `Apply ${conditions.length} AND-connected predicate${conditions.length === 1 ? "" : "s"}.` ? `Apply ${ast.where.length} parsed AND predicate${ast.where.length === 1 ? "" : "s"}.`
: "No filter predicate.", : "No filter predicate.",
aggregate || groups.length aggregate || ast.groupBy.length
? `Build bounded groups on ${groups.join(", ") || "one global bucket"}; compute aggregates.` ? `Build bounded groups on ${ast.groupBy.map((path) => path.join(".")).join(", ") || "one global bucket"}; compute aggregates.`
: `Project ${selects.length} selection${selects.length === 1 ? "" : "s"}.`, : `Project ${ast.select.length} parsed selection${ast.select.length === 1 ? "" : "s"}.`,
orderPath ast.orderBy
? `Stable-sort by ${orderPath} ${descending ? "descending" : "ascending"}.` ? `Stable-sort by ${ast.orderBy.path.join(".")} ${ast.orderBy.direction.toLowerCase()}.`
: "Preserve source order.", : "Preserve source order.",
`Return at most ${limit.toLocaleString()} rows.`, `Return at most ${ast.limit.toLocaleString()} rows.`,
], ],
}; };
} }
function runPath(root: JsonValue, source: string): QueryResult { function executePath(root: JsonValue, ast: PathQueryAst): QueryResult {
let rest = source.trim();
if (!rest.startsWith("$")) throw new Error("Path queries start with $.");
rest = rest.slice(1);
let values: JsonValue[] = [root]; let values: JsonValue[] = [root];
const explain = ["Start at the document root ($)."]; const explain = ["Start at the document root ($)."];
while (rest) { for (const segment of ast.segments) {
let match: RegExpExecArray | null; if (segment.type === "property") {
if ((match = /^\.([A-Za-z_$][\w$-]*)/u.exec(rest))) {
const key = match[1]!;
values = values.flatMap((value) => values = values.flatMap((value) =>
isObject(value) && value[key] !== undefined ? [value[key]!] : [], isObject(value) && value[segment.key] !== undefined
); ? [value[segment.key]!]
rest = rest.slice(match[0].length);
explain.push(`Select property ${key}.`);
} else if ((match = /^\[['"]([^'"\]]+)['"]\]/u.exec(rest))) {
const key = match[1]!;
values = values.flatMap((value) =>
isObject(value) && value[key] !== undefined ? [value[key]!] : [],
);
rest = rest.slice(match[0].length);
explain.push(`Select quoted property ${key}.`);
} else if ((match = /^\[(\d+)\]/u.exec(rest))) {
const index = Number(match[1]);
values = values.flatMap((value) =>
Array.isArray(value) && value[index] !== undefined
? [value[index]!]
: [], : [],
); );
rest = rest.slice(match[0].length); explain.push(`Select property ${segment.key}.`);
explain.push(`Select array index ${index}.`); } else if (segment.type === "index") {
} else if ((match = /^\[\*\]/u.exec(rest))) { values = values.flatMap((value) =>
Array.isArray(value) && value[segment.index] !== undefined
? [value[segment.index]!]
: [],
);
explain.push(`Select array index ${segment.index}.`);
} else if (segment.type === "wildcard") {
values = values.flatMap((value) => values = values.flatMap((value) =>
Array.isArray(value) Array.isArray(value)
? value ? value
@@ -139,112 +482,60 @@ function runPath(root: JsonValue, source: string): QueryResult {
? Object.values(value) ? Object.values(value)
: [], : [],
); );
rest = rest.slice(match[0].length);
explain.push("Expand one wildcard level."); explain.push("Expand one wildcard level.");
} else if ( } else {
(match =
/^\[\?\(@\.([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<)\s*([^\]]+)\)\]/u.exec(
rest,
))
) {
const condition = {
path: validatePath(match[1]!),
operator: match[2]! as Operator,
value: parseLiteral(match[3]!.trim()),
};
values = values.flatMap((value) => values = values.flatMap((value) =>
Array.isArray(value) Array.isArray(value)
? value.filter((item) => evaluateCondition(item, condition)) ? value.filter((item) => evaluateCondition(item, segment.condition))
: [], : [],
); );
rest = rest.slice(match[0].length);
explain.push( explain.push(
`Filter an array with ${condition.path} ${condition.operator} literal.`, `Filter an array with ${segment.condition.path.join(".")} ${segment.condition.operator} literal.`,
);
} else
throw new Error(
`Unsupported path token near “${rest.slice(0, 30)}”. Supported: .name, ['name'], [n], [*], [?(@.field op literal)].`,
); );
}
if (values.length > 10_000) if (values.length > 10_000)
throw new Error("Path expansion exceeds 10,000 values."); throw new Error("Path expansion exceeds 10,000 values.");
} }
const value: JsonValue = values.length === 1 ? values[0]! : values; const value: JsonValue = values.length === 1 ? values[0]! : values;
return { value, rows: normalizeRows(value), scanned: values.length, explain }; return {
value,
rows: normalizeRows(value),
scanned: values.length,
explain,
ast,
engine: "focused",
};
} }
function parseSelect(source: string): SelectItem { function validatePath(path: string): string[] {
const value = source.trim();
if (value === "*") return { kind: "all", alias: "*" };
const aggregate =
/^(COUNT|SUM|AVG|MIN|MAX)\s*\(\s*(\*|[A-Za-z_$][\w$.-]*)\s*\)(?:\s+AS\s+([A-Za-z_$][\w$-]*))?$/iu.exec(
value,
);
if (aggregate) {
const fn = aggregate[1]!.toUpperCase() as SelectItem["fn"];
const path = aggregate[2] === "*" ? undefined : validatePath(aggregate[2]!);
return {
kind: "aggregate",
fn,
path,
alias: aggregate[3] ?? `${fn!.toLowerCase()}_${path ?? "all"}`,
};
}
const field = /^([A-Za-z_$][\w$.-]*)(?:\s+AS\s+([A-Za-z_$][\w$-]*))?$/iu.exec(
value,
);
if (!field) throw new Error(`Invalid SELECT expression: ${value}`);
return {
kind: "field",
path: validatePath(field[1]!),
alias: field[2] ?? field[1]!,
};
}
function parseCondition(source: string): Condition {
const match =
/^([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<|CONTAINS)\s*([\s\S]+)$/iu.exec(
source.trim(),
);
if (!match) throw new Error(`Invalid WHERE predicate: ${source}`);
return {
path: validatePath(match[1]!),
operator: match[2]!.toUpperCase() as Operator,
value: parseLiteral(match[3]!.trim()),
};
}
function parseLiteral(source: string): JsonValue {
if (
(source.startsWith("'") && source.endsWith("'")) ||
(source.startsWith('"') && source.endsWith('"'))
)
return source.slice(1, -1).replace(/\\(['"\\])/gu, "$1");
if (/^-?(?:\d+\.?\d*|\.\d+)$/u.test(source)) return Number(source);
if (/^(?:true|false)$/iu.test(source)) return source.toLowerCase() === "true";
if (/^null$/iu.test(source)) return null;
throw new Error(
`Literal must be quoted text, number, boolean or null: ${source}`,
);
}
function validatePath(path: string): string {
if (!/^[A-Za-z_$][\w$-]*(?:\.[A-Za-z_$][\w$-]*)*$/u.test(path)) if (!/^[A-Za-z_$][\w$-]*(?:\.[A-Za-z_$][\w$-]*)*$/u.test(path))
throw new Error(`Invalid field path: ${path}`); throw new Error(`Invalid field path: ${path}`);
return path; return path.split(".");
} }
function getPath(value: JsonValue, path: string): JsonValue | undefined { function samePath(left: string[], right: string[]): boolean {
return (
left.length === right.length && left.every((part, i) => part === right[i])
);
}
function getPath(
value: JsonValue,
path: readonly string[],
): JsonValue | undefined {
let current: JsonValue | undefined = value; let current: JsonValue | undefined = value;
for (const part of path.split(".")) { for (const part of path) {
if (!current || !isObject(current)) return undefined; if (!current || !isObject(current)) return undefined;
current = current[part]; current = current[part];
} }
return current; return current;
} }
function evaluateCondition(row: JsonValue, condition: Condition): boolean { function evaluateCondition(row: JsonValue, condition: ConditionAst): boolean {
const left = getPath(row, condition.path), const left = getPath(row, condition.path);
right = condition.value; const right = condition.value;
if (condition.operator === "CONTAINS") if (condition.operator === "CONTAINS")
return typeof left === "string" && left.includes(String(right)); return typeof left === "string" && left.includes(String(right));
const order = compare(left, right); const order = compare(left, right);
if (condition.operator === "=") return equal(left, right); if (condition.operator === "=") return left === right;
if (condition.operator === "!=") return !equal(left, right); if (condition.operator === "!=") return left !== right;
return condition.operator === ">" return condition.operator === ">"
? order > 0 ? order > 0
: condition.operator === ">=" : condition.operator === ">="
@@ -253,9 +544,6 @@ function evaluateCondition(row: JsonValue, condition: Condition): boolean {
? order < 0 ? order < 0
: order <= 0; : order <= 0;
} }
function equal(a: JsonValue | undefined, b: JsonValue): boolean {
return a === b;
}
function compare(a: JsonValue | undefined, b: JsonValue | undefined): number { function compare(a: JsonValue | undefined, b: JsonValue | undefined): number {
if (a === b) return 0; if (a === b) return 0;
if (a === undefined || a === null) return -1; if (a === undefined || a === null) return -1;
@@ -263,21 +551,21 @@ function compare(a: JsonValue | undefined, b: JsonValue | undefined): number {
if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b), undefined, { numeric: true }); return String(a).localeCompare(String(b), undefined, { numeric: true });
} }
function project(row: JsonValue, selects: SelectItem[]): JsonValue { function project(row: JsonValue, selects: SelectAst[]): JsonValue {
if (selects.length === 1 && selects[0]!.kind === "all") return row; if (selects.length === 1 && selects[0]!.type === "all") return row;
const output: Record<string, JsonValue> = {}; const output: Record<string, JsonValue> = {};
for (const item of selects) for (const item of selects)
if (item.kind === "field") if (item.type === "field")
output[item.alias] = getPath(row, item.path!) ?? null; output[item.alias] = getPath(row, item.path) ?? null;
else if (item.kind === "all" && isObject(row)) Object.assign(output, row); else if (item.type === "all" && isObject(row)) Object.assign(output, row);
return output; return output;
} }
function projectAggregate(rows: JsonValue[], selects: SelectItem[]): JsonValue { function projectAggregate(rows: JsonValue[], selects: SelectAst[]): JsonValue {
const output: Record<string, JsonValue> = {}; const output: Record<string, JsonValue> = {};
for (const item of selects) { for (const item of selects) {
if (item.kind === "field") if (item.type === "field")
output[item.alias] = getPath(rows[0] ?? null, item.path!) ?? null; output[item.alias] = getPath(rows[0] ?? null, item.path) ?? null;
else if (item.kind === "aggregate") { else if (item.type === "aggregate") {
const values = item.path const values = item.path
? rows ? rows
.map((row) => getPath(row, item.path!)) .map((row) => getPath(row, item.path!))
@@ -305,29 +593,3 @@ function projectAggregate(rows: JsonValue[], selects: SelectItem[]): JsonValue {
} }
return output; return output;
} }
function splitComma(source: string): string[] {
const values: string[] = [];
let current = "",
depth = 0,
quote = "";
for (const char of source) {
if (quote) {
current += char;
if (char === quote) quote = "";
} else if (char === "'" || char === '"') {
quote = char;
current += char;
} else if (char === "(") {
depth++;
current += char;
} else if (char === ")") {
depth--;
current += char;
} else if (char === "," && depth === 0) {
values.push(current);
current = "";
} else current += char;
}
if (current.trim()) values.push(current);
return values;
}
+39 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.query-tools", "id": "de.add-ideas.query-tools",
"name": "Query Tools", "name": "Query Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Query structured data locally.", "description": "Query structured data locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,44 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson", ".jsonl"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
}
]
},
"capabilities": {
"required": [],
"optional": ["workers", "webassembly"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+35 -1
View File
@@ -41,6 +41,40 @@ test("retains results on an error and exports", async ({ page }) => {
await page.getByRole("button", { name: "JSON", exact: true }).click(); await page.getByRole("button", { name: "JSON", exact: true }).click();
expect((await pending).suggestedFilename()).toBe("query-result.json"); expect((await pending).suggestedFilename()).toBe("query-result.json");
}); });
test("runs the locally bundled bounded DuckDB worker in Chromium", async ({
page,
browserName,
request,
}) => {
test.skip(browserName !== "chromium", "Targeted DuckDB-WASM Chromium flow");
const external = await local(page);
await page.goto("/deep/nested/query/");
await page.getByLabel("Execution engine").selectOption("duckdb-wasm");
await page
.getByRole("textbox", { name: "Query", exact: true })
.fill(
"SELECT team, avg(score) AS average FROM data GROUP BY team ORDER BY team",
);
const wasmResponse = page.waitForResponse((response) =>
response.url().endsWith(".wasm"),
);
await page.getByRole("button", { name: "Run query" }).click();
await expect(page.getByRole("status")).toContainText(
"returned 2 result rows",
{
timeout: 60_000,
},
);
await expect(page.getByRole("cell", { name: "blue" })).toBeVisible();
const wasmUrl = (await wasmResponse).url();
const wasm = await request.get(wasmUrl);
expect(wasm.headers()["content-type"]).toBe("application/wasm");
expect(wasm.headers()["cache-control"]).toContain("immutable");
expect(wasm.headers()["content-security-policy"]).toContain(
"'wasm-unsafe-eval'",
);
expect(external).toEqual([]);
});
test("shell PWA headers, offline reload, and theme", async ({ test("shell PWA headers, offline reload, and theme", async ({
page, page,
context, context,
@@ -76,6 +110,6 @@ test("shell PWA headers, offline reload, and theme", async ({
const manifest = await request.get("/deep/nested/query/toolbox-app.json"); const manifest = await request.get("/deep/nested/query/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.query-tools", id: "de.add-ideas.query-tools",
version: "0.1.0", version: "0.2.0",
}); });
}); });
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/query/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+36 -1
View File
@@ -2,12 +2,47 @@ import { describe, expect, it } from "vitest";
import { parseData } from "../../src/core/data"; import { parseData } from "../../src/core/data";
describe("data parser", () => { describe("data parser", () => {
it("parses CSV inference and NDJSON", () => { it("parses CSV inference and NDJSON", () => {
expect(parseData("name,n\nAda,3", "csv").rows[0]).toEqual({ const csv = parseData("name,n\nAda,3", "csv");
expect(csv.rows[0]).toEqual({
name: "Ada", name: "Ada",
n: 3, n: 3,
}); });
expect(csv.evidence.csv).toMatchObject({
rawRows: [["Ada", "3"]],
cells: expect.arrayContaining([
expect.objectContaining({ field: "n", raw: "3", inferred: "number" }),
]),
});
expect(parseData('{"a":1}\n{"a":2}', "ndjson").rows).toHaveLength(2); expect(parseData('{"a":1}\n{"a":2}', "ndjson").rows).toHaveLength(2);
}); });
it("makes inference explicit and preserves unsafe-number evidence", () => {
expect(
parseData("id,empty\n9007199254740993,null", "csv", {
csvInference: "safe",
inferNulls: false,
}).rows[0],
).toEqual({ id: "9007199254740993", empty: "null" });
const json = parseData(
'{"id":9007199254740993,"decimal":0.12345678901234567,"exact":"9007199254740993"}',
"json",
);
expect(json.evidence.jsonNumbers).toEqual([
expect.objectContaining({
raw: "9007199254740993",
risk: "unsafe-integer",
}),
expect.objectContaining({
raw: "0.12345678901234567",
risk: "precision-risk",
}),
]);
expect(() =>
parseData('{"id":9007199254740993}', "json", {
rejectUnsafeJsonNumbers: true,
}),
).toThrow(/precision risk/iu);
});
it("converts static XML and rejects entities", () => { it("converts static XML and rejects entities", () => {
expect( expect(
parseData( parseData(
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { parseDuckDbQuery } from "../../src/core/duckdb";
describe("DuckDB query boundary", () => {
it("records a single SELECT token stream", () => {
expect(
parseDuckDbQuery("SELECT team, avg(score) FROM data GROUP BY team"),
).toMatchObject({
type: "duckdb-query",
statement: "SELECT",
tokens: expect.arrayContaining([
{ kind: "word", value: "data", offset: 29 },
]),
});
});
it.each([
"INSTALL httpfs",
"SELECT * FROM data; DROP TABLE data",
"COPY data TO 'x.csv'",
"PRAGMA version",
])("rejects non-read-only statement %s", (source) => {
expect(() => parseDuckDbQuery(source)).toThrow();
});
});
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseData } from "../../src/core/data"; import { parseData } from "../../src/core/data";
import { runQuery } from "../../src/core/query"; import { parsePathQuery, parseSqlQuery, runQuery } from "../../src/core/query";
describe("query engine", () => { describe("query engine", () => {
const root = parseData( const root = parseData(
'[{"team":"a","n":2},{"team":"a","n":4},{"team":"b","n":9}]', '[{"team":"a","n":2},{"team":"a","n":4},{"team":"b","n":9}]',
@@ -32,4 +32,31 @@ describe("query engine", () => {
/Unsupported path/iu, /Unsupported path/iu,
); );
}); });
it("exposes parsed SQL and path ASTs", () => {
expect(
parseSqlQuery(
"SELECT team AS group_name WHERE n >= 2 ORDER BY n DESC LIMIT 4",
),
).toMatchObject({
type: "sql-query",
select: [{ type: "field", path: ["team"], alias: "group_name" }],
where: [{ type: "condition", path: ["n"], operator: ">=", value: 2 }],
orderBy: { path: ["n"], direction: "DESC" },
limit: 4,
});
expect(parsePathQuery("$[*][?(@.n >= 4)].team").segments).toEqual([
{ type: "wildcard" },
{
type: "filter",
condition: {
type: "condition",
path: ["n"],
operator: ">=",
value: 4,
},
},
{ type: "property", key: "team", quoted: false },
]);
});
}); });