commit 181a67f26144430a527cefb0ba2051597873519f Author: Harvmaster Date: Sat Aug 1 15:11:04 2026 +0000 feat: add local-first EventDB storage package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7de0fe0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/dist +/spec.md \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f123ddb --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Harvmaster + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4d0284 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# EventDB Storage + +A small local-first event and document database for applications that need offline writes, deterministic synchronization, and replaceable persistence without adopting a distributed database framework. + +The package deliberately uses a simple model: + +```text +DocumentDB + derives current documents from immutable events + ↓ +EventDB + identifies, verifies, deduplicates, and orders events + ↓ +BlobStorage + transforms and stores logical values by immutable ID +``` + +It is designed for trusted applications with bounded event histories. Writes succeed locally, synchronization is idempotent, and replicas with the same event set converge to the same document state. + +## Installation + +```sh +npm install @harvmaster/eventdb-storage +``` + +The package is ESM-only and requires Web Crypto. Node.js 20 or newer and current browsers provide the required APIs. TypeScript applications should include the `DOM` library because the cross-platform public adapters expose standard Fetch, Web Crypto, LocalStorage, and IndexedDB types. + +## Quick start + +```ts +import { + DocumentDB, + EventDB, + IndexedDbBlobStorage, + createDocumentEventIdentity, + type DocumentEvent, +} from '@harvmaster/eventdb-storage'; + +const storage = new IndexedDbBlobStorage('my-application'); + +const eventDB = await EventDB.open(storage, { + identify: createDocumentEventIdentity(), +}); + +const documentDB = await DocumentDB.open(eventDB); + +const notes = documentDB.collection<{ + _id: string; + title: string; + content: string; +}>('notes'); + +await notes.put({ + _id: 'note-1', + title: 'Local first', + content: 'This write is available immediately and can replicate later.', +}); + +console.log(notes.get('note-1')); + +await notes.delete('note-1'); + +// Close from the highest layer to the lowest layer. +await documentDB.close(); +await eventDB.close(); +await storage.close(); +``` + +`DocumentDB.open()` performs a complete initial rebuild, so synchronous collection reads are ready when the promise resolves. + +## Choosing a storage adapter + +| Adapter | Intended use | Durability | Cross-context observations | +| --- | --- | --- | --- | +| `MemoryBlobStorage` | Tests and temporary processing | None | Current instance only | +| `LocalStorageBlobStorage` | Small browser datasets and fallbacks | Browser storage | Browser `storage` events | +| `IndexedDbBlobStorage` | Normal browser applications | IndexedDB | `BroadcastChannel` when available | +| `SqliteBlobStorage` | Node, Electron, native shells, and servers | Supplied SQLite database | Current instance only | +| `HttpBlobStorage` | Remote persistence and SSE observations | Remote server | Server-Sent Events | +| `CompositeBlobStorage` | Local-first storage with replicas | Primary adapter | Primary and replica notifications | + +The SQLite adapter is driver-neutral. Supply an object implementing `SqliteDatabase`; this avoids forcing applications to install a particular native SQLite package. + +## Encryption + +Storage encryption is a transform and does not change event identity: + +```ts +import { + LocalStorageBlobStorage, + composeStorageTransforms, + createAesGcmTransform, + createMsgPackTransform, + type DocumentEvent, +} from '@harvmaster/eventdb-storage'; + +const encryptionKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], +); + +const transform = composeStorageTransforms( + createMsgPackTransform(), + createAesGcmTransform(encryptionKey), +); + +const storage = new LocalStorageBlobStorage('event-db', { + transform, +}); +``` + +AES-GCM uses a fresh random IV for every encoding. Two physical ciphertexts can therefore differ while representing the same logical event ID. Keep the encryption key and transform configuration available for the lifetime of the stored data. + +## Local and remote replication + +```ts +import { + CompositeBlobStorage, + HttpBlobStorage, + IndexedDbBlobStorage, + type DocumentEvent, +} from '@harvmaster/eventdb-storage'; + +const local = new IndexedDbBlobStorage('event-db'); +const remote = new HttpBlobStorage('https://example.com/storage'); + +const storage = new CompositeBlobStorage({ + primary: local, + replicas: [remote], +}); +``` + +Normal reads use the primary. Writes persist to the primary before replication is attempted, so remote outages do not invalidate successful local writes. Composite storage performs a full immutable-ID reconciliation when constructed; `synchronize()` can also be called explicitly after connectivity returns. + +### HTTP protocol + +`HttpBlobStorage` expects these endpoints beneath its base URL: + +- `GET /keys?prefix=...` returns a JSON array of IDs. +- `GET /blobs/:encodedId` returns transformed binary data or `404`. +- `POST /blobs/:encodedId` creates the transformed binary value and treats an existing ID as success. +- `GET /changes` returns Server-Sent Events whose JSON `data` is `{ "id": string, "data": base64 }`. + +The server can remain blind to logical values and encryption keys. EventDB verifies every decoded event against its expected ID. + +## Consistency model + +The package guarantees: + +- immutable, idempotent event storage; +- immediate local read-your-writes behavior; +- deterministic ordering by `(timestamp, eventId)`; +- convergence when replicas receive the same valid event set; +- complete recovery from authoritative events after notifications are missed; +- whole-document replacement and deletion. + +It intentionally does not provide transactions across events, causal ordering, field-level merging, authorization, signatures, or proof that a remote returned every event. Timestamp-and-ID ordering is deterministic last-writer-wins, not a trusted global clock. + +## Lifecycle and ownership + +Each layer releases only resources it owns: + +- `DocumentDB.close()` removes its EventDB listener. +- `EventDB.close()` removes its BlobStorage listeners. +- A leaf storage adapter closes its physical resource. +- `CompositeBlobStorage.close()` owns and closes its primary and replica adapters. + +All `close()` methods are idempotent. Applications should stop document activity first and close dependencies in reverse construction order. + +## Custom logical events + +DocumentDB uses its built-in canonical identity function, but EventDB can store any event extending `BaseEventData`: + +```ts +import { + CanonicalJsonEventCodec, + EventDB, + MemoryBlobStorage, + createEventIdentity, + type BaseEventData, +} from '@harvmaster/eventdb-storage'; + +interface CounterIncremented extends BaseEventData { + type: 'counter.incremented'; + amount: number; +} + +const codec = new CanonicalJsonEventCodec(); +const identify = createEventIdentity( + (event) => codec.encodeCanonical(event), +); +const storage = new MemoryBlobStorage(); +const events = await EventDB.open(storage, { identify }); +``` + +Canonical identity supports plain objects, arrays, finite numbers, strings, booleans, `null`, `Uint8Array`, `Date`, and `bigint`. Functions, symbols, sparse arrays, cyclic references, accessors, and non-plain object instances are rejected. + +## Development + +```sh +npm test +npm run typecheck +npm run build +``` + +The compiled package and declarations are written to `dist/`. + +## License + +ISC. See [LICENSE](./LICENSE). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cf8e720 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1771 @@ +{ + "name": "@harvmaster/eventdb-storage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@harvmaster/eventdb-storage", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "msgpackr": "^2.0.5" + }, + "devDependencies": { + "typescript": "^7.0.2", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "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/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "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, + "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, + "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, + "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, + "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/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "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.4.0", + "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.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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..9061075 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@harvmaster/eventdb-storage", + "version": "1.0.0", + "description": "A small local-first event and document database with interchangeable storage adapters and optional encryption.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "type": "module", + "scripts": { + "build": "tsc", + "prepack": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:unit": "vitest run" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@git.harvmaster.com/Harvmaster/storage-v1.git" + }, + "keywords": [ + "local-first", + "event-store", + "document-database", + "offline-first", + "indexeddb", + "sqlite", + "encryption" + ], + "author": "Harvmaster", + "license": "ISC", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "typescript": "^7.0.2", + "vitest": "^4.1.10" + }, + "dependencies": { + "msgpackr": "^2.0.5" + } +} diff --git a/src/blob-storage/composite-blob-storage.ts b/src/blob-storage/composite-blob-storage.ts new file mode 100644 index 0000000..32a0307 --- /dev/null +++ b/src/blob-storage/composite-blob-storage.ts @@ -0,0 +1,131 @@ +import { BlobStorageClosedError } from "./errors.js"; +import { + BlobStorage, + type BlobStorageAddedEvent, + type CompositeBlobStorageOptions, +} from "./types.js"; +import type { OffCallback } from "../utils/event-emitter.js"; + +/** + * Local-first BlobStorage combining one primary with zero or more replicas. + * + * Normal reads use the primary. Local write completion waits for the primary + * and tolerates replica failure. Immutable-ID synchronization copies logical + * values so each physical adapter can apply its own transform independently. + */ +export class CompositeBlobStorage extends BlobStorage { + readonly #primary: BlobStorage; + readonly #replicas: readonly BlobStorage[]; + readonly #knownIds = new Set(); + readonly #unsubscribe: OffCallback[] = []; + #closed = false; + + /** Subscribes to every child adapter and begins initial reconciliation. */ + constructor(options: CompositeBlobStorageOptions) { + super(); + this.#primary = options.primary; + this.#replicas = [...options.replicas]; + this.#unsubscribe.push( + this.#primary.on("added", (event) => { + this.#forwardAdded(event); + void this.#replicate(event.id, event.data).catch((error: unknown) => this.emitError(error)); + }), + this.#primary.on("error", (error) => this.emitError(error)), + ); + for (const replica of this.#replicas) { + this.#unsubscribe.push( + replica.on("added", (event) => { + void this.#importReplicaValue(replica, event).catch((error: unknown) => this.emitError(error)); + }), + replica.on("error", (error) => this.emitError(error)), + ); + } + void this.synchronize().catch((error: unknown) => this.emitError(error)); + } + + /** Lists IDs from the primary store only. */ + async keys(prefix?: string): Promise { + this.#assertOpen(); + return this.#primary.keys(prefix); + } + + /** Reads one logical value from the primary store only. */ + async get(id: string): Promise { + this.#assertOpen(); + return this.#primary.get(id); + } + + /** Persists to the primary, then makes best-effort replica writes. */ + async set(id: string, data: T): Promise { + this.#assertOpen(); + await this.#primary.set(id, data); + await this.#replicate(id, data); + } + + /** + * Reconciles the immutable ID union between primary and every replica. + * + * Call this after known connectivity recovery when immediate reconciliation + * matters; all operations are safe to repeat. + */ + async synchronize(): Promise { + this.#assertOpen(); + for (const replica of this.#replicas) { + const [primaryIds, replicaIds] = await Promise.all([ + this.#primary.keys(), + replica.keys(), + ]); + const primarySet = new Set(primaryIds); + const replicaSet = new Set(replicaIds); + + for (const id of replicaSet) { + if (primarySet.has(id)) continue; + const data = await replica.get(id); + if (data !== undefined) await this.#primary.set(id, data); + } + for (const id of primarySet) { + if (replicaSet.has(id)) continue; + const data = await this.#primary.get(id); + if (data !== undefined) await replica.set(id, data); + } + } + } + + /** Unsubscribes and closes every owned child adapter exactly once. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + for (const unsubscribe of this.#unsubscribe.splice(0)) unsubscribe(); + this.removeAllListeners(); + await Promise.allSettled([...new Set([this.#primary, ...this.#replicas])].map( + async (storage) => storage.close(), + )); + } + + async #importReplicaValue( + source: BlobStorage, + event: BlobStorageAddedEvent, + ): Promise { + if (await this.#primary.has(event.id)) return; + await this.#primary.set(event.id, event.data); + await Promise.allSettled(this.#replicas + .filter((replica) => replica !== source) + .map(async (replica) => replica.set(event.id, event.data))); + } + + #forwardAdded(event: BlobStorageAddedEvent): void { + if (this.#knownIds.has(event.id)) return; + this.#knownIds.add(event.id); + this.emitAdded(event); + } + + async #replicate(id: string, data: T, exclude?: BlobStorage): Promise { + await Promise.allSettled(this.#replicas + .filter((replica) => replica !== exclude) + .map(async (replica) => replica.set(id, data))); + } + + #assertOpen(): void { + if (this.#closed) throw new BlobStorageClosedError(); + } +} diff --git a/src/blob-storage/errors.ts b/src/blob-storage/errors.ts new file mode 100644 index 0000000..91821b3 --- /dev/null +++ b/src/blob-storage/errors.ts @@ -0,0 +1,8 @@ +/** Raised when an operation requiring an open BlobStorage runs after close. */ +export class BlobStorageClosedError extends Error { + override readonly name = "BlobStorageClosedError"; + + constructor() { + super("Blob storage is closed"); + } +} diff --git a/src/blob-storage/http-blob-storage.ts b/src/blob-storage/http-blob-storage.ts new file mode 100644 index 0000000..ce0116d --- /dev/null +++ b/src/blob-storage/http-blob-storage.ts @@ -0,0 +1,146 @@ +import type { SSEvent } from "../utils/sse-session.js"; +import { SSESession } from "../utils/sse-session.js"; +import { base64ToBytes } from "../shared/bytes.js"; +import { BlobStorageClosedError } from "./errors.js"; +import { BlobStorage, type BlobStorageOptions } from "./types.js"; + +/** HTTP transport, transform, and SSE subscription configuration. */ +export interface HttpBlobStorageOptions extends Partial> { + /** Injectable Fetch implementation; defaults to global Fetch. */ + readonly fetch?: typeof globalThis.fetch; + /** Headers applied to blob reads, writes, key listing, and SSE. */ + readonly headers?: Readonly>; + /** Set false to disable automatic `/changes` subscription. */ + readonly subscribe?: boolean; + /** Existing SSESession, primarily for custom transports and testing. */ + readonly session?: SSESession; +} + +interface RemoteBlobMessage { + readonly id: string; + readonly data: string; +} + +/** + * Remote BlobStorage using binary HTTP requests and Server-Sent Events. + * + * The server stores transformed bytes and does not need the transform or + * encryption key. Received values are decoded here and subsequently verified + * by EventDB against their logical IDs. + */ +export class HttpBlobStorage extends BlobStorage { + readonly #url: string; + readonly #fetch: typeof globalThis.fetch; + readonly #headers: Readonly>; + readonly #knownIds = new Set(); + readonly #session: SSESession | undefined; + #closed = false; + + /** Configures HTTP endpoints beneath `url` and starts SSE unless disabled. */ + constructor(url: string, options: HttpBlobStorageOptions = {}) { + super(options); + this.#url = url.replace(/\/+$/, ""); + const fetchImplementation = options.fetch ?? globalThis.fetch; + if (fetchImplementation === undefined) throw new Error("Fetch is not available"); + this.#fetch = fetchImplementation; + this.#headers = options.headers ?? {}; + + if (options.subscribe !== false) { + this.#session = options.session ?? new SSESession(`${this.#url}/changes`, { + fetch: async (requestUrl, requestOptions) => fetchImplementation(requestUrl, requestOptions), + headers: { Accept: "text/event-stream", ...this.#headers }, + persistent: true, + }); + this.#session.on("message", (message) => { + void this.#handleRemoteMessage(message as SSEvent); + }); + this.#session.on("error", (error) => this.emitError(error)); + if (options.session === undefined) { + void this.#session.connect().catch((error: unknown) => this.emitError(error)); + } + } + } + + /** Calls `GET /keys`, validates its JSON shape, and records known IDs. */ + async keys(prefix?: string): Promise { + this.#assertOpen(); + const url = new URL(`${this.#url}/keys`); + if (prefix !== undefined) url.searchParams.set("prefix", prefix); + const response = await this.#fetch(url, { headers: this.#headers }); + await assertSuccessful(response, "enumerate blobs"); + const value: unknown = await response.json(); + if (!Array.isArray(value) || !value.every((id) => typeof id === "string")) { + throw new TypeError("Blob ID response must be an array of strings"); + } + for (const id of value) this.#knownIds.add(id); + return value; + } + + /** Calls `GET /blobs/:id` and decodes its transformed binary response. */ + async get(id: string): Promise { + this.#assertOpen(); + const response = await this.#fetch(this.#blobUrl(id), { headers: this.#headers }); + if (response.status === 404) return undefined; + await assertSuccessful(response, "read blob"); + const data = await this.transform.decode(new Uint8Array(await response.arrayBuffer())); + this.#knownIds.add(id); + return data; + } + + /** Encodes and posts a previously unknown logical value by immutable ID. */ + async set(id: string, data: T): Promise { + this.#assertOpen(); + if (this.#knownIds.has(id)) return; + const encoded = await this.transform.encode(data); + const body = new Uint8Array(encoded.byteLength); + body.set(encoded); + const response = await this.#fetch(this.#blobUrl(id), { + method: "POST", + headers: { "content-type": "application/octet-stream", ...this.#headers }, + body: body.buffer, + }); + await assertSuccessful(response, "write blob"); + if (this.#knownIds.has(id)) return; + this.#knownIds.add(id); + this.emitAdded({ id, data, source: "local" }); + } + + /** Stops SSE and removes all storage listeners; remote data remains intact. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#session?.close(); + this.removeAllListeners(); + } + + async #handleRemoteMessage(message: SSEvent): Promise { + try { + const payload: unknown = JSON.parse(message.data); + if ( + typeof payload !== "object" || payload === null || + typeof (payload as { id?: unknown }).id !== "string" || + typeof (payload as { data?: unknown }).data !== "string" + ) throw new TypeError("Remote blob message is invalid"); + const remote = payload as RemoteBlobMessage; + if (this.#knownIds.has(remote.id)) return; + const data = await this.transform.decode(base64ToBytes(remote.data)); + if (this.#knownIds.has(remote.id)) return; + this.#knownIds.add(remote.id); + this.emitAdded({ id: remote.id, data, source: "remote" }); + } catch (error) { + this.emitError(error); + } + } + + #blobUrl(id: string): string { + return `${this.#url}/blobs/${encodeURIComponent(id)}`; + } + + #assertOpen(): void { + if (this.#closed) throw new BlobStorageClosedError(); + } +} + +async function assertSuccessful(response: Response, operation: string): Promise { + if (!response.ok) throw new Error(`Could not ${operation}: HTTP ${response.status}`); +} diff --git a/src/blob-storage/indexed-db-blob-storage.ts b/src/blob-storage/indexed-db-blob-storage.ts new file mode 100644 index 0000000..7a33091 --- /dev/null +++ b/src/blob-storage/indexed-db-blob-storage.ts @@ -0,0 +1,155 @@ +import { BlobStorageClosedError } from "./errors.js"; +import { BlobStorage, type BlobStorageOptions } from "./types.js"; +import { copyBytes } from "../shared/bytes.js"; + +/** Browser database, object-store, transform, and broadcast configuration. */ +export interface IndexedDbBlobStorageOptions extends Partial> { + /** IndexedDB object-store name; defaults to `blobs`. */ + readonly storeName?: string; + /** Injectable IndexedDB factory for alternate runtimes and tests. */ + readonly indexedDB?: IDBFactory; + /** Set false to disable BroadcastChannel cross-context notifications. */ + readonly broadcastChannel?: boolean; +} + +interface BroadcastBlobChange { + readonly id: string; + readonly storedData: Uint8Array; +} + +/** + * Durable browser BlobStorage backed by one IndexedDB object store. + * + * Inserts run in read-write transactions and use `add`, preserving immutable + * IDs. BroadcastChannel carries first-observation hints between tabs while the + * database remains authoritative. + */ +export class IndexedDbBlobStorage extends BlobStorage { + readonly #database: Promise; + readonly #storeName: string; + readonly #channel: BroadcastChannel | undefined; + readonly #knownIds = new Set(); + #closed = false; + + /** Opens or creates `databaseName` asynchronously on first use. */ + constructor(databaseName: string, options: IndexedDbBlobStorageOptions = {}) { + super(options); + const factory = options.indexedDB ?? globalThis.indexedDB; + if (factory === undefined) throw new Error("IndexedDB is not available"); + this.#storeName = options.storeName ?? "blobs"; + this.#database = openDatabase(factory, databaseName, this.#storeName); + + if (options.broadcastChannel !== false && typeof BroadcastChannel !== "undefined") { + this.#channel = new BroadcastChannel(`eventdb:${databaseName}:${this.#storeName}`); + this.#channel.onmessage = (event: MessageEvent) => { + const message = event.data; + if (typeof message?.id === "string" && message.storedData instanceof Uint8Array) { + void this.#handleRemote(message); + } + }; + } + } + + /** Enumerates string IDs from the object store and applies logical prefixes. */ + async keys(prefix = ""): Promise { + const database = await this.#getDatabase(); + const result = await requestResult( + database.transaction(this.#storeName, "readonly").objectStore(this.#storeName).getAllKeys(), + ); + return result.filter((id): id is string => typeof id === "string" && id.startsWith(prefix)); + } + + /** Reads physical bytes and decodes one logical value. */ + async get(id: string): Promise { + const stored = await this.#getPhysical(id); + if (stored === undefined) return undefined; + this.#knownIds.add(id); + return this.transform.decode(stored); + } + + /** Transactionally creates an ID and broadcasts it after commit. */ + async set(id: string, data: T): Promise { + if (await this.#getPhysical(id) !== undefined) { + this.#knownIds.add(id); + return; + } + const encoded = await this.transform.encode(data); + const database = await this.#getDatabase(); + const transaction = database.transaction(this.#storeName, "readwrite"); + const store = transaction.objectStore(this.#storeName); + const existing = await requestResult(store.get(id)); + if (existing !== undefined) { + this.#knownIds.add(id); + await transactionDone(transaction); + return; + } + const storedData = copyBytes(encoded); + store.add(storedData, id); + await transactionDone(transaction); + this.#knownIds.add(id); + this.emitAdded({ id, data, source: "local" }); + this.#channel?.postMessage({ id, storedData } satisfies BroadcastBlobChange); + } + + /** Closes BroadcastChannel and IndexedDB resources idempotently. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#channel?.close(); + this.removeAllListeners(); + (await this.#database).close(); + } + + async #getPhysical(id: string): Promise { + const database = await this.#getDatabase(); + const result = await requestResult( + database.transaction(this.#storeName, "readonly").objectStore(this.#storeName).get(id), + ); + if (result === undefined) return undefined; + return result instanceof Uint8Array ? copyBytes(result) : new Uint8Array(result.slice(0)); + } + + async #handleRemote(message: BroadcastBlobChange): Promise { + if (this.#closed || this.#knownIds.has(message.id)) return; + try { + const data = await this.transform.decode(copyBytes(message.storedData)); + if (this.#knownIds.has(message.id)) return; + this.#knownIds.add(message.id); + this.emitAdded({ id: message.id, data, source: "remote" }); + } catch (error) { + this.emitError(error); + } + } + + async #getDatabase(): Promise { + if (this.#closed) throw new BlobStorageClosedError(); + return this.#database; + } +} + +function openDatabase(factory: IDBFactory, name: string, storeName: string): Promise { + return new Promise((resolve, reject) => { + const request = factory.open(name, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(storeName)) request.result.createObjectStore(storeName); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Could not open IndexedDB")); + request.onblocked = () => reject(new Error("IndexedDB upgrade was blocked")); + }); +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); + }); +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")); + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")); + }); +} diff --git a/src/blob-storage/local-storage-blob-storage.ts b/src/blob-storage/local-storage-blob-storage.ts new file mode 100644 index 0000000..28dcd7e --- /dev/null +++ b/src/blob-storage/local-storage-blob-storage.ts @@ -0,0 +1,109 @@ +import { BlobStorageClosedError } from "./errors.js"; +import { BlobStorage, type BlobStorageOptions } from "./types.js"; +import { base64ToBytes, bytesToBase64 } from "../shared/bytes.js"; + +/** Browser dependencies and transform configuration for LocalStorage storage. */ +export interface LocalStorageBlobStorageOptions extends Partial> { + /** Storage implementation; defaults to the current global `localStorage`. */ + readonly storage?: Storage; + /** Storage-event source; defaults to the current browser window. */ + readonly eventTarget?: Pick; +} + +/** + * Small browser BlobStorage that stores transformed bytes as base64 strings. + * + * Namespaces prevent collisions between independent databases sharing the + * same LocalStorage area. Cross-tab additions are decoded and emitted as + * remote observations through the browser `storage` event. + */ +export class LocalStorageBlobStorage extends BlobStorage { + readonly #namespace: string; + readonly #storage: Storage; + readonly #eventTarget: Pick | undefined; + readonly #knownIds = new Set(); + #closed = false; + + readonly #onStorage = (event: Event): void => { + const change = event as StorageEvent; + if ( + change.storageArea !== this.#storage || change.key === null || + !change.key.startsWith(this.#storageKey("")) || change.newValue === null + ) return; + const id = change.key.slice(this.#storageKey("").length); + if (this.#knownIds.has(id)) return; + void this.#decodeAndEmit(id, change.newValue, "remote"); + }; + + /** Creates a LocalStorage adapter scoped to `namespace`. */ + constructor(namespace: string, options: LocalStorageBlobStorageOptions = {}) { + super(options); + this.#namespace = namespace; + const storage = options.storage ?? globalThis.localStorage; + if (storage === undefined) throw new Error("LocalStorage is not available"); + this.#storage = storage; + this.#eventTarget = options.eventTarget ?? ( + typeof globalThis.window === "undefined" ? undefined : globalThis.window + ); + this.#eventTarget?.addEventListener("storage", this.#onStorage); + } + + /** Enumerates logical IDs without exposing namespace-prefixed physical keys. */ + async keys(prefix = ""): Promise { + this.#assertOpen(); + const namespacePrefix = this.#storageKey(""); + const requiredPrefix = this.#storageKey(prefix); + const result: string[] = []; + for (let index = 0; index < this.#storage.length; index += 1) { + const key = this.#storage.key(index); + if (key?.startsWith(requiredPrefix) === true) result.push(key.slice(namespacePrefix.length)); + } + return result; + } + + /** Reads base64 physical data and reverses the configured transform. */ + async get(id: string): Promise { + this.#assertOpen(); + const stored = this.#storage.getItem(this.#storageKey(id)); + return stored === null ? undefined : this.transform.decode(base64ToBytes(stored)); + } + + /** Creates a namespace-scoped value and emits after LocalStorage succeeds. */ + async set(id: string, data: T): Promise { + this.#assertOpen(); + const key = this.#storageKey(id); + if (this.#storage.getItem(key) !== null) return; + const encoded = await this.transform.encode(data); + if (this.#storage.getItem(key) !== null) return; + this.#storage.setItem(key, bytesToBase64(encoded)); + this.#knownIds.add(id); + this.emitAdded({ id, data, source: "local" }); + } + + /** Detaches the cross-tab listener and removes package listeners. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#eventTarget?.removeEventListener("storage", this.#onStorage); + this.removeAllListeners(); + } + + async #decodeAndEmit(id: string, stored: string, source: "remote" | "bootstrap"): Promise { + try { + const data = await this.transform.decode(base64ToBytes(stored)); + if (this.#knownIds.has(id)) return; + this.#knownIds.add(id); + this.emitAdded({ id, data, source }); + } catch (error) { + this.emitError(error); + } + } + + #storageKey(id: string): string { + return `${this.#namespace}|${id}`; + } + + #assertOpen(): void { + if (this.#closed) throw new BlobStorageClosedError(); + } +} diff --git a/src/blob-storage/memory-blob-storage.ts b/src/blob-storage/memory-blob-storage.ts new file mode 100644 index 0000000..519d92a --- /dev/null +++ b/src/blob-storage/memory-blob-storage.ts @@ -0,0 +1,53 @@ +import { BlobStorageClosedError } from "./errors.js"; +import { BlobStorage, type BlobStorageOptions } from "./types.js"; +import { copyBytes } from "../shared/bytes.js"; + +/** + * Ephemeral in-process BlobStorage backed by owned byte copies. + * + * Useful for tests and temporary workflows. Closing removes listeners but the + * instance is intentionally not reopenable. + */ +export class MemoryBlobStorage extends BlobStorage { + readonly #values = new Map(); + #closed = false; + + /** Creates an empty memory store using the optional logical transform. */ + constructor(options: Partial> = {}) { + super(options); + } + + /** Lists in-memory IDs in insertion order, optionally filtered by prefix. */ + async keys(prefix = ""): Promise { + this.#assertOpen(); + return [...this.#values.keys()].filter((id) => id.startsWith(prefix)); + } + + /** Decodes a defensive copy of the physical bytes stored for `id`. */ + async get(id: string): Promise { + this.#assertOpen(); + const value = this.#values.get(id); + return value === undefined ? undefined : this.transform.decode(copyBytes(value)); + } + + /** Encodes and stores a previously unknown ID, emitting one local event. */ + async set(id: string, data: T): Promise { + this.#assertOpen(); + if (this.#values.has(id)) return; + const encoded = await this.transform.encode(data); + if (this.#values.has(id)) return; + this.#values.set(id, copyBytes(encoded)); + this.emitAdded({ id, data, source: "local" }); + } + + /** Makes the instance unusable and removes every notification listener. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.removeAllListeners(); + } + + #assertOpen(): void { + if (this.#closed) throw new BlobStorageClosedError(); + } +} diff --git a/src/blob-storage/sqlite-blob-storage.ts b/src/blob-storage/sqlite-blob-storage.ts new file mode 100644 index 0000000..5e652ba --- /dev/null +++ b/src/blob-storage/sqlite-blob-storage.ts @@ -0,0 +1,117 @@ +import { BlobStorageClosedError } from "./errors.js"; +import { BlobStorage, type BlobStorageOptions } from "./types.js"; +import { copyBytes } from "../shared/bytes.js"; + +type MaybePromise = T | Promise; + +/** Awaitable statement surface supported by synchronous and asynchronous drivers. */ +export interface SqliteStatement { + /** Executes a mutation or DDL statement. */ + run(...parameters: readonly unknown[]): MaybePromise; + /** Returns the first result row, or `undefined` when no row matches. */ + get(...parameters: readonly unknown[]): MaybePromise; + /** Returns every result row. */ + all(...parameters: readonly unknown[]): MaybePromise; +} + +/** Minimal driver-neutral SQLite connection required by the adapter. */ +export interface SqliteDatabase { + /** Executes schema-level SQL. */ + exec(sql: string): MaybePromise; + /** Compiles SQL into the minimal statement interface. */ + prepare(sql: string): SqliteStatement; + /** Releases the underlying SQLite connection. */ + close(): MaybePromise; +} + +/** SQLite dependency and logical transform configuration. */ +export interface SqliteBlobStorageOptions extends Partial> { + /** Open driver connection owned and closed by this storage adapter. */ + readonly database: SqliteDatabase; +} + +/** + * Durable BlobStorage for any SQLite driver matching {@link SqliteDatabase}. + * + * The adapter creates a minimal `blobs(key, value)` table and relies on the + * primary-key constraint for create-if-absent behavior. + */ +export class SqliteBlobStorage extends BlobStorage { + readonly #database: SqliteDatabase; + readonly #ready: Promise; + #closed = false; + + /** Initializes the schema using the supplied database connection. */ + constructor(options: SqliteBlobStorageOptions) { + super(options); + this.#database = options.database; + this.#ready = Promise.resolve(options.database.exec( + "CREATE TABLE IF NOT EXISTS blobs (key TEXT PRIMARY KEY, value BLOB NOT NULL)", + )).then(() => undefined); + } + + /** Enumerates IDs from SQLite and filters them by logical prefix. */ + async keys(prefix = ""): Promise { + await this.#assertReady(); + const rows = await this.#database.prepare("SELECT key FROM blobs").all(); + return rows.map(readKey).filter((id) => id.startsWith(prefix)); + } + + /** Reads and decodes one SQLite BLOB. */ + async get(id: string): Promise { + await this.#assertReady(); + const storedData = await this.#getPhysical(id); + return storedData === undefined ? undefined : this.transform.decode(storedData); + } + + /** Inserts transformed bytes without replacing an existing ID. */ + async set(id: string, data: T): Promise { + await this.#assertReady(); + if (await this.#hasPhysical(id)) return; + const encoded = await this.transform.encode(data); + await this.#database.prepare( + "INSERT INTO blobs (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING", + ).run(id, copyBytes(encoded)); + const stored = await this.#getPhysical(id); + if (stored === undefined) throw new Error(`SQLite failed to store blob: ${id}`); + this.emitAdded({ id, data, source: "local" }); + } + + /** Removes listeners and closes the supplied database connection. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.removeAllListeners(); + await this.#ready.catch(() => undefined); + await this.#database.close(); + } + + async #hasPhysical(id: string): Promise { + return await this.#getPhysical(id) !== undefined; + } + + async #getPhysical(id: string): Promise { + const row = await this.#database.prepare("SELECT value FROM blobs WHERE key = ?").get(id); + return row === undefined ? undefined : copyBytes(readValue(row)); + } + + async #assertReady(): Promise { + if (this.#closed) throw new BlobStorageClosedError(); + await this.#ready; + } +} + +function readKey(row: unknown): string { + if (typeof row !== "object" || row === null || typeof (row as { key?: unknown }).key !== "string") { + throw new TypeError("SQLite key row is invalid"); + } + return (row as { key: string }).key; +} + +function readValue(row: unknown): Uint8Array { + if (typeof row !== "object" || row === null) throw new TypeError("SQLite value row is invalid"); + const value = (row as { value?: unknown }).value; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + throw new TypeError("SQLite blob value is not binary"); +} diff --git a/src/blob-storage/transforms.ts b/src/blob-storage/transforms.ts new file mode 100644 index 0000000..35b946d --- /dev/null +++ b/src/blob-storage/transforms.ts @@ -0,0 +1,87 @@ +import { Packr, Unpackr } from "msgpackr"; +import type { StorageTransform } from "./types.js"; + +/** + * Creates the default logical-value transform using msgpackr. + * + * Keep the msgpackr configuration stable for the lifetime of stored data. + */ +export function createMsgPackTransform(): StorageTransform { + const packr = new Packr(); + const unpackr = new Unpackr(); + + return { + async encode(data: T): Promise { + return Uint8Array.from(packr.pack(data)); + }, + async decode(storedData: Uint8Array): Promise { + return unpackr.unpack(storedData) as T; + }, + }; +} + +/** + * Chains a logical transform with a binary transform. + * + * Encoding runs logical then binary; decoding runs them in reverse order. + */ +export function composeStorageTransforms( + logicalTransform: StorageTransform, + binaryTransform: StorageTransform, +): StorageTransform { + return { + async encode(data: T): Promise { + return binaryTransform.encode(await logicalTransform.encode(data)); + }, + async decode(storedData: Uint8Array): Promise { + return logicalTransform.decode(await binaryTransform.decode(storedData)); + }, + }; +} + +/** + * Creates a versioned AES-256/128-GCM binary transform with random 96-bit IVs. + * + * The caller owns the key. Losing or rotating it without a migration makes + * existing values unreadable. Modified ciphertext is rejected by Web Crypto. + */ +export function createAesGcmTransform(key: CryptoKey): StorageTransform { + return { + async encode(plaintext: Uint8Array): Promise { + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const input = copyToArrayBuffer(plaintext); + const encrypted = await globalThis.crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + input, + ); + const ciphertext = new Uint8Array(encrypted); + const result = new Uint8Array(1 + iv.length + ciphertext.length); + result[0] = 1; + result.set(iv, 1); + result.set(ciphertext, 13); + return result; + }, + async decode(storedData: Uint8Array): Promise { + if (storedData[0] !== 1) { + throw new Error(`Unsupported encryption format: ${storedData[0]}`); + } + if (storedData.length < 29) throw new Error("Encrypted value is truncated"); + const iv = storedData.slice(1, 13); + const ciphertext = copyToArrayBuffer(storedData.slice(13)); + const decrypted = await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext, + ); + return new Uint8Array(decrypted); + }, + }; +} + +/** Copies a Uint8Array into an ArrayBuffer accepted consistently by Web Crypto. */ +function copyToArrayBuffer(value: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(value.byteLength); + copy.set(value); + return copy.buffer; +} diff --git a/src/blob-storage/types.ts b/src/blob-storage/types.ts new file mode 100644 index 0000000..1c54219 --- /dev/null +++ b/src/blob-storage/types.ts @@ -0,0 +1,109 @@ +import { EventEmitter } from "../utils/event-emitter.js"; +import { createMsgPackTransform } from "./transforms.js"; + +/** Converts between an application's logical value and adapter-owned bytes. */ +export interface StorageTransform { + /** Serializes, compresses, encrypts, or otherwise transforms a logical value. */ + encode(data: T): Promise; + /** Reverses {@link encode} and reconstructs the logical value. */ + decode(storedData: Uint8Array): Promise; +} + +/** Shared construction options for physical BlobStorage adapters. */ +export interface BlobStorageOptions { + /** Transform used for every physical value read and written by the adapter. */ + readonly transform: StorageTransform; +} + +/** Notification emitted after an adapter first observes an immutable ID. */ +export interface BlobStorageAddedEvent { + /** Caller-provided immutable identifier for the logical value. */ + readonly id: string; + /** Decoded logical value associated with {@link id}. */ + readonly data: T; + /** Path through which this storage instance first observed the value. */ + readonly source: "local" | "remote" | "bootstrap"; +} + +/** Events exposed by every BlobStorage implementation. */ +export type BlobStorageEventMap = { + /** A new immutable ID became visible to this storage instance. */ + added: BlobStorageAddedEvent; + /** A notification, transform, transport, or background operation failed. */ + error: unknown; +}; + +/** + * Append-only logical-value storage shared by local and remote adapters. + * + * Implementations must persist before emitting `added`. Existing IDs are + * idempotent and must never be replaced. Events are an optimization: callers + * recover authoritative state through {@link keys} and {@link get}. + */ +export abstract class BlobStorage extends EventEmitter> { + /** Adapter-specific logical-to-physical transform. */ + protected readonly transform: StorageTransform; + + /** Initializes the adapter with MessagePack unless a transform is supplied. */ + protected constructor(options: Partial> = {}) { + super(); + const transform = options.transform ?? createMsgPackTransform(); + if (typeof transform.encode !== "function" || typeof transform.decode !== "function") { + throw new TypeError("Storage transform must provide encode and decode functions"); + } + this.transform = transform; + } + + /** Lists known IDs, optionally restricted to IDs beginning with `prefix`. */ + abstract keys(prefix?: string): Promise; + /** Returns the decoded value for an ID, or `undefined` when it is unknown. */ + abstract get(id: string): Promise; + /** Creates an immutable ID/value association; existing IDs are no-ops. */ + abstract set(id: string, data: T): Promise; + + /** Returns whether an ID currently resolves to a logical value. */ + async has(id: string): Promise { + return await this.get(id) !== undefined; + } + + /** Releases adapter resources and listeners. Calling it repeatedly is safe. */ + abstract close(): Promise; + + /** Emits `added` without allowing listener failures to undo persistence. */ + protected emitAdded(event: BlobStorageAddedEvent): void { + try { + this.emit("added", event); + } catch (error) { + try { + this.emit("error", error); + } catch { + // Persistence has already completed; listener failures cannot undo it. + } + } + } + + /** Forwards a background failure while isolating failures in error listeners. */ + protected emitError(error: unknown): void { + try { + this.emit("error", error); + } catch { + // Error observers are isolated from storage lifecycle operations. + } + } +} + +/** Privileged administrative extension kept outside EventDB dependencies. */ +export interface MutableBlobStorage extends BlobStorage { + /** Destructively removes one ID. EventDB must never receive this capability. */ + delete(id: string): Promise; + /** Destructively removes every ID. EventDB must never receive this capability. */ + clear(): Promise; +} + +/** Dependencies used to compose a local-first primary with replica stores. */ +export interface CompositeBlobStorageOptions { + /** Authoritative store used for normal reads and local write completion. */ + readonly primary: BlobStorage; + /** Secondary stores that exchange missing immutable logical values. */ + readonly replicas: readonly BlobStorage[]; +} diff --git a/src/document-db/document-db.ts b/src/document-db/document-db.ts new file mode 100644 index 0000000..adab7b8 --- /dev/null +++ b/src/document-db/document-db.ts @@ -0,0 +1,265 @@ +import { EventEmitter } from "../utils/event-emitter.js"; +import type { OffCallback } from "../utils/event-emitter.js"; +import { EventDB as EventDatabase } from "../event-db/event-db.js"; +import type { StoredEvent } from "../event-db/types.js"; +import { DocumentDBClosedError, InvalidDocumentEventError } from "./errors.js"; +import type { + DeleteDocumentEvent, + Document, + DocumentCollection, + DocumentDatabase as DocumentDatabaseContract, + DocumentDBEventMap, + DocumentEvent, + PutDocumentEvent, +} from "./types.js"; + +/** Document event creation and remote materialization configuration. */ +export interface DocumentDBOptions { + /** Injectable Unix-millisecond clock; defaults to `Date.now`. */ + readonly now?: () => number; + /** Injectable unique nonce generator; defaults to `crypto.randomUUID`. */ + readonly nonce?: () => string; + /** Window for coalescing remote notifications into one rebuild. */ + readonly remoteRebuildDebounceMs?: number; +} + +/** + * In-memory document database materialized from verified immutable events. + * + * Documents are disposable derived state. Every rebuild reads the complete + * sorted event set, which guarantees correct handling of late older events. + * Use {@link DocumentDB.open} before obtaining collections so initial reads are + * fully materialized. + */ +export class DocumentDB +extends EventEmitter +implements DocumentDatabaseContract { + readonly #eventDB: EventDatabase; + readonly #now: () => number; + readonly #nonce: () => string; + readonly #remoteRebuildDebounceMs: number; + readonly #state = new Map>(); + readonly #unsubscribeEvent: OffCallback; + #rebuildChain: Promise = Promise.resolve(); + #scheduledRebuild: ReturnType | undefined; + #closed = false; + #lastTimestamp = -1; + + private constructor(eventDB: EventDatabase, options: DocumentDBOptions) { + super(); + this.#eventDB = eventDB; + this.#now = options.now ?? Date.now; + this.#nonce = options.nonce ?? defaultNonce; + this.#remoteRebuildDebounceMs = options.remoteRebuildDebounceMs ?? 0; + this.#unsubscribeEvent = eventDB.on("event", () => this.#scheduleRebuild()); + } + + /** Creates, subscribes, and fully materializes a ready DocumentDB instance. */ + static async open( + eventDB: EventDatabase, + options: DocumentDBOptions = {}, + ): Promise { + const database = new DocumentDB(eventDB, options); + try { + await database.rebuild(); + return database; + } catch (error) { + await database.close(); + throw error; + } + } + + /** Returns a lightweight typed collection view backed by shared state. */ + collection(name: string): DocumentCollection { + this.#assertOpen(); + assertIdentifier(name, "Collection name"); + return new BasicDocumentCollection(this, name); + } + + /** Serializes a complete verified replay with any rebuild already in flight. */ + async rebuild(): Promise { + this.#assertOpen(); + const run = async (): Promise => { + const events = await this.#eventDB.events(); + const nextState = new Map>(); + for (const event of events) applyEvent(nextState, event); + this.#state.clear(); + for (const [name, collection] of nextState) this.#state.set(name, collection); + }; + this.#rebuildChain = this.#rebuildChain.then(run, run); + return this.#rebuildChain; + } + + /** Cancels scheduled work, detaches observation, and waits for active replay. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#unsubscribeEvent(); + if (this.#scheduledRebuild !== undefined) clearTimeout(this.#scheduledRebuild); + await this.#rebuildChain.catch(() => undefined); + this.removeAllListeners(); + } + + /** Creates a put event and waits until complete replay exposes the document. */ + async put(collection: string, document: TDocument): Promise { + this.#assertOpen(); + assertDocument(document); + const data: PutDocumentEvent = { + version: 1, + timestamp: this.#createTimestamp(), + nonce: this.#createNonce(), + type: "document.put", + collection, + documentId: document._id, + document, + }; + await this.#eventDB.append(data); + await this.rebuild(); + } + + /** Creates a delete event and waits until complete replay removes the document. */ + async delete(collection: string, documentId: string): Promise { + this.#assertOpen(); + assertIdentifier(documentId, "Document ID"); + const data: DeleteDocumentEvent = { + version: 1, + timestamp: this.#createTimestamp(), + nonce: this.#createNonce(), + type: "document.delete", + collection, + documentId, + }; + await this.#eventDB.append(data); + await this.rebuild(); + } + + /** Reads a materialized document without storage I/O. */ + get(collection: string, documentId: string): TDocument | undefined { + return this.#state.get(collection)?.get(documentId) as TDocument | undefined; + } + + /** Returns a snapshot array of one materialized collection. */ + all(collection: string): readonly TDocument[] { + return [...(this.#state.get(collection)?.values() ?? [])] as TDocument[]; + } + + #scheduleRebuild(): void { + if (this.#closed || this.#scheduledRebuild !== undefined) return; + this.#scheduledRebuild = setTimeout(() => { + this.#scheduledRebuild = undefined; + if (this.#closed) return; + void this.rebuild().catch((error: unknown) => this.#emitError(error)); + }, this.#remoteRebuildDebounceMs); + } + + #emitError(error: unknown): void { + try { + this.emit("error", error); + } catch { + // Derived-state refresh errors cannot invalidate authoritative events. + } + } + + #createNonce(): string { + const nonce = this.#nonce(); + if (typeof nonce !== "string" || nonce.length === 0) { + throw new InvalidDocumentEventError("Nonce generator returned an invalid nonce"); + } + return nonce; + } + + #createTimestamp(): number { + const current = this.#now(); + if (!Number.isSafeInteger(current) || current < 0) { + throw new InvalidDocumentEventError("Clock returned an invalid Unix timestamp"); + } + const timestamp = Math.max(current, this.#lastTimestamp + 1); + if (!Number.isSafeInteger(timestamp)) { + throw new InvalidDocumentEventError("Document event timestamp exceeds the safe integer range"); + } + this.#lastTimestamp = timestamp; + return timestamp; + } + + #assertOpen(): void { + if (this.#closed) throw new DocumentDBClosedError(); + } +} + +class BasicDocumentCollection implements DocumentCollection { + constructor( + private readonly database: DocumentDB, + private readonly name: string, + ) {} + + async put(document: TDocument): Promise { + await this.database.put(this.name, document); + } + + async delete(documentId: string): Promise { + await this.database.delete(this.name, documentId); + } + + get(documentId: string): TDocument | undefined { + return this.database.get(this.name, documentId); + } + + all(): readonly TDocument[] { + return this.database.all(this.name); + } + + find(predicate: (document: TDocument) => boolean): readonly TDocument[] { + return this.all().filter(predicate); + } +} + +function applyEvent( + state: Map>, + event: StoredEvent, +): void { + const data: unknown = event.data; + if (!isDocumentEvent(data)) return; + let collection = state.get(data.collection); + if (collection === undefined) { + collection = new Map(); + state.set(data.collection, collection); + } + if (data.type === "document.put") collection.set(data.documentId, data.document); + else collection.delete(data.documentId); +} + +function isDocumentEvent(value: unknown): value is DocumentEvent { + if (typeof value !== "object" || value === null) return false; + const event = value as Partial; + if ( + event.version !== 1 || + (event.type !== "document.put" && event.type !== "document.delete") || + typeof event.collection !== "string" || event.collection.length === 0 || + typeof event.documentId !== "string" || event.documentId.length === 0 + ) return false; + const document = (event as Partial).document; + return event.type === "document.delete" || ( + typeof document === "object" && document !== null && + (document as Partial)._id === event.documentId + ); +} + +function assertDocument(document: Document): void { + if (typeof document !== "object" || document === null) { + throw new InvalidDocumentEventError("Document must be an object"); + } + assertIdentifier(document._id, "Document ID"); +} + +function assertIdentifier(value: string, label: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new InvalidDocumentEventError(`${label} must be a non-empty string`); + } +} + +function defaultNonce(): string { + if (typeof globalThis.crypto?.randomUUID !== "function") { + throw new Error("Web Crypto randomUUID is not available"); + } + return globalThis.crypto.randomUUID(); +} diff --git a/src/document-db/document-event-codec.ts b/src/document-db/document-event-codec.ts new file mode 100644 index 0000000..3a9465a --- /dev/null +++ b/src/document-db/document-event-codec.ts @@ -0,0 +1,12 @@ +import { CanonicalJsonEventCodec } from "../event-db/canonical-json-event-codec.js"; +import { createEventIdentity } from "../event-db/event-identity.js"; +import type { EventIdentity } from "../event-db/types.js"; +import type { DocumentEvent } from "./types.js"; + +/** Shared canonical codec defining stable identity bytes for document events. */ +export const documentEventCodec = new CanonicalJsonEventCodec(); + +/** Creates the standard SHA-256 canonical identity function for DocumentDB. */ +export function createDocumentEventIdentity(): EventIdentity { + return createEventIdentity((event) => documentEventCodec.encodeCanonical(event)); +} diff --git a/src/document-db/errors.ts b/src/document-db/errors.ts new file mode 100644 index 0000000..dfe34ba --- /dev/null +++ b/src/document-db/errors.ts @@ -0,0 +1,17 @@ +/** Raised when a write cannot produce a valid document event. */ +export class InvalidDocumentEventError extends Error { + override readonly name = "InvalidDocumentEventError"; + + constructor(message = "Invalid document event") { + super(message); + } +} + +/** Raised when a mutating or rebuilding operation runs after close. */ +export class DocumentDBClosedError extends Error { + override readonly name = "DocumentDBClosedError"; + + constructor() { + super("DocumentDB is closed"); + } +} diff --git a/src/document-db/types.ts b/src/document-db/types.ts new file mode 100644 index 0000000..097cfdd --- /dev/null +++ b/src/document-db/types.ts @@ -0,0 +1,62 @@ +import type { BaseEventData } from "../event-db/types.js"; + +/** Minimum shape required for values managed by DocumentDB collections. */ +export interface Document { + /** Stable identifier unique within a collection. */ + readonly _id: string; +} + +/** Immutable event replacing a complete document value. */ +export interface PutDocumentEvent extends BaseEventData { + /** Discriminator selecting whole-document replacement semantics. */ + readonly type: "document.put"; + /** Collection containing the logical document. */ + readonly collection: string; + /** Stable ID duplicated from `document._id` for routing and validation. */ + readonly documentId: string; + /** Complete replacement document. */ + readonly document: Document; +} + +/** Immutable event removing a document from materialized state. */ +export interface DeleteDocumentEvent extends BaseEventData { + /** Discriminator selecting deletion semantics. */ + readonly type: "document.delete"; + /** Collection containing the logical document. */ + readonly collection: string; + /** Stable identifier to remove from derived state. */ + readonly documentId: string; +} + +/** Complete set of event semantics understood by the initial DocumentDB. */ +export type DocumentEvent = PutDocumentEvent | DeleteDocumentEvent; + +/** Synchronous-read, asynchronous-write view over one named collection. */ +export interface DocumentCollection { + /** Creates or completely replaces one document and waits for materialization. */ + put(document: TDocument): Promise; + /** Writes an immutable delete event and waits for materialization. */ + delete(documentId: string): Promise; + /** Reads one currently materialized document synchronously. */ + get(documentId: string): TDocument | undefined; + /** Returns a snapshot array of every currently materialized document. */ + all(): readonly TDocument[]; + /** Applies an in-memory predicate to the current materialized collection. */ + find(predicate: (document: TDocument) => boolean): readonly TDocument[]; +} + +/** Public lifecycle and collection contract for the document layer. */ +export interface DocumentDatabase { + /** Returns a typed view over a non-empty collection name. */ + collection(name: string): DocumentCollection; + /** Replaces all derived state by replaying the complete verified event set. */ + rebuild(): Promise; + /** Stops remote-event observation without closing lower-level dependencies. */ + close(): Promise; +} + +/** Diagnostic events emitted by DocumentDB background materialization. */ +export type DocumentDBEventMap = { + /** A scheduled rebuild or notification listener failed. */ + error: unknown; +}; diff --git a/src/event-db/canonical-json-event-codec.ts b/src/event-db/canonical-json-event-codec.ts new file mode 100644 index 0000000..632dbc7 --- /dev/null +++ b/src/event-db/canonical-json-event-codec.ts @@ -0,0 +1,165 @@ +import { base64ToBytes, bytesToBase64 } from "../shared/bytes.js"; +import { EventDecodeError } from "./errors.js"; +import type { BaseEventData } from "./types.js"; + +type CanonicalNode = + | ["null"] + | ["boolean", boolean] + | ["string", string] + | ["number", string] + | ["bigint", string] + | ["date", string] + | ["bytes", string] + | ["array", CanonicalNode[]] + | ["object", Array<[string, CanonicalNode]>]; + +/** Optional runtime validation applied after canonical decoding. */ +export interface CanonicalJsonEventCodecOptions { + /** Returns a validated event or throws for invalid decoded data. */ + readonly validate?: (value: unknown) => TEventData; +} + +/** + * A precisely tagged canonical extended-JSON codec. Every value, including + * objects and arrays, is tagged so user data cannot collide with extension + * markers. Object properties are ordered lexicographically. The format + * distinguishes `-0`, dates, bigints, and byte arrays and rejects ambiguous + * JavaScript values such as cycles, sparse arrays, symbols, and accessors. + */ +export class CanonicalJsonEventCodec { + readonly #validate: ((value: unknown) => TEventData) | undefined; + + /** Creates a codec with optional post-decode runtime validation. */ + constructor(options: CanonicalJsonEventCodecOptions = {}) { + this.#validate = options.validate; + } + + /** Encodes logical event data into stable UTF-8 canonical bytes. */ + encodeCanonical(event: TEventData): Uint8Array { + return new TextEncoder().encode(JSON.stringify(toCanonicalNode(event, new Set()))); + } + + /** Decodes canonical bytes and rejects malformed or non-canonical shapes. */ + decode(value: Uint8Array): TEventData { + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(value); + const parsed: unknown = JSON.parse(text); + const decoded = fromCanonicalNode(parsed); + return this.#validate === undefined ? decoded as TEventData : this.#validate(decoded); + } catch (error) { + if (error instanceof EventDecodeError) throw error; + throw new EventDecodeError("Event could not be decoded", { cause: error }); + } + } +} + +function toCanonicalNode(value: unknown, ancestors: Set): CanonicalNode { + if (value === null) return ["null"]; + if (typeof value === "boolean") return ["boolean", value]; + if (typeof value === "string") return ["string", value]; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Non-finite numbers are not supported"); + return ["number", Object.is(value, -0) ? "-0" : String(value)]; + } + if (typeof value === "bigint") return ["bigint", value.toString(10)]; + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") { + throw new TypeError(`Unsupported value: ${typeof value}`); + } + if (ancestors.has(value)) throw new TypeError("Cyclic values are not supported"); + + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new TypeError("Invalid dates are not supported"); + return ["date", value.toISOString()]; + } + if (value instanceof Uint8Array) return ["bytes", bytesToBase64(value)]; + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key === "symbol")) { + throw new TypeError("Symbol properties are not supported"); + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError("Sparse arrays are not supported"); + } + if ((ownKeys as string[]).some((key) => key !== "length" && !/^(?:0|[1-9]\d*)$/.test(key))) { + throw new TypeError("Array properties are not supported"); + } + return ["array", value.map((item) => toCanonicalNode(item, ancestors))]; + } + + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Only plain objects, arrays, dates, and Uint8Array values are supported"); + } + + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key === "symbol")) { + throw new TypeError("Symbol properties are not supported"); + } + const stringKeys = keys as string[]; + for (const key of stringKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor?.enumerable !== true || descriptor.get !== undefined || descriptor.set !== undefined) { + throw new TypeError("Only enumerable data properties are supported"); + } + } + stringKeys.sort(); + return [ + "object", + stringKeys.map((key) => [key, toCanonicalNode((value as Record)[key], ancestors)]), + ]; + } finally { + ancestors.delete(value); + } +} + +function fromCanonicalNode(value: unknown): unknown { + if (!Array.isArray(value) || typeof value[0] !== "string") { + throw new EventDecodeError("Invalid canonical value"); + } + + const tag = value[0]; + if (tag === "null" && value.length === 1) return null; + if (tag === "boolean" && value.length === 2 && typeof value[1] === "boolean") return value[1]; + if (tag === "string" && value.length === 2 && typeof value[1] === "string") return value[1]; + if (tag === "number" && value.length === 2 && typeof value[1] === "string") { + if (value[1] === "-0") return -0; + const number = Number(value[1]); + if (!Number.isFinite(number) || String(number) !== value[1]) throw new EventDecodeError("Invalid number"); + return number; + } + if (tag === "bigint" && value.length === 2 && typeof value[1] === "string" && /^-?(?:0|[1-9]\d*)$/.test(value[1])) { + return BigInt(value[1]); + } + if (tag === "date" && value.length === 2 && typeof value[1] === "string") { + const date = new Date(value[1]); + if (Number.isNaN(date.getTime()) || date.toISOString() !== value[1]) throw new EventDecodeError("Invalid date"); + return date; + } + if (tag === "bytes" && value.length === 2 && typeof value[1] === "string") return base64ToBytes(value[1]); + if (tag === "array" && value.length === 2 && Array.isArray(value[1])) { + return value[1].map(fromCanonicalNode); + } + if (tag === "object" && value.length === 2 && Array.isArray(value[1])) { + const result: Record = {}; + let previous: string | undefined; + for (const entry of value[1]) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") { + throw new EventDecodeError("Invalid object entry"); + } + if (previous !== undefined && entry[0] <= previous) throw new EventDecodeError("Object keys are not canonical"); + previous = entry[0]; + Object.defineProperty(result, entry[0], { + configurable: true, + enumerable: true, + writable: true, + value: fromCanonicalNode(entry[1]), + }); + } + return result; + } + + throw new EventDecodeError(`Invalid canonical tag: ${tag}`); +} diff --git a/src/event-db/errors.ts b/src/event-db/errors.ts new file mode 100644 index 0000000..b24297f --- /dev/null +++ b/src/event-db/errors.ts @@ -0,0 +1,20 @@ +/** Raised when decoded event data does not hash to its requested storage ID. */ +export class EventHashMismatchError extends Error { + override readonly name = "EventHashMismatchError"; + + constructor( + public readonly expectedId: string, + public readonly actualId: string, + ) { + super(`Event hash mismatch: expected ${expectedId}, received ${actualId}`); + } +} + +/** Raised when canonical or persisted event data cannot be interpreted safely. */ +export class EventDecodeError extends Error { + override readonly name = "EventDecodeError"; + + constructor(message = "Event could not be decoded", options?: ErrorOptions) { + super(message, options); + } +} diff --git a/src/event-db/event-db.ts b/src/event-db/event-db.ts new file mode 100644 index 0000000..2f3eb35 --- /dev/null +++ b/src/event-db/event-db.ts @@ -0,0 +1,200 @@ +import type { BlobStorage, BlobStorageAddedEvent } from "../blob-storage/types.js"; +import type { OffCallback } from "../utils/event-emitter.js"; +import { EventEmitter } from "../utils/event-emitter.js"; +import { EventDecodeError, EventHashMismatchError } from "./errors.js"; +import type { + BaseEventData, + EventDatabase, + EventDBEventMap, + EventDBOptions, + StoredEvent, +} from "./types.js"; + +/** + * Verified append-only event database built over logical BlobStorage. + * + * Every read recomputes logical identity. Storage notifications are + * deduplicated hints; authoritative enumeration always comes from storage. + * Use {@link EventDB.open} so historical events are verified before use. + */ +export class EventDB +extends EventEmitter> +implements EventDatabase { + readonly #storage: BlobStorage; + readonly #options: EventDBOptions; + readonly #seen = new Set(); + readonly #unsubscribeStorage: OffCallback[]; + #closed = false; + + private constructor(storage: BlobStorage, options: EventDBOptions) { + super(); + this.#storage = storage; + this.#options = options; + this.#unsubscribeStorage = [ + storage.on("added", (event) => { + void this.#observeAdded(event); + }), + storage.on("error", (error) => this.#emitError(error)), + ]; + } + + /** + * Opens EventDB, attaches storage listeners, and validates historical data. + * + * Construction fails and listeners are rolled back if any stored event does + * not match its ID or required base metadata. + */ + static async open( + storage: BlobStorage, + options: EventDBOptions, + ): Promise> { + const database = new EventDB(storage, options); + try { + await database.#loadHistoricalEvents(); + return database; + } catch (error) { + await database.close(); + throw error; + } + } + + /** + * Derives an ID and stores one immutable event idempotently. + * + * Existing data is read and verified. Completion never depends solely on an + * EventEmitter listener, preserving local append/read ordering. + */ + async append(data: TEvent): Promise> { + this.#assertOpen(); + assertBaseEventData(data); + const id = await this.#options.identify(data); + const existing = await this.#storage.get(id); + if (existing !== undefined) { + await this.#verify(id, existing); + await this.#observe(id, existing); + return { id, data: existing }; + } + + await this.#storage.set(id, data); + await this.#observe(id, data); + return { id, data }; + } + + /** Reads one logical value and verifies it against the requested ID. */ + async get(id: string): Promise | undefined> { + this.#assertOpen(); + const data = await this.#storage.get(id); + if (data === undefined) return undefined; + await this.#verify(id, data); + return { id, data }; + } + + /** Reads, verifies, and sorts all events by timestamp then ID. */ + async events(): Promise[]> { + this.#assertOpen(); + const ids = await this.#storage.keys(); + const events: StoredEvent[] = []; + for (const id of ids) { + const data = await this.#storage.get(id); + if (data === undefined) continue; + await this.#verify(id, data); + events.push({ id, data }); + } + events.sort(compareEvents); + return events; + } + + /** Detaches storage listeners without closing caller-owned storage. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + for (const unsubscribe of this.#unsubscribeStorage.splice(0)) unsubscribe(); + this.removeAllListeners(); + } + + async #loadHistoricalEvents(): Promise { + const ids = await this.#storage.keys(); + for (const id of ids) { + const data = await this.#storage.get(id); + if (data === undefined) continue; + await this.#verify(id, data); + this.#seen.add(id); + } + } + + async #observeAdded(event: BlobStorageAddedEvent): Promise { + try { + await this.#observe(event.id, event.data); + } catch (error) { + this.#emitError(error); + } + } + + async #observe(id: string, data: TEvent): Promise { + if (this.#seen.has(id)) return; + await this.#verify(id, data); + if (this.#seen.has(id)) return; + this.#seen.add(id); + try { + this.emit("event", { id, data }); + } catch (error) { + this.#emitError(error); + } + } + + async #verify(expectedId: string, data: TEvent): Promise { + try { + assertBaseEventData(data); + } catch (error) { + throw new EventDecodeError("Stored event data is invalid", { cause: error }); + } + const actualId = await this.#options.identify(data); + if (actualId !== expectedId) throw new EventHashMismatchError(expectedId, actualId); + } + + #emitError(error: unknown): void { + try { + this.emit("error", error); + } catch { + // An error listener must not break storage observation. + } + } + + #assertOpen(): void { + if (this.#closed) throw new Error("EventDB is closed"); + } +} + +/** + * Deterministically compares verified events for materialization. + * + * IDs provide a stable tie-break for independently created events sharing a + * timestamp; ordering is deterministic but does not imply causality. + */ +export function compareEvents( + left: StoredEvent, + right: StoredEvent, +): number { + if (left.data.timestamp < right.data.timestamp) return -1; + if (left.data.timestamp > right.data.timestamp) return 1; + if (left.id < right.id) return -1; + if (left.id > right.id) return 1; + return 0; +} + +function assertBaseEventData(value: unknown): asserts value is BaseEventData { + if (typeof value !== "object" || value === null) throw new TypeError("Event data must be an object"); + const candidate = value as Partial; + if (!Number.isSafeInteger(candidate.version) || (candidate.version ?? 0) < 0) { + throw new TypeError("Event version must be a non-negative safe integer"); + } + if (!Number.isSafeInteger(candidate.timestamp) || (candidate.timestamp ?? -1) < 0) { + throw new TypeError("Event timestamp must be a non-negative safe integer"); + } + if (typeof candidate.nonce !== "string" || candidate.nonce.length === 0) { + throw new TypeError("Event nonce must be a non-empty string"); + } + if (typeof candidate.type !== "string" || candidate.type.length === 0) { + throw new TypeError("Event type must be a non-empty string"); + } +} diff --git a/src/event-db/event-identity.ts b/src/event-db/event-identity.ts new file mode 100644 index 0000000..ab21107 --- /dev/null +++ b/src/event-db/event-identity.ts @@ -0,0 +1,21 @@ +import type { EventIdentity } from "./types.js"; + +/** + * Creates a SHA-256 event identity function from a canonical logical encoder. + * + * The encoder may be synchronous or asynchronous but must produce identical + * bytes for equivalent values on every participating runtime. + */ +export function createEventIdentity( + canonicalEncode: (value: T) => Uint8Array | Promise, +): EventIdentity { + return async (event: T): Promise => { + const bytes = await canonicalEncode(event); + const input = new Uint8Array(bytes.byteLength); + input.set(bytes); + const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input.buffer)); + let hexadecimal = ""; + for (const byte of digest) hexadecimal += byte.toString(16).padStart(2, "0"); + return `sha256:${hexadecimal}`; + }; +} diff --git a/src/event-db/types.ts b/src/event-db/types.ts new file mode 100644 index 0000000..8b27c19 --- /dev/null +++ b/src/event-db/types.ts @@ -0,0 +1,61 @@ +import type { BlobStorage } from "../blob-storage/types.js"; + +/** Metadata required on every immutable logical event. */ +export interface BaseEventData { + /** Domain schema version used when interpreting this event. */ + readonly version: number; + /** Creator-supplied Unix timestamp in milliseconds. */ + readonly timestamp: number; + /** Stable random value distinguishing otherwise identical logical events. */ + readonly nonce: string; + /** Domain discriminator used to select event semantics. */ + readonly type: string; +} + +/** Verified event data paired with its derived content identifier. */ +export interface StoredEvent { + /** Result of applying the configured identity function to {@link data}. */ + readonly id: string; + /** Immutable logical event contents. */ + readonly data: TEvent; +} + +/** Backwards-readable shorthand for {@link StoredEvent}. */ +export type Event = StoredEvent; + +/** Derives a stable ID from logical event contents before storage transforms. */ +export type EventIdentity = (event: TEvent) => Promise; + +/** Notifications exposed by an open EventDB. */ +export type EventDBEventMap = { + /** A newly verified event ID was observed. */ + event: StoredEvent; + /** Verification, storage observation, or listener processing failed. */ + error: unknown; +}; + +/** EventDB behavior supplied by the application at construction. */ +export interface EventDBOptions { + /** Canonical logical identity function shared by every replica. */ + readonly identify: EventIdentity; +} + +/** Append-only verified event database contract consumed by higher layers. */ +export interface EventDatabase { + /** Stores or retrieves an idempotent logical event and returns its derived ID. */ + append(data: TEvent): Promise>; + /** Reads and verifies one expected event ID. */ + get(id: string): Promise | undefined>; + /** Reads, verifies, and deterministically sorts the complete event set. */ + events(): Promise[]>; + /** Stops observing storage notifications; storage ownership remains external. */ + close(): Promise; +} + +/** Explicit EventDB dependencies, useful to composition roots and factories. */ +export interface EventDBDependencies { + /** Append-only logical-value adapter storing event data by derived ID. */ + readonly storage: BlobStorage; + /** Identity behavior used on every read and append. */ + readonly options: EventDBOptions; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b05185b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,30 @@ +/** + * Public API for local-first blob, event, and document storage. + * + * Applications normally compose one BlobStorage adapter, EventDB.open(), and + * DocumentDB.open(). Lower-level exports support custom events and transports. + */ +export * from "./blob-storage/types.js"; +export * from "./blob-storage/errors.js"; +export * from "./blob-storage/transforms.js"; +export * from "./blob-storage/memory-blob-storage.js"; +export * from "./blob-storage/local-storage-blob-storage.js"; +export * from "./blob-storage/indexed-db-blob-storage.js"; +export * from "./blob-storage/sqlite-blob-storage.js"; +export * from "./blob-storage/http-blob-storage.js"; +export * from "./blob-storage/composite-blob-storage.js"; + +export * from "./event-db/types.js"; +export * from "./event-db/errors.js"; +export * from "./event-db/canonical-json-event-codec.js"; +export * from "./event-db/event-identity.js"; +export * from "./event-db/event-db.js"; + +export * from "./document-db/types.js"; +export * from "./document-db/errors.js"; +export * from "./document-db/document-event-codec.js"; +export * from "./document-db/document-db.js"; + +export * from "./utils/event-emitter.js"; +export * from "./utils/exponential-backoff.js"; +export * from "./utils/sse-session.js"; diff --git a/src/shared/bytes.ts b/src/shared/bytes.ts new file mode 100644 index 0000000..3ec58f9 --- /dev/null +++ b/src/shared/bytes.ts @@ -0,0 +1,60 @@ +/** Compares two byte sequences without coercion or serialization. */ +export function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + + return true; +} + +/** Returns an owned copy so callers cannot mutate adapter-held bytes. */ +export function copyBytes(value: Uint8Array): Uint8Array { + return value.slice(); +} + +const BASE64_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Encodes arbitrary bytes using RFC 4648 standard base64 with padding. */ +export function bytesToBase64(value: Uint8Array): string { + let result = ""; + + for (let index = 0; index < value.length; index += 3) { + const first = value[index] ?? 0; + const second = value[index + 1] ?? 0; + const third = value[index + 2] ?? 0; + const packed = (first << 16) | (second << 8) | third; + result += BASE64_ALPHABET[(packed >>> 18) & 63]; + result += BASE64_ALPHABET[(packed >>> 12) & 63]; + result += index + 1 < value.length ? BASE64_ALPHABET[(packed >>> 6) & 63] : "="; + result += index + 2 < value.length ? BASE64_ALPHABET[packed & 63] : "="; + } + + return result; +} + +/** Decodes strict, padded RFC 4648 base64 and rejects malformed input. */ +export function base64ToBytes(value: string): Uint8Array { + if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new TypeError("Invalid base64 value"); + } + + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + const result = new Uint8Array((value.length / 4) * 3 - padding); + let output = 0; + + for (let index = 0; index < value.length; index += 4) { + const a = BASE64_ALPHABET.indexOf(value[index] ?? ""); + const b = BASE64_ALPHABET.indexOf(value[index + 1] ?? ""); + const c = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2] ?? ""); + const d = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3] ?? ""); + const packed = (a << 18) | (b << 12) | (c << 6) | d; + if (output < result.length) result[output++] = (packed >>> 16) & 255; + if (output < result.length) result[output++] = (packed >>> 8) & 255; + if (output < result.length) result[output++] = packed & 255; + } + + return result; +} diff --git a/src/utils/event-emitter.ts b/src/utils/event-emitter.ts new file mode 100644 index 0000000..477b881 --- /dev/null +++ b/src/utils/event-emitter.ts @@ -0,0 +1,164 @@ +/** String-keyed payload map accepted by the typed EventEmitter. */ +export type EventMap = Record; + +type Listener = (detail: T) => void; + +interface ListenerEntry { + listener: Listener; + wrappedListener: Listener; + debounceTime?: number | undefined; + once?: boolean | undefined; +} + +/** Idempotent callback returned by listener registration methods. */ +export type OffCallback = () => void; + +/** + * Small synchronous, strongly typed event emitter with optional debouncing. + * + * Emission never awaits listeners. Callers performing durable work must finish + * that work before emitting and must not treat listener completion as a commit. + */ +export class EventEmitter { + private listeners: Map>> = new Map(); + + /** Registers a listener and returns a callback that removes it. */ + on( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(listener, debounceMilliseconds) + : listener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener, + debounceTime: debounceMilliseconds, + }; + + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** Registers a listener that removes itself after its first invocation. */ + once( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener: Listener = (detail: T[K]) => { + this.off(type, listener); + listener(detail); + }; + + const debouncedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(wrappedListener, debounceMilliseconds) + : wrappedListener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener: debouncedListener, + debounceTime: debounceMilliseconds, + once: true, + }; + + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** Removes the matching original or wrapped listener for one event type. */ + off(type: K, listener: Listener): void { + const listeners = this.listeners.get(type); + if (!listeners) return; + + const listenerEntry = Array.from(listeners).find( + (entry) => + entry.listener === listener || entry.wrappedListener === listener, + ); + + if (listenerEntry) { + listeners.delete(listenerEntry); + } + } + + /** Invokes current listeners synchronously and reports whether any existed. */ + emit(type: K, payload: T[K]): boolean { + const listeners = this.listeners.get(type); + if (!listeners) return false; + + listeners.forEach((entry) => { + entry.wrappedListener(payload); + }); + + return listeners.size > 0; + } + + /** Removes every listener for every event type. */ + removeAllListeners(): void { + this.listeners.clear(); + } + + /** Resolves with the first matching event or rejects after an optional timeout. */ + async waitFor( + type: K, + predicate: (payload: T[K]) => boolean, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const listener = (payload: T[K]) => { + if (predicate(payload)) { + // Clean up + this.off(type, listener); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + resolve(payload); + } + }; + + // Set up timeout if specified + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + this.off(type, listener); + reject(new Error(`Timeout waiting for event "${String(type)}"`)); + }, timeoutMs); + } + + this.on(type, listener); + }); + } + + private debounce( + func: Listener, + wait: number, + ): Listener { + let timeout: ReturnType | undefined; + + return (detail: T[K]) => { + if (timeout !== undefined) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + func(detail); + }, wait); + }; + } +} diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts new file mode 100644 index 0000000..03188ce --- /dev/null +++ b/src/utils/exponential-backoff.ts @@ -0,0 +1,155 @@ +/** + * Exponential backoff is a technique used to retry a function after a delay. + * + * The delay increases exponentially with each attempt, up to a maximum delay. + * + * The jitter is a random amount of time added to the delay to prevent thundering herd problems. + * + * The growth rate is the factor by which the delay increases with each attempt. + */ +export class ExponentialBackoff { + /** + * Create a new ExponentialBackoff instance + * + * @param config - The configuration for the exponential backoff + * @returns The ExponentialBackoff instance + */ + static from(config?: Partial): ExponentialBackoff { + const backoff = new ExponentialBackoff(config); + return backoff; + } + + /** + * Run the function with exponential backoff + * + * @param fn - The function to run + * @param onError - The callback to call when an error occurs + * @param options - The configuration for the exponential backoff + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + static run( + fn: () => Promise, + onError = (_error: Error) => {}, + options?: Partial, + ): Promise { + const backoff = ExponentialBackoff.from(options); + return backoff.run(fn, onError); + } + + private readonly options: ExponentialBackoffOptions; + + constructor(options?: Partial) { + this.options = { + maxDelay: 10000, + maxAttempts: 10, + baseDelay: 1000, + growthRate: 2, + jitter: 0.1, + ...options, + }; + } + + /** + * Run the function with exponential backoff + * + * If the function fails but we have not hit the max attempts, the error will be passed to the onError callback + * and the function will be retried with an exponential delay + * + * If the function fails and we have hit the max attempts, the last error will be thrown + * + * @param fn - The function to run + * @param onError - The callback to call when an error occurs + * + * @throws The last error if the function fails and we have hit the max attempts + * + * @returns The result of the function + */ + async run( + fn: () => Promise, + onError = (_error: Error) => {}, + ): Promise { + let lastError: Error = new Error('Exponential backoff: Max retries hit'); + + let attempt = 0; + + while ( + attempt < this.options.maxAttempts || + this.options.maxAttempts == 0 + ) { + try { + return await fn(); + } catch (error) { + // Store the error in case we fail every attempt + lastError = error instanceof Error ? error : new Error(`${error}`); + onError(lastError); + + // Wait before going to the next attempt + const delay = this.calculateDelay(attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + attempt++; + } + + // We completed the loop without ever succeeding. Throw the last error we got + throw lastError; + } + + /** + * Calculate the delay before we should attempt to retry + * + * NOTE: The maximum delay is (maxDelay * (1 + jitter)) + * + * @param attempt + * @returns The time in milliseconds before another attempt should be made + */ + private calculateDelay(attempt: number): number { + // Get the power of the growth rate + const power = Math.pow(this.options.growthRate, attempt); + + // Get the delay before jitter or limit + const rawDelay = this.options.baseDelay * power; + + // Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay + const cappedDelay = Math.min(rawDelay, this.options.maxDelay); + + // Get the jitter direction. This will be between -1 and 1 + const jitterDirection = 2 * Math.random() - 1; + + // Calculate the jitter + const jitter = jitterDirection * this.options.jitter * cappedDelay; + + // Add the jitter to the delay + return cappedDelay + jitter; + } +} + +export type ExponentialBackoffOptions = { + /** + * The maximum delay between attempts in milliseconds + */ + maxDelay: number; + + /** + * The maximum number of attempts. Passing 0 will result in infinite attempts. + */ + maxAttempts: number; + + /** + * The base delay between attempts in milliseconds + */ + baseDelay: number; + + /** + * The growth rate of the delay + */ + growthRate: number; + + /** + * The jitter of the delay as a percentage of growthRate + */ + jitter: number; +}; diff --git a/src/utils/sse-session.ts b/src/utils/sse-session.ts new file mode 100644 index 0000000..9e938e2 --- /dev/null +++ b/src/utils/sse-session.ts @@ -0,0 +1,361 @@ +import { ExponentialBackoff } from './exponential-backoff.js'; +import { EventEmitter } from './event-emitter.js'; + +/** Observable connection and message events produced by SSESession. */ +export type SSESessionEventMap = { + /** One parsed Server-Sent Event. */ + message: unknown; + /** The HTTP event stream has connected successfully. */ + connected: void; + /** The stream ended or disconnected; payload may contain the cause. */ + disconnected: unknown; + /** Connection, parsing, reconnection, or listener processing failed. */ + error: unknown; +}; + +/** + * A Server-Sent Events client implementation using fetch API. + * Supports custom headers, POST requests, and is non-blocking. + */ +export class SSESession extends EventEmitter { + /** + * Creates and connects a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + * @returns A new connected SSESession instance + */ + public static async from( + url: string, + options: Partial = {}, + ): Promise { + const client = new SSESession(url, options); + await client.connect(); + return client; + } + + // State. + private url: string; + private controller: AbortController; + private connected: boolean = false; + protected options: SSESessionOptions; + protected messageBuffer: Uint8Array = new Uint8Array(); + + // Listener for when the tab is hidden or shown. + private visibilityChangeHandler: ((event: Event) => void) | null = null; + + // Text decoders and encoders for parsing the message buffer. + private textDecoder: TextDecoder = new TextDecoder(); + private textEncoder: TextEncoder = new TextEncoder(); + + /** + * Creates a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + */ + constructor(url: string, options: Partial = {}) { + super(); + + this.url = url; + this.options = { + // Use default fetch function. + fetch: (...args) => fetch(...args), + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + + // Reconnection options + attemptReconnect: true, + retryDelay: 1000, + persistent: false, + ...options, + }; + this.controller = new AbortController(); + + // Set up visibility change handling if in mobile browser environment + if (typeof document !== 'undefined') { + this.visibilityChangeHandler = this.handleVisibilityChange.bind(this); + document.addEventListener( + 'visibilitychange', + this.visibilityChangeHandler, + ); + } + } + + /** + * Handles visibility change events in the browser. + */ + private async handleVisibilityChange(): Promise { + // When going to background, close the current connection cleanly + // This allows us to reconnect mobile devices when they come back after leaving the tab or browser app. + if (document.visibilityState === 'hidden') { + this.controller.abort(); + } + + // When coming back to foreground, attempt to reconnect if not connected + if (document.visibilityState === 'visible' && !this.connected) { + await this.connect(); + } + } + + /** + * Connects to the SSE endpoint. + */ + public async connect(): Promise { + if (this.connected) return; + + this.connected = true; + this.controller = new AbortController(); + + const { method, headers, body } = this.options; + + const fetchOptions: RequestInit = { + method, + signal: this.controller.signal, + cache: 'no-store', + }; + if (headers !== undefined) fetchOptions.headers = headers; + if (body !== undefined) fetchOptions.body = body; + + const exponentialBackoff = ExponentialBackoff.from({ + baseDelay: this.options.retryDelay, + maxDelay: 10000, + maxAttempts: 0, + growthRate: 1.3, + jitter: 0.3, + }); + + // Establish the connection and get the reader using the exponential backoff + const reader = await exponentialBackoff.run(async () => { + const res = await this.options.fetch(this.url, fetchOptions); + if (!res.ok) { + throw new Error(`HTTP error! Status: ${res.status}`); + } + + if (!res.body) { + throw new Error('Response body is null'); + } + + return res.body.getReader(); + }); + + // Call the onConnected callback + this.emit('connected', undefined); + + const readStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + this.connected = false; + + // Call the onDisconnected callback. + this.emit('disconnected', undefined); + + // If the connection was closed by the server, we want to attempt a reconnect if the connection should be persistent. + if (this.options.persistent) { + await this.connect(); + } + + break; + } + + const events = this.parseEvents(value); + + for (const event of events) { + this.emit('message', event); + } + } + } catch (error) { + this.connected = false; + + // Call the onDisconnected callback. + this.emit('disconnected', error); + + // If the connection was aborted using the controller, we don't need to call onError. + if (this.controller.signal.aborted) { + return; + } + + // Call the onError callback. + // NOTE: we dont use the handleCallback here because it would result in 2 error callbacks. + try { + this.emit('error', error); + } catch (error) { + console.log(`SSE Session: onError callback error:`, error); + } + + // Attempt to reconnect if enabled + if (this.options.attemptReconnect) { + await this.connect(); + } + } + }; + + void readStream().catch((error: unknown) => this.emit('error', error)); + + return; + } + + protected parseEvents(chunk: Uint8Array): SSEvent[] { + // Append new chunk to existing buffer + this.messageBuffer = new Uint8Array([...this.messageBuffer, ...chunk]); + + const events: SSEvent[] = []; + const lines = this.textDecoder + .decode(this.messageBuffer) + .split(/\r\n|\r|\n/); + + let currentEvent: Partial = {}; + let completeEventCount = 0; + + // Iterate over the lines to find complete events + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + + // Empty line signals the end of an event + if (line === '') { + if (currentEvent.data) { + // Remove trailing newline if present + currentEvent.data = currentEvent.data.replace(/\n$/, ''); + events.push(currentEvent as SSEvent); + currentEvent = {}; + completeEventCount = i + 1; + } + continue; + } + + // Parse field: value format + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const field = line.slice(0, colonIndex); + // Skip initial space after colon if present + const valueStartIndex = + colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0); + const value = line.slice(valueStartIndex); + + if (field === 'data') { + currentEvent.data = currentEvent.data + ? currentEvent.data + '\n' + value + : value; + } else if (field === 'event') { + currentEvent.event = value; + } else if (field === 'id') { + currentEvent.id = value; + } else if (field === 'retry') { + const retryMs = parseInt(value, 10); + if (!isNaN(retryMs)) { + currentEvent.retry = retryMs; + } + } + } + + // Store the remainder of the buffer for the next chunk + const remainder = lines.slice(completeEventCount).join('\n'); + this.messageBuffer = this.textEncoder.encode(remainder); + + return events; + } + + /** + * Closes the SSE connection and cleans up event listeners. + */ + public close(): void { + // Clean up everything including the visibility handler + this.controller.abort(); + this.connected = false; + + // Remove the visibility handler (This is only required on browsers) + if (this.visibilityChangeHandler && typeof document !== 'undefined') { + document.removeEventListener( + 'visibilitychange', + this.visibilityChangeHandler, + ); + this.visibilityChangeHandler = null; + } + this.removeAllListeners(); + } + + /** + * Checks if the client is currently connected. + * @returns Whether the client is connected + */ + public isConnected(): boolean { + return this.connected; + } + +} + +/** + * Configuration options for the SSESession. + */ +export interface SSESessionOptions { + /** + * The fetch function to use. + * + * NOTE: This is compatible with Browser/Node's native "fetcH" function. + * We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here). + * For example, a LibP2P adapter might not use a standardized URL format (and might only include "path"). + * This would cause a type error as native fetch expects type "URL". + */ + fetch: (url: string, options: RequestInit) => Promise; + + /** + * HTTP method to use (GET or POST). + */ + method: 'GET' | 'POST'; + + /** + * HTTP headers to send with the request. + */ + headers?: Record; + + /** + * Body to send with POST requests. + */ + body?: string | FormData | URLSearchParams; + + /** + * Whether to attempt to reconnect. + */ + attemptReconnect: boolean; + + /** + * The delay in milliseconds between reconnection attempts. + */ + retryDelay: number; + + /** + * Whether to reconnect when the session is terminated by the server. + */ + persistent: boolean; +} + +/** + * Represents a Server-Sent Event. + */ +export interface SSEvent { + /** + * Event data. + */ + data: string; + + /** + * Event type. + */ + event?: string; + + /** + * Event ID. + */ + id?: string; + + /** + * Reconnection time in milliseconds. + */ + retry?: number; +} diff --git a/tests/blob-storage.test.ts b/tests/blob-storage.test.ts new file mode 100644 index 0000000..bb4994f --- /dev/null +++ b/tests/blob-storage.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CompositeBlobStorage, + LocalStorageBlobStorage, + MemoryBlobStorage, + composeStorageTransforms, + createAesGcmTransform, + createMsgPackTransform, + type StorageTransform, +} from "../src/index.js"; + +interface ExampleValue { + readonly name: string; + readonly count: bigint; + readonly createdAt: Date; +} + +describe("storage transforms", () => { + it("round trips nested MessagePack values", async () => { + const transform = createMsgPackTransform(); + const value = { name: "test", count: 9n, createdAt: new Date("2026-01-01T00:00:00.000Z") }; + expect(await transform.decode(await transform.encode(value))).toEqual(value); + }); + + it("composes randomized AES-GCM encryption and rejects modified ciphertext", async () => { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); + const transform = composeStorageTransforms( + createMsgPackTransform(), + createAesGcmTransform(key), + ); + const value = { name: "secret", count: 3n, createdAt: new Date("2026-02-01T00:00:00.000Z") }; + const first = await transform.encode(value); + const second = await transform.encode(value); + expect(first).not.toEqual(second); + expect(await transform.decode(first)).toEqual(value); + const modified = first.slice(); + modified[modified.length - 1] ^= 1; + await expect(transform.decode(modified)).rejects.toBeDefined(); + }); +}); + +describe("MemoryBlobStorage", () => { + it("stores logical values and emits added exactly once after persistence", async () => { + const encode = vi.fn(async (value: { value: number }) => new Uint8Array([value.value])); + const transform: StorageTransform<{ value: number }> = { + encode, + async decode(bytes) { return { value: bytes[0] ?? 0 }; }, + }; + const storage = new MemoryBlobStorage({ transform }); + const events: unknown[] = []; + storage.on("added", (event) => events.push(event)); + + await storage.set("item/a", { value: 1 }); + await storage.set("item/a", { value: 99 }); + + expect(await storage.get("item/a")).toEqual({ value: 1 }); + expect(await storage.keys("item/")).toEqual(["item/a"]); + expect(await storage.has("missing")).toBe(false); + expect(encode).toHaveBeenCalledTimes(1); + expect(events).toEqual([{ id: "item/a", data: { value: 1 }, source: "local" }]); + }); + + it("isolates listener exceptions from successful persistence", async () => { + const storage = new MemoryBlobStorage<{ value: number }>(); + const errors: unknown[] = []; + storage.on("added", () => { throw new Error("listener failed"); }); + storage.on("error", (error) => errors.push(error)); + await expect(storage.set("id", { value: 1 })).resolves.toBeUndefined(); + expect(await storage.get("id")).toEqual({ value: 1 }); + expect(errors).toHaveLength(1); + }); +}); + +describe("LocalStorageBlobStorage", () => { + it("stores transformed values under a namespace and reopens them", async () => { + const physical = new FakeStorage(); + const first = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical }); + await first.set("a", { value: "persisted" }); + await first.close(); + const reopened = new LocalStorageBlobStorage<{ value: string }>("test", { storage: physical }); + expect(await reopened.get("a")).toEqual({ value: "persisted" }); + expect(await reopened.keys()).toEqual(["a"]); + }); +}); + +describe("CompositeBlobStorage", () => { + it("synchronizes the logical ID union and imports remote observations once", async () => { + const primary = new MemoryBlobStorage<{ value: number }>(); + const replica = new MemoryBlobStorage<{ value: number }>(); + await primary.set("primary", { value: 1 }); + await replica.set("replica", { value: 2 }); + const composite = new CompositeBlobStorage({ primary, replicas: [replica] }); + await composite.synchronize(); + expect(await primary.get("replica")).toEqual({ value: 2 }); + expect(await replica.get("primary")).toEqual({ value: 1 }); + + const added = composite.waitFor("added", (event) => event.id === "remote"); + await replica.set("remote", { value: 3 }); + await expect(added).resolves.toMatchObject({ id: "remote", data: { value: 3 } }); + expect(await primary.get("remote")).toEqual({ value: 3 }); + }); +}); + +class FakeStorage implements Storage { + readonly #values = new Map(); + get length(): number { return this.#values.size; } + clear(): void { this.#values.clear(); } + getItem(key: string): string | null { return this.#values.get(key) ?? null; } + key(index: number): string | null { return [...this.#values.keys()][index] ?? null; } + removeItem(key: string): void { this.#values.delete(key); } + setItem(key: string, value: string): void { this.#values.set(key, value); } +} diff --git a/tests/document-db.test.ts b/tests/document-db.test.ts new file mode 100644 index 0000000..c21228c --- /dev/null +++ b/tests/document-db.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + DocumentDB, + EventDB, + MemoryBlobStorage, + createDocumentEventIdentity, + type DocumentEvent, +} from "../src/index.js"; + +interface Note { + readonly _id: string; + readonly title: string; +} + +async function createDatabases(options: { now?: () => number; nonce?: () => string; remoteRebuildDebounceMs?: number } = {}) { + const storage = new MemoryBlobStorage(); + const events = await EventDB.open(storage, { identify: createDocumentEventIdentity() }); + const documents = await DocumentDB.open(events, options); + return { storage, events, documents }; +} + +describe("DocumentDB", () => { + it("provides read-your-writes for replacement, deletion, and recreation", async () => { + let nonce = 0; + const { documents } = await createDatabases({ now: () => 10, nonce: () => `nonce-${nonce++}` }); + const notes = documents.collection("notes"); + await notes.put({ _id: "one", title: "first" }); + await notes.put({ _id: "one", title: "replacement" }); + expect(notes.get("one")).toEqual({ _id: "one", title: "replacement" }); + await notes.delete("one"); + expect(notes.get("one")).toBeUndefined(); + await notes.put({ _id: "one", title: "recreated" }); + expect(notes.find((note) => note.title.startsWith("re"))).toEqual([{ _id: "one", title: "recreated" }]); + }); + + it("opens with historical state and rebuilds late events in deterministic order", async () => { + const storage = new MemoryBlobStorage(); + const events = await EventDB.open(storage, { identify: createDocumentEventIdentity() }); + await events.append({ + version: 1, timestamp: 20, nonce: "new", type: "document.put", + collection: "notes", documentId: "one", document: { _id: "one", title: "newer" }, + }); + const documents = await DocumentDB.open(events); + const notes = documents.collection("notes"); + expect(notes.get("one")?.title).toBe("newer"); + await events.append({ + version: 1, timestamp: 10, nonce: "late", type: "document.delete", + collection: "notes", documentId: "one", + }); + await documents.rebuild(); + expect(notes.get("one")?.title).toBe("newer"); + }); +}); diff --git a/tests/event-db.test.ts b/tests/event-db.test.ts new file mode 100644 index 0000000..20dba2a --- /dev/null +++ b/tests/event-db.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + CanonicalJsonEventCodec, + EventDB, + EventHashMismatchError, + MemoryBlobStorage, + composeStorageTransforms, + createAesGcmTransform, + createEventIdentity, + createMsgPackTransform, + type BaseEventData, + type BlobStorageAddedEvent, +} from "../src/index.js"; + +interface TestEvent extends BaseEventData { + readonly type: "test"; + readonly payload: unknown; +} + +const codec = new CanonicalJsonEventCodec(); +const identify = createEventIdentity((event) => codec.encodeCanonical(event)); + +describe("EventDB", () => { + it("loads historical events, deduplicates observations, and sorts deterministically", async () => { + const storage = new ObservableMemoryStorage(); + const first: TestEvent = { version: 1, timestamp: 2, nonce: "a", type: "test", payload: 1 }; + const firstId = await identify(first); + await storage.set(firstId, first); + const database = await EventDB.open(storage, { identify }); + const observed: string[] = []; + database.on("event", (event) => observed.push(event.id)); + + expect((await database.append(first)).id).toBe(firstId); + storage.notify({ id: firstId, data: first, source: "remote" }); + const earlier = await database.append({ ...first, timestamp: 1, nonce: "b" }); + const tied = await database.append({ ...first, nonce: "c" }); + + const events = await database.events(); + expect(events[0]?.id).toBe(earlier.id); + expect(events.slice(1).map((event) => event.id)).toEqual([firstId, tied.id].sort()); + expect(observed).toEqual([earlier.id, tied.id]); + }); + + it("rejects a logical value stored under the wrong ID", async () => { + const storage = new MemoryBlobStorage(); + await storage.set("sha256:wrong", { + version: 1, timestamp: 1, nonce: "a", type: "test", payload: null, + }); + await expect(EventDB.open(storage, { identify })).rejects.toBeInstanceOf(EventHashMismatchError); + }); + + it("keeps event identity stable when storage encryption changes physical bytes", async () => { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); + const transform = composeStorageTransforms( + createMsgPackTransform(), + createAesGcmTransform(key), + ); + const plainStorage = new MemoryBlobStorage(); + const encryptedStorage = new MemoryBlobStorage({ transform }); + const plain = await EventDB.open(plainStorage, { identify }); + const encrypted = await EventDB.open(encryptedStorage, { identify }); + const event: TestEvent = { version: 1, timestamp: 1, nonce: "same", type: "test", payload: "value" }; + expect((await plain.append(event)).id).toBe((await encrypted.append(event)).id); + }); +}); + +class ObservableMemoryStorage extends MemoryBlobStorage { + notify(event: BlobStorageAddedEvent): void { + this.emitAdded(event); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d718b16 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,48 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"] +}