diff --git a/.gitignore b/.gitignore index 54f651c..b48f0ec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ webbase-screenshot.png playwright-report/ test-results/ out/ +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3ed83..f2b6f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su --- -## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, Overtime demo +## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, test hardening, Overtime demo ### Added - `TIME` column type — `CREATE TABLE ... (col TIME)` / `TIME(n)` for a minute-granularity @@ -27,6 +27,14 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su live in `src/shared/cellValidation.ts` and run on both the client (instant feedback) and the server (`grid-edit` is now validated authoritatively — previously it wrote straight to SQLite with no check at all). (#45) +- `NUM(p,s)` is now a genuinely supported qualifier — the precision and scale are parsed, + recorded, and enforced on grid edits (`NUM(8,2)` accepts `123456.78`, rejects `1.234`). + Previously the scale silently corrupted the schema; see Fixed. (#45) The Assistant's + **New table** wizard accepts a width (`8`) or a precision,scale pair (`8,2`). (#50) +- `LIST STRUCTURE` prints the **declared** type of every column (`CHAR(10)`, `NUM(8,2)`, + `DATE`, `TIME(15)`, `LOGICAL`, `INT`) rather than SQLite's storage class (`TEXT`/`REAL`/ + `INTEGER`). Declared types are recorded per `(database, table, column)` in + `server/ColumnMetaStore.ts`. (#45) ### Fixed - `CREATE TABLE t (price NUM(8,2))` silently created a **phantom column named `2`** of @@ -38,6 +46,28 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su tables previously shared (and overwrote) one another's declared column types, so a `TIME(15)` column in one database could be validated against another database's `CHAR(20)` declaration of the same name. (#45) +- `CREATE TABLE` now **rejects a malformed column list** instead of silently inventing + columns from tokens it doesn't understand. `CREATE TABLE t (a CHAR(10) b INT)` (missing + comma), `(a)` (no type), `(a NUM(8,2,9))` and an unclosed paren all now raise a parse + error naming the offending column, and create nothing. This permissiveness was the root + cause of the phantom-column bug above. (#50) +- **Index metadata is now scoped per database.** `indexes`/`active_indexes` were keyed by + table name alone, so opening `PEOPLE` in one database silently activated an index defined + on a *different* database's `PEOPLE` — pointing the record order at a column that need not + even exist there, and breaking `BROWSE`/`LIST`. On first run, existing index definitions + are adopted into the one database that owns the table; definitions whose owner is ambiguous + (same table name in two databases) or missing are dropped and must be recreated with + `INDEX ON`. The underlying SQLite indexes are untouched. (#50) +- A bare `INPUT "prompt" TO ` typed at the REPL silently discarded the value: the + submitted form was only applied when a continuation existed, which is never the case for + a single statement. Values a form collects are now always stored. (#50) + +### Changed +- Removed the `input-request` / `input-response` WebSocket message types. They were declared + in the protocol but never sent or handled by anything — `INPUT` collects its value through + `form-open` / `form-submit`. (#50) +- New `npm run coverage` (vitest + v8, reporting only, no thresholds), so modules no test ever + executes stop hiding. (#50) --- diff --git a/CLAUDE.md b/CLAUDE.md index d96fd90..e3426f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ server/ SessionManager.ts Tracks all active sessions; broadcast() fans data-changed to peers viewing a mutated table ServerDatabaseBridge.ts IDatabaseBridge impl wrapping better-sqlite3 ProgramStore.ts .prg program storage in data/system.sqlite3 - IndexStore.ts Index metadata + active index in data/system.sqlite3 + IndexStore.ts Index metadata + active index per (db, table) in data/system.sqlite3 ColumnMetaStore.ts Declared column types per (db, table, column) in data/system.sqlite3 — SQLite affinity can't distinguish TIME/DATE/CHAR, LOGICAL/INT, or recover NUM(p,s) ReportStore.ts Report definition storage in data/system.sqlite3 (reports table) ReportRunner.ts ASCII and HTML report rendering, group breaks, subtotals, grand totals @@ -115,6 +115,10 @@ tests/ ColumnMeta.test.ts NUM(p,s) parsing, declared types in LIST STRUCTURE, grid-open columnTypes, server-side grid-edit validation ColumnMetaStore.test.ts Per-(db,table,column) type metadata + legacy-schema migration CellValidation.test.ts Shared per-type cell validation rules + CreateTableParse.test.ts Strict CREATE TABLE grammar — malformed column lists must throw + DemoSchemas.test.ts Golden column lists for every table the demos create + GridMessages.test.ts grid-edit / grid-delete / grid-new-row / grid-refresh + INPUT form round-trip + IndexStoreMigration.test.ts Adopting pre-#50 unscoped index rows into their owning database Print.test.ts `?` / `??` print command Aggregate.test.ts `SUM` / `AVERAGE` Builtins.test.ts / BuiltinsParse.test.ts built-in functions (direct + through the parser) @@ -315,6 +319,8 @@ line. qualifier validated on write; declared types tracked in `server/ColumnMetaStore.ts` (#43) ✅ - ~~`WEEK()` built-in~~ — ISO-8601 week number (#44) ✅ - ~~BROWSE per-cell validation~~ — grid rejects invalid edits per column type, validated on both client and server via `src/shared/cellValidation.ts` (#45) ✅ +- ~~Test hardening~~ — strict `CREATE TABLE` grammar, golden demo schemas, coverage for every + grid WS message, per-database index/column metadata scoping, `npm run coverage` (#50) ✅ - `demos/overtime.prg` — overtime tracker showcasing all three of the above (#46) ## Boolean literals @@ -324,11 +330,37 @@ Both styles accepted: `TRUE`/`FALSE` and `.T.`/`.TRUE.`/`.F.`/`.FALSE.` (dBASE I ## Testing ```bash -npm test # Vitest unit + integration (316 tests) +npm test # Vitest unit + integration (358 tests) +npm run coverage # Vitest + v8 coverage report (reporting only, no thresholds) npx playwright test # E2E browser tests — requires dev server on :5173/:3000 ``` -Playwright suites (79 tests): `tests/assistant.spec.ts` (22 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). +Playwright suites (83 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (5 tests — `?`/`??`, built-in functions, `WEEK()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block). + +## Test discipline + +Two bugs shipped through a 283-test suite (found in #45/#50). Both were structural blind +spots, not bad luck. When adding tests, remember what the existing ones cannot see: + +- **`toContain` can only prove presence, never absence.** Almost every assertion in this + repo greps rendered text for a substring, so a *phantom extra column* (`NUM(8,2)` used to + create a column literally named `2`) sailed through every `LIST`/`LIST STRUCTURE` check. + Assert **exact** structure — column lists, record counts — with `toEqual`/`toHaveLength` + wherever you can. `tests/DemoSchemas.test.ts` pins the demo tables for exactly this reason. +- **Test the surface, not the happy path through it.** Four of twelve `ClientMessage` types + had zero tests; `grid-edit` wrote straight to SQLite with no validation and nobody noticed, + because the grid tests only opened the grid and pressed Escape. Every WS message type + should have a test that drives it and asserts the database/UI effect + (`tests/GridMessages.test.ts`). +- **Green CI does not mean correct.** The cross-database `ColumnMetaStore` leak shipped with + seven passing tests, because they all used a single database. When state is keyed by name, + write the test that uses two. +- **Prefer failing loudly to guessing.** The parser used to absorb any token it didn't + understand and invent a column from it. `CREATE TABLE` is now strict; keep it that way. + +Run `npm run coverage` when touching an area you suspect is untested. **Never run `npm test` +and `npx playwright test` concurrently** — both mutate `data/` and `data/system.sqlite3`, and +a state-dependent e2e test will fail for reasons that have nothing to do with your change. ## Definition of done diff --git a/README.md b/README.md index d2c732c..2a7cd11 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ WebBase-III supports **unlimited work areas** — each independently holding a t > Column ops that can invalidate an index (DROP, RENAME, ALTER type) drop all of the table's indexes and warn you to rebuild with `INDEX ON`. +> `CREATE TABLE` rejects a malformed column list (missing comma, missing type, unclosed paren, a third +> type argument) with a parse error naming the offending column, and creates nothing. + **Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM`/`NUM(p,s)` (`NUMERIC`/`FLOAT`/`DOUBLE`/`DECIMAL`), `INT`/`INTEGER`, `LOGICAL`/`BOOLEAN`, `DATE`, and `TIME`/`TIME(n)`. `TIME` stores `HH:MM` (24-hour); the optional `TIME(n)` qualifier (e.g. `TIME(15)`) requires minutes to be a multiple of `n`. `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value instead of silently coercing it, and `LIST STRUCTURE` prints the declared type (`NUM(8,2)`, `TIME(15)`) rather than SQLite's storage class. > **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless, diff --git a/package-lock.json b/package-lock.json index 7534dfc..7688817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", "tsx": "^4.22.4", "typescript": "^5.4.5", @@ -23,22 +24,82 @@ "vitest": "^4.1.8" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -47,9 +108,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -499,6 +560,16 @@ "node": ">=12" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -506,15 +577,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -526,9 +608,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -552,9 +634,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -569,9 +651,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -586,9 +668,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -603,9 +685,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -620,9 +702,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -637,9 +719,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -654,9 +736,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -671,9 +753,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -688,9 +770,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -705,9 +787,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -722,9 +804,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -739,9 +821,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -756,9 +838,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -766,18 +848,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -792,9 +874,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1173,9 +1255,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1238,17 +1320,48 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1257,9 +1370,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1270,13 +1383,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1284,14 +1397,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1300,9 +1413,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1310,13 +1423,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1360,6 +1473,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1722,6 +1847,23 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1754,6 +1896,65 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2025,6 +2226,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2127,9 +2356,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2187,9 +2416,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -2282,13 +2511,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2298,21 +2527,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rollup": { @@ -3197,19 +3426,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3237,12 +3466,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3287,9 +3516,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -3305,9 +3534,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -3323,9 +3552,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -3341,9 +3570,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -3359,9 +3588,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -3377,9 +3606,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -3395,9 +3624,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -3413,9 +3642,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -3431,9 +3660,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -3449,9 +3678,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -3467,9 +3696,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -3485,9 +3714,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -3503,9 +3732,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -3521,9 +3750,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -3539,9 +3768,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -3557,9 +3786,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -3575,9 +3804,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -3592,10 +3821,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -3610,10 +3857,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -3628,10 +3893,28 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -3647,9 +3930,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -3665,9 +3948,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -3683,9 +3966,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -3701,13 +3984,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3728,9 +4011,9 @@ } }, "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3743,45 +4026,45 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/vitest/node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -3798,7 +4081,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index f6f2f6f..8f2a160 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,15 @@ "build": "tsc --noEmit && vite build", "serve": "npm run build && tsx server/index.ts", "test": "vitest run --config vitest.config.ts", - "clean:data": "node scripts/clean-data.mjs" + "clean:data": "node scripts/clean-data.mjs", + "coverage": "vitest run --config vitest.config.ts --coverage" }, "devDependencies": { "@playwright/test": "^1.60.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.9.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", "tsx": "^4.22.4", "typescript": "^5.4.5", diff --git a/server/IndexStore.ts b/server/IndexStore.ts index 1d9951c..8c53c28 100644 --- a/server/IndexStore.ts +++ b/server/IndexStore.ts @@ -6,71 +6,187 @@ import type { IIndexStore, IndexDef } from '../src/shared/types.js'; const DATA_DIR = path.join(process.cwd(), 'data'); const DB_PATH = path.join(DATA_DIR, 'system.sqlite3'); +/** + * Index metadata, keyed by (database, table, tag). + * + * Scoping by database matters: two databases may each hold a table of the same + * name. Before v1.2.0 (#50) the key omitted the database, so opening `PEOPLE` in + * one database silently activated an index defined on another database's + * `PEOPLE` — pointing the record order at a column that need not even exist. + */ export class IndexStore implements IIndexStore { private db: Database.Database; - constructor(dbPath = DB_PATH) { + constructor(dbPath = DB_PATH, dataDir = DATA_DIR) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); this.db = new Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.exec(` CREATE TABLE IF NOT EXISTS indexes ( id INTEGER PRIMARY KEY, + db_name TEXT NOT NULL DEFAULT '', table_name TEXT NOT NULL, tag TEXT NOT NULL, expression TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()), - UNIQUE(table_name, tag) + UNIQUE(db_name, table_name, tag) ); CREATE TABLE IF NOT EXISTS active_indexes ( - table_name TEXT PRIMARY KEY, - tag TEXT NOT NULL + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (db_name, table_name) ); `); + this.addDbNameColumn(dataDir); + this.migrateUnscoped(dataDir); + } + + /** + * A pre-#50 system.sqlite3 has `indexes`/`active_indexes` without db_name (and + * with the wrong key). CREATE TABLE IF NOT EXISTS leaves those alone, so rebuild + * them here, carrying every row across with an empty db_name for migrateUnscoped + * to adopt. + */ + private addDbNameColumn(_dataDir: string): void { + const hasCol = (t: string) => + (this.db.prepare(`PRAGMA table_info(${t})`).all() as { name: string }[]) + .some(c => c.name === 'db_name'); + if (hasCol('indexes') && hasCol('active_indexes')) return; + + this.db.transaction(() => { + if (!hasCol('indexes')) { + this.db.exec(` + CREATE TABLE indexes_new ( + id INTEGER PRIMARY KEY, + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + expression TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + UNIQUE(db_name, table_name, tag) + ); + INSERT INTO indexes_new (db_name, table_name, tag, expression) + SELECT '', table_name, tag, expression FROM indexes; + DROP TABLE indexes; + ALTER TABLE indexes_new RENAME TO indexes; + `); + } + if (!hasCol('active_indexes')) { + this.db.exec(` + CREATE TABLE active_indexes_new ( + db_name TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (db_name, table_name) + ); + INSERT INTO active_indexes_new (db_name, table_name, tag) + SELECT '', table_name, tag FROM active_indexes; + DROP TABLE active_indexes; + ALTER TABLE active_indexes_new RENAME TO active_indexes; + `); + } + })(); + } + + /** + * Pre-#50 rows carry no db_name. Index definitions are not re-derivable (only + * `INDEX ON` creates them), so rather than discard them, adopt each row into the + * one database that actually owns a table of that name. Rows whose owner is + * ambiguous or gone are dropped — keeping them unscoped is what caused the bug. + */ + private migrateUnscoped(dataDir: string): void { + const hasLegacy = (this.db.prepare( + `SELECT COUNT(*) AS n FROM indexes WHERE db_name = ''` + ).get() as { n: number }).n > 0; + if (!hasLegacy) return; + + const owners = new Map(); // TABLE (upper) → [dbName] + if (fs.existsSync(dataDir)) { + for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.sqlite3') || f === 'system.sqlite3') continue; + const dbName = f.slice(0, -8); + try { + const user = new Database(path.join(dataDir, f), { readonly: true }); + const tables = user.prepare( + "SELECT name FROM sqlite_master WHERE type='table'" + ).all() as { name: string }[]; + user.close(); + for (const t of tables) { + const key = t.name.toUpperCase(); + owners.set(key, [...(owners.get(key) ?? []), dbName]); + } + } catch { /* unreadable file — skip, its rows become ambiguous */ } + } + } + + const legacy = this.db.prepare( + `SELECT table_name, tag FROM indexes WHERE db_name = ''` + ).all() as { table_name: string; tag: string }[]; + + const adopt = this.db.prepare( + `UPDATE indexes SET db_name = ? WHERE db_name = '' AND table_name = ?` + ); + const adoptActive = this.db.prepare( + `UPDATE active_indexes SET db_name = ? WHERE db_name = '' AND table_name = ?` + ); + const migrate = this.db.transaction(() => { + for (const row of legacy) { + const cands = owners.get(row.table_name.toUpperCase()) ?? []; + if (cands.length === 1) { + adopt.run(cands[0], row.table_name); + adoptActive.run(cands[0], row.table_name); + } + } + this.db.prepare(`DELETE FROM indexes WHERE db_name = ''`).run(); + this.db.prepare(`DELETE FROM active_indexes WHERE db_name = ''`).run(); + }); + migrate(); } - saveIndex(tableName: string, tag: string, expression: string): void { + saveIndex(dbName: string, tableName: string, tag: string, expression: string): void { this.db.prepare(` - INSERT INTO indexes (table_name, tag, expression) - VALUES (?, ?, ?) - ON CONFLICT(table_name, tag) DO UPDATE SET expression = excluded.expression - `).run(tableName, tag, expression); + INSERT INTO indexes (db_name, table_name, tag, expression) + VALUES (?, ?, ?, ?) + ON CONFLICT(db_name, table_name, tag) DO UPDATE SET expression = excluded.expression + `).run(dbName, tableName, tag, expression); } - listIndexes(tableName: string): IndexDef[] { + listIndexes(dbName: string, tableName: string): IndexDef[] { return this.db.prepare( - 'SELECT tag, expression FROM indexes WHERE table_name = ? ORDER BY tag' - ).all(tableName) as IndexDef[]; + 'SELECT tag, expression FROM indexes WHERE db_name = ? AND table_name = ? ORDER BY tag' + ).all(dbName, tableName) as IndexDef[]; } - getActive(tableName: string): IndexDef | null { + getActive(dbName: string, tableName: string): IndexDef | null { const row = this.db.prepare(` SELECT i.tag, i.expression FROM active_indexes a - JOIN indexes i ON i.table_name = a.table_name AND i.tag = a.tag - WHERE a.table_name = ? - `).get(tableName) as IndexDef | undefined; + JOIN indexes i ON i.db_name = a.db_name AND i.table_name = a.table_name AND i.tag = a.tag + WHERE a.db_name = ? AND a.table_name = ? + `).get(dbName, tableName) as IndexDef | undefined; return row ?? null; } - setActive(tableName: string, tag: string): void { + setActive(dbName: string, tableName: string, tag: string): void { const exists = this.db.prepare( - 'SELECT 1 FROM indexes WHERE table_name = ? AND tag = ?' - ).get(tableName, tag); + 'SELECT 1 FROM indexes WHERE db_name = ? AND table_name = ? AND tag = ?' + ).get(dbName, tableName, tag); if (!exists) throw new Error(`Index '${tag}' not found on table '${tableName}'`); this.db.prepare(` - INSERT INTO active_indexes (table_name, tag) VALUES (?, ?) - ON CONFLICT(table_name) DO UPDATE SET tag = excluded.tag - `).run(tableName, tag); + INSERT INTO active_indexes (db_name, table_name, tag) VALUES (?, ?, ?) + ON CONFLICT(db_name, table_name) DO UPDATE SET tag = excluded.tag + `).run(dbName, tableName, tag); } - clearActive(tableName: string): void { - this.db.prepare('DELETE FROM active_indexes WHERE table_name = ?').run(tableName); + clearActive(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM active_indexes WHERE db_name = ? AND table_name = ?') + .run(dbName, tableName); } - dropTable(tableName: string): void { - this.db.prepare('DELETE FROM active_indexes WHERE table_name = ?').run(tableName); - this.db.prepare('DELETE FROM indexes WHERE table_name = ?').run(tableName); + dropTable(dbName: string, tableName: string): void { + this.db.prepare('DELETE FROM active_indexes WHERE db_name = ? AND table_name = ?').run(dbName, tableName); + this.db.prepare('DELETE FROM indexes WHERE db_name = ? AND table_name = ?').run(dbName, tableName); } } diff --git a/server/Session.ts b/server/Session.ts index b1724b6..fef543f 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -46,11 +46,14 @@ export class Session { await this.runCommand(msg.text); break; - case 'form-submit': + case 'form-submit': { + // Always store what the form collected. A bare `INPUT "…" TO var` at the + // REPL leaves no continuation (there is no following statement), and + // gating the assignment on one silently discarded the typed value. (#50) + for (const [k, v] of Object.entries(msg.values)) { + this.executor.setVar(k, v); + } if (this.pendingContinuation !== null) { - for (const [k, v] of Object.entries(msg.values)) { - this.executor.setVar(k, v); - } const cont = this.pendingContinuation; const fromProgram = this.pendingFromProgram; this.pendingContinuation = null; @@ -61,8 +64,12 @@ export class Session { } finally { if (fromProgram) this.executor.exitProgram(); } + } else { + this.send({ type: 'view-terminal' }); + this.sendStatus(); } break; + } case 'grid-edit': { const { rowid, col, value } = msg; @@ -138,8 +145,8 @@ export class Session { } if (area.table && await this.bridge.tableExists(area.table)) { columns = await this.bridge.getStructure(area.table); - const active = indexStore.getActive(area.table); - indexes = indexStore.listIndexes(area.table) + const active = indexStore.getActive(area.db ?? '', area.table); + indexes = indexStore.listIndexes(area.db ?? '', area.table) .map(i => ({ tag: i.tag, expression: i.expression, active: active?.tag === i.tag })); } } diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index acf1a4b..f155f03 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -92,8 +92,8 @@ export class Executor implements IndexCommandsHost { return this.areas.get(this.activeAlias)!; } - /** Database key for column metadata — same-named tables in different DBs must not collide. */ - private get metaDb(): string { + /** Database key for index/column metadata — same-named tables in different DBs must not collide. */ + get metaDb(): string { return this.area.db ?? ''; } @@ -238,7 +238,7 @@ export class Executor implements IndexCommandsHost { this.area.table = name; this.area.filter = null; this.area.rowPtr = 1; - this.area.activeIndex = this.indexStore?.getActive(name) ?? null; + this.area.activeIndex = this.indexStore?.getActive(this.metaDb, name) ?? null; const exists = await this.db.tableExists(name); const storage = this.area.opfsAvailable ? 'OPFS (persistent)' : 'server-side persistent'; const lines: OutputLine[] = [ @@ -889,7 +889,7 @@ export class Executor implements IndexCommandsHost { private async doDropTable(name: string): Promise { await this.db.exec(`DROP TABLE IF EXISTS ${q(name)}`); - this.indexStore?.dropTable(name); + this.indexStore?.dropTable(this.metaDb, name); this.columnMetaStore?.dropTable(this.metaDb, name); if (this.area.table === name) { this.area.table = null; @@ -991,12 +991,12 @@ export class Executor implements IndexCommandsHost { // Used by column ops that can invalidate an index expression. private async dropAllIndexes(table: string): Promise { if (!this.indexStore) return []; - const tags = this.indexStore.listIndexes(table).map(i => i.tag); + const tags = this.indexStore.listIndexes(this.metaDb, table).map(i => i.tag); for (const tag of tags) { const sqlName = `idx_${table}_${tag}`.replace(/"/g, '""'); await this.db.exec(`DROP INDEX IF EXISTS "${sqlName}"`); } - this.indexStore.dropTable(table); // clears metadata + active marker + this.indexStore.dropTable(this.metaDb, table); // clears metadata + active marker if (this.area.table === table) this.area.activeIndex = null; return tags; } diff --git a/src/interpreter/IndexCommands.ts b/src/interpreter/IndexCommands.ts index 79e71d9..db4aeb7 100644 --- a/src/interpreter/IndexCommands.ts +++ b/src/interpreter/IndexCommands.ts @@ -8,6 +8,8 @@ export interface IndexCommandsHost { readonly area: WorkArea; readonly activeAlias: string; readonly indexStore: IIndexStore | null; + /** Database key for index/column metadata — same-named tables in different DBs must not collide. */ + readonly metaDb: string; readonly db: IDatabaseBridge; evalExpr(e: Expr): unknown; requireTable(): void; @@ -23,7 +25,7 @@ export class IndexCommands { this.host.requireTable(); if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; - this.host.indexStore.saveIndex(table, tag, expression); + this.host.indexStore.saveIndex(this.host.metaDb, table, tag, expression); if (/^[A-Z_][A-Z0-9_]*$/i.test(expression.trim())) { try { await this.host.db.exec( @@ -31,7 +33,7 @@ export class IndexCommands { ); } catch { /* ignore — expression may not be a valid SQL column ref */ } } - this.host.indexStore.setActive(table, tag); + this.host.indexStore.setActive(this.host.metaDb, table, tag); this.host.area.activeIndex = { tag, expression }; return { output: [{ text: `Index created: ${tag} ON ${expression}`, cls: 'ok' }] }; } @@ -41,13 +43,13 @@ export class IndexCommands { if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; if (tag === null) { - this.host.indexStore.clearActive(table); + this.host.indexStore.clearActive(this.host.metaDb, table); this.host.area.activeIndex = null; return { output: [{ text: 'Active index cleared', cls: 'ok' }] }; } - const def = this.host.indexStore.listIndexes(table).find(i => i.tag.toUpperCase() === tag.toUpperCase()); + const def = this.host.indexStore.listIndexes(this.host.metaDb, table).find(i => i.tag.toUpperCase() === tag.toUpperCase()); if (!def) return { output: [{ text: `Index '${tag}' not found — use INDEX ON to create it`, cls: 'warn' }] }; - this.host.indexStore.setActive(table, def.tag); + this.host.indexStore.setActive(this.host.metaDb, table, def.tag); this.host.area.activeIndex = { tag: def.tag, expression: def.expression }; return { output: [{ text: `Index active: ${def.tag} (${def.expression})`, cls: 'ok' }] }; } @@ -62,7 +64,7 @@ export class IndexCommands { this.host.requireTable(); if (!this.host.indexStore) return { output: [{ text: '** IndexStore not available', cls: 'error' }] }; const table = this.host.area.table!; - const indexes = this.host.indexStore.listIndexes(table); + const indexes = this.host.indexStore.listIndexes(this.host.metaDb, table); if (!indexes.length) return { output: [{ text: '(No indexes defined)', cls: 'info' }] }; const out: OutputLine[] = [ { text: `Indexes for table: ${table}`, cls: 'hdr' }, diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts index db4a66f..0ae02b3 100644 --- a/src/interpreter/Parser.ts +++ b/src/interpreter/Parser.ts @@ -473,29 +473,64 @@ export class Parser { if (this.peek().type === 'LPAREN') { this.adv(); while (!this.end() && this.peek().type !== 'RPAREN') { - const cname = this.ident(); - const ctype = this.ident(); + const cname = this.colName(); + const ctype = this.colType(cname); let size: number | undefined; let scale: number | undefined; if (this.peek().type === 'LPAREN') { this.adv(); - size = this.tryNum() ?? undefined; + size = this.typeArg(cname); // NUM(p,s) — the second argument is the scale. Without consuming it the // comma ends the column and the scale is parsed as the next column name. if (this.peek().type === 'COMMA') { this.adv(); - scale = this.tryNum() ?? undefined; + scale = this.typeArg(cname); } - if (this.peek().type === 'RPAREN') this.adv(); + this.expectRParen(`type qualifier for column '${cname}'`); } cols.push({ name: cname, colType: ctype, size, scale }); if (this.peek().type === 'COMMA') this.adv(); + else break; // no comma → the list must end here } - if (this.peek().type === 'RPAREN') this.adv(); + this.expectRParen(`column list of table '${name}'`); } return { type: 'CREATE_TABLE', name, cols }; } + // ── CREATE TABLE column-list parsing ─────────────────────────────────────── + // Strict on purpose (#50). The old code called ident() — "take the next token, + // whatever it is" — so a stray ')' or ',' became a column name or a type, and + // NUM(8,2) silently produced a phantom column named "2" of type ")". + + private createErr(msg: string): never { + const t = this.peek(); + const at = t.type === 'EOF' ? 'end of input' : `'${t.val}'`; + throw new Error(`CREATE TABLE: ${msg} (at ${at}, line ${t.line})`); + } + + private colName(): string { + const t = this.peek(); + if (t.type !== 'ID' && t.type !== 'KW') this.createErr('expected a column name'); + return this.adv().val; + } + + private colType(colName: string): string { + const t = this.peek(); + if (t.type !== 'ID' && t.type !== 'KW') this.createErr(`expected a type for column '${colName}'`); + return this.adv().val; + } + + private typeArg(colName: string): number { + const n = this.tryNum(); + if (n === null) this.createErr(`expected a number in the type qualifier for column '${colName}'`); + return n; + } + + private expectRParen(what: string): void { + if (this.peek().type !== 'RPAREN') this.createErr(`expected ')' to close the ${what}`); + this.adv(); + } + private parseDrop(): ASTNode { this.adv(); this.skipKw('TABLE'); diff --git a/src/shared/types.ts b/src/shared/types.ts index 5118e4f..c9ce093 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,13 +41,14 @@ export interface IndexDef { expression: string; } +// Scoped by database: two databases can hold same-named tables with different indexes. export interface IIndexStore { - saveIndex(tableName: string, tag: string, expression: string): void; - listIndexes(tableName: string): IndexDef[]; - getActive(tableName: string): IndexDef | null; - setActive(tableName: string, tag: string): void; - clearActive(tableName: string): void; - dropTable(tableName: string): void; + saveIndex(dbName: string, tableName: string, tag: string, expression: string): void; + listIndexes(dbName: string, tableName: string): IndexDef[]; + getActive(dbName: string, tableName: string): IndexDef | null; + setActive(dbName: string, tableName: string, tag: string): void; + clearActive(dbName: string, tableName: string): void; + dropTable(dbName: string, tableName: string): void; } // Metadata for the column types SQLite's own affinity can't distinguish (TIME vs @@ -127,7 +128,6 @@ export interface Catalog { // Client → Server export type ClientMessage = | { type: 'command'; text: string } - | { type: 'input-response'; value: string } | { type: 'form-submit'; values: Record } | { type: 'grid-edit'; rowid: number; col: string; value: string } | { type: 'grid-delete'; rowid: number } @@ -143,7 +143,6 @@ export type ClientMessage = export type ServerMessage = | { type: 'output'; lines: OutputLine[] } | { type: 'status'; db: string | null; table: string | null; record: number; total: number } - | { type: 'input-request'; prompt: string } | { type: 'grid-open'; table: string; filter: string | null; columns: ColInfo[]; columnTypes: Record; rows: Record[] } | { type: 'modstruct-open'; table: string; columns: ColInfo[] } | { type: 'form-open'; fields: FormField[] } diff --git a/src/ui/wizards/TableWizard.ts b/src/ui/wizards/TableWizard.ts index 4ab08aa..12978c1 100644 --- a/src/ui/wizards/TableWizard.ts +++ b/src/ui/wizards/TableWizard.ts @@ -27,7 +27,18 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) if (!n) continue; // blank rows are skipped if (!NAME_RE.test(n)) return { cmd: null, err: `Invalid column name: ${n}` }; const t = r.type.value; - if (NEEDS_LEN.has(t)) { + if (t === 'NUM') { + // Accept a plain width ("8") or a precision,scale pair ("8,2"). + const raw = r.len.value.trim(); + const m = raw.match(/^(\d+)\s*(?:,\s*(\d+))?$/); + if (!m) return { cmd: null, err: `Length required for ${n} (NUM) — e.g. 8 or 8,2` }; + const p = parseInt(m[1], 10); + if (!p || p < 1) return { cmd: null, err: `Length required for ${n} (NUM)` }; + if (m[2] === undefined) { cols.push(`${n} NUM(${p})`); continue; } + const s = parseInt(m[2], 10); + if (s >= p) return { cmd: null, err: `Scale must be smaller than precision for ${n} (NUM)` }; + cols.push(`${n} NUM(${p},${s})`); + } else if (NEEDS_LEN.has(t)) { const len = parseInt(r.len.value, 10); if (!len || len < 1) return { cmd: null, err: `Length required for ${n} (${t})` }; cols.push(`${n} ${t}(${len})`); @@ -66,7 +77,7 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) type.appendChild(o); } const len = document.createElement('input'); - len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.style.minWidth = '50px'; len.style.width = '50px'; + len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.title = 'CHAR: length · NUM: width or precision,scale (8,2) · TIME: minute granularity'; len.style.minWidth = '56px'; len.style.width = '56px'; row.append(name, type, len); colsWrap.appendChild(row); rows.push({ name, type, len }); @@ -75,7 +86,7 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) shell = new WizardShell( 'New table', - 'Define columns; blank rows are ignored. CHAR and NUM need a length; TIME takes an optional minute-granularity (e.g. 15).', + 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15).', { okLabel: 'Create table', onOk: () => { const { cmd } = buildCommand(); if (cmd) { run(cmd); shell.close(); } diff --git a/tests/CreateTableParse.test.ts b/tests/CreateTableParse.test.ts new file mode 100644 index 0000000..5da91bd --- /dev/null +++ b/tests/CreateTableParse.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { Lexer } from '../src/interpreter/Lexer'; +import { Parser } from '../src/interpreter/Parser'; + +function parse(src: string) { + return new Parser(new Lexer(src).tokenize()).parse(); +} +function cols(src: string) { + return (parse(src)[0] as any).cols; +} + +// The parser used to absorb any token it did not understand and invent a column +// from it. That is how `NUM(8,2)` silently produced a phantom column named "2" +// of type ")". Malformed input must fail loudly instead. (#50) +describe('CREATE TABLE rejects malformed column definitions', () => { + it('rejects a missing comma between columns', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10) b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an empty column slot (double comma)', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10),, b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a column with no type', () => { + expect(() => parse('CREATE TABLE t (a)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an unclosed column list', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10)')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a third argument in a type qualifier', () => { + expect(() => parse('CREATE TABLE t (a NUM(8,2,9))')).toThrow(/CREATE TABLE/i); + }); + + it('rejects a non-numeric type qualifier', () => { + expect(() => parse('CREATE TABLE t (a CHAR(x))')).toThrow(/CREATE TABLE/i); + }); + + it('rejects an unclosed type qualifier', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10, b INT)')).toThrow(/CREATE TABLE/i); + }); + + it('names the offending column in the error', () => { + expect(() => parse('CREATE TABLE t (a CHAR(10) b INT)')).toThrow(/b/i); + }); +}); + +describe('CREATE TABLE still accepts every valid form', () => { + it('a bare table with no column list', () => { + expect((parse('CREATE TABLE t')[0] as any).cols).toEqual([]); + }); + + it('types with no qualifier', () => { + expect(cols('CREATE TABLE t (a DATE, b LOGICAL, c INT, d MEMO)')).toEqual([ + { name: 'A', colType: 'DATE' }, + { name: 'B', colType: 'LOGICAL' }, + { name: 'C', colType: 'INT' }, + { name: 'D', colType: 'MEMO' }, + ]); + }); + + it('single-argument qualifiers', () => { + expect(cols('CREATE TABLE t (a CHAR(40), b NUM(6), c TIME(15))')).toEqual([ + { name: 'A', colType: 'CHAR', size: 40 }, + { name: 'B', colType: 'NUM', size: 6 }, + { name: 'C', colType: 'TIME', size: 15 }, + ]); + }); + + it('two-argument NUM(p,s)', () => { + expect(cols('CREATE TABLE t (price NUM(8,2), active LOGICAL)')).toEqual([ + { name: 'PRICE', colType: 'NUM', size: 8, scale: 2 }, + { name: 'ACTIVE', colType: 'LOGICAL' }, + ]); + }); + + it('a trailing comma before the closing paren', () => { + // dBASE-era sources are sloppy; a trailing comma is harmless, not corrupting. + expect(cols('CREATE TABLE t (a INT,)')).toEqual([{ name: 'A', colType: 'INT' }]); + }); + + it('the exact demo-table definitions still parse to their declared columns', () => { + expect(cols('CREATE TABLE PRODUCTS (PRODID CHAR(6), CATID CHAR(4), NAME CHAR(40), STOCK NUM(6), REORDER NUM(6), PRICE NUM(8,2), ACTIVE LOGICAL)') + .map((c: any) => c.name)) + .toEqual(['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE']); + }); +}); diff --git a/tests/DemoSchemas.test.ts b/tests/DemoSchemas.test.ts new file mode 100644 index 0000000..a5f524a --- /dev/null +++ b/tests/DemoSchemas.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Session } from '../server/Session'; +import { Lexer } from '../src/interpreter/Lexer'; +import { Parser } from '../src/interpreter/Parser'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +/** + * Golden schemas for the tables the demo programs create. (#50) + * + * These are deliberate pins, not derived from the source — a demo schema change + * must be a conscious edit here too. They exist because `NUM(8,2)` used to add a + * phantom column named "2" to PRODUCTS/DEALS/SALES and every `toContain`-style + * assertion in the suite sailed straight past it. + */ +const DEMO_SCHEMAS: Record = { + COMPANIES: ['COMPID', 'NAME', 'INDUSTRY', 'CITY'], + CONTACTS: ['CONTID', 'COMPID', 'NAME', 'EMAIL', 'PHONE'], + DEALS: ['DEALID', 'COMPID', 'TITLE', 'STAGE', 'VALUE', 'CLOSEMONTH'], + CATEGORIES: ['CATID', 'CATNAME', 'NOTES'], + PRODUCTS: ['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE'], + MOVEMENTS: ['MOVID', 'PRODID', 'KIND', 'QTY', 'MMONTH', 'REASON'], + SALES: ['REGION', 'PRODUCT', 'AMOUNT', 'QTY'], +}; + +const DEMOS_DIR = path.join(process.cwd(), 'demos'); + +/** Every `CREATE TABLE …` statement written in demos/*.prg, keyed by table name. */ +function demoCreateStatements(): Map { + const out = new Map(); + for (const f of fs.readdirSync(DEMOS_DIR).filter(f => f.toLowerCase().endsWith('.prg'))) { + const src = fs.readFileSync(path.join(DEMOS_DIR, f), 'utf8'); + for (const line of src.split('\n')) { + const m = line.trim().match(/^CREATE\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/i); + if (m) out.set(m[1].toUpperCase(), line.trim()); + } + } + return out; +} + +let dbCounter = 0; +function uniqueDb() { return `test_demoschema_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_demoschema_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +describe('demo table schemas', () => { + const statements = demoCreateStatements(); + + it('every pinned table is actually created by a demo program', () => { + expect([...statements.keys()].sort()).toEqual(Object.keys(DEMO_SCHEMAS).sort()); + }); + + for (const [table, expectedCols] of Object.entries(DEMO_SCHEMAS)) { + it(`${table} parses to exactly its declared columns`, () => { + const stmt = statements.get(table); + expect(stmt, `no CREATE TABLE ${table} found in demos/*.prg`).toBeDefined(); + const ast = new Parser(new Lexer(stmt!).tokenize()).parse()[0] as any; + expect(ast.cols.map((c: any) => c.name)).toEqual(expectedCols); + }); + + it(`${table} creates exactly its declared columns in SQLite`, async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + await session.handleMessage({ type: 'command', text: `USE DATABASE ${uniqueDb()}` }); + await session.handleMessage({ type: 'command', text: statements.get(table)! }); + await session.handleMessage({ type: 'command', text: `USE ${table}` }); + + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.columns.map((c: any) => c.name)).toEqual(expectedCols); + }); + } + + it('no demo table has a column whose name is a bare number', () => { + // The phantom-column signature: NUM(8,2) leaked a column literally named "2". + for (const [table, stmt] of statements) { + const ast = new Parser(new Lexer(stmt).tokenize()).parse()[0] as any; + for (const c of ast.cols) { + expect(/^\d+$/.test(c.name), `${table} has a numeric column name "${c.name}"`).toBe(false); + } + } + }); +}); diff --git a/tests/GridMessages.test.ts b/tests/GridMessages.test.ts new file mode 100644 index 0000000..dd54155 --- /dev/null +++ b/tests/GridMessages.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { Session } from '../server/Session'; +import type { ServerMessage } from '../src/shared/types'; +import fs from 'fs'; +import path from 'path'; + +/** + * The grid's write path had no test at all before #50: `grid-edit`, `grid-delete`, + * `grid-new-row` and `grid-refresh` were never sent by any test, so `grid-edit` + * could `UPDATE` any column with any value unnoticed. These drive each message and + * assert the effect on the database. + */ +let dbCounter = 0; +function uniqueDb() { return `test_gridmsg_${Date.now()}_${++dbCounter}`; } + +afterEach(() => { + const dataDir = path.join(process.cwd(), 'data'); + if (fs.existsSync(dataDir)) { + fs.readdirSync(dataDir) + .filter(f => f.toLowerCase().startsWith('test_gridmsg_')) + .forEach(f => fs.unlinkSync(path.join(dataDir, f))); + } +}); + +async function setup() { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + const run = async (text: string) => { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text).join('\n'); + }; + await run(`USE DATABASE ${uniqueDb()}`); + await run('CREATE TABLE t (name CHAR(20), qty INT)'); + await run('USE t'); + return { session, sent, run }; +} + +/** Open the grid and return its rows. */ +async function browse(session: Session, sent: ServerMessage[]) { + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + return grid; +} + +describe('grid WebSocket messages', () => { + it('grid-new-row inserts a blank record and returns the refreshed grid', async () => { + const { session, sent, run } = await setup(); + await browse(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-new-row' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.rows).toHaveLength(1); + expect(grid.rows[0].NAME).toBeNull(); + + expect(await run('LIST')).not.toContain('(No records)'); + }); + + it('grid-edit writes the value to the right row and column', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await run('APPEND RECORD'); + const grid = await browse(session, sent); + const secondRowId = grid.rows[1]._rowid; + + await session.handleMessage({ type: 'grid-edit', rowid: secondRowId, col: 'NAME', value: 'second' }); + + const after = await browse(session, sent); + expect(after.rows[0].NAME).toBeNull(); // first row untouched + expect(after.rows[1].NAME).toBe('second'); + }); + + it('grid-delete removes only the targeted row', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await run('REPLACE name WITH "keep"'); + await run('APPEND RECORD'); + await run('REPLACE name WITH "drop"'); + + const grid = await browse(session, sent); + const dropId = grid.rows.find((r: any) => r.NAME === 'drop')._rowid; + + sent.length = 0; + await session.handleMessage({ type: 'grid-delete', rowid: dropId }); + const refreshed = sent.find(m => m.type === 'grid-open') as any; + expect(refreshed.rows).toHaveLength(1); + expect(refreshed.rows[0].NAME).toBe('keep'); + + expect(await run('LIST')).not.toContain('drop'); + }); + + it('grid-refresh re-reads the table after an out-of-band change', async () => { + const { session, sent, run } = await setup(); + await run('APPEND RECORD'); + await browse(session, sent); + + // Mutate through the REPL while the grid is open. + await run('REPLACE name WITH "changed"'); + + sent.length = 0; + await session.handleMessage({ type: 'grid-refresh' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + expect(grid.rows[0].NAME).toBe('changed'); + }); + + it('grid-edit is rejected when it violates the declared column type', async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + const run = async (text: string) => { + sent.length = 0; + await session.handleMessage({ type: 'command', text }); + const out = sent.find(m => m.type === 'output') as any; + return (out?.lines ?? []).map((l: any) => l.text).join('\n'); + }; + await run(`USE DATABASE ${uniqueDb()}`); + await run('CREATE TABLE s (shift TIME(15))'); + await run('USE s'); + await run('APPEND RECORD'); + const grid = await browse(session, sent); + + sent.length = 0; + await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'SHIFT', value: '08:07' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/multiple of 15/); + expect(await run('LIST')).not.toContain('08:07'); + }); +}); + +describe('INPUT command', () => { + // `INPUT` collects its value through the form surface (form-open / form-submit). + // The `input-request`/`input-response` message types were declared in the protocol + // but never sent or handled by anything; they were removed in #50. + it('opens a form and stores the submitted value in the variable', async () => { + const sent: ServerMessage[] = []; + const session = new Session((m) => sent.push(m)); + + await session.handleMessage({ type: 'command', text: 'INPUT "Name? " TO who' }); + const form = sent.find(m => m.type === 'form-open') as any; + expect(form).toBeDefined(); + expect(form.fields.at(-1)).toMatchObject({ varName: 'WHO', label: 'Name? ' }); + + await session.handleMessage({ type: 'form-submit', values: { WHO: 'Ada' } }); + + sent.length = 0; + await session.handleMessage({ type: 'command', text: '? who' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toContain('Ada'); + }); +}); diff --git a/tests/IndexStoreMigration.test.ts b/tests/IndexStoreMigration.test.ts new file mode 100644 index 0000000..bfcbe3b --- /dev/null +++ b/tests/IndexStoreMigration.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { IndexStore } from '../server/IndexStore'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const dirs: string[] = []; +function workspace(): { sysPath: string; dataDir: string } { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wb3-idxmig-')); + dirs.push(dataDir); + return { sysPath: path.join(dataDir, 'system.sqlite3'), dataDir }; +} +function legacySystemDb(sysPath: string, rows: [string, string, string][]) { + const d = new Database(sysPath); + d.exec(` + CREATE TABLE indexes ( + id INTEGER PRIMARY KEY, table_name TEXT NOT NULL, tag TEXT NOT NULL, + expression TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()), + UNIQUE(table_name, tag) + ); + CREATE TABLE active_indexes (table_name TEXT PRIMARY KEY, tag TEXT NOT NULL); + `); + for (const [t, tag, expr] of rows) { + d.prepare('INSERT INTO indexes (table_name, tag, expression) VALUES (?,?,?)').run(t, tag, expr); + d.prepare('INSERT OR REPLACE INTO active_indexes (table_name, tag) VALUES (?,?)').run(t, tag); + } + d.close(); +} +function userDbWithTable(dataDir: string, dbName: string, table: string) { + const d = new Database(path.join(dataDir, `${dbName}.sqlite3`)); + d.exec(`CREATE TABLE "${table}" (x TEXT)`); + d.close(); +} + +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); + +describe('IndexStore migration from the pre-#50 unscoped schema', () => { + it('adopts a legacy index into the one database that owns the table', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('HRDB', 'PEOPLE')).toEqual([{ tag: 'BYNAME', expression: 'LASTNAME' }]); + expect(store.getActive('HRDB', 'PEOPLE')).toEqual({ tag: 'BYNAME', expression: 'LASTNAME' }); + expect(store.listIndexes('', 'PEOPLE')).toEqual([]); // no unscoped rows survive + }); + + it('drops a legacy index whose owning database is ambiguous', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + userDbWithTable(dataDir, 'CRMDB', 'PEOPLE'); // two owners → ambiguous + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('HRDB', 'PEOPLE')).toEqual([]); + expect(store.listIndexes('CRMDB', 'PEOPLE')).toEqual([]); + }); + + it('drops a legacy index whose table no longer exists anywhere', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['GHOST', 'BYNAME', 'LASTNAME']]); + + const store = new IndexStore(sysPath, dataDir); + expect(store.listIndexes('', 'GHOST')).toEqual([]); + expect(store.getActive('', 'GHOST')).toBeNull(); + }); + + it('is idempotent — reopening an already-migrated store keeps the rows', () => { + const { sysPath, dataDir } = workspace(); + legacySystemDb(sysPath, [['PEOPLE', 'BYNAME', 'LASTNAME']]); + userDbWithTable(dataDir, 'HRDB', 'PEOPLE'); + + new IndexStore(sysPath, dataDir); + const reopened = new IndexStore(sysPath, dataDir); + expect(reopened.listIndexes('HRDB', 'PEOPLE')).toEqual([{ tag: 'BYNAME', expression: 'LASTNAME' }]); + }); +}); diff --git a/tests/Indexing.test.ts b/tests/Indexing.test.ts index 2e15856..a837950 100644 --- a/tests/Indexing.test.ts +++ b/tests/Indexing.test.ts @@ -25,8 +25,8 @@ afterEach(() => { describe('IndexStore', () => { it('saves and retrieves an index definition', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname+firstname'); - const indexes = store.listIndexes('customers'); + store.saveIndex('DB', 'customers', 'byname', 'lastname+firstname'); + const indexes = store.listIndexes('DB', 'customers'); expect(indexes).toHaveLength(1); expect(indexes[0].tag).toBe('byname'); expect(indexes[0].expression).toBe('lastname+firstname'); @@ -34,36 +34,61 @@ describe('IndexStore', () => { it('sets and gets active index', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.setActive('customers', 'byname'); - expect(store.getActive('customers')).toEqual({ tag: 'byname', expression: 'lastname' }); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.setActive('DB', 'customers', 'byname'); + expect(store.getActive('DB', 'customers')).toEqual({ tag: 'byname', expression: 'lastname' }); }); it('clears active index', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.setActive('customers', 'byname'); - store.clearActive('customers'); - expect(store.getActive('customers')).toBeNull(); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.setActive('DB', 'customers', 'byname'); + store.clearActive('DB', 'customers'); + expect(store.getActive('DB', 'customers')).toBeNull(); }); it('returns null getActive when no index set', () => { const store = new IndexStore(tmpPath()); - expect(store.getActive('customers')).toBeNull(); + expect(store.getActive('DB', 'customers')).toBeNull(); }); it('upserts index definition on duplicate tag', () => { const store = new IndexStore(tmpPath()); - store.saveIndex('customers', 'byname', 'lastname'); - store.saveIndex('customers', 'byname', 'firstname'); - const indexes = store.listIndexes('customers'); + store.saveIndex('DB', 'customers', 'byname', 'lastname'); + store.saveIndex('DB', 'customers', 'byname', 'firstname'); + const indexes = store.listIndexes('DB', 'customers'); expect(indexes).toHaveLength(1); expect(indexes[0].expression).toBe('firstname'); }); it('setActive throws when tag does not exist', () => { const store = new IndexStore(tmpPath()); - expect(() => store.setActive('customers', 'ghost')).toThrow("Index 'ghost' not found on table 'customers'"); + expect(() => store.setActive('DB', 'customers', 'ghost')).toThrow("Index 'ghost' not found on table 'customers'"); + }); + + // #50 — the key used to omit the database, so opening PEOPLE in one database + // activated an index defined on another database's PEOPLE. + it('scopes index definitions and the active marker per database', () => { + const store = new IndexStore(tmpPath()); + store.saveIndex('A', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.setActive('A', 'PEOPLE', 'BYNAME'); + + expect(store.listIndexes('B', 'PEOPLE')).toEqual([]); + expect(store.getActive('B', 'PEOPLE')).toBeNull(); + expect(store.getActive('A', 'PEOPLE')).toEqual({ tag: 'BYNAME', expression: 'LASTNAME' }); + + store.saveIndex('B', 'PEOPLE', 'BYFULL', 'FULLNAME'); + store.setActive('B', 'PEOPLE', 'BYFULL'); + expect(store.getActive('A', 'PEOPLE')?.tag).toBe('BYNAME'); // unchanged + }); + + it('dropTable only clears the named database', () => { + const store = new IndexStore(tmpPath()); + store.saveIndex('A', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.saveIndex('B', 'PEOPLE', 'BYNAME', 'LASTNAME'); + store.dropTable('A', 'PEOPLE'); + expect(store.listIndexes('A', 'PEOPLE')).toEqual([]); + expect(store.listIndexes('B', 'PEOPLE')).toHaveLength(1); }); }); diff --git a/tests/assistant.spec.ts b/tests/assistant.spec.ts index 9e347e5..e69f106 100644 --- a/tests/assistant.spec.ts +++ b/tests/assistant.spec.ts @@ -167,6 +167,43 @@ test.describe('Assistant wizards — table', () => { await page.waitForTimeout(400); await expect(page.locator('#terminal-output')).toContainText('08:15'); }); + + // #50 — NUM(p,s) is a real qualifier now, so the wizard must be able to express it. + test('New table wizard emits NUM(p,s) and the table has exactly the declared columns', async ({ page }) => { + await boot(page); + for (const c of ['USE DATABASE ASSISTDEMO', 'DROP TABLE wiz_priced']) { + await page.locator('#terminal-input').fill(c); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + } + + await clickAction(page, 'New table…'); + await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#wz-table-name').fill('wiz_priced'); + await page.locator('.wz-col-name').first().fill('PRICE'); + await page.locator('.wz-col-type').first().selectOption('NUM'); + await page.locator('.wz-col-len').first().fill('8,2'); + await expect(page.locator('.wz-preview')).toContainText('CREATE TABLE wiz_priced (PRICE NUM(8,2))'); + + // Scale must be smaller than precision — the wizard blocks it. + await page.locator('.wz-col-len').first().fill('2,8'); + await expect(page.locator('.wz-error')).toContainText('Scale must be smaller'); + + await page.locator('.wz-col-len').first().fill('8,2'); + await page.locator('#wizard-view button', { hasText: 'Create table' }).click(); + await expect(page.locator('#terminal-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#terminal-input').fill('LIST STRUCTURE'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(500); + await expect(page.locator('#terminal-output')).toContainText('NUM(8,2)'); + + // Exactly one column — no phantom "2" from the scale. + const lines = await page.locator('#terminal-output .t-line').allTextContents(); + const numbered = lines.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l)); + expect(numbered).toHaveLength(1); + }); }); test.describe('Assistant wizards — filter / index / search', () => { diff --git a/tests/schema-errors.spec.ts b/tests/schema-errors.spec.ts new file mode 100644 index 0000000..71da012 --- /dev/null +++ b/tests/schema-errors.spec.ts @@ -0,0 +1,70 @@ +/** #50 — CREATE TABLE fails loudly on malformed input instead of inventing columns. */ +import { test, expect, Page } from '@playwright/test'; + +async function cmd(page: Page, command: string, waitMs = 600): Promise { + const input = page.locator('#terminal-input'); + await input.fill(command); + await input.press('Enter'); + await page.waitForTimeout(waitMs); +} + +async function boot(page: Page, db: string): Promise { + await page.goto('/'); + await expect(page.locator('#terminal-output')).toContainText('Connected.', { timeout: 8000 }); + await cmd(page, `USE DATABASE ${db}`); +} + +test.describe('CREATE TABLE schema errors', () => { + test('a malformed column list reports an error in the REPL', async ({ page }) => { + await boot(page, `e2e_schema_err_${Date.now()}`); + + await cmd(page, 'CREATE TABLE bad (a CHAR(10) b INT)'); // missing comma + await expect(page.locator('#terminal-output')).toContainText('Parse error'); + await expect(page.locator('#terminal-output')).toContainText("expected ')'"); + await expect(page.locator('#terminal-output')).toContainText("table 'BAD'"); + + // The table must not exist — a failed parse creates nothing. + await cmd(page, 'LIST TABLES'); + await expect(page.locator('#terminal-output')).toContainText('(No tables)'); + }); + + test('NUM(p,s) creates exactly the declared columns — no phantom "2"', async ({ page }) => { + await boot(page, `e2e_schema_nps_${Date.now()}`); + + await cmd(page, 'CREATE TABLE prod (name CHAR(20), price NUM(8,2), active LOGICAL)'); + await cmd(page, 'USE prod'); + await cmd(page, 'LIST STRUCTURE', 900); + + const text = await page.locator('#terminal-output').textContent() ?? ''; + expect(text).toContain('NAME'); + expect(text).toContain('PRICE'); + expect(text).toContain('ACTIVE'); + expect(text).toContain('NUM(8,2)'); + + // Exactly three columns: the structure listing numbers them 1..n. + const rows = await page.locator('#terminal-output .t-line').allTextContents(); + const numbered = rows.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l)); + expect(numbered).toHaveLength(3); + }); +}); + +test.describe('INPUT at the REPL', () => { + // A bare `INPUT … TO var` produces no continuation, and the submitted value used + // to be discarded. It only worked inside a program, where a following statement + // happened to create one. (#50) + test('a bare INPUT stores the submitted value in the variable', async ({ page }) => { + await boot(page, `e2e_input_${Date.now()}`); + + await cmd(page, 'INPUT "Name? " TO who'); + await expect(page.locator('#form-view')).toBeVisible({ timeout: 5000 }); + + const field = page.locator('#form-view input.f-get').last(); + await field.fill('Ada'); + await field.press('Enter'); + await expect(page.locator('#form-view')).toBeHidden({ timeout: 5000 }); + + await cmd(page, '? who'); + const last = await page.locator('#terminal-output .t-line').last().textContent(); + expect(last?.trim()).toBe('Ada'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8363e16..b14efea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,14 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + // Reporting only — no thresholds. `npm run coverage` exists so untested + // modules stop hiding: two bugs shipped in code no test ever executed (#50). + coverage: { + provider: 'v8', + reporter: ['text-summary', 'html'], + reportsDirectory: 'coverage', + include: ['src/**/*.ts', 'server/**/*.ts'], + exclude: ['src/main.ts', '**/*.d.ts'], + }, }, });