From 5f33c90c3f8c9b2eb7088b51bdcf5a3c3723dc82 Mon Sep 17 00:00:00 2001 From: MURANGWA Date: Tue, 29 Sep 2026 15:22:13 +0200 Subject: [PATCH] Add the KinDB engine and CI Ship Kinyarwanda SQL with CRUD, selection, and inner join over 512-byte page files, and print hitamo results as tables. GitHub Actions builds the package and runs the test suite on pull requests and on main. --- .github/workflows/ci.yml | 31 + .gitignore | 4 + LICENSE | 21 + README.md | 170 +++++ examples/huza.kindb | 15 + examples/ishuri.dbclear | 4 + examples/ishuri.kindb | 14 + package-lock.json | 1260 +++++++++++++++++++++++++++++++++++++ package.json | 50 ++ src/cli.ts | 245 ++++++++ src/database.ts | 182 ++++++ src/errors.ts | 17 + src/execute.ts | 92 +++ src/format/table.ts | 76 +++ src/index.ts | 20 + src/lexer/chars.ts | 11 + src/lexer/lexer.ts | 152 +++++ src/lexer/tokens.ts | 76 +++ src/pager/btree.ts | 325 ++++++++++ src/pager/format.ts | 192 ++++++ src/pager/pager.ts | 177 ++++++ src/pager/record.ts | 121 ++++ src/parser/ast.ts | 29 + src/parser/parser.ts | 339 ++++++++++ src/relational/algebra.ts | 206 ++++++ src/store.ts | 302 +++++++++ src/value.ts | 179 ++++++ tests/engine.test.ts | 485 ++++++++++++++ tsconfig.json | 18 + 29 files changed, 4813 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/huza.kindb create mode 100644 examples/ishuri.dbclear create mode 100644 examples/ishuri.kindb create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/database.ts create mode 100644 src/errors.ts create mode 100644 src/execute.ts create mode 100644 src/format/table.ts create mode 100644 src/index.ts create mode 100644 src/lexer/chars.ts create mode 100644 src/lexer/lexer.ts create mode 100644 src/lexer/tokens.ts create mode 100644 src/pager/btree.ts create mode 100644 src/pager/format.ts create mode 100644 src/pager/pager.ts create mode 100644 src/pager/record.ts create mode 100644 src/parser/ast.ts create mode 100644 src/parser/parser.ts create mode 100644 src/relational/algebra.ts create mode 100644 src/store.ts create mode 100644 src/value.ts create mode 100644 tests/engine.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac210f8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d7067a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.db +coverage/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96c2444 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kin contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e424d71 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# @kin-lang/db + +In-memory relational database with **Kinyarwanda SQL**. Records live in named tables. CRUD matches [`json-base`](https://www.npmjs.com/package/@ndzhwr/json-base): get (with `where` and `limit`), add, set, del, and delAll. The relational operators are selection (`aho`) and inner join (`huza`). + +The package is standalone. It reads and writes a `.db` page file, runs a `.kindb` script, and is what Kin calls through `KIN_UBUBIKO`. + +```bash +npm install @kin-lang/db +``` + +## Kinyarwanda SQL + +Keywords are case-insensitive. `#` and `--` comment to the end of the line. Semicolons between statements are optional. + +```sql +kora itsinda abanyeshuri (id umubare, izina ijambo, mwarimu umubare, yize ukuri) +kora itsinda abigisha (id umubare, umwarimu ijambo) +shira muri abanyeshuri { id: 1, izina: "Aline", mwarimu: 1, yize: nibyo } +shira muri abanyeshuri (id, izina) agaciro (2, "Keza") + +hindura abanyeshuri shyira amanota = 90 aho id = 1 +siba muri abanyeshuri aho id = 2 +siba itsinda abanyeshuri +siba byose + +hitamo * muri abanyeshuri +hitamo izina, amanota muri abanyeshuri aho amanota >= 50 kandi yize = nibyo imipaka 10 +hitamo a.izina, b.umwarimu muri abanyeshuri a huza abigisha b aho a.mwarimu = b.id +``` + +| Statement | SQL | json-base | +| --- | --- | --- | +| `kora itsinda` | create table | a key under `db` | +| `kora itsinda name (col type, ...)` | create with column types | — | +| `shira muri` | `INSERT` | `add` | +| `hitamo ... muri ... aho ... imipaka` | selection, projection, limit | `get` | +| `hitamo ... muri ... huza ... aho` | inner join, then selection | — | +| `hindura ... shyira ... aho` | `UPDATE` | `set` (exactly one match) | +| `siba muri ... aho` | `DELETE` | `del` (every match) | +| `siba itsinda` | drop table | delete that key | +| `siba byose` | wipe | `delAll` | + +`aho` comparisons are combined with `kandi` only. + +| Form | Meaning | +| --- | --- | +| `=` or `==` | equality; the number `1` also matches the string `"1"` | +| `<` `munsi` | less than | +| `<=` `munsi_cyangwa` | less than or equal | +| `>` `hejuru` | greater than | +| `>=` `hejuru_cyangwa` | greater than or equal | +| `ni` | numeric equality (`1` matches `1`, not `"1"`) | + +Values are numbers, `"strings"`, `nibyo`, `sibyo`, and `ubusa`. + +`kora itsinda` may list columns and types: `umubare` (number), `ijambo` (string), `ukuri` (`nibyo` or `sibyo`), and `ikintu` (any JSON value). Without that list, columns appear when you insert. A declared column rejects a value of the wrong type. `null` (`ubusa`) is allowed in every column. + +`huza` is an inner join. `aho` is selection. A predicate that names one table is applied to that table, and a predicate that names both sides is the join condition. Use an alias when the same table appears twice (`muri abanyeshuri a huza abanyeshuri b`). There is no `OR`, no outer join, no `ORDER BY`, and no `GROUP BY`. + +`hindura` changes the fields you list and leaves the others in place. Numbers stay numbers. + +## Files + +A `.kindb` file is a script of those statements. A `.db` file is a page file, the same idea as SQLite scaled down to this package: + +- every page is 512 bytes +- page 1 is the file header and starts with `KinDB page v1` +- each table is a btree of rows, addressed by an internal row id +- a value that does not fit in its cell continues on overflow pages +- pages released by delete sit on a freelist and are reused +- the file does not shrink; the freelist hands those pages out again + +An older JSON `.db` (`{ "kindb": 1, "db": { } }`) and a json-base file (`{ "$schema": "...", "db": { } }`) still open. The next save rewrites them as pages. `collections()` does not list the hidden schema table. + +## JavaScript + +`require('@kin-lang/db')` and `import` both work. Kin loads this package with `require`. + +```ts +import { Database } from '@kin-lang/db' + +const db = Database.open('./ishuri.db', { create: true }) +db.execute(` + kora itsinda abanyeshuri + shira muri abanyeshuri { id: 1, izina: "Aline", amanota: 88 } +`) + +const passed = db.query('hitamo izina muri abanyeshuri aho amanota >= 50') + +db.add({ collection: 'abanyeshuri', data: { id: 2, izina: 'Keza', amanota: 41 } }) +db.get({ collection: 'abanyeshuri', where: { amanota: { gte: 50 } }, limit: 10 }) +db.set({ collection: 'abanyeshuri', where: { id: 1 }, data: { amanota: 91 } }) +db.del({ collection: 'abanyeshuri', where: { id: 2 } }) +db.delAll() +``` + +`Database.memory()` never touches the disk until `save(path)`. `execute` rolls memory back when a statement fails, and it does not write the file in that case. `get` returns copies of the rows. + +`where` accepts the json-base operators `lt`, `lte`, `gt`, `gte`, and `eq`, plus `munsi`, `munsi_cyangwa`, `hejuru`, `hejuru_cyangwa`, and `ni`. + +```ts +const script = new URL('./examples/ishuri.kindb', import.meta.url) +Database.runFile(script, { db: './ishuri.db' }) +``` + +## CLI + +```bash +npx kindb init ishuri.db +npx kindb run examples/ishuri.kindb --db ishuri.db +npx kindb exec "HITAMO * MURI abanyeshuri" --db ishuri.db +npx kindb repl --db ishuri.db +``` + +Press Enter to run the statement you just typed. A line that still has an open `{` waits for the rest. `.exit` leaves the prompt. + +`exec`, `repl`, and `run` print each `hitamo` as a table. Numbers are right-aligned. A null cell is the word `null`. + +``` +┌────┬───────┬─────────┬───────┐ +│ id │ izina │ amanota │ yize │ +├────┼───────┼─────────┼───────┤ +│ 1 │ Aline │ 88 │ true │ +│ 2 │ Keza │ 70 │ false │ +└────┴───────┴─────────┴───────┘ +(2 rows) +``` + +`exec` and `repl` load `ishuri.kindb` automatically when `ishuri.db` exists beside it and still has no collections. `run` does not: it executes only the script you name. `run` creates a missing `--db` file. Pass `--no-create` to refuse. Without `--db`, the script runs in memory and each `hitamo` is still printed as a table. + +## Kin + +From a `.kin` file, after this package is installed next to Kin: + +```kin +reka db = KIN_UBUBIKO.fungura("ishuri.db") +db.baza("KORA ITSINDA abanyeshuri") +db.shira("abanyeshuri", { id: 1, izina: "Aline", amanota: 88 }) +reka abatsinze = db.hitamo("abanyeshuri", { amanota: { hejuru_cyangwa: 50 } }) +tangaza_amakuru(abatsinze) +db.bika() +``` + +`KIN_UBUBIKO.mu_mutwe()` is a memory database. Paths are relative to the `.kin` file, same as `KIN_INYANDIKO`. See `examples/ububiko` in the Kin repository. + +## Errors + +Failures throw `KinDbError` with a `code`: + +| Code | When | +| --- | --- | +| `E_SYNTAX` | The script does not parse. `incomplete` is true at EOF mid-statement. | +| `E_COLLECTION_MISSING` | The collection is not in the database. | +| `E_COLLECTION_EXISTS` | `kora itsinda` used an existing name. | +| `E_MISSING_PARAM` | `where` or another required piece is missing. | +| `E_NOT_FOUND` | `hindura` / `siba` matched nothing. | +| `E_DUPLICATE` | `hindura` matched more than one row. | +| `E_OPERATOR` | The comparison is not one of lt/gt/lte/gte/eq. | +| `E_TYPE` | A value, limit, or name has the wrong shape. | +| `E_IO` | The `.db` or `.kindb` file cannot be read or written. | + +## Development + +```bash +npm install +npm test +npm run build +``` + +The published entry is `dist/index.js`. The `kindb` binary is `dist/cli.js`. diff --git a/examples/huza.kindb b/examples/huza.kindb new file mode 100644 index 0000000..7f87290 --- /dev/null +++ b/examples/huza.kindb @@ -0,0 +1,15 @@ +# Huza — inner join of students and teachers. +# kindb run examples/huza.kindb --db examples/huza.db + +kora itsinda abanyeshuri (id umubare, izina ijambo, mwarimu umubare) +kora itsinda abigisha (id umubare, umwarimu ijambo) + +shira muri abanyeshuri { id: 1, izina: "Aline", mwarimu: 1 } +shira muri abanyeshuri { id: 2, izina: "Keza", mwarimu: 2 } +shira muri abigisha { id: 1, umwarimu: "Mukama" } +shira muri abigisha { id: 2, umwarimu: "Kalisa" } + +hitamo a.izina, b.umwarimu +muri abanyeshuri a +huza abigisha b +aho a.mwarimu = b.id diff --git a/examples/ishuri.dbclear b/examples/ishuri.dbclear new file mode 100644 index 0000000..1bed373 --- /dev/null +++ b/examples/ishuri.dbclear @@ -0,0 +1,4 @@ +{ + "kindb": 1, + "db": {} +} diff --git a/examples/ishuri.kindb b/examples/ishuri.kindb new file mode 100644 index 0000000..682b415 --- /dev/null +++ b/examples/ishuri.kindb @@ -0,0 +1,14 @@ +# Ishuri — demo script for `kindb run examples/ishuri.kindb --db examples/ishuri.db` +# Statements are Kinyarwanda SQL. Semicolons are optional. # and -- start comments. + +kora itsinda abanyeshuri +kora itsinda abigisha + +shira muri abanyeshuri { id: 1, izina: "Aline", amanota: 88, yize: nibyo } +shira muri abanyeshuri (id, izina, amanota, yize) agaciro (2, "Keza", 41, sibyo) +shira muri abigisha { id: 1, izina: "Mukama", umurimo: ubusa } + +hindura abanyeshuri shyira amanota = 70 aho id = 2 + +hitamo izina, amanota muri abanyeshuri aho amanota >= 50 +hitamo * muri abigisha aho izina = "Mukama" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1a9063c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1260 @@ +{ + "name": "@kin-lang/db", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kin-lang/db", + "version": "0.1.0", + "license": "MIT", + "bin": { + "kindb": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.3.3", + "vitest": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1219cfa --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "@kin-lang/db", + "version": "0.1.0", + "description": "In-memory relational database with Kinyarwanda SQL. Speaks .kindb scripts and page-structured .db files, and plugs into the Kin language.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "kindb": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc", + "test": "vitest --no-watch", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "kin", + "kinyarwanda", + "database", + "json", + "sql", + "kindb" + ], + "author": "Kin contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/kin-lang/db.git" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.3.3", + "vitest": "^4.0.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..76e591c --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,245 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { resolve } from 'node:path'; +import { Database } from './database'; +import { isKinDbError, KinDbError } from './errors'; +import type { ExecuteResult } from './execute'; +import { formatRows } from './format/table'; +import { Store } from './store'; + +const HELP = `kindb — Kinyarwanda SQL for a small relational database + +Usage + kindb init [file.db] Create an empty page file (default: database.db) + kindb run [options] Run a .kindb script + kindb exec "" [options] Run one SQL string + kindb repl [options] Run each statement when Enter completes it + kindb help + kindb version + +Options + --db Load and save this database (run/exec/repl) + --create Create the .db file if it is missing (default for run) + --no-create Refuse to create a missing .db file + +A .kindb file is a list of statements: KORA ITSINDA, SHIRA, HINDURA, +SIBA, SIBA BYOSE, HITAMO, HUZA. A .db file is 512-byte pages, starting +with "KinDB page v1". An older JSON .db file, or a json-base document, +still opens. The next save rewrites it as pages. + +Press Enter to run the statement you just typed. A line that is still +open, such as a '{', keeps the ...> prompt. .exit leaves the prompt. + +exec and repl load the .kindb beside a .db that has no collections yet. +run does not: it executes only the script you pass. + +HITAMO prints a table. A null cell is the word null. +`; + +interface Flags { + db?: string; + create: boolean; + positionals: string[]; +} + +export interface ReplStep { + prompt: string; + output: string; + error: string; + exit: boolean; +} + +export async function main(argv: string[]): Promise { + const [command, ...rest] = argv; + try { + switch (command) { + case 'init': + return init(rest[0] ?? 'database.db'); + case 'run': + return run(parseFlags(rest)); + case 'exec': + return exec(parseFlags(rest)); + case 'repl': + return await repl(parseFlags(rest)); + case 'version': + case '--version': + process.stdout.write(`${readVersion()}\n`); + return 0; + case 'help': + case '--help': + case undefined: + process.stdout.write(HELP); + return 0; + default: + process.stderr.write(`Unknown command '${command}'.\n\n${HELP}`); + return 1; + } + } catch (error) { + return fail(error); + } +} + +/** + * One line in, one step out. A finished statement runs immediately. + * An unfinished one keeps the buffer and switches the prompt to `...> `. + */ +export function createReplSession(database: Database): { + push: (line: string) => ReplStep; +} { + seedIfEmpty(database); + const buffer: string[] = []; + return { + push(line: string): ReplStep { + if (buffer.length === 0 && line.trim() === '.exit') { + return { prompt: 'kindb> ', output: '', error: '', exit: true }; + } + buffer.push(line); + const source = buffer.join('\n'); + try { + const result = database.execute(source); + buffer.length = 0; + return { prompt: 'kindb> ', output: formatResult(result), error: '', exit: false }; + } catch (error) { + if (isKinDbError(error) && error.incomplete) { + return { prompt: '...> ', output: '', error: '', exit: false }; + } + buffer.length = 0; + return { prompt: 'kindb> ', output: '', error: formatError(error), exit: false }; + } + }, + }; +} + +function init(filePath: string): number { + const absolute = resolve(filePath); + if (existsSync(absolute)) { + throw new KinDbError('E_IO', `Database file already exists: ${absolute}`); + } + writeFileSync(absolute, Store.empty().toBytes()); + process.stdout.write(`Created ${absolute}\n`); + return 0; +} + +function run(flags: Flags): number { + const script = flags.positionals[0]; + if (!script) throw new KinDbError('E_MISSING_PARAM', 'kindb run needs a .kindb file'); + const database = openFromFlags(flags); + const source = readFileSync(resolve(script), 'utf8'); + printResult(database.execute(source)); + return 0; +} + +function exec(flags: Flags): number { + const source = flags.positionals.join(' '); + if (!source.trim()) throw new KinDbError('E_MISSING_PARAM', 'kindb exec needs a SQL string'); + const database = openFromFlags(flags); + seedIfEmpty(database); + printResult(database.execute(source)); + return 0; +} + +function repl(flags: Flags): Promise { + const session = createReplSession(openFromFlags(flags)); + process.stdout.write('kindb. Press Enter to run a statement. Exit with .exit\n'); + const reader = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'kindb> ', + }); + return new Promise((done) => { + reader.prompt(); + reader.on('line', (line) => { + const step = session.push(line); + if (step.output) process.stdout.write(step.output); + if (step.error) process.stderr.write(step.error); + if (step.exit) { + reader.close(); + return; + } + reader.setPrompt(step.prompt); + reader.prompt(); + }); + reader.on('close', () => done(0)); + }); +} + +/** Apply `name.kindb` when `name.db` exists and still has no collections. */ +function seedIfEmpty(database: Database): void { + const filePath = database.path; + if (!filePath || !filePath.toLowerCase().endsWith('.db')) return; + const script = `${filePath.slice(0, -3)}.kindb`; + if (!existsSync(script) || database.collections().length > 0) return; + database.execute(readFileSync(script, 'utf8')); +} + +function openFromFlags(flags: Flags): Database { + if (!flags.db) return Database.memory(); + return Database.open(flags.db, { create: flags.create }); +} + +function parseFlags(args: string[]): Flags { + const positionals: string[] = []; + let db: string | undefined; + let create = true; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--db') { + const value = args[i + 1]; + if (!value) throw new KinDbError('E_MISSING_PARAM', '--db needs a file path'); + db = value; + i += 1; + continue; + } + if (arg === '--create') { + create = true; + continue; + } + if (arg === '--no-create') { + create = false; + continue; + } + if (arg.startsWith('--')) { + throw new KinDbError('E_SYNTAX', `Unknown option '${arg}'`); + } + positionals.push(arg); + } + return { db, create, positionals }; +} + +function printResult(result: ExecuteResult): void { + const text = formatResult(result); + if (text) process.stdout.write(text); +} + +function formatResult(result: ExecuteResult): string { + return result.rows.map((rows) => formatRows(rows)).join('\n'); +} + +function formatError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const code = isKinDbError(error) ? error.code : 'E_IO'; + return `${code}: ${message}\n`; +} + +function readVersion(): string { + const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')) as { + version: string; + }; + return pkg.version; +} + +function fail(error: unknown): number { + process.stderr.write(formatError(error)); + return 1; +} + +if (require.main === module) { + main(process.argv.slice(2)) + .then((code) => { + process.exit(code); + }) + .catch((error: unknown) => { + process.exit(fail(error)); + }); +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..909627d --- /dev/null +++ b/src/database.ts @@ -0,0 +1,182 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { KinDbError } from './errors'; +import { executeSource, type ExecuteResult } from './execute'; +import { MAGIC } from './pager/format'; +import type { ColumnDef } from './pager/record'; +import { Store } from './store'; +import type { Row, Where } from './value'; + +export interface OpenOptions { + /** Create an empty .db file when the path is missing. */ + create?: boolean; +} + +export interface RunFileOptions { + /** .db file to load and save. Omit to run purely in memory. */ + db?: string; + /** Create the .db file when it does not exist. Defaults to true when db is set. */ + create?: boolean; +} + +/** + * In-memory relational database that can be tied to a `.db` page file. + * Kinyarwanda SQL is executed with {@link Database.execute}. The same data + * is available through get / add / set / del / delAll. + */ +export class Database { + private store: Store; + private filePath: string | null; + + private constructor(store: Store, filePath: string | null) { + this.store = store; + this.filePath = filePath; + } + + /** A database that is never written unless {@link Database.save} is called. */ + static memory(): Database { + return new Database(Store.empty(), null); + } + + /** Load a `.db` file into memory. Later mutations are written back. */ + static open(filePath: string, options: OpenOptions = {}): Database { + const absolute = resolve(filePath); + if (!existsSync(absolute)) { + if (!options.create) { + throw new KinDbError('E_IO', `Database file not found: ${absolute}`); + } + const created = new Database(Store.empty(), absolute); + created.commit(); + return created; + } + return new Database(readStore(absolute), absolute); + } + + /** + * Run a `.kindb` script. + * When `db` is set, that file is loaded first and saved after a successful run. + */ + static runFile(kindbPath: string, options: RunFileOptions = {}): ExecuteResult { + const source = readText(resolve(kindbPath)); + const database = options.db + ? Database.open(options.db, { create: options.create ?? true }) + : Database.memory(); + return database.execute(source); + } + + /** Absolute path of the bound `.db` file, or null for a pure memory database. */ + get path(): string | null { + return this.filePath; + } + + /** + * Run Kinyarwanda SQL. A failure rolls the in-memory data back to the + * state before this call, and does not write the `.db` file. + */ + execute(source: string): ExecuteResult { + const snapshot = this.store.clone(); + try { + const result = executeSource(source, this.store); + this.commit(); + return result; + } catch (error) { + this.store = snapshot; + throw error; + } + } + + /** Run a script whose last statement must be HITAMO. Returns those rows. */ + query(source: string): Row[] { + const result = this.execute(source); + if (result.last.kind !== 'select') { + throw new KinDbError('E_TYPE', 'query() expects the last statement to be HITAMO'); + } + return result.last.rows; + } + + /** Write the `.db` file. Passing a path binds future saves to that file. */ + save(filePath?: string): void { + if (filePath) this.filePath = resolve(filePath); + if (!this.filePath) { + throw new KinDbError('E_IO', 'save() needs a file path for a memory database'); + } + this.commit(); + } + + collections(): string[] { + return this.store.names(); + } + + createCollection(name: string, columns?: ColumnDef[]): void { + this.store.createCollection(name, columns); + this.commit(); + } + + dropCollection(name: string): void { + this.store.dropCollection(name); + this.commit(); + } + + add(params: { collection: string; data: Row }): Row { + const row = this.store.add(params.collection, params.data); + this.commit(); + return row; + } + + get(params: { collection: string; where?: Where; limit?: number }): Row[] { + return this.store.get(params.collection, params.where, params.limit); + } + + set(params: { collection: string; where: Where; data: Row }): void { + this.store.set(params.collection, params.where, params.data); + this.commit(); + } + + del(params: { collection: string; where: Where }): number { + const removed = this.store.del(params.collection, params.where); + this.commit(); + return removed; + } + + delAll(): void { + this.store.delAll(); + this.commit(); + } + + private commit(): void { + if (!this.filePath) return; + writeFileSync(this.filePath, this.store.toBytes()); + } +} + +function readStore(filePath: string): Store { + let bytes: Buffer; + try { + bytes = readFileSync(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KinDbError('E_IO', message); + } + if (bytes.length >= MAGIC.length && bytes.subarray(0, MAGIC.length).equals(MAGIC)) { + return Store.fromBytes(bytes); + } + // Older KinDB documents and json-base files are JSON objects. + if (bytes[0] === 0x7b) { + try { + return Store.fromDocument(JSON.parse(bytes.toString('utf8')) as unknown); + } catch (error) { + if (error instanceof KinDbError) throw error; + throw new KinDbError('E_IO', `Database file is not valid JSON: ${filePath}`); + } + } + throw new KinDbError('E_IO', `Database file is not a KinDB page file: ${filePath}`); +} + +function readText(filePath: string): string { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KinDbError('E_IO', message); + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..e4014fe --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,17 @@ +/** Failure raised by the KinSQL parser or the in-memory engine. */ +export class KinDbError extends Error { + readonly code: string; + /** True when the parser hit EOF while a statement was still open. */ + readonly incomplete: boolean; + + constructor(code: string, message: string, incomplete = false) { + super(message); + this.name = 'KinDbError'; + this.code = code; + this.incomplete = incomplete; + } +} + +export function isKinDbError(error: unknown): error is KinDbError { + return error instanceof KinDbError; +} diff --git a/src/execute.ts b/src/execute.ts new file mode 100644 index 0000000..59da371 --- /dev/null +++ b/src/execute.ts @@ -0,0 +1,92 @@ +import { evaluateSelect } from './relational/algebra'; +import type { Statement } from './parser/ast'; +import { parse } from './parser/parser'; +import { Store } from './store'; +import type { Row } from './value'; + +export interface ExecuteResult { + statements: number; + /** One array per HITAMO, in source order. */ + rows: Row[][]; + inserted: number; + updated: number; + deleted: number; + last: + | { kind: 'empty' } + | { kind: 'create' } + | { kind: 'drop' } + | { kind: 'dropAll' } + | { kind: 'insert'; row: Row } + | { kind: 'update'; count: number } + | { kind: 'delete'; count: number } + | { kind: 'select'; rows: Row[] }; +} + +export function executeSource(source: string, store: Store): ExecuteResult { + return executeStatements(parse(source), store); +} + +export function executeStatements(statements: Statement[], store: Store): ExecuteResult { + const result: ExecuteResult = { + statements: statements.length, + rows: [], + inserted: 0, + updated: 0, + deleted: 0, + last: { kind: 'empty' }, + }; + + for (const statement of statements) { + switch (statement.kind) { + case 'create': + store.createCollection(statement.name, statement.columns); + result.last = { kind: 'create' }; + break; + case 'drop': + store.dropCollection(statement.name); + result.last = { kind: 'drop' }; + break; + case 'dropAll': + store.delAll(); + result.last = { kind: 'dropAll' }; + break; + case 'insert': { + const row = store.add(statement.collection, statement.row); + result.inserted += 1; + result.last = { kind: 'insert', row }; + break; + } + case 'update': { + const count = store.updateWhere(statement.collection, statement.where, statement.data); + result.updated += count; + result.last = { kind: 'update', count }; + break; + } + case 'delete': { + const count = store.deleteWhere(statement.collection, statement.where); + result.deleted += count; + result.last = { kind: 'delete', count }; + break; + } + case 'select': { + const rows = evaluateSelect( + statement.from.map((ref) => ({ + ref, + rows: store.rows(ref.name), + columns: store.columnNames(ref.name), + })), + statement.where, + statement.columns, + statement.limit, + ); + result.rows.push(rows); + result.last = { kind: 'select', rows }; + break; + } + } + } + + return result; +} + + diff --git a/src/format/table.ts b/src/format/table.ts new file mode 100644 index 0000000..f44f6d5 --- /dev/null +++ b/src/format/table.ts @@ -0,0 +1,76 @@ +import type { JsonValue, Row } from '../value'; + +/** + * Print HITAMO rows as a text table. + * Numbers are right-aligned. Null is the word null, so it stays distinct from an empty string. + */ +export function formatRows(rows: Row[]): string { + if (rows.length === 0) return '(0 rows)\n'; + const columns = columnNames(rows); + if (columns.length === 0) return `${countLine(rows.length)}\n`; + + const alignRight = columns.map((name) => numericColumn(rows, name)); + const body = rows.map((row) => columns.map((name) => cellText(name in row ? row[name] : null))); + const widths = columns.map((name, index) => + Math.max(name.length, ...body.map((line) => line[index].length)), + ); + + return [ + rule('┌', '┬', '┐', widths), + render(columns, widths, alignRight), + rule('├', '┼', '┤', widths), + ...body.map((line) => render(line, widths, alignRight)), + rule('└', '┴', '┘', widths), + countLine(rows.length), + '', + ].join('\n'); +} + +function columnNames(rows: Row[]): string[] { + const names: string[] = []; + const seen = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (seen.has(key)) continue; + seen.add(key); + names.push(key); + } + } + return names; +} + +function numericColumn(rows: Row[], name: string): boolean { + let sawNumber = false; + for (const row of rows) { + if (!(name in row) || row[name] === null) continue; + if (typeof row[name] !== 'number') return false; + sawNumber = true; + } + return sawNumber; +} + +function cellText(value: JsonValue): string { + if (value === null) return 'null'; + if (value === true) return 'true'; + if (value === false) return 'false'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return value.replace(/\r\n|\n|\r/g, '\\n').replace(/\t/g, '\\t'); + return JSON.stringify(value); +} + +function rule(left: string, mid: string, right: string, widths: number[]): string { + return left + widths.map((width) => '─'.repeat(width + 2)).join(mid) + right; +} + +function render(values: string[], widths: number[], alignRight: boolean[]): string { + const cells = values.map((value, index) => { + const gap = widths[index] - value.length; + const text = alignRight[index] ? `${' '.repeat(gap)}${value}` : `${value}${' '.repeat(gap)}`; + return ` ${text} `; + }); + return `│${cells.join('│')}│`; +} + +function countLine(count: number): string { + return `(${count} ${count === 1 ? 'row' : 'rows'})`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0d0065b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +export { KinDbError, isKinDbError } from './errors'; +export { Database } from './database'; +export type { OpenOptions, RunFileOptions } from './database'; +export { executeSource, executeStatements } from './execute'; +export type { ExecuteResult } from './execute'; +export { parse } from './parser/parser'; +export { tokenize } from './lexer/lexer'; +export type { Token, TokenType } from './lexer/tokens'; +export { KINDB_VERSION, Store } from './store'; +export type { ColumnDef, ColumnType } from './pager/record'; +export type { Statement } from './parser/ast'; +export type { + CompareOp, + FieldFilter, + JsonPrimitive, + JsonValue, + Predicate, + Row, + Where, +} from './value'; diff --git a/src/lexer/chars.ts b/src/lexer/chars.ts new file mode 100644 index 0000000..e4e4459 --- /dev/null +++ b/src/lexer/chars.ts @@ -0,0 +1,11 @@ +export function isDigit(char: string): boolean { + return char >= '0' && char <= '9'; +} + +export function isIdentStart(char: string): boolean { + return (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '_'; +} + +export function isIdentPart(char: string): boolean { + return isIdentStart(char) || isDigit(char); +} diff --git a/src/lexer/lexer.ts b/src/lexer/lexer.ts new file mode 100644 index 0000000..acd0ec0 --- /dev/null +++ b/src/lexer/lexer.ts @@ -0,0 +1,152 @@ +import { KinDbError } from '../errors'; +import { isDigit, isIdentPart, isIdentStart } from './chars'; +import { KEYWORDS, type Token, type TokenType } from './tokens'; + +const SYMBOLS: Record = { + '==': 'eqeq', + '<=': 'lte', + '>=': 'gte', + '*': 'star', + ',': 'comma', + '{': 'lbrace', + '}': 'rbrace', + '(': 'lparen', + ')': 'rparen', + ':': 'colon', + '.': 'dot', + '=': 'eq', + '<': 'lt', + '>': 'gt', + '-': 'minus', + ';': 'semi', +}; + +const ESCAPES: Record = { + n: '\n', + t: '\t', + r: '\r', + '\\': '\\', + '"': '"', + "'": "'", +}; + +export function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + let line = 1; + let column = 1; + + const peek = (offset = 0) => source[index + offset] ?? ''; + const advance = () => { + const char = source[index] ?? ''; + index += 1; + if (char === '\n') { + line += 1; + column = 1; + } else { + column += 1; + } + return char; + }; + + while (index < source.length) { + const char = peek(); + if (char === ' ' || char === '\t' || char === '\r' || char === '\n') { + advance(); + continue; + } + if (char === '#' || (char === '-' && peek(1) === '-')) { + while (index < source.length && peek() !== '\n') advance(); + continue; + } + + const startLine = line; + const startColumn = column; + if (char === '"' || char === "'") { + tokens.push(scanString(char, startLine, startColumn)); + continue; + } + if (isDigit(char)) { + tokens.push(scanNumber(startLine, startColumn)); + continue; + } + if (isIdentStart(char)) { + tokens.push(scanIdentifier(startLine, startColumn)); + continue; + } + + const two = char + peek(1); + const type = SYMBOLS[two] ?? SYMBOLS[char]; + if (!type) { + throw new KinDbError( + 'E_SYNTAX', + `Unexpected character '${char}' at ${startLine}:${startColumn}`, + ); + } + const lexeme = SYMBOLS[two] ? two : char; + advance(); + if (lexeme.length === 2) advance(); + tokens.push({ type, lexeme, line: startLine, column: startColumn }); + } + + tokens.push({ type: 'eof', lexeme: '', line, column }); + return tokens; + + function scanString(quote: string, startLine: number, startColumn: number): Token { + advance(); + let value = ''; + while (index < source.length && peek() !== quote) { + const current = advance(); + if (current === '\n') { + throw new KinDbError('E_SYNTAX', `Unterminated string at ${startLine}:${startColumn}`); + } + if (current !== '\\') { + value += current; + continue; + } + const escaped = advance(); + if (!(escaped in ESCAPES)) { + throw new KinDbError( + 'E_SYNTAX', + `Unknown string escape '\\${escaped}' at ${line}:${column}`, + ); + } + value += ESCAPES[escaped]; + } + if (peek() !== quote) { + throw new KinDbError('E_SYNTAX', `Unterminated string at ${startLine}:${startColumn}`); + } + advance(); + return { type: 'string', lexeme: value, value, line: startLine, column: startColumn }; + } + + function scanNumber(startLine: number, startColumn: number): Token { + const start = index; + while (isDigit(peek())) advance(); + if (peek() === '.' && isDigit(peek(1))) { + advance(); + while (isDigit(peek())) advance(); + } + const lexeme = source.slice(start, index); + return { + type: 'number', + lexeme, + value: Number(lexeme), + line: startLine, + column: startColumn, + }; + } + + function scanIdentifier(startLine: number, startColumn: number): Token { + const start = index; + while (isIdentPart(peek())) advance(); + const lexeme = source.slice(start, index); + const keyword = KEYWORDS[lexeme.toLowerCase()]; + if (keyword) { + return { type: keyword, lexeme, line: startLine, column: startColumn }; + } + return { type: 'identifier', lexeme, line: startLine, column: startColumn }; + } +} + +export type { Token, TokenType }; diff --git a/src/lexer/tokens.ts b/src/lexer/tokens.ts new file mode 100644 index 0000000..916de27 --- /dev/null +++ b/src/lexer/tokens.ts @@ -0,0 +1,76 @@ +export type TokenType = + | 'identifier' + | 'string' + | 'number' + | 'star' + | 'comma' + | 'lbrace' + | 'rbrace' + | 'lparen' + | 'rparen' + | 'colon' + | 'dot' + | 'eq' + | 'eqeq' + | 'lt' + | 'lte' + | 'gt' + | 'gte' + | 'minus' + | 'semi' + | 'kora' + | 'itsinda' + | 'siba' + | 'byose' + | 'shira' + | 'muri' + | 'agaciro' + | 'hindura' + | 'shyira' + | 'aho' + | 'hitamo' + | 'huza' + | 'imipaka' + | 'kandi' + | 'ni' + | 'munsi' + | 'munsi_cyangwa' + | 'hejuru' + | 'hejuru_cyangwa' + | 'nibyo' + | 'sibyo' + | 'ubusa' + | 'eof'; + +export interface Token { + type: TokenType; + lexeme: string; + value?: string | number; + line: number; + column: number; +} + +export const KEYWORDS: Record = { + kora: 'kora', + itsinda: 'itsinda', + siba: 'siba', + byose: 'byose', + shira: 'shira', + muri: 'muri', + agaciro: 'agaciro', + hindura: 'hindura', + shyira: 'shyira', + aho: 'aho', + hitamo: 'hitamo', + huza: 'huza', + imipaka: 'imipaka', + kandi: 'kandi', + ni: 'ni', + munsi: 'munsi', + munsi_cyangwa: 'munsi_cyangwa', + hejuru: 'hejuru', + hejuru_cyangwa: 'hejuru_cyangwa', + nibyo: 'nibyo', + sibyo: 'sibyo', + ubusa: 'ubusa', +}; diff --git a/src/pager/btree.ts b/src/pager/btree.ts new file mode 100644 index 0000000..c20be30 --- /dev/null +++ b/src/pager/btree.ts @@ -0,0 +1,325 @@ +import { KinDbError } from '../errors'; +import { + blankLeaf, + PAGE_INTERIOR, + PAGE_LEAF, + PAGE_SIZE, + readInterior, + readLeaf, + writeInterior, + writeLeaf, + type Divider, + type LeafCell, +} from './format'; +import { Pager } from './pager'; + +/** On-page payload cap. The rest of a long record goes to overflow pages. */ +const MAX_LOCAL = 64; + +export interface StoredRecord { + rowid: number; + payload: Buffer; + overflow: number; +} + +/** In-order scan of a table btree. */ +export function scanTree(pager: Pager, root: number): StoredRecord[] { + const found: StoredRecord[] = []; + walk(pager, root, found); + return found; +} + +export function insertTree(pager: Pager, root: number, rowid: number, payload: Buffer): number { + const cell = spill(pager, rowid, payload); + return place(pager, root, cell).root; +} + +export function deleteTree(pager: Pager, root: number, rowid: number): number { + const path = descend(pager, root, rowid); + const cells = readLeaf(pager.get(path.leaf)); + const index = cells.findIndex((cell) => cell.rowid === rowid); + if (index < 0) throw new KinDbError('E_IO', `Row ${rowid} was not found in its page`); + const [removed] = cells.splice(index, 1); + freeOverflow(pager, removed.overflow); + if (cells.length > 0 || path.parents.length === 0) { + commitLeaf(pager, path.leaf, cells); + return root; + } + pager.free(path.leaf); + return unlink(pager, root, path.parents, path.slot); +} + +/** Free every page in a btree, including overflow pages hanging off its leaves. */ +export function freeTree(pager: Pager, root: number): void { + const records = scanTree(pager, root); + const pages = collectPages(pager, root); + for (const record of records) freeOverflow(pager, record.overflow); + for (const page of pages) pager.free(page); +} + +function walk(pager: Pager, pageNumber: number, into: StoredRecord[]): void { + const page = pager.get(pageNumber); + if (page.readUInt8(0) === PAGE_LEAF) { + for (const cell of readLeaf(page)) { + into.push({ rowid: cell.rowid, payload: readPayload(pager, cell), overflow: cell.overflow }); + } + return; + } + if (page.readUInt8(0) !== PAGE_INTERIOR) { + throw new KinDbError('E_IO', `Page ${pageNumber} is not a table page`); + } + const node = readInterior(page); + for (const divider of node.dividers) walk(pager, divider.left, into); + walk(pager, node.right, into); +} + +function collectPages(pager: Pager, pageNumber: number): number[] { + const page = pager.get(pageNumber); + if (page.readUInt8(0) === PAGE_LEAF) return [pageNumber]; + const node = readInterior(page); + const nested = node.dividers.flatMap((divider) => collectPages(pager, divider.left)); + return [pageNumber, ...nested, ...collectPages(pager, node.right)]; +} + +interface Path { + leaf: number; + /** Parents from root down to the leaf's parent. `slot` is the child index. */ + parents: { page: number; slot: number }[]; + slot: number; +} + +function descend(pager: Pager, root: number, rowid: number): Path { + const parents: { page: number; slot: number }[] = []; + let pageNumber = root; + while (pager.get(pageNumber).readUInt8(0) === PAGE_INTERIOR) { + const node = readInterior(pager.get(pageNumber)); + let slot = node.dividers.length; + let next = node.right; + for (let i = 0; i < node.dividers.length; i += 1) { + if (rowid <= node.dividers[i].key) { + slot = i; + next = node.dividers[i].left; + break; + } + } + parents.push({ page: pageNumber, slot }); + pageNumber = next; + } + const parent = parents[parents.length - 1]; + return { leaf: pageNumber, parents, slot: parent ? parent.slot : 0 }; +} + +function place(pager: Pager, root: number, cell: LeafCell): { root: number } { + const path = descend(pager, root, cell.rowid); + const cells = readLeaf(pager.get(path.leaf)); + const next = [...cells, cell].sort((a, b) => a.rowid - b.rowid); + if (writeLeaf(next)) { + commitLeaf(pager, path.leaf, next); + return { root }; + } + const [leftCells, rightCells] = splitBalanced(next, (group) => writeLeaf(group) !== null); + const rightPage = pager.allocate(); + commitLeaf(pager, path.leaf, leftCells); + commitLeaf(pager, rightPage, rightCells); + const separator = leftCells[leftCells.length - 1].rowid; + const newRoot = attach(pager, root, path, separator, rightPage); + return { root: newRoot }; +} + +function attach(pager: Pager, root: number, path: Path, separator: number, rightPage: number): number { + if (path.parents.length === 0) { + const created = pager.allocate(); + commitInterior(pager, created, [{ left: path.leaf, key: separator }], rightPage); + return created; + } + const parent = path.parents[path.parents.length - 1]; + const node = readInterior(pager.get(parent.page)); + const dividers = node.dividers.map((divider) => ({ ...divider })); + let right = node.right; + if (parent.slot === dividers.length) { + dividers.push({ left: right, key: separator }); + right = rightPage; + } else { + const current = dividers[parent.slot]; + dividers.splice(parent.slot, 1, { left: current.left, key: separator }, { left: rightPage, key: current.key }); + } + if (writeInterior(dividers, right)) { + commitInterior(pager, parent.page, dividers, right); + return root; + } + const interiorFits = (count: number): boolean => { + if (count < 1 || count >= dividers.length) return false; + const leftGroup = dividers.slice(0, count); + const rightGroup = dividers.slice(count); + const boundary = leftGroup[leftGroup.length - 1]; + return ( + writeInterior(leftGroup.slice(0, -1), boundary.left) !== null && + writeInterior(rightGroup, right) !== null + ); + }; + let cut = 0; + const middle = Math.ceil(dividers.length / 2); + for (let distance = 0; distance < dividers.length && cut === 0; distance += 1) { + const candidates = distance === 0 ? [middle] : [middle - distance, middle + distance]; + for (const count of candidates) { + if (interiorFits(count)) { + cut = count; + break; + } + } + } + if (cut === 0) throw new KinDbError('E_IO', 'An interior page split could not place its cells'); + const leftDividers = dividers.slice(0, cut); + const promoted = leftDividers[leftDividers.length - 1]; + const rightRest = dividers.slice(cut); + // The promoted key separates the two interior pages. Its left page stays + // in the left interior; the right interior starts at the following child. + const leftKept = leftDividers.slice(0, -1); + const rightChildOfLeft = promoted.left; + const newRight = pager.allocate(); + commitInterior(pager, parent.page, leftKept, rightChildOfLeft); + commitInterior(pager, newRight, rightRest, right); + const above = path.parents.slice(0, -1); + const synthetic: Path = { + leaf: parent.page, + parents: above, + slot: above.length ? above[above.length - 1].slot : 0, + }; + return attach(pager, root, synthetic, promoted.key, newRight); +} + +function unlink(pager: Pager, root: number, parents: { page: number; slot: number }[], slot: number): number { + const parent = parents[parents.length - 1]; + const node = readInterior(pager.get(parent.page)); + const children = node.dividers.map((divider) => divider.left).concat([node.right]); + const bounds = node.dividers.map((divider) => divider.key); + children.splice(slot, 1); + if (slot < bounds.length) bounds.splice(slot, 1); + else bounds.pop(); + if (children.length === 0) { + pager.free(parent.page); + if (parents.length === 1) { + const leaf = pager.allocate(); + pager.put(leaf, blankLeaf()); + return leaf; + } + const above = parents.slice(0, -1); + return unlink(pager, root, above, above[above.length - 1].slot); + } + if (children.length === 1) { + pager.free(parent.page); + const only = children[0]; + if (parents.length === 1) return only; + return retarget(pager, root, parents.slice(0, -1), only, parent.page); + } + const right = children[children.length - 1]; + const dividers: Divider[] = children.slice(0, -1).map((left, index) => ({ + left, + key: bounds[index], + })); + commitInterior(pager, parent.page, dividers, right); + return root; +} + +/** A collapsed parent was removed. Point the grandparent slot from `oldPage` to `replacement`. */ +function retarget( + pager: Pager, + root: number, + parents: { page: number; slot: number }[], + replacement: number, + oldPage: number, +): number { + const parent = parents[parents.length - 1]; + const node = readInterior(pager.get(parent.page)); + const dividers = node.dividers.map((divider) => ({ + ...divider, + left: divider.left === oldPage ? replacement : divider.left, + })); + const right = node.right === oldPage ? replacement : node.right; + commitInterior(pager, parent.page, dividers, right); + return root; +} + +function spill(pager: Pager, rowid: number, payload: Buffer): LeafCell { + if (payload.length <= MAX_LOCAL) { + return { rowid, total: payload.length, overflow: 0, local: Buffer.from(payload) }; + } + const local = Buffer.from(payload.subarray(0, MAX_LOCAL)); + const overflow = writeOverflow(pager, payload.subarray(MAX_LOCAL)); + return { rowid, total: payload.length, overflow, local }; +} + +function writeOverflow(pager: Pager, data: Buffer): number { + const capacity = PAGE_SIZE - 4; + let first = 0; + let previous = 0; + let offset = 0; + while (offset < data.length) { + const pageNumber = pager.allocate(); + const chunk = data.subarray(offset, Math.min(data.length, offset + capacity)); + const page = Buffer.alloc(PAGE_SIZE); + chunk.copy(page, 4); + if (previous !== 0) { + const prior = Buffer.from(pager.get(previous)); + prior.writeUInt32BE(pageNumber, 0); + pager.put(previous, prior); + } else { + first = pageNumber; + } + pager.put(pageNumber, page); + previous = pageNumber; + offset += chunk.length; + } + return first; +} + +function readPayload(pager: Pager, cell: LeafCell): Buffer { + if (cell.overflow === 0) return Buffer.from(cell.local); + const chunks = [cell.local]; + let pageNumber = cell.overflow; + let remaining = cell.total - cell.local.length; + while (pageNumber !== 0 && remaining > 0) { + const page = pager.get(pageNumber); + const take = Math.min(remaining, PAGE_SIZE - 4); + chunks.push(Buffer.from(page.subarray(4, 4 + take))); + remaining -= take; + pageNumber = page.readUInt32BE(0); + } + if (remaining !== 0) throw new KinDbError('E_IO', 'An overflow chain ended early'); + return Buffer.concat(chunks); +} + +function freeOverflow(pager: Pager, pageNumber: number): void { + let current = pageNumber; + while (current !== 0) { + const next = pager.get(current).readUInt32BE(0); + pager.free(current); + current = next; + } +} + +function splitBalanced(items: T[], fits: (group: T[]) => boolean): [T[], T[]] { + const middle = Math.floor(items.length / 2); + for (let distance = 0; distance < items.length; distance += 1) { + const candidates = distance === 0 ? [middle] : [middle - distance, middle + distance]; + for (const count of candidates) { + if (count < 1 || count >= items.length) continue; + const left = items.slice(0, count); + const right = items.slice(count); + if (fits(left) && fits(right)) return [left, right]; + } + } + throw new KinDbError('E_IO', 'A page split could not place its cells'); +} + +function commitLeaf(pager: Pager, pageNumber: number, cells: LeafCell[]): void { + const page = writeLeaf(cells); + if (!page) throw new KinDbError('E_IO', `Leaf page ${pageNumber} cannot hold its cells`); + pager.put(pageNumber, page); +} + +function commitInterior(pager: Pager, pageNumber: number, dividers: Divider[], right: number): void { + const page = writeInterior(dividers, right); + if (!page) throw new KinDbError('E_IO', `Interior page ${pageNumber} cannot hold its cells`); + pager.put(pageNumber, page); +} diff --git a/src/pager/format.ts b/src/pager/format.ts new file mode 100644 index 0000000..6571197 --- /dev/null +++ b/src/pager/format.ts @@ -0,0 +1,192 @@ +/** Fixed page size. Small on purpose so a short table spans more than one page. */ +export const PAGE_SIZE = 512; + +/** 16-byte magic at the start of page 1. ASCII "KinDB page v1". */ +export const MAGIC = Buffer.from('KinDB page v1\0\0\0'); + +export const PAGE_LEAF = 0x0d; +export const PAGE_INTERIOR = 0x05; +export const PAGE_FREELIST = 0x02; + +/** Bytes reserved at the front of page 1, same idea as SQLite's 100-byte header. */ +export const HEADER_BYTES = 100; + +export const LEAF_HEADER = 8; +export const INTERIOR_HEADER = 12; +export const CELL_HEADER = 14; +export const INTERIOR_CELL = 8; + +const FREELIST_HEADER = 7; + +export interface FileHeader { + pageCount: number; + freelistHead: number; + freelistCount: number; + schemaRoot: number; + schemaNext: number; + changeCounter: number; +} + +export interface LeafCell { + rowid: number; + /** Full payload length, including bytes stored on overflow pages. */ + total: number; + overflow: number; + local: Buffer; +} + +export interface Divider { + left: number; + key: number; +} + +export function emptyHeader(pageCount: number): FileHeader { + return { + pageCount, + freelistHead: 0, + freelistCount: 0, + schemaRoot: 2, + schemaNext: 1, + changeCounter: 0, + }; +} + +export function readHeader(page: Buffer): FileHeader { + if (page.length < HEADER_BYTES || !page.subarray(0, 16).equals(MAGIC)) { + throw new Error('not a KinDB page file'); + } + const pageSize = page.readUInt16BE(16); + if (pageSize !== PAGE_SIZE) { + throw new Error(`unsupported page size ${pageSize}`); + } + return { + pageCount: page.readUInt32BE(24), + freelistHead: page.readUInt32BE(28), + freelistCount: page.readUInt32BE(32), + schemaRoot: page.readUInt32BE(36), + changeCounter: page.readUInt32BE(40), + schemaNext: page.readUInt32BE(44), + }; +} + +export function writeHeader(page: Buffer, header: FileHeader): void { + page.fill(0, 0, HEADER_BYTES); + MAGIC.copy(page, 0); + page.writeUInt16BE(PAGE_SIZE, 16); + page.writeUInt8(1, 18); + page.writeUInt8(1, 19); + page.writeUInt32BE(header.pageCount, 24); + page.writeUInt32BE(header.freelistHead, 28); + page.writeUInt32BE(header.freelistCount, 32); + page.writeUInt32BE(header.schemaRoot, 36); + page.writeUInt32BE(header.changeCounter, 40); + page.writeUInt32BE(header.schemaNext, 44); +} + +export function blankLeaf(): Buffer { + const page = Buffer.alloc(PAGE_SIZE); + page.writeUInt8(PAGE_LEAF, 0); + page.writeUInt16BE(PAGE_SIZE, 3); + return page; +} + +export function readLeaf(page: Buffer): LeafCell[] { + assertType(page, PAGE_LEAF); + const count = page.readUInt16BE(1); + const cells: LeafCell[] = []; + for (let i = 0; i < count; i += 1) { + const offset = page.readUInt16BE(LEAF_HEADER + i * 2); + const rowid = page.readUInt32BE(offset); + const total = page.readUInt32BE(offset + 4); + const overflow = page.readUInt32BE(offset + 8); + const localLength = page.readUInt16BE(offset + 12); + const local = Buffer.from(page.subarray(offset + CELL_HEADER, offset + CELL_HEADER + localLength)); + cells.push({ rowid, total, overflow, local }); + } + return cells; +} + +/** Pack cells from the end of the page. Returns null when they do not fit. */ +export function writeLeaf(cells: LeafCell[]): Buffer | null { + const page = Buffer.alloc(PAGE_SIZE); + let cursor = PAGE_SIZE; + const pointers: number[] = []; + for (const cell of cells) { + const size = CELL_HEADER + cell.local.length; + cursor -= size; + if (cursor < LEAF_HEADER + (pointers.length + 1) * 2) return null; + pointers.push(cursor); + page.writeUInt32BE(cell.rowid, cursor); + page.writeUInt32BE(cell.total, cursor + 4); + page.writeUInt32BE(cell.overflow, cursor + 8); + page.writeUInt16BE(cell.local.length, cursor + 12); + cell.local.copy(page, cursor + CELL_HEADER); + } + page.writeUInt8(PAGE_LEAF, 0); + page.writeUInt16BE(cells.length, 1); + page.writeUInt16BE(cells.length === 0 ? PAGE_SIZE : cursor, 3); + pointers.forEach((pointer, index) => page.writeUInt16BE(pointer, LEAF_HEADER + index * 2)); + return page; +} + +export function readInterior(page: Buffer): { dividers: Divider[]; right: number } { + assertType(page, PAGE_INTERIOR); + const count = page.readUInt16BE(1); + const right = page.readUInt32BE(8); + const dividers: Divider[] = []; + for (let i = 0; i < count; i += 1) { + const offset = page.readUInt16BE(INTERIOR_HEADER + i * 2); + dividers.push({ + left: page.readUInt32BE(offset), + key: page.readUInt32BE(offset + 4), + }); + } + return { dividers, right }; +} + +export function writeInterior(dividers: Divider[], right: number): Buffer | null { + const page = Buffer.alloc(PAGE_SIZE); + let cursor = PAGE_SIZE; + const pointers: number[] = []; + for (const divider of dividers) { + cursor -= INTERIOR_CELL; + if (cursor < INTERIOR_HEADER + (pointers.length + 1) * 2) return null; + pointers.push(cursor); + page.writeUInt32BE(divider.left, cursor); + page.writeUInt32BE(divider.key, cursor + 4); + } + page.writeUInt8(PAGE_INTERIOR, 0); + page.writeUInt16BE(dividers.length, 1); + page.writeUInt16BE(dividers.length === 0 ? PAGE_SIZE : cursor, 3); + page.writeUInt32BE(right, 8); + pointers.forEach((pointer, index) => page.writeUInt16BE(pointer, INTERIOR_HEADER + index * 2)); + return page; +} + +export function freelistCapacity(): number { + return Math.floor((PAGE_SIZE - FREELIST_HEADER) / 4); +} + +export function readFreelist(page: Buffer): { next: number; ids: number[] } { + assertType(page, PAGE_FREELIST); + const next = page.readUInt32BE(1); + const count = page.readUInt16BE(5); + const ids: number[] = []; + for (let i = 0; i < count; i += 1) ids.push(page.readUInt32BE(FREELIST_HEADER + i * 4)); + return { next, ids }; +} + +export function writeFreelist(next: number, ids: number[]): Buffer { + const page = Buffer.alloc(PAGE_SIZE); + page.writeUInt8(PAGE_FREELIST, 0); + page.writeUInt32BE(next, 1); + page.writeUInt16BE(ids.length, 5); + ids.forEach((id, index) => page.writeUInt32BE(id, FREELIST_HEADER + index * 4)); + return page; +} + +function assertType(page: Buffer, type: number): void { + if (page.readUInt8(0) !== type) { + throw new Error(`expected page type ${type}, found ${page.readUInt8(0)}`); + } +} diff --git a/src/pager/pager.ts b/src/pager/pager.ts new file mode 100644 index 0000000..5d08a21 --- /dev/null +++ b/src/pager/pager.ts @@ -0,0 +1,177 @@ +import { KinDbError } from '../errors'; +import { + blankLeaf, + emptyHeader, + freelistCapacity, + MAGIC, + PAGE_SIZE, + readFreelist, + readHeader, + writeFreelist, + writeHeader, + type FileHeader, +} from './format'; + +/** + * A database file is a sequence of fixed-size pages. + * Page 1 holds the file header. Later pages are btree leaves, btree + * interior pages, overflow chains, or freelist trunks. + */ +export class Pager { + private pages: Buffer[]; + private header: FileHeader; + + private constructor(pages: Buffer[], header: FileHeader) { + this.pages = pages; + this.header = header; + } + + static create(): Pager { + const pages = [Buffer.alloc(PAGE_SIZE), blankLeaf()]; + const header = emptyHeader(pages.length); + const pager = new Pager(pages, header); + pager.syncHeader(); + return pager; + } + + static open(bytes: Buffer): Pager { + if (bytes.length < 16 || !bytes.subarray(0, 16).equals(MAGIC)) { + throw new KinDbError('E_IO', 'Database file is not a KinDB page file'); + } + if (bytes.length % PAGE_SIZE !== 0) { + throw new KinDbError('E_IO', 'Database file length is not a multiple of the page size'); + } + let header: FileHeader; + try { + header = readHeader(bytes.subarray(0, PAGE_SIZE)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KinDbError('E_IO', message); + } + const pageCount = bytes.length / PAGE_SIZE; + if (header.pageCount !== pageCount) { + throw new KinDbError('E_IO', 'Database header page count does not match the file'); + } + const pages: Buffer[] = []; + for (let i = 0; i < pageCount; i += 1) { + pages.push(Buffer.from(bytes.subarray(i * PAGE_SIZE, (i + 1) * PAGE_SIZE))); + } + return new Pager(pages, header); + } + + get pageCount(): number { + return this.pages.length; + } + + get freelistCount(): number { + return this.header.freelistCount; + } + + get schemaRoot(): number { + return this.header.schemaRoot; + } + + set schemaRoot(page: number) { + this.header.schemaRoot = page; + this.touch(); + } + + get schemaNext(): number { + return this.header.schemaNext; + } + + set schemaNext(value: number) { + this.header.schemaNext = value; + this.touch(); + } + + get(pageNumber: number): Buffer { + this.assertPage(pageNumber); + return this.pages[pageNumber - 1]; + } + + put(pageNumber: number, page: Buffer): void { + this.assertPage(pageNumber); + if (page.length !== PAGE_SIZE) { + throw new KinDbError('E_IO', 'A page write must be the fixed page size'); + } + this.pages[pageNumber - 1] = page; + this.touch(); + } + + /** Take a page from the freelist, or append a new page at the end of the file. */ + allocate(): number { + if (this.header.freelistHead === 0) { + this.pages.push(Buffer.alloc(PAGE_SIZE)); + this.header.pageCount = this.pages.length; + this.touch(); + return this.pages.length; + } + const trunkNumber = this.header.freelistHead; + const trunk = readFreelist(this.get(trunkNumber)); + this.header.freelistCount -= 1; + if (trunk.ids.length > 0) { + const id = trunk.ids[trunk.ids.length - 1]; + this.put(trunkNumber, writeFreelist(trunk.next, trunk.ids.slice(0, -1))); + this.put(id, Buffer.alloc(PAGE_SIZE)); + return id; + } + this.header.freelistHead = trunk.next; + this.put(trunkNumber, Buffer.alloc(PAGE_SIZE)); + this.touch(); + return trunkNumber; + } + + /** Return a page to the freelist trunk chain. */ + free(pageNumber: number): void { + this.assertPage(pageNumber); + if (pageNumber === 1) throw new KinDbError('E_IO', 'Page 1 cannot be freed'); + const capacity = freelistCapacity(); + if (this.header.freelistHead === 0) { + this.put(pageNumber, writeFreelist(0, [])); + this.header.freelistHead = pageNumber; + this.header.freelistCount = 1; + this.touch(); + return; + } + const trunkNumber = this.header.freelistHead; + const trunk = readFreelist(this.get(trunkNumber)); + if (trunk.ids.length < capacity) { + this.put(trunkNumber, writeFreelist(trunk.next, [...trunk.ids, pageNumber])); + this.header.freelistCount += 1; + this.touch(); + return; + } + this.put(pageNumber, writeFreelist(trunkNumber, [])); + this.header.freelistHead = pageNumber; + this.header.freelistCount += 1; + this.touch(); + } + + toBuffer(): Buffer { + this.syncHeader(); + return Buffer.concat(this.pages.map((page) => Buffer.from(page))); + } + + copy(): Pager { + return Pager.open(this.toBuffer()); + } + + private syncHeader(): void { + this.header.pageCount = this.pages.length; + const page = Buffer.from(this.pages[0]); + writeHeader(page, this.header); + this.pages[0] = page; + } + + private touch(): void { + this.header.changeCounter += 1; + this.syncHeader(); + } + + private assertPage(pageNumber: number): void { + if (!Number.isInteger(pageNumber) || pageNumber < 1 || pageNumber > this.pages.length) { + throw new KinDbError('E_IO', `Page ${pageNumber} is outside the file`); + } + } +} diff --git a/src/pager/record.ts b/src/pager/record.ts new file mode 100644 index 0000000..24a1512 --- /dev/null +++ b/src/pager/record.ts @@ -0,0 +1,121 @@ +import { KinDbError } from '../errors'; +import type { JsonValue, Row } from '../value'; + +export type ColumnType = 'any' | 'number' | 'string' | 'boolean'; + +export interface ColumnDef { + name: string; + type: ColumnType; +} + +const TYPE_NAMES: Record = { + umubare: 'number', + ijambo: 'string', + ukuri: 'boolean', + ikintu: 'any', +}; + +export function columnTypeFromName(name: string): ColumnType { + const type = TYPE_NAMES[name]; + if (!type) { + throw new KinDbError( + 'E_TYPE', + `Column type '${name}' is not umubare, ijambo, ukuri, or ikintu`, + ); + } + return type; +} + +export function assertValueType(column: ColumnDef, value: JsonValue): void { + if (value === null || column.type === 'any') return; + if (column.type === 'number' && typeof value !== 'number') { + throw new KinDbError('E_TYPE', `Column '${column.name}' expects a number`); + } + if (column.type === 'string' && typeof value !== 'string') { + throw new KinDbError('E_TYPE', `Column '${column.name}' expects a string`); + } + if (column.type === 'boolean' && typeof value !== 'boolean') { + throw new KinDbError('E_TYPE', `Column '${column.name}' expects nibyo or sibyo`); + } +} + +/** Record body: present columns only, indexed into the table's column list. */ +export function encodeRecord(columns: ColumnDef[], row: Row): Buffer { + const parts: Buffer[] = []; + const present: Buffer[] = []; + columns.forEach((column, index) => { + if (!(column.name in row)) return; + const value = encodeValue(row[column.name]); + const head = Buffer.alloc(2); + head.writeUInt16BE(index, 0); + present.push(Buffer.concat([head, value])); + }); + const count = Buffer.alloc(2); + count.writeUInt16BE(present.length, 0); + parts.push(count, ...present); + return Buffer.concat(parts); +} + +export function decodeRecord(columns: ColumnDef[], payload: Buffer): Row { + if (payload.length < 2) throw new KinDbError('E_IO', 'A stored record is truncated'); + const count = payload.readUInt16BE(0); + const row: Row = {}; + let offset = 2; + for (let i = 0; i < count; i += 1) { + if (offset + 2 > payload.length) throw new KinDbError('E_IO', 'A stored record is truncated'); + const index = payload.readUInt16BE(offset); + offset += 2; + const decoded = decodeValue(payload, offset); + offset = decoded.offset; + const column = columns[index]; + if (!column) throw new KinDbError('E_IO', 'A stored record names a missing column'); + row[column.name] = decoded.value; + } + return row; +} + +function encodeValue(value: JsonValue): Buffer { + if (value === null) return Buffer.from([0]); + if (value === false) return Buffer.from([1]); + if (value === true) return Buffer.from([2]); + if (typeof value === 'number') { + const body = Buffer.alloc(9); + body.writeUInt8(3, 0); + body.writeDoubleBE(value, 1); + return body; + } + if (typeof value === 'string') return encodeBytes(4, Buffer.from(value, 'utf8')); + return encodeBytes(5, Buffer.from(JSON.stringify(value), 'utf8')); +} + +function encodeBytes(serial: number, bytes: Buffer): Buffer { + const body = Buffer.alloc(5 + bytes.length); + body.writeUInt8(serial, 0); + body.writeUInt32BE(bytes.length, 1); + bytes.copy(body, 5); + return body; +} + +function decodeValue(payload: Buffer, offset: number): { value: JsonValue; offset: number } { + if (offset >= payload.length) throw new KinDbError('E_IO', 'A stored value is truncated'); + const serial = payload.readUInt8(offset); + if (serial === 0) return { value: null, offset: offset + 1 }; + if (serial === 1) return { value: false, offset: offset + 1 }; + if (serial === 2) return { value: true, offset: offset + 1 }; + if (serial === 3) { + if (offset + 9 > payload.length) throw new KinDbError('E_IO', 'A stored number is truncated'); + const value = payload.readDoubleBE(offset + 1); + return { value: Number.isInteger(value) ? value : value, offset: offset + 9 }; + } + if (serial === 4 || serial === 5) { + if (offset + 5 > payload.length) throw new KinDbError('E_IO', 'A stored string is truncated'); + const length = payload.readUInt32BE(offset + 1); + const start = offset + 5; + const end = start + length; + if (end > payload.length) throw new KinDbError('E_IO', 'A stored string is truncated'); + const text = payload.subarray(start, end).toString('utf8'); + if (serial === 4) return { value: text, offset: end }; + return { value: JSON.parse(text) as JsonValue, offset: end }; + } + throw new KinDbError('E_IO', `Unknown value serial ${serial}`); +} diff --git a/src/parser/ast.ts b/src/parser/ast.ts new file mode 100644 index 0000000..0178884 --- /dev/null +++ b/src/parser/ast.ts @@ -0,0 +1,29 @@ +import type { ColumnDef } from '../pager/record'; +import type { JsonValue, Predicate, Row } from '../value'; + +export interface ColumnRef { + qualifier?: string; + name: string; +} + +export interface TableRef { + name: string; + alias?: string; +} + +export type Statement = + | { kind: 'create'; name: string; columns?: ColumnDef[] } + | { kind: 'drop'; name: string } + | { kind: 'dropAll' } + | { kind: 'insert'; collection: string; row: Row } + | { kind: 'update'; collection: string; data: Row; where: Predicate[] } + | { kind: 'delete'; collection: string; where: Predicate[] } + | { + kind: 'select'; + columns: '*' | ColumnRef[]; + from: TableRef[]; + where: Predicate[]; + limit?: number; + }; + +export type { JsonValue, Predicate, Row }; diff --git a/src/parser/parser.ts b/src/parser/parser.ts new file mode 100644 index 0000000..b2870aa --- /dev/null +++ b/src/parser/parser.ts @@ -0,0 +1,339 @@ +import type { ColumnRef, Statement, TableRef } from './ast'; +import { KinDbError } from '../errors'; +import { tokenize, type Token, type TokenType } from '../lexer/lexer'; +import { columnTypeFromName, type ColumnDef } from '../pager/record'; +import type { JsonValue, Predicate, Row } from '../value'; + +export function parse(source: string): Statement[] { + return new Parser(tokenize(source)).parseProgram(); +} + +class Parser { + private index = 0; + + constructor(private readonly tokens: Token[]) {} + + parseProgram(): Statement[] { + const statements: Statement[] = []; + while (!this.check('eof')) { + this.skipSemis(); + if (this.check('eof')) break; + statements.push(this.statement()); + if (!this.check('eof') && !this.check('semi') && this.startsStatement(this.peek().type)) { + continue; + } + if (!this.check('eof')) this.expect('semi', true); + this.skipSemis(); + } + return statements; + } + + private statement(): Statement { + const token = this.peek(); + switch (token.type) { + case 'kora': + return this.createStatement(); + case 'siba': + return this.deleteOrDrop(); + case 'shira': + return this.insertStatement(); + case 'hindura': + return this.updateStatement(); + case 'hitamo': + return this.selectStatement(); + default: + throw this.syntax(`Expected a statement but found '${token.lexeme || token.type}'`, false); + } + } + + private createStatement(): Statement { + this.expect('kora'); + this.expect('itsinda'); + const name = this.identifier(); + if (!this.match('lparen')) return { kind: 'create', name }; + const columns: ColumnDef[] = []; + if (!this.check('rparen')) { + columns.push(this.columnDef()); + while (this.match('comma')) columns.push(this.columnDef()); + } + this.expect('rparen'); + return { kind: 'create', name, columns }; + } + + private deleteOrDrop(): Statement { + this.expect('siba'); + if (this.match('byose')) return { kind: 'dropAll' }; + if (this.match('itsinda')) return { kind: 'drop', name: this.identifier() }; + this.expect('muri'); + const collection = this.identifier(); + this.expect('aho'); + return { kind: 'delete', collection, where: this.condition() }; + } + + private insertStatement(): Statement { + this.expect('shira'); + this.expect('muri'); + const collection = this.identifier(); + if (this.check('lbrace')) { + return { kind: 'insert', collection, row: this.objectRow() }; + } + this.expect('lparen'); + const columns = this.identifierList(); + this.expect('rparen'); + this.expect('agaciro'); + this.expect('lparen'); + const values: JsonValue[] = []; + if (!this.check('rparen')) { + values.push(this.value()); + while (this.match('comma')) values.push(this.value()); + } + this.expect('rparen'); + if (columns.length !== values.length) { + throw this.syntax( + `SHIRA lists ${columns.length} columns but ${values.length} values`, + false, + ); + } + const row: Row = {}; + columns.forEach((column, index) => { + if (column in row) { + throw this.syntax(`Duplicate column '${column}'`, false); + } + row[column] = values[index]; + }); + return { kind: 'insert', collection, row }; + } + + private updateStatement(): Statement { + this.expect('hindura'); + const collection = this.identifier(); + this.expect('shyira'); + const data: Row = {}; + this.assignment(data); + while (this.match('comma')) this.assignment(data); + this.expect('aho'); + return { kind: 'update', collection, data, where: this.condition() }; + } + + private selectStatement(): Statement { + this.expect('hitamo'); + const columns = this.selectColumns(); + this.expect('muri'); + const from = [this.tableRef()]; + while (this.match('huza')) from.push(this.tableRef()); + const where = this.match('aho') ? this.condition() : []; + let limit: number | undefined; + if (this.match('imipaka')) { + const token = this.expect('number'); + const value = token.value; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw this.syntax('IMIPAKA expects a non-negative integer', false); + } + limit = value; + } + return { kind: 'select', columns, from, where, limit }; + } + + private selectColumns(): '*' | ColumnRef[] { + if (this.match('star')) return '*'; + const columns = [this.columnRef()]; + while (this.match('comma')) columns.push(this.columnRef()); + return columns; + } + + private tableRef(): TableRef { + const name = this.identifier(); + if (!this.check('identifier')) return { name }; + return { name, alias: this.identifier() }; + } + + private columnRef(): ColumnRef { + const first = this.identifier(); + if (!this.match('dot')) return { name: first }; + return { qualifier: first, name: this.identifier() }; + } + + private columnDef(): ColumnDef { + const name = this.identifier(); + const typeName = this.identifier(); + return { name, type: columnTypeFromName(typeName.toLowerCase()) }; + } + + private assignment(data: Row): void { + const field = this.identifier(); + if (this.check('eqeq')) { + throw this.syntax("SHYIRA uses '=' between a field and its value", false); + } + this.expect('eq'); + if (field in data) throw this.syntax(`Duplicate field '${field}'`, false); + data[field] = this.value(); + } + + private condition(): Predicate[] { + const predicates = [this.comparison()]; + while (this.match('kandi')) predicates.push(this.comparison()); + return predicates; + } + + private comparison(): Predicate { + const left = this.columnRef(); + const operator = this.peek(); + const finish = (op: Predicate['op'], numeric: boolean): Predicate => { + this.advance(); + if (this.check('identifier')) { + const right = this.columnRef(); + return { + op, + field: left.name, + qualifier: left.qualifier, + rightField: right.name, + rightQualifier: right.qualifier, + }; + } + const value = this.value(); + if (numeric && typeof value !== 'number') { + throw this.syntax(`Comparison '${operator.lexeme}' expects a number`, false); + } + return { op, field: left.name, qualifier: left.qualifier, value }; + }; + switch (operator.type) { + case 'eq': + case 'eqeq': + return finish('loose', false); + case 'lt': + case 'munsi': + return finish('lt', true); + case 'lte': + case 'munsi_cyangwa': + return finish('lte', true); + case 'gt': + case 'hejuru': + return finish('gt', true); + case 'gte': + case 'hejuru_cyangwa': + return finish('gte', true); + case 'ni': + return finish('eq', true); + default: + throw this.syntax( + `Expected a comparison after '${this.formatColumn(left)}'`, + operator.type === 'eof', + ); + } + } + + private formatColumn(column: ColumnRef): string { + return column.qualifier ? `${column.qualifier}.${column.name}` : column.name; + } + + private objectRow(): Row { + this.expect('lbrace'); + const row: Row = {}; + if (this.match('rbrace')) return row; + this.objectField(row); + while (this.match('comma')) { + if (this.check('rbrace')) break; + this.objectField(row); + } + this.expect('rbrace'); + return row; + } + + private objectField(row: Row): void { + const keyToken = this.peek(); + let key: string; + if (keyToken.type === 'string' && typeof keyToken.value === 'string') { + this.advance(); + key = keyToken.value; + } else { + key = this.identifier(); + } + this.expect('colon'); + if (key in row) throw this.syntax(`Duplicate field '${key}'`, false); + row[key] = this.value(); + } + + private identifierList(): string[] { + const names = [this.identifier()]; + while (this.match('comma')) names.push(this.identifier()); + return names; + } + + private identifier(): string { + const token = this.peek(); + if (token.type !== 'identifier') { + throw this.syntax( + `Expected a name but found '${token.lexeme || token.type}'`, + token.type === 'eof', + ); + } + this.advance(); + return token.lexeme; + } + + private value(): JsonValue { + if (this.match('minus')) { + const token = this.expect('number'); + return -(token.value as number); + } + const token = this.peek(); + if (token.type === 'number' && typeof token.value === 'number') { + this.advance(); + return token.value; + } + if (token.type === 'string' && typeof token.value === 'string') { + this.advance(); + return token.value; + } + if (this.match('nibyo')) return true; + if (this.match('sibyo')) return false; + if (this.match('ubusa')) return null; + throw this.syntax(`Expected a value but found '${token.lexeme || token.type}'`, token.type === 'eof'); + } + + private skipSemis(): void { + while (this.match('semi')) { + /* statement separators are optional between keywords too */ + } + } + + private startsStatement(type: TokenType): boolean { + return type === 'kora' || type === 'siba' || type === 'shira' || type === 'hindura' || type === 'hitamo'; + } + + private match(type: TokenType): boolean { + if (!this.check(type)) return false; + this.advance(); + return true; + } + + private check(type: TokenType): boolean { + return this.peek().type === type; + } + + private advance(): Token { + const token = this.peek(); + if (token.type !== 'eof') this.index += 1; + return token; + } + + private peek(): Token { + return this.tokens[this.index] ?? this.tokens[this.tokens.length - 1]; + } + + private expect(type: TokenType, optionalAtEof = false): Token { + const token = this.peek(); + if (token.type === type) return this.advance(); + const incomplete = token.type === 'eof'; + if (optionalAtEof && incomplete) return token; + throw this.syntax( + `Expected '${type}' but found '${token.lexeme || token.type}'`, + incomplete, + ); + } + + private syntax(message: string, incomplete: boolean): KinDbError { + const token = this.peek(); + return new KinDbError('E_SYNTAX', `${message} at ${token.line}:${token.column}`, incomplete); + } +} diff --git a/src/relational/algebra.ts b/src/relational/algebra.ts new file mode 100644 index 0000000..ebbed34 --- /dev/null +++ b/src/relational/algebra.ts @@ -0,0 +1,206 @@ +import { KinDbError } from '../errors'; +import type { ColumnRef, TableRef } from '../parser/ast'; +import { cloneJson, looseEqual, compare, type JsonValue, type Predicate, type Row } from '../value'; + +/** + * One tuple in a relation. `sources` keeps each input row addressable + * by table name or alias so selection and join can see qualified columns. + */ +export interface Scope { + row: Row; + sources: Record; + primary: string; +} + +export interface InputRelation { + ref: TableRef; + rows: Row[]; + columns: string[]; +} + +/** + * Run a select as relational operators only: + * selection on each input, theta-join for `huza`, then selection of whatever + * predicate still crosses the result, then projection and limit. + */ +export function evaluateSelect( + inputs: InputRelation[], + where: Predicate[], + columns: '*' | ColumnRef[], + limit?: number, +): Row[] { + if (inputs.length === 0) return []; + const seen = new Set(); + for (const input of inputs) { + const name = input.ref.alias ?? input.ref.name; + if (seen.has(name)) { + throw new KinDbError( + 'E_TYPE', + `Join name '${name}' is already in use. Give the table an alias`, + ); + } + seen.add(name); + } + for (const predicate of where) { + for (const column of referenced(predicate)) { + if (column.qualifier) continue; + const owners = inputs.filter((input) => input.columns.includes(column.field)); + if (owners.length > 1) { + throw new KinDbError('E_TYPE', `Column '${column.field}' is ambiguous`); + } + } + } + const pending = [...where]; + let scopes = selection(asScopes(inputs[0]), take(pending, (predicate) => covers(predicate, inputs[0]))); + for (let index = 1; index < inputs.length; index += 1) { + const right = inputs[index]; + const rightScopes = selection(asScopes(right), take(pending, (predicate) => covers(predicate, right))); + const joinOn = take(pending, (predicate) => crosses(predicate, inputs.slice(0, index), right)); + scopes = join(scopes, rightScopes, joinOn); + } + scopes = selection(scopes, pending.splice(0)); + const projected = project(scopes, columns); + if (limit === undefined) return projected; + if (!Number.isInteger(limit) || limit < 0) { + throw new KinDbError('E_TYPE', 'Limit must be a non-negative integer'); + } + return projected.slice(0, limit); +} + +/** σ — keep tuples that match every predicate. */ +export function selection(scopes: Scope[], predicates: Predicate[]): Scope[] { + return scopes.filter((scope) => predicates.every((predicate) => holds(scope, predicate))); +} + +/** ⋈ — inner join. Predicates that mention both sides are the theta condition. */ +export function join(left: Scope[], right: Scope[], predicates: Predicate[]): Scope[] { + const joined: Scope[] = []; + for (const leftScope of left) { + for (const rightScope of right) { + const overlap = Object.keys(leftScope.sources).find((name) => name in rightScope.sources); + if (overlap) { + throw new KinDbError( + 'E_TYPE', + `Join name '${overlap}' is already in use. Give the table an alias`, + ); + } + const sources = { ...leftScope.sources, ...rightScope.sources }; + const row = mergeRows(leftScope.row, rightScope.row, leftScope.primary, rightScope.primary); + const scope: Scope = { row, sources, primary: leftScope.primary }; + if (predicates.every((predicate) => holds(scope, predicate))) joined.push(scope); + } + } + return joined; +} + +export function project(scopes: Scope[], columns: '*' | ColumnRef[]): Row[] { + if (columns === '*') return scopes.map((scope) => cloneJson(scope.row)); + const names = columns.map((column) => column.name); + const duplicated = new Set(names.filter((name, index) => names.indexOf(name) !== index)); + return scopes.map((scope) => { + const row: Row = {}; + for (const column of columns) { + const key = + column.qualifier && duplicated.has(column.name) + ? `${column.qualifier}.${column.name}` + : column.name; + const value = readColumn(scope, column.qualifier, column.name); + row[key] = value === undefined ? null : cloneJson(value); + } + return row; + }); +} + +function asScopes(input: InputRelation): Scope[] { + const qualifiers = input.ref.alias ? [input.ref.alias] : [input.ref.name]; + return input.rows.map((row) => ({ + row: cloneJson(row), + sources: Object.fromEntries(qualifiers.map((qualifier) => [qualifier, row])), + primary: input.ref.alias ?? input.ref.name, + })); +} + +function take(predicates: Predicate[], accept: (predicate: Predicate) => boolean): Predicate[] { + const kept: Predicate[] = []; + for (let index = predicates.length - 1; index >= 0; index -= 1) { + if (accept(predicates[index])) kept.unshift(predicates.splice(index, 1)[0]); + } + return kept; +} + +function covers(predicate: Predicate, input: InputRelation): boolean { + const columns = referenced(predicate); + return columns.length > 0 && columns.every((column) => side(column, [], input) === 'right'); +} + +function crosses(predicate: Predicate, left: InputRelation[], right: InputRelation): boolean { + const sides = referenced(predicate).map((column) => side(column, left, right)); + return sides.includes('left') && sides.includes('right'); +} + +function side( + column: { qualifier?: string; field: string }, + left: InputRelation[], + right: InputRelation, +): 'left' | 'right' | 'both' | 'none' { + if (column.qualifier) { + const inLeft = left.some((input) => qualifierOf(input) === column.qualifier); + const inRight = qualifierOf(right) === column.qualifier; + if (inLeft && inRight) return 'both'; + if (inLeft) return 'left'; + if (inRight) return 'right'; + return 'none'; + } + const inLeft = left.some((input) => input.columns.includes(column.field)); + const inRight = right.columns.includes(column.field); + if (inLeft && inRight) return 'both'; + if (inLeft) return 'left'; + if (inRight) return 'right'; + return 'none'; +} + +function qualifierOf(input: InputRelation): string { + return input.ref.alias ?? input.ref.name; +} + +function referenced(predicate: Predicate): { qualifier?: string; field: string }[] { + const columns = [{ qualifier: predicate.qualifier, field: predicate.field }]; + if (predicate.rightField) { + columns.push({ qualifier: predicate.rightQualifier, field: predicate.rightField }); + } + return columns; +} + +function holds(scope: Scope, predicate: Predicate): boolean { + const left = readColumn(scope, predicate.qualifier, predicate.field); + if (predicate.rightField) { + const right = readColumn(scope, predicate.rightQualifier, predicate.rightField); + if (predicate.op === 'loose') return looseEqual(left, right ?? null); + return compare(left, predicate.op, typeof right === 'number' ? right : Number.NaN); + } + if (predicate.op === 'loose') return looseEqual(left, predicate.value ?? null); + return compare(left, predicate.op, typeof predicate.value === 'number' ? predicate.value : Number.NaN); +} + +function readColumn(scope: Scope, qualifier: string | undefined, field: string): JsonValue | undefined { + if (qualifier) { + const source = scope.sources[qualifier]; + if (!source) throw new KinDbError('E_TYPE', `Unknown table '${qualifier}'`); + return source[field]; + } + const hits = Object.values(scope.sources).filter((source) => field in source); + if (hits.length > 1) throw new KinDbError('E_TYPE', `Column '${field}' is ambiguous`); + return hits.length === 1 ? hits[0][field] : undefined; +} + +function mergeRows(left: Row, right: Row, leftName: string, rightName: string): Row { + const row: Row = {}; + const rightKeys = new Set(Object.keys(right)); + for (const [key, value] of Object.entries(left)) { + row[rightKeys.has(key) ? `${leftName}.${key}` : key] = cloneJson(value); + } + for (const [key, value] of Object.entries(right)) { + row[key in left ? `${rightName}.${key}` : key] = cloneJson(value); + } + return row; +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..d0650cc --- /dev/null +++ b/src/store.ts @@ -0,0 +1,302 @@ +import { KinDbError } from './errors'; +import { deleteTree, freeTree, insertTree, scanTree } from './pager/btree'; +import { blankLeaf } from './pager/format'; +import { Pager } from './pager/pager'; +import { + assertValueType, + decodeRecord, + encodeRecord, + type ColumnDef, +} from './pager/record'; +import { + assertCollectionName, + assertRow, + cloneJson, + matches, + whereToPredicates, + type Predicate, + type Row, + type Where, +} from './value'; + +export const KINDB_VERSION = 1; + +const CATALOG: ColumnDef[] = [ + { name: 'name', type: 'string' }, + { name: 'root', type: 'number' }, + { name: 'nextRowid', type: 'number' }, + { name: 'columns', type: 'string' }, +]; + +interface Table { + /** Rowid of this table inside the schema btree. */ + rowid: number; + name: string; + root: number; + nextRowid: number; + columns: ColumnDef[]; +} + +/** + * Relations stored in a page file. + * Each user table is a btree. The schema btree on the header's schema root + * lists those tables. CRUD still matches json-base: get, add, set, del, delAll. + */ +export class Store { + private pager: Pager; + + private constructor(pager: Pager) { + this.pager = pager; + } + + static empty(): Store { + return new Store(Pager.create()); + } + + static fromBytes(bytes: Buffer): Store { + return new Store(Pager.open(bytes)); + } + + /** Open a json-base or older KinDB JSON document and copy it into pages. */ + static fromDocument(raw: unknown): Store { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new KinDbError('E_IO', 'A .db file must be a JSON object with a db field'); + } + const document = raw as Record; + if ('kindb' in document && document.kindb !== KINDB_VERSION) { + throw new KinDbError( + 'E_IO', + `Unsupported .db version '${String(document.kindb)}'. This package reads kindb ${KINDB_VERSION}`, + ); + } + if (!('db' in document)) { + throw new KinDbError('E_IO', 'A .db file must contain a db object'); + } + const db = document.db; + if (db === null || typeof db !== 'object' || Array.isArray(db)) { + throw new KinDbError('E_IO', 'The db field must be an object of collections'); + } + const store = Store.empty(); + for (const [name, rows] of Object.entries(db as Record)) { + assertCollectionName(name); + if (!Array.isArray(rows)) { + throw new KinDbError('E_IO', `Collection '${name}' must be an array of records`); + } + store.createCollection(name); + rows.forEach((row, index) => { + assertRow(row, `${name}[${index}]`); + store.add(name, row); + }); + } + return store; + } + + clone(): Store { + return Store.fromBytes(this.toBytes()); + } + + toBytes(): Buffer { + return this.pager.toBuffer(); + } + + get pageCount(): number { + return this.pager.pageCount; + } + + get freelistCount(): number { + return this.pager.freelistCount; + } + + names(): string[] { + return this.catalog().map((table) => table.name); + } + + columnNames(name: string): string[] { + return this.require(name).columns.map((column) => column.name); + } + + createCollection(name: string, columns: ColumnDef[] = []): void { + assertCollectionName(name); + if (this.find(name)) { + throw new KinDbError('E_COLLECTION_EXISTS', `Collection '${name}' already exists`); + } + const seen = new Set(); + for (const column of columns) { + assertCollectionName(column.name); + if (seen.has(column.name)) { + throw new KinDbError('E_TYPE', `Duplicate column '${column.name}'`); + } + seen.add(column.name); + } + const root = this.pager.allocate(); + this.pager.put(root, blankLeaf()); + const rowid = this.pager.schemaNext; + this.pager.schemaNext = rowid + 1; + this.insertCatalog({ rowid, name, root, nextRowid: 1, columns: columns.map((column) => ({ ...column })) }); + } + + dropCollection(name: string): void { + const table = this.require(name); + freeTree(this.pager, table.root); + this.pager.schemaRoot = deleteTree(this.pager, this.pager.schemaRoot, table.rowid); + } + + /** json-base delAll: every user relation is removed. */ + delAll(): void { + for (const table of this.catalog()) freeTree(this.pager, table.root); + freeTree(this.pager, this.pager.schemaRoot); + const root = this.pager.allocate(); + this.pager.put(root, blankLeaf()); + this.pager.schemaRoot = root; + this.pager.schemaNext = 1; + } + + add(collection: string, data: Row): Row { + const table = this.require(collection); + assertRow(data, 'data'); + const row = cloneJson(data); + this.admitColumns(table, row); + const rowid = table.nextRowid; + table.nextRowid += 1; + table.root = insertTree(this.pager, table.root, rowid, encodeRecord(table.columns, row)); + this.saveCatalog(table); + return row; + } + + get(collection: string, where?: Where, limit?: number): Row[] { + const predicates = where ? whereToPredicates(where) : []; + return this.filter(this.rows(collection), predicates, limit); + } + + set(collection: string, where: Where, data: Row): void { + if (!where || Object.keys(where).length === 0) { + throw new KinDbError('E_MISSING_PARAM', 'Missing parameter where'); + } + assertRow(data, 'data'); + this.updateWhere(collection, whereToPredicates(where), data); + } + + del(collection: string, where: Where): number { + if (!where || Object.keys(where).length === 0) { + throw new KinDbError('E_MISSING_PARAM', 'Missing parameter where'); + } + return this.deleteWhere(collection, whereToPredicates(where)); + } + + rows(collection: string): Row[] { + const table = this.require(collection); + return scanTree(this.pager, table.root).map((record) => + decodeRecord(table.columns, record.payload), + ); + } + + filter(rows: Row[], predicates: Predicate[], limit?: number): Row[] { + const matched = rows.filter((row) => matches(row, predicates)).map((row) => cloneJson(row)); + if (limit === undefined) return matched; + if (!Number.isInteger(limit) || limit < 0) { + throw new KinDbError('E_TYPE', 'Limit must be a non-negative integer'); + } + return matched.slice(0, limit); + } + + deleteWhere(collection: string, predicates: Predicate[]): number { + if (predicates.length === 0) { + throw new KinDbError('E_MISSING_PARAM', 'Missing parameter where'); + } + const table = this.require(collection); + const hits = scanTree(this.pager, table.root).filter((record) => + matches(decodeRecord(table.columns, record.payload), predicates), + ); + if (hits.length === 0) { + throw new KinDbError('E_NOT_FOUND', `No record in '${collection}' matched the where clause`); + } + for (const hit of hits) { + table.root = deleteTree(this.pager, table.root, hit.rowid); + } + this.saveCatalog(table); + return hits.length; + } + + updateWhere(collection: string, predicates: Predicate[], data: Row): number { + if (predicates.length === 0) { + throw new KinDbError('E_MISSING_PARAM', 'Missing parameter where'); + } + const table = this.require(collection); + const records = scanTree(this.pager, table.root); + const hits = records.filter((record) => + matches(decodeRecord(table.columns, record.payload), predicates), + ); + if (hits.length === 0) { + throw new KinDbError('E_NOT_FOUND', `No record in '${collection}' matched the where clause`); + } + if (hits.length > 1) { + throw new KinDbError( + 'E_DUPLICATE', + `More than one record in '${collection}' matched the where clause`, + ); + } + const current = decodeRecord(table.columns, hits[0].payload); + const merged = { ...current, ...cloneJson(data) }; + this.admitColumns(table, merged); + table.root = deleteTree(this.pager, table.root, hits[0].rowid); + table.root = insertTree(this.pager, table.root, hits[0].rowid, encodeRecord(table.columns, merged)); + this.saveCatalog(table); + return 1; + } + + private admitColumns(table: Table, data: Row): void { + for (const key of Object.keys(data)) { + assertCollectionName(key); + if (!table.columns.some((column) => column.name === key)) { + table.columns.push({ name: key, type: 'any' }); + } + } + for (const column of table.columns) { + if (column.name in data) assertValueType(column, data[column.name]); + } + } + + private require(name: string): Table { + assertCollectionName(name); + const table = this.find(name); + if (!table) throw new KinDbError('E_COLLECTION_MISSING', `Collection '${name}' was not found`); + return table; + } + + private find(name: string): Table | undefined { + return this.catalog().find((table) => table.name === name); + } + + private catalog(): Table[] { + return scanTree(this.pager, this.pager.schemaRoot).map((record) => { + const row = decodeRecord(CATALOG, record.payload); + const columns = JSON.parse(String(row.columns)) as ColumnDef[]; + return { + rowid: record.rowid, + name: String(row.name), + root: Number(row.root), + nextRowid: Number(row.nextRowid), + columns, + }; + }); + } + + private insertCatalog(table: Table): void { + const payload = encodeRecord(CATALOG, catalogRow(table)); + this.pager.schemaRoot = insertTree(this.pager, this.pager.schemaRoot, table.rowid, payload); + } + + private saveCatalog(table: Table): void { + this.pager.schemaRoot = deleteTree(this.pager, this.pager.schemaRoot, table.rowid); + this.insertCatalog(table); + } +} + +function catalogRow(table: Table): Row { + return { + name: table.name, + root: table.root, + nextRowid: table.nextRowid, + columns: JSON.stringify(table.columns), + }; +} diff --git a/src/value.ts b/src/value.ts new file mode 100644 index 0000000..54fbc40 --- /dev/null +++ b/src/value.ts @@ -0,0 +1,179 @@ +import { KinDbError } from './errors'; + +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +/** One collection record. Nested objects are stored, but WHERE compares scalars. */ +export type Row = { [key: string]: JsonValue }; + +export type CompareOp = 'lt' | 'lte' | 'gt' | 'gte' | 'eq'; + +/** + * A WHERE field. + * A scalar uses loose equality (the number 1 matches the string "1"). + * An object is a bag of comparison operators, combined with AND. + */ +export type FieldFilter = JsonValue | Partial>; +export type Where = Record; + +/** Kinyarwanda names for the json-base comparison operators. */ +export type CompareAlias = + | 'munsi' + | 'munsi_cyangwa' + | 'hejuru' + | 'hejuru_cyangwa' + | 'ni'; + +export type Predicate = { + op: 'loose' | CompareOp; + field: string; + qualifier?: string; + /** Set for `column = value`. Absent when the right side is another column. */ + value?: JsonValue; + rightField?: string; + rightQualifier?: string; +}; + +const OPERATOR_ALIAS: Record = { + lt: 'lt', + lte: 'lte', + gt: 'gt', + gte: 'gte', + eq: 'eq', + munsi: 'lt', + munsi_cyangwa: 'lte', + hejuru: 'gt', + hejuru_cyangwa: 'gte', + ni: 'eq', +}; + +const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function assertCollectionName(name: string): void { + if (!NAME_PATTERN.test(name)) { + throw new KinDbError( + 'E_TYPE', + `Collection name '${name}' must be a letter or underscore followed by letters, digits, or underscores`, + ); + } +} + +export function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function assertJson(value: unknown, path = 'value'): asserts value is JsonValue { + if (value === null) return; + const kind = typeof value; + if (kind === 'string' || kind === 'boolean') return; + if (kind === 'number') { + if (!Number.isFinite(value)) { + throw new KinDbError('E_TYPE', `${path} must be a finite number`); + } + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => assertJson(item, `${path}[${index}]`)); + return; + } + if (kind === 'object') { + for (const [key, item] of Object.entries(value as Record)) { + assertJson(item, `${path}.${key}`); + } + return; + } + throw new KinDbError('E_TYPE', `${path} is not JSON data`); +} + +export function assertRow(value: unknown, label = 'record'): asserts value is Row { + assertJson(value, label); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new KinDbError('E_TYPE', `A ${label} must be an object`); + } +} + +/** + * Loose equality used by a plain WHERE value. + * Same-type values use ===. A number and a numeric string match each other, + * which is the useful part of json-base's `==` string coercion. + */ +export function looseEqual(left: JsonValue | undefined, right: JsonValue): boolean { + if (left === undefined) return false; + if (left === right) return true; + if (typeof left === 'number' && typeof right === 'string') { + return numericString(right) === left; + } + if (typeof left === 'string' && typeof right === 'number') { + return numericString(left) === right; + } + return false; +} + +function numericString(value: string): number { + if (value.trim() === '') return NaN; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : NaN; +} + +export function compare(left: JsonValue | undefined, op: CompareOp, right: number): boolean { + // `eq` / `ni` follows json-base: the field must already be that number. + if (op === 'eq') return left === right; + // lt/lte/gt/gte follow json-base and coerce numeric strings ("5" < 10). + const value = typeof left === 'number' ? left : typeof left === 'string' ? numericString(left) : NaN; + if (!Number.isFinite(value)) return false; + switch (op) { + case 'lt': + return value < right; + case 'lte': + return value <= right; + case 'gt': + return value > right; + case 'gte': + return value >= right; + } +} + +export function matches(row: Row, predicates: Predicate[]): boolean { + for (const predicate of predicates) { + const left = row[predicate.field]; + const right = predicate.rightField ? row[predicate.rightField] : predicate.value; + if (predicate.op === 'loose') { + if (!looseEqual(left, right ?? null)) return false; + } else if (typeof right !== 'number' || !compare(left, predicate.op, right)) { + return false; + } + } + return true; +} + +/** Turn a json-base style `where` object into AND predicates. */ +export function whereToPredicates(where: Where): Predicate[] { + const predicates: Predicate[] = []; + for (const [field, filter] of Object.entries(where)) { + if (!NAME_PATTERN.test(field)) { + throw new KinDbError('E_TYPE', `Field name '${field}' is not a valid identifier`); + } + if (filter !== null && typeof filter === 'object' && !Array.isArray(filter)) { + const entries = Object.entries(filter); + if (entries.length === 0) { + throw new KinDbError('E_MISSING_PARAM', `Where field '${field}' has no comparison`); + } + for (const [operator, raw] of entries) { + const op = OPERATOR_ALIAS[operator]; + if (!op) { + throw new KinDbError('E_OPERATOR', `Operator '${operator}' is not supported`); + } + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + throw new KinDbError( + 'E_TYPE', + `Operator '${operator}' on '${field}' expects a finite number`, + ); + } + predicates.push({ op, field, value: raw }); + } + continue; + } + assertJson(filter, `where.${field}`); + predicates.push({ op: 'loose', field, value: filter as JsonValue }); + } + return predicates; +} diff --git a/tests/engine.test.ts b/tests/engine.test.ts new file mode 100644 index 0000000..3773f25 --- /dev/null +++ b/tests/engine.test.ts @@ -0,0 +1,485 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; +import { createReplSession, main } from '../src/cli'; +import { Database } from '../src/database'; +import { KinDbError } from '../src/errors'; +import { formatRows } from '../src/format/table'; +import { PAGE_SIZE } from '../src/pager/format'; +import { Store } from '../src/store'; + +const dirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'kindb-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const SCRIPT = ` +# ishuri +kora itsinda abanyeshuri +kora itsinda abigisha + +shira muri abanyeshuri { id: 1, izina: "Aline", amanota: 88, yize: nibyo } +shira muri abanyeshuri (id, izina, amanota, yize) agaciro (2, "Keza", 41, sibyo) +shira muri abigisha { id: 1, izina: "Mukama", umurimo: ubusa } + +hindura abanyeshuri shyira amanota = 70 aho id = 2 +hitamo izina, amanota muri abanyeshuri aho amanota >= 50 kandi yize = nibyo imipaka 10 +`; + +describe('Kinyarwanda SQL', () => { + test('creates, inserts, updates, and selects', () => { + const db = Database.memory(); + const result = db.execute(SCRIPT); + expect(result.inserted).toBe(3); + expect(result.updated).toBe(1); + expect(result.last).toEqual({ + kind: 'select', + rows: [{ izina: 'Aline', amanota: 88 }], + }); + expect(db.get({ collection: 'abanyeshuri', where: { id: 2 } })).toEqual([ + { id: 2, izina: 'Keza', amanota: 70, yize: false }, + ]); + expect(db.get({ collection: 'abigisha' })[0].umurimo).toBeNull(); + }); + + test('accepts uppercase keywords, comments, and semicolons', () => { + const db = Database.memory(); + db.execute(` + KORA ITSINDA abakoresha; + SHIRA MURI abakoresha { id: 1, izina: "Pacifique" }; + -- comment + HITAMO * MURI abakoresha AHO izina = "Pacifique" + `); + expect(db.collections()).toEqual(['abakoresha']); + expect(db.query('HITAMO izina MURI abakoresha')).toEqual([{ izina: 'Pacifique' }]); + }); + + test('comparison words match lt gt lte gte eq', () => { + const db = Database.memory(); + db.execute(` + kora itsinda abanyeshuri + shira muri abanyeshuri { id: 1, amanota: 10 } + shira muri abanyeshuri { id: 2, amanota: 20 } + shira muri abanyeshuri { id: 3, amanota: 30 } + `); + const rows = db.query(` + hitamo id muri abanyeshuri + aho amanota munsi 30 kandi amanota hejuru 10 kandi id ni 2 + `); + expect(rows).toEqual([{ id: 2 }]); + expect( + db.get({ + collection: 'abanyeshuri', + where: { amanota: { lt: 30, gt: 10 } }, + limit: 1, + }), + ).toEqual([{ id: 2, amanota: 20 }]); + }); + + test('loose equality matches a number to its numeric string', () => { + const db = Database.memory(); + db.createCollection('users'); + db.add({ collection: 'users', data: { id: '1', username: 'leerob' } }); + expect(db.get({ collection: 'users', where: { id: 1 } })).toEqual([ + { id: '1', username: 'leerob' }, + ]); + expect(db.get({ collection: 'users', where: { id: { eq: 1 } } })).toEqual([]); + }); + + test('projects missing columns as null and applies limit', () => { + const db = Database.memory(); + db.execute(` + kora itsinda t + shira muri t { id: 1 } + shira muri t { id: 2 } + shira muri t { id: 3 } + `); + expect(db.query('hitamo id, izina muri t imipaka 2')).toEqual([ + { id: 1, izina: null }, + { id: 2, izina: null }, + ]); + expect(db.query('hitamo * muri t imipaka 0')).toEqual([]); + }); + + test('deletes every match and refuses an empty where', () => { + const db = Database.memory(); + db.execute(` + kora itsinda t + shira muri t { id: 1, kind: "a" } + shira muri t { id: 2, kind: "a" } + shira muri t { id: 3, kind: "b" } + `); + expect(db.execute('siba muri t aho kind = "a"').deleted).toBe(2); + expect(db.get({ collection: 't' })).toEqual([{ id: 3, kind: 'b' }]); + expect(() => db.del({ collection: 't', where: {} })).toThrow(KinDbError); + }); + + test('update requires exactly one row and keeps unrelated fields', () => { + const db = Database.memory(); + db.createCollection('users'); + db.add({ + collection: 'users', + data: { id: 1, username: 'leerob', email: 'lee@rob.io' }, + }); + db.add({ collection: 'users', data: { id: 2, username: 'leerob', email: 'x' } }); + expect(() => + db.set({ + collection: 'users', + where: { username: 'leerob' }, + data: { email: 'new@kin.rw' }, + }), + ).toThrow(/More than one record/); + db.set({ + collection: 'users', + where: { id: 1 }, + data: { email: 'leerobin@gmail.com' }, + }); + expect(db.get({ collection: 'users', where: { id: 1 } })[0]).toEqual({ + id: 1, + username: 'leerob', + email: 'leerobin@gmail.com', + }); + }); + + test('rolls back a script when a later statement fails', () => { + const db = Database.memory(); + expect(() => + db.execute(` + kora itsinda t + shira muri t { id: 1 } + siba muri t aho id = 9 + `), + ).toThrow(/No record/); + expect(db.collections()).toEqual([]); + }); + + test('siba byose removes every collection', () => { + const db = Database.memory(); + db.execute('kora itsinda t shira muri t { id: 1 }'); + db.delAll(); + expect(db.collections()).toEqual([]); + expect(() => db.get({ collection: 't' })).toThrow(/was not found/); + }); + + test('rejects duplicate collections, bad operators, and syntax', () => { + const db = Database.memory(); + db.createCollection('t'); + expect(() => db.createCollection('t')).toThrow(/already exists/); + expect(() => db.get({ collection: 't', where: { id: { like: 1 } as never } })).toThrow( + /not supported/, + ); + expect(() => db.execute('hitamo muri t')).toThrow(KinDbError); + }); +}); + +describe('.db and .kindb files', () => { + test('round-trips a .db file and reads a json-base document', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.execute(SCRIPT); + const reopened = Database.open(file); + expect(reopened.get({ collection: 'abanyeshuri' })).toHaveLength(2); + const bytes = readFileSync(file); + expect(bytes.subarray(0, 13).toString('utf8')).toBe('KinDB page v1'); + expect(bytes.length % PAGE_SIZE).toBe(0); + expect(reopened.get({ collection: 'abanyeshuri', where: { id: 2 } })[0].amanota).toBe(70); + expect(reopened.get({ collection: 'abanyeshuri', where: { id: 1 } })[0].yize).toBe(true); + + const legacy = path.join(dir, 'database.json'); + writeFileSync( + legacy, + JSON.stringify({ + $schema: 'http://json-schema.org/draft-04/schema#', + db: { users: [{ id: '1', username: 'leerob' }] }, + }), + ); + const fromJsonBase = Database.open(legacy); + expect(fromJsonBase.get({ collection: 'users', where: { username: 'leerob' } })).toEqual([ + { id: '1', username: 'leerob' }, + ]); + fromJsonBase.execute('hitamo id muri users'); + expect(readFileSync(legacy).subarray(0, 13).toString('utf8')).toBe('KinDB page v1'); + expect(Database.open(legacy).get({ collection: 'users' })[0].id).toBe('1'); + }); + + test('runFile executes a .kindb script into a .db file', () => { + const dir = tempDir(); + const script = path.join(dir, 'ishuri.kindb'); + const file = path.join(dir, 'ishuri.db'); + writeFileSync(script, SCRIPT); + const result = Database.runFile(script, { db: file }); + expect(result.rows[0]).toEqual([{ izina: 'Aline', amanota: 88 }]); + expect(Database.open(file).collections().sort()).toEqual(['abanyeshuri', 'abigisha']); + }); + + test('does not write the file when a script fails', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const db = Database.open(file, { create: true }); + db.createCollection('t'); + expect(() => db.execute('shira muri t { id: 1 } siba muri missing aho id = 1')).toThrow( + /was not found/, + ); + expect(Database.open(file).get({ collection: 't' })).toEqual([]); + }); +}); + +describe('kindb cli', () => { + test('init, exec, and run', async () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const script = path.join(dir, 'query.kindb'); + writeFileSync(script, 'hitamo * muri abanyeshuri'); + const logs: string[] = []; + const write = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + logs.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + expect(await main(['init', file])).toBe(0); + expect(readFileSync(file).length).toBe(PAGE_SIZE * 2); + expect(readFileSync(file).subarray(0, 13).toString('utf8')).toBe('KinDB page v1'); + expect(await main(['init', file])).toBe(1); + expect( + await main([ + 'exec', + 'kora itsinda abanyeshuri shira muri abanyeshuri { id: 1, izina: "Aline" }', + '--db', + file, + ]), + ).toBe(0); + expect(await main(['run', script, '--db', file, '--no-create'])).toBe(0); + } finally { + process.stdout.write = write; + } + expect(logs.join('')).toContain('Aline'); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toHaveLength(1); + }); + + test('exec applies the .kindb beside an empty .db before the query', async () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + writeFileSync(path.join(dir, 'ishuri.kindb'), SCRIPT); + writeFileSync(file, '{ "kindb": 1, "db": {} }\n'); + const logs: string[] = []; + const write = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + logs.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + expect(await main(['exec', 'hitamo * muri abanyeshuri', '--db', file])).toBe(0); + } finally { + process.stdout.write = write; + } + const printed = logs.join(''); + expect(printed).toContain('Aline'); + expect(printed.startsWith('┌') || printed.includes('\n┌')).toBe(true); + expect(printed).toContain('(2 rows)'); + expect(printed).not.toContain('"izina"'); + expect(Database.open(file).collections().sort()).toEqual(['abanyeshuri', 'abigisha']); + }); + + test('repl runs a finished statement on one Enter', () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + writeFileSync(path.join(dir, 'ishuri.kindb'), SCRIPT); + writeFileSync(file, '{ "kindb": 1, "db": {} }\n'); + const seeded = Database.open(file); + seeded.execute(SCRIPT); + const session = createReplSession(Database.open(file)); + const step = session.push('hitamo * muri abanyeshuri'); + expect(step.exit).toBe(false); + expect(step.prompt).toBe('kindb> '); + expect(step.output).toContain('Aline'); + expect(step.output).toContain('Keza'); + expect(step.output.startsWith('┌')).toBe(true); + expect(step.output).toContain('(2 rows)'); + expect(step.output).not.toContain('"izina"'); + + const open = session.push('shira muri abanyeshuri {'); + expect(open.prompt).toBe('...> '); + expect(open.output).toBe(''); + const done = session.push('id: 3, izina: "Kaliza", amanota: 75, yize: nibyo }'); + expect(done.prompt).toBe('kindb> '); + expect(done.error).toBe(''); + expect(Database.open(file).get({ collection: 'abanyeshuri' })).toHaveLength(3); + }); + + test('run does not apply the .kindb beside an empty .db', async () => { + const dir = tempDir(); + const file = path.join(dir, 'ishuri.db'); + const script = path.join(dir, 'query.kindb'); + writeFileSync(path.join(dir, 'ishuri.kindb'), SCRIPT); + writeFileSync(file, '{ "kindb": 1, "db": {} }\n'); + writeFileSync(script, 'hitamo * muri abanyeshuri'); + expect(await main(['run', script, '--db', file])).toBe(1); + expect(Database.open(file).collections()).toEqual([]); + }); +}); + +describe('pages, types, and join', () => { + test('declares column types and still allows a schemaless collection', () => { + const db = Database.memory(); + db.execute(` + kora itsinda abanyeshuri (id umubare, izina ijambo, yize ukuri) + shira muri abanyeshuri { id: 1, izina: "Aline", yize: nibyo, extra: 1 } + `); + expect(db.get({ collection: 'abanyeshuri' })).toEqual([ + { id: 1, izina: 'Aline', yize: true, extra: 1 }, + ]); + expect(() => db.execute('shira muri abanyeshuri { id: "1", izina: "Keza" }')).toThrow( + /expects a number/, + ); + expect(() => db.execute('kora itsinda t (id umubare, id ijambo)')).toThrow(/Duplicate column/); + expect(() => db.execute('kora itsinda u (id integer)')).toThrow(/not umubare/); + db.execute('kora itsinda w shira muri w { id: 1 }'); + expect(db.get({ collection: 'w' })).toEqual([{ id: 1 }]); + }); + + test('inner join with selection', () => { + const db = Database.memory(); + db.execute(` + kora itsinda abanyeshuri (id umubare, izina ijambo, mwarimu umubare) + kora itsinda abigisha (id umubare, umwarimu ijambo) + shira muri abanyeshuri { id: 1, izina: "Aline", mwarimu: 1 } + shira muri abanyeshuri { id: 2, izina: "Keza", mwarimu: 2 } + shira muri abigisha { id: 1, umwarimu: "Mukama" } + shira muri abigisha { id: 2, umwarimu: "Kalisa" } + `); + expect( + db.query(` + hitamo a.izina, b.umwarimu + muri abanyeshuri a + huza abigisha b + aho a.mwarimu = b.id kandi a.id = 1 + `), + ).toEqual([{ izina: 'Aline', umwarimu: 'Mukama' }]); + expect( + db.query('hitamo a.id, b.id muri abanyeshuri a huza abanyeshuri b aho a.id < b.id'), + ).toEqual([{ 'a.id': 1, 'b.id': 2 }]); + expect( + db.query(` + hitamo * + muri abanyeshuri a + huza abigisha b + aho a.mwarimu = b.id kandi a.id = 1 + `)[0], + ).toMatchObject({ izina: 'Aline', umwarimu: 'Mukama', 'a.id': 1, 'b.id': 1 }); + expect(() => db.query('hitamo * muri abanyeshuri huza abanyeshuri')).toThrow(/already in use/); + expect(() => + db.query('hitamo id muri abanyeshuri a huza abigisha b aho a.mwarimu = b.id'), + ).toThrow(/ambiguous/); + }); + + test('a tall btree keeps row order, spills overflow, and reuses the freelist', () => { + const store = Store.empty(); + store.createCollection('t', [ + { name: 'id', type: 'number' }, + { name: 'izina', type: 'string' }, + ]); + const fat = 'z'.repeat(300); + for (let id = 1; id <= 800; id += 1) { + store.add('t', { id, izina: id === 400 ? fat : `u${id}` }); + } + expect(store.pageCount).toBeGreaterThan(4); + expect(store.pageCount).toBeLessThan(400); + + const opened = Store.fromBytes(store.toBytes()); + expect(opened.rows('t').map((row) => row.id)).toEqual(range(1, 800)); + expect(opened.rows('t')[399].izina).toBe(fat); + + store.updateWhere('t', [{ op: 'eq', field: 'id', value: 100 }], { izina: 'hindutse' }); + store.deleteWhere('t', [{ op: 'eq', field: 'id', value: 400 }]); + store.deleteWhere('t', [{ op: 'lt', field: 'id', value: 100 }]); + const kept = store.rows('t'); + expect(kept.map((row) => row.id)).toEqual([...range(100, 399), ...range(401, 800)]); + expect(kept.find((row) => row.id === 100)?.izina).toBe('hindutse'); + + const high = store.pageCount; + store.deleteWhere('t', [{ op: 'gte', field: 'id', value: 1 }]); + expect(store.rows('t')).toEqual([]); + expect(store.freelistCount).toBeGreaterThan(0); + expect(store.pageCount).toBe(high); + for (let id = 1; id <= 800; id += 1) { + store.add('t', { id, izina: `u${id}` }); + } + expect(store.pageCount).toBe(high); + expect(store.rows('t').map((row) => row.id)).toEqual(range(1, 800)); + + store.dropCollection('t'); + expect(store.names()).toEqual([]); + store.createCollection('t'); + store.add('t', { id: 1, izina: 'Aline' }); + expect(Store.fromBytes(store.toBytes()).rows('t')).toEqual([{ id: 1, izina: 'Aline' }]); + }); +}); + +describe('table output', () => { + test('renders rows as a bordered table', () => { + expect( + formatRows([ + { id: 1, izina: 'Aline', yize: true }, + { id: 2, izina: 'Keza', yize: false }, + ]), + ).toBe( + [ + '┌────┬───────┬───────┐', + '│ id │ izina │ yize │', + '├────┼───────┼───────┤', + '│ 1 │ Aline │ true │', + '│ 2 │ Keza │ false │', + '└────┴───────┴───────┘', + '(2 rows)', + '', + ].join('\n'), + ); + expect(formatRows([{ id: 1, note: null }])).toBe( + ['┌────┬──────┐', '│ id │ note │', '├────┼──────┤', '│ 1 │ null │', '└────┴──────┘', '(1 row)', ''].join( + '\n', + ), + ); + expect(formatRows([])).toBe('(0 rows)\n'); + }); + + test('exec prints the table for every hitamo', async () => { + const dir = tempDir(); + const logs: string[] = []; + const write = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + logs.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + expect( + await main([ + 'exec', + 'kora itsinda t shira muri t { id: 1, izina: "Aline" } shira muri t { id: 2, izina: "Keza" } hitamo izina muri t imipaka 1 hitamo id muri t', + ]), + ).toBe(0); + } finally { + process.stdout.write = write; + } + const printed = logs.join(''); + expect(printed).toContain('(1 row)'); + expect(printed).toContain('(2 rows)'); + expect(printed).not.toContain('"izina"'); + expect(printed.match(/┌/g)).toHaveLength(2); + }); +}); + +function range(start: number, end: number): number[] { + const values: number[] = []; + for (let value = start; value <= end; value += 1) values.push(value); + return values; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc076d8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}