diff --git a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md new file mode 100644 index 000000000..ad0752ea8 --- /dev/null +++ b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md @@ -0,0 +1,90 @@ +# Common-v8 Parity Plan (Bun-first) + +## Goal + +Reach practical `upstream/common-v8` compatibility for architecture/API/behavior while keeping: + +- `bun` runtime and package manager +- `biome` instead of eslint/prettier +- reduced dependencies and Bun-native replacements where beneficial + +## Hard Rules + +- No wholesale merge of upstream branch. +- No return of removed tooling (`pnpm`, `pnpx`, `eslint`, `prettier`). +- Every upstream commit must be classified: + - `verified-equivalent` + - `pending-adopt` + - `intentional-divergence` +- Any divergence must include reason + impact. + +## Verification Gates + +- `bunx tsc --build packages/common packages/web packages/react-native packages/nodejs` +- `bunx vitest run` for targeted suites around touched areas +- `bun run verify:fast` +- `SYNC_GUARD_MAIN_REF=miccy-dev bun run sync:guard:common-v8:strict` + +## Upstream Commit Matrix (Current Snapshot) + +Source: `git cherry -v miccy-dev upstream/common-v8` + +| Upstream | Subject | Status | Local Note | +|---|---|---|---| +| `fc99998` | Add evoluError tab channel and error handling | `verified-equivalent` | Implemented via local worker/tab error flow (`EvoluTabOutput`, `InitTab`, shared `evoluError` store). | +| `c3e32b0` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile is authoritative. | +| `e62d12a` | Add microtask batch utility | `verified-equivalent` | Present in local fork (`microtask batch` utility + export). | +| `31e07eb` | Implement mutation methods | `verified-equivalent` | `createEvolu` mutation dispatch/coalescing is implemented and covered; queue processing is serialized across microtasks. | +| `da75834` | Rename worker module to Shared | `verified-equivalent` | `Shared.ts` is now present and exported; legacy `Worker.ts` stays as compatibility layer. | +| `019ad76` | Add testCreateWorker and worker tests | `verified-equivalent` | Backported: `testCreateWorker`, `testCreateMessageChannel`, `testCreateMessagePort`, `testCreateSharedWorker` + `packages/common/test/Worker.test.ts`. | +| `b90fae5` | Wire createEvolu to DB worker init | `verified-equivalent` | Local runtime already wires InitEvolu + DB worker init flow; covered by local-first Evolu tests. | +| `d4fb5ba` | Wire platform DB workers and worker rename | `intentional-divergence` | Keep Bun-first Worker/DbWorker layout, but `local-first` now exports upstream `Db` contract and moves fork-only protocol under `Experimental*` aliases. | +| `a986d23` | Add leader lock and worker init wiring | `verified-equivalent` | LeaderLock wiring is present across common/web/react-native runtime deps and tests. | +| `cc87fde` | Add canonical test name constants | `verified-equivalent` | `testName`/`testAppName` present locally. | +| `857608f` | Rename common test deps module | `verified-equivalent` | Test helper imports now converge on `_deps.ts`; `_deps.nodejs.ts` left as compatibility re-export shim. | +| `d5e7e3c` | Add React Native leader lock tests | `verified-equivalent` | RN leader lock tests are present and green. | +| `27a34ec` | Add web leader lock browser tests | `verified-equivalent` | Web leader lock browser tests are present and green. | +| `cb1e7e0` | Use store-only worker console output | `verified-equivalent` | Worker bootstrap now uses `createWorkerRun` + store output forwarding path. | +| `f05ec75` | Use Run type for run.daemon | `verified-equivalent` | Already ported in local fork. | +| `af7aca4` | Update TreeShaking.test.ts | `intentional-divergence` | Bun-first keeps normalized tree-shaking snapshot to stabilize cross-runtime size variance. | +| `2cc6272` | Sort imports | `intentional-divergence` | Non-functional; handled by Biome formatting policy. | +| `2a585dc` | Centralize deterministic test values | `verified-equivalent` | Already ported. | +| `fb46547` | Use deterministic AppOwner in playground | `verified-equivalent` | Already ported. | +| `fefc8e3` | Refactor db worker init flow | `verified-equivalent` | Stack ownership + daemon init flow is already present in local worker startup path. | +| `db759d4` | Broker channel wiring + leader acquired output | `verified-equivalent` | Leader channel wiring and `LeaderAcquired` handling are implemented and regression-tested. | +| `a2a448d` | Typed NativeMessagePort generics | `verified-equivalent` | Already ported. | +| `746ea84` | Refactored local-first worker channel wiring | `verified-equivalent` | Already ported with Bun-first file layout. | +| `0b390f6` | Clarified SharedWorker communication comment | `verified-equivalent` | Doc-level parity covered in local comments/docs. | +| `0bbef0a` | Simplified worker deps + forwarded DbWorker console entries | `verified-equivalent` | Ported behavior; continue regression checks. | +| `57d2390` | Wired DbWorker init payload and worker output flow | `verified-equivalent` | Ported with local worker protocol/wiring. | +| `d4f0e5f` | Wired sqlite driver into React Native deps | `verified-equivalent` | Ported. | +| `096326e` | Documented type aliases for composed deps | `intentional-divergence` | Upstream `apps/web` docs were removed from this fork; docs parity is tracked in `website/apps/docs`. | +| `7f12041` | Refactored DB worker startup to initialize schema/bootstrap clock | `verified-equivalent` | `Db.ts` is now aligned with upstream startup/bootstrap flow (clock + schema init + quarantine reapply) with Bun-first cleanup only. | +| `f94006d` | SQLite throw-first semantics + protocol/storage alignment | `verified-equivalent` | Ported in local fork (conflict-resolved variant). | +| `1f7be0d` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile only. | +| `af2e3f6` | Use getOk in SQLite setup paths | `verified-equivalent` | Ported. | +| `717abb4` | Upgrade Biome to v2.4.0 and update config | `verified-equivalent` | Ported via local dependency/config update. | +| `c089ae9` | Queue mutations, add quarantine, rename messages | `intentional-divergence` | Upstream `Db.ts`/`Shared.ts` queue+ack path is now aligned; local `Evolu.ts` keeps Bun-first direct `DbWorkerClient` queue path as internal runtime divergence. Public API remains compatible. | + +## Remaining Blocking Gaps + +No unresolved blocking gaps in the tracked `common-v8` matrix. + +Open divergence to track: + +1. `c089ae9` runtime path in `Evolu.ts` remains intentionally Bun-first (direct DbWorker client queue) instead of upstream SharedWorker mutation queue orchestration. + +## Execution Order + +1. Worker/runtime parity batch: + - `a986d23`, `fefc8e3`, `db759d4`, `b90fae5`, `d4fb5ba`, `857608f`, `d5e7e3c`, `27a34ec`, `cb1e7e0` +2. Mutation queue/protocol batch: + - `31e07eb`, `c089ae9` +3. DB startup/docs batch: + - `7f12041`, `019ad76`, `af7aca4`, `096326e` + +## Bun-native Replacement Opportunities (After Parity) + +- Keep `webpack` only where explicitly needed for compatibility snapshots (tree-shaking baseline). +- Add optional Bun-native comparison path (`bun build --minify`) for internal perf tracking, not as baseline replacement. +- Review and remove redundant Node-era tooling wrappers where Bun provides equivalent runtime/build/test behavior. diff --git a/.ai/memory/user-preferences.md b/.ai/memory/user-preferences.md index b8dff9b44..ffa8acac6 100644 --- a/.ai/memory/user-preferences.md +++ b/.ai/memory/user-preferences.md @@ -9,3 +9,15 @@ ## Specifika Projektu - **Aktuální stav**: - **Kritické Todo**: + +## Sync Pravidla (`upstream/common-v8` -> `evolu-plan-b`) +- **Baseline**: `common-v8` je výchozí architektura/UX/API kompatibilita. +- **Bez wholesale merge**: nikdy neprovádět slepé 1:1 mergnutí celé větve, které vrací odstraněné dependency/tooling. +- **Bun-first/Biome-first**: zachovat `bun` + `biome`, nevracet `pnpm`, `pnpx`, `eslint`, `prettier` ani jejich workflow. +- **Commit-parity workflow**: každou upstream změnu mapovat jako `adopt exact` / `equivalent` / `intentional divergence`. +- **Povinný reporting odchylek předem**: pokud nebude něco implementováno nebo bude řešeno jinak, vždy předem uvést důvod, dopad a návrh. +- **Performance & deps policy**: aktivně hledat náhrady 3rd-party knihoven nativními Bun funkcemi a hlásit je; preferovat méně závislostí a vyšší výkon. +- **No chaos refactors**: nepřesouvat naming/soubory bez jasného přínosu (výkon, stabilita, kompatibilita). +- **Keep local supersets**: pokud máme navíc oproti upstreamu (vyřešený issue, lepší implementace, funkce navíc), nikdy to nerevertovat, pokud je zachovaná upstream kompatibilita. +- **Testy můžou být napřed**: aktivně přidávat/rozšiřovat test coverage i nad rámec upstreamu, pokud to zvyšuje jistotu kompatibility a stability. +- **Escalation style**: při nalezení upstream chyby nebo lepší Bun varianty nejdřív zastavit a dodat stručný report: problém -> dopad -> návrh -> riziko. diff --git a/.changeset/sqlite-refactor.md b/.changeset/sqlite-refactor.md index 022b6b1c4..d2fa7dacd 100644 --- a/.changeset/sqlite-refactor.md +++ b/.changeset/sqlite-refactor.md @@ -5,11 +5,31 @@ "@evolu/react-native": major --- -Refactored createSqlite and CreateSqliteDriver to use Task +Refactored SQLite integration to use Task and throw-first semantics -- Changed `createSqlite` to `Task` -- Changed `CreateSqliteDriver` to `Task` +- Changed `createSqlite` to `Task` +- Changed `CreateSqliteDriver` to `Task` +- Removed `SqliteError` from SQLite driver/task APIs +- Changed `Sqlite.exec` to return `SqliteExecResult` directly (no `Result<..., SqliteError>`) +- Changed `Sqlite.transaction` to support callbacks returning either `Result` or `void` (no `SqliteError` in the error channel) +- Changed `Sqlite.export` to return `Uint8Array` directly (no `Result<..., SqliteError>`) - Simplified `SqliteDriver.exec` by removing the `isMutation` parameter — the driver now determines read vs write internally - Replaced `options.memory` and `options.encryptionKey` with a discriminated `options.mode` field (`"memory"` | `"encrypted"`) -- Added 100% test coverage for better-sqlite3 and WASM SQLite drivers - Updated Expo and op-sqlite drivers to match the new API + +Why `SqliteError` was removed: + +- In Evolu, SQLite runs in-process. Failures are infrastructure-level and unrecoverable at the call site. +- Wrapping these failures as `Result` values did not create meaningful recovery paths; callers still had to fail. +- The correct behavior is to let such failures throw and surface them through platform `createRun` global handlers (web, nodejs, react-native), which report uncaught errors via Evolu `console.error`. +- Evolu also propagates `console.error` entries through its messaging layer into the shared `evoluError` global store, so app-level error subscriptions still receive these failures. + +Boundary handling: + +- At protocol boundaries (for example Protocol ↔ Storage), error handling remains explicit. +- Since storage implementations may throw, boundary code uses `try/catch`, logs with `console.error(error)`, and returns protocol-level outcomes. +- Protocol handles all thrown errors as boundary concerns, without coupling to SQLite-specific error types. + +Developer experience: + +- This change reduces boilerplate (`if (!result.ok)` branches and `SqliteError` plumbing), making infrastructure code less verbose and easier to follow. diff --git a/biome.json b/biome.json index cf69eb077..1254b377a 100644 --- a/biome.json +++ b/biome.json @@ -30,6 +30,7 @@ "noExplicitAny": "off", "noImplicitAnyLet": "off", "useIterableCallbackReturn": "off", + "noImportCycles": "error", "noThenProperty": "off", "noAssignInExpressions": "off" }, @@ -54,14 +55,7 @@ "security": { "noDangerouslySetInnerHtml": "warn" }, - "nursery": { - "noImportCycles": { - "level": "error", - "options": { - "ignoreTypes": true - } - } - } + "nursery": {} } }, "javascript": { diff --git a/bun.lock b/bun.lock index 8d13d7e99..4207bafaf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "@evolu/monorepo", "devDependencies": { - "@biomejs/biome": "2.3.15", + "@biomejs/biome": "2.4.0", "@changesets/cli": "^2.29.8", "@types/webpack": "^5.28.5", "@vitest/browser": "^4.0.17", "@vitest/browser-playwright": "^4.0.17", "@vitest/coverage-v8": "^4.0.17", "rimraf": "^6.1.2", - "turbo": "^2.8.8", + "turbo": "^2.8.9", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.9.3", @@ -694,23 +694,23 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], + "@biomejs/biome": ["@biomejs/biome@2.4.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.0", "@biomejs/cli-darwin-x64": "2.4.0", "@biomejs/cli-linux-arm64": "2.4.0", "@biomejs/cli-linux-arm64-musl": "2.4.0", "@biomejs/cli-linux-x64": "2.4.0", "@biomejs/cli-linux-x64-musl": "2.4.0", "@biomejs/cli-win32-arm64": "2.4.0", "@biomejs/cli-win32-x64": "2.4.0" }, "bin": { "biome": "bin/biome" } }, "sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q=="], "@blazejkustra/react-native-alert": ["@blazejkustra/react-native-alert@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bgvKlnhfS39vz38BSBdHk1smVME0Nf5tJEzgoQOIPpci8KuTBcOORa93B2PV3/S5a0QkR1d8nW2yw76HDj6zqQ=="], @@ -2862,7 +2862,7 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -3066,7 +3066,7 @@ "suppressed-error": ["suppressed-error@1.0.3", "", { "dependencies": { "define-data-property": "^1.1.1", "define-properties": "^1.2.1", "es-abstract": "^1.22.3", "es-errors": "^1.1.0", "function-bind": "^1.1.2", "globalthis": "^1.0.3", "has-property-descriptors": "^1.0.1", "set-function-name": "^2.0.1" } }, "sha512-6+ZiCVUmDLFRyYRswTrDTYWaM/IT01W/cqQBLnnyg8T0njVrWj3tP+EXFevXk6qK61yDXnmZsOFVzFfYoUy/KA=="], - "svelte": ["svelte@5.51.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-r8r6p+NFC2ckAkxW4lqpGs1AZWBi5Y+TbJMmAglqSbokN5UWkDsKKkybfGBKXd8yYMri7KJ2L78fO9SO+NOelA=="], + "svelte": ["svelte@5.51.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w=="], "svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="], @@ -3076,7 +3076,7 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], + "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -3272,7 +3272,7 @@ "webpack": ["webpack@5.105.2", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw=="], - "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], @@ -3440,8 +3440,6 @@ "@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], - "@expo/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "@expo/fingerprint/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -3452,8 +3450,6 @@ "@expo/prebuild-config/@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], - "@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], diff --git a/packages/bun/src/BunDbWorker.ts b/packages/bun/src/BunDbWorker.ts index da9242d2f..72e04c956 100644 --- a/packages/bun/src/BunDbWorker.ts +++ b/packages/bun/src/BunDbWorker.ts @@ -14,10 +14,10 @@ import { type SQLQueryBindings, } from "bun:sqlite"; import type { - DbWorkerInput, - DbWorkerMutateResponseMessage, - DbWorkerOutput, - DbWorkerQueryResponseMessage, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerMutateResponseMessage as DbWorkerMutateResponseMessage, + ExperimentalDbWorkerOutput as DbWorkerOutput, + ExperimentalDbWorkerQueryResponseMessage as DbWorkerQueryResponseMessage, Row, } from "@evolu/common/local-first"; diff --git a/packages/bun/test/BunDbWorker.test.ts b/packages/bun/test/BunDbWorker.test.ts index d3f48f9cb..9c9cf46d9 100644 --- a/packages/bun/test/BunDbWorker.test.ts +++ b/packages/bun/test/BunDbWorker.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import type { DbWorkerInput, DbWorkerOutput } from "@evolu/common/local-first"; +import type { + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerOutput as DbWorkerOutput, +} from "@evolu/common/local-first"; import { type BunDbWorkerScope, runBunDbWorkerScope, diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index a30b83a86..e454eb9e8 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -10,6 +10,7 @@ import { isNonEmptyArray, type NonEmptyReadonlyArray, } from "./Array.js"; +import { assert } from "./Assert.js"; import type { UnknownError } from "./Error.js"; import type { Lazy } from "./Function.js"; import { exhaustiveCheck } from "./Function.js"; @@ -182,9 +183,9 @@ import type { Typed } from "./Type.js"; * handleable at the app level. Group them in a union type like `AppError`: * * ```ts - * type AppError = SqliteError | SyncError | UnknownError; + * type AppError = TimestampError | SyncError | UnknownError; * - * interface SqliteError extends Typed<"SqliteError"> { + * interface TimestampError extends Typed<"TimestampError"> { * readonly error: UnknownError; * } * ``` @@ -197,9 +198,11 @@ import type { Typed } from "./Type.js"; * ```ts * const handleAppError = (error: AppError): void => { * switch (error.type) { - * case "SqliteError": + * case "TimestampError": * console.error(error.error.stack); // Log preserved stack trace - * showToast("Database error. Please restart the app."); + * showToast( + * "Timestamp error. Your computer clock appears to be incorrect.", + * ); * break; * case "SyncError": * showToast("Sync failed. Retrying..."); @@ -379,6 +382,18 @@ export const getOrThrow = (result: Result): T => { export const getOrNull = (result: Result): T | null => result.ok ? result.value : null; +/** + * Extracts the value from a {@link Result} whose error type is `never`. + * + * This is useful when the type system guarantees the result cannot fail (for + * example `Result`), avoiding impossible `if (!result.ok)` branches + * at call sites. + */ +export const getOk = (result: Result): T => { + assert(result.ok, "Expected Ok result."); + return result.value; +}; + /** * Wraps a synchronous function that may throw, returning a {@link Result}. * diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index 05d9c374c..b1ac5c738 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -8,10 +8,8 @@ import type { Brand } from "./Brand.js"; import type { EncryptionKey } from "./Crypto.js"; import type { Eq } from "./Eq.js"; import { eqArrayNumber } from "./Eq.js"; -import type { UnknownError } from "./Error.js"; -import { createUnknownError } from "./Error.js"; import type { Result } from "./Result.js"; -import { err, ok, trySync } from "./Result.js"; +import { ok } from "./Result.js"; import type { Task } from "./Task.js"; import type { SimpleName, Typed } from "./Type.js"; import { Null, Number, String, Uint8Array, union } from "./Type.js"; @@ -26,25 +24,32 @@ import { Null, Number, String, Uint8Array, union } from "./Type.js"; export interface Sqlite extends Disposable { readonly exec: ( query: SqliteQuery, - ) => Result, SqliteError>; + ) => SqliteExecResult; /** * Executes a transaction, running the provided callback within a begin/commit - * block. If the callback returns an error, the transaction is rolled back. If - * the rollback fails, a {@link SqliteError} is returned with both the original - * error and rollbackError. + * block. + * + * If the callback returns a {@link Result} error, the transaction is rolled + * back and the error result is returned. + * + * If the callback returns `void`, the transaction is committed unless the + * callback throws. */ - readonly transaction: ( - callback: () => Result, - ) => Result; + readonly transaction: SqliteTransaction; - readonly export: () => Result; + readonly export: () => Uint8Array; } export interface SqliteDep { readonly sqlite: Sqlite; } +export interface SqliteTransaction { + (callback: () => Result): Result; + (callback: () => void): void; +} + /** Represents a SQL query to be executed on a {@link Sqlite} database. */ export interface SqliteQuery { readonly sql: SafeSql; @@ -106,12 +111,6 @@ export interface SqliteExecResult { readonly changes: number; } -/** Represents an error that occurred during a SQLite operation. */ -export interface SqliteError extends Typed<"SqliteError"> { - readonly error: UnknownError; - readonly rollbackError?: UnknownError; -} - /** * A row returned from a {@link Sqlite} query, mapping column names to * {@link SqliteValue}. @@ -158,7 +157,7 @@ export const createSqlite = ( name: SimpleName, options?: SqliteDriverOptions, - ): Task => + ): Task => async (run) => { const { createSqliteDriver } = run.deps; const console = run.deps.console.child("sql"); @@ -170,100 +169,59 @@ export const createSqlite = let isDisposed = false; - const rollback = ( - result: Result, - error: UnknownError, - ): Result => { - const rollbackResult = trySync(() => { - console.debug("rollback"); - driver.exec(sql`rollback;`); - }, createSqliteError); - if (!rollbackResult.ok) { - console.warn("rollback failed", rollbackResult.error); - return err({ - type: "SqliteError", - error, - rollbackError: rollbackResult.error.error, - }); - } - return result; - }; - return ok({ - exec: (query) => - trySync( - () => { - console.debug({ query }); - - const label = - query.options?.logQueryExecutionTime && - `SqliteQueryExecutionTime ${query.sql}`; - - if (label) console.time(label); - const result = driver.exec(query); - if (label) console.timeEnd(label); - - if (query.options?.logExplainQueryPlan) { - const result = driver.exec({ - ...query, - sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, - }); - console.log("[logExplainQueryPlan]", query); - console.log( - drawSqliteQueryPlan( - result.rows as unknown as Array, - ), - ); - } - - console.debug({ result }); - return result as never; - }, - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), - - transaction: (callback) => { - const beginResult = trySync(() => { - console.debug("begin"); - driver.exec(sql`begin;`); - }, createSqliteError); - if (!beginResult.ok) return beginResult; - - const callbackResult = trySync(callback, createSqliteError); - if (!callbackResult.ok) { - return rollback(callbackResult, callbackResult.error.error); - } - - const callbackValue = callbackResult.value; - if (!callbackValue.ok) { - return rollback( - callbackValue, - createUnknownError(callbackValue.error), + exec: (query: SqliteQuery) => { + console.debug({ query }); + + const label = + query.options?.logQueryExecutionTime && + `SqliteQueryExecutionTime ${query.sql}`; + + if (label) console.time(label); + const result = driver.exec(query); + if (label) console.timeEnd(label); + + if (query.options?.logExplainQueryPlan) { + const result = driver.exec({ + ...query, + sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, + }); + console.log("[logExplainQueryPlan]", query); + console.log( + drawSqliteQueryPlan( + result.rows as unknown as Array, + ), ); } - const commitResult = trySync(() => { - console.debug("commit"); - driver.exec(sql`commit;`); - }, createSqliteError); - if (!commitResult.ok) { - return rollback(commitResult, commitResult.error.error); - } - - return ok(callbackValue.value); + console.debug({ result }); + return result as SqliteExecResult; }, - export: () => - trySync( - () => driver.export(), - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), + transaction: ((callback: () => Result | undefined) => { + console.debug("begin"); + driver.exec(sql`begin;`); + + let shouldRollback = true; + using _rollback = { + [Symbol.dispose]: () => { + if (!shouldRollback) return; + console.debug("rollback"); + driver.exec(sql`rollback;`); + }, + }; + + const result = callback(); + if (result != null && !result.ok) return result; + + console.debug("commit"); + driver.exec(sql`commit;`); + shouldRollback = false; + + return result; + }) as SqliteTransaction, + + export: () => driver.export(), [Symbol.dispose]: () => { if (isDisposed) return; @@ -273,11 +231,6 @@ export const createSqlite = }); }; -const createSqliteError = (error: unknown): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), -}); - interface SqliteQueryPlanRow { id: number; parent: number; diff --git a/packages/common/src/Type.ts b/packages/common/src/Type.ts index f1cb2191a..608139bb3 100644 --- a/packages/common/src/Type.ts +++ b/packages/common/src/Type.ts @@ -1658,6 +1658,21 @@ export const SimpleName = /*#__PURE__*/ brand( export type SimpleName = typeof SimpleName.Type; export interface SimpleNameError extends TypeError<"SimpleName"> {} +/** + * @deprecated Use {@link SimpleName}. Kept for upstream/common-v8 API parity. + */ +export const Name = SimpleName; + +/** + * @deprecated Use {@link SimpleName}. Kept for upstream/common-v8 API parity. + */ +export type Name = SimpleName; + +/** + * @deprecated Use {@link SimpleNameError}. Kept for upstream/common-v8 API parity. + */ +export type NameError = SimpleNameError; + /** * Trimmed string between 8 and 64 characters, branded as `SimplePassword`. * diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 4a5be1c40..7585bb92e 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -5,6 +5,7 @@ */ import type { Brand } from "./Brand.js"; +import type { ConsoleDep, ConsoleStoreOutputEntryDep } from "./Console.js"; import type { GlobalErrorScope } from "./Error.js"; /** @@ -72,10 +73,10 @@ export interface MessagePort extends Disposable { onMessage: ((message: Output) => void) | null; /** The native underlying port. Use this only for transferring via postMessage. */ - readonly native: NativeMessagePort; + readonly native: NativeMessagePort; } -export type Transferable = NativeMessagePort | ArrayBuffer; +export type Transferable = NativeMessagePort | ArrayBuffer; /** * Opaque type for platform-specific native MessagePort. @@ -84,17 +85,30 @@ export type Transferable = NativeMessagePort | ArrayBuffer; * a wrapper. Ensures type-safe wiring between {@link MessagePort.native} and * {@link CreateMessagePort} without exposing platform details. */ -export type NativeMessagePort = Brand<"NativeMessagePort">; +export type NativeMessagePort< + Input = unknown, + Output = never, +> = Brand<"NativeMessagePort"> & { + readonly [nativeMessagePortInput]?: Input; + readonly [nativeMessagePortOutput]?: Output; +}; + +declare const nativeMessagePortInput: unique symbol; +declare const nativeMessagePortOutput: unique symbol; /** Factory function to create a {@link MessagePort} from a native port. */ export type CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ) => MessagePort; export interface CreateMessagePortDep { readonly createMessagePort: CreateMessagePort; } +/** Common dependencies for worker entry points. */ +export type WorkerDeps = ConsoleDep & + ConsoleStoreOutputEntryDep & + CreateMessagePortDep; /** * Platform-agnostic MessageChannel. * @@ -192,3 +206,185 @@ export interface WorkerScope export interface SharedWorkerScope extends SharedWorkerSelf, GlobalErrorScope {} + +/** + * Creates a connected {@link Worker} / {@link WorkerSelf} pair for testing. + * + * Returns both sides so tests can exercise dedicated worker communication + * without a real worker thread. + */ +export const testCreateWorker = (): { + readonly worker: Worker; + readonly self: WorkerSelf; +} => { + const channel = testCreateMessageChannel(); + + const worker: Worker = { + postMessage: channel.port1.postMessage, + get onMessage() { + return channel.port1.onMessage; + }, + set onMessage(value) { + channel.port1.onMessage = value; + }, + native: channel.port1.native, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + const self: WorkerSelf = { + postMessage: channel.port2.postMessage, + get onMessage() { + return channel.port2.onMessage; + }, + set onMessage(value) { + channel.port2.onMessage = value; + }, + native: channel.port2.native, + [Symbol.dispose]: () => { + channel.port2[Symbol.dispose](); + }, + }; + + return { worker, self }; +}; + +/** + * Creates a connected {@link SharedWorker} / {@link SharedWorkerSelf} pair for + * testing. + * + * Returns both sides so tests can exercise the full worker ↔ client pipeline + * without a real worker thread. Calling `connect()` triggers `self.onConnect`. + */ +export const testCreateSharedWorker = (): { + readonly worker: SharedWorker; + readonly self: SharedWorkerSelf; + readonly connect: () => void; +} => { + const channel = testCreateMessageChannel(); + + const self: SharedWorkerSelf = { + onConnect: null, + [Symbol.dispose]: () => { + self.onConnect = null; + }, + }; + + const worker: SharedWorker = { + port: channel.port1, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + const connect = () => { + if (self.onConnect) self.onConnect(channel.port2); + }; + + return { worker, self, connect }; +}; + +/** {@link MessageChannel} with disposal tracking for testing. */ +export interface TestMessageChannel + extends MessageChannel { + readonly isDisposed: () => boolean; +} + +/** + * Creates an in-memory {@link MessageChannel} for testing. + * + * Messages are queued until `onMessage` is assigned, matching the browser + * MessagePort behavior where the port message queue starts disabled. + * + * Both ports are registered in the native port registry so + * {@link testCreateMessagePort} can look them up by their native token. + */ +export const testCreateMessageChannel = < + Input, + Output = never, +>(): TestMessageChannel => { + const state1: TestPortState = { handler: null, queue: [] }; + const state2: TestPortState = { handler: null, queue: [] }; + + const native1 = Symbol("NativeMessagePort1") as unknown as NativeMessagePort< + Input, + Output + >; + const native2 = Symbol("NativeMessagePort2") as unknown as NativeMessagePort< + Output, + Input + >; + + const port1 = createTestPort(state1, state2, native1); + const port2 = createTestPort(state2, state1, native2); + + nativePortRegistry.set(native1, port1); + nativePortRegistry.set(native2, port2); + + let disposed = false; + + return { + port1, + port2, + isDisposed: () => disposed, + [Symbol.dispose]: () => { + disposed = true; + port1[Symbol.dispose](); + port2[Symbol.dispose](); + }, + }; +}; + +/** Creates an in-memory {@link CreateMessagePort} for testing. */ +export const testCreateMessagePort: CreateMessagePort = ( + nativePort: NativeMessagePort, +): MessagePort => { + const pair = nativePortRegistry.get(nativePort); + if (!pair) throw new Error("Unknown native port — did you transfer it?"); + return pair as MessagePort; +}; + +interface TestPortState { + handler: ((message: T) => void) | null; + readonly queue: Array; +} + +const createTestPort = ( + receive: TestPortState, + peerReceive: TestPortState, + native: NativeMessagePort, +): MessagePort => ({ + postMessage: (message) => { + if (peerReceive.handler) peerReceive.handler(message); + else peerReceive.queue.push(message); + }, + get onMessage() { + return receive.handler; + }, + set onMessage(fn) { + receive.handler = fn; + if (fn) { + for (const msg of receive.queue.splice(0)) fn(msg); + } + }, + native, + [Symbol.dispose]: () => { + receive.handler = null; + }, +}); + +/** + * Registry mapping native port tokens to their in-memory port counterparts. + * + * When {@link testCreateMessageChannel} creates a pair, both ports are + * registered here. When {@link testCreateMessagePort} wraps a native token + * (received via `postMessage` transfer), it looks up the actual port. + * + * Uses `WeakMap` so entries are garbage collected when the native token is no + * longer referenced. + */ +const nativePortRegistry = new WeakMap< + NativeMessagePort, + MessagePort +>(); diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 21d740180..7c9aa7269 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -3,6 +3,437 @@ * * @module */ + +import { firstInArray } from "../Array.js"; +import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import type { CallbackId } from "../Callbacks.js"; +import type { ConsoleLevel } from "../Console.js"; +import type { EncryptionKey, RandomBytesDep } from "../Crypto.js"; +import { getProperty } from "../Object.js"; +import type { LeaderLockDep } from "../Platform.js"; +import { getOk, ok } from "../Result.js"; +import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; +import { createSqlite, type SqliteValue, sql } from "../Sqlite.js"; +import type { AsyncDisposableStack, Task } from "../Task.js"; +import type { Name } from "../Type.js"; +import { type Id, type IdBytes, idBytesToId } from "../Type.js"; +import type { + MessagePort, + NativeMessagePort, + Worker, + WorkerDeps, + WorkerSelf, +} from "../Worker.js"; +import type { OwnerId, OwnerIdBytes } from "./Owner.js"; +import { ownerIdBytesToOwnerId } from "./Owner.js"; +import { protocolVersion } from "./Protocol.js"; +import type { Query } from "./Query.js"; +import type { DbSchema, MutationChange } from "./Schema.js"; +import { ensureDbSchema, getDbSchema, systemColumns } from "./Schema.js"; +import type { EvoluTabOutput } from "./Shared.js"; +import { createBaseSqliteStorageTables } from "./Storage.js"; +import type { Timestamp } from "./Timestamp.js"; +import { + createInitialTimestamp, + type TimestampBytes, + timestampBytesToTimestamp, + timestampToTimestampBytes, +} from "./Timestamp.js"; + +export interface DbWorkerInput { + readonly type: "Init"; + readonly name: Name; + readonly consoleLevel: ConsoleLevel; + readonly dbSchema: DbSchema; + readonly encryptionKey: EncryptionKey; + readonly port: NativeMessagePort; +} + +export type DbWorker = Worker; + +export type CreateDbWorker = () => DbWorker; + +export interface CreateDbWorkerDep { + readonly createDbWorker: CreateDbWorker; +} + +export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep; + +export interface DbWorkerLeaderInput { + readonly type: "Mutate"; + readonly requestId: CallbackId; + readonly changes: ReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + +export type DbWorkerLeaderOutput = + | { + readonly type: "LeaderAcquired"; + readonly name: Name; + } + | { + readonly type: "OnMutate"; + readonly requestId: CallbackId; + } + | EvoluTabOutput; + +export interface PortDep { + readonly port: MessagePort; +} + +export const initDbWorker = + ( + self: WorkerSelf, + ): Task => + (run) => { + const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; + const stack = run.stack(); + + let initialized = false; + + self.onMessage = ({ + name, + consoleLevel, + dbSchema, + encryptionKey, + port: nativeLeaderPort, + }) => { + if (!initialized) { + initialized = true; + const console = run.deps.console.child(name).child("DbWorker"); + console.setLevel(consoleLevel); + console.info("initDbWorker"); + + const port = stack.use( + createMessagePort( + nativeLeaderPort, + ), + ); + + const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe( + () => { + const entry = consoleStoreOutputEntry.get(); + if (entry) port.postMessage({ type: "ConsoleEntry", entry }); + }, + ); + + stack.defer(() => { + unsubscribeConsoleStore(); + return ok(); + }); + + void run.daemon(async (run) => { + await stack.use(leaderLock.acquire(name)); + console.info("leaderAcquired"); + port.postMessage({ type: "LeaderAcquired", name }); + return run.addDeps({ port })( + startDbWorker(name, dbSchema, encryptionKey), + ); + }); + } + }; + + return ok(stack); + }; + +const startDbWorker = + ( + name: Name, + dbSchema: DbSchema, + encryptionKey: EncryptionKey, + ): Task => + async (run) => { + await using stack = run.stack(); + const { port: _port } = run.deps; + const _console = run.deps.console.child(name).child("DbWorker"); + + const sqlite = getOk( + await stack.use(createSqlite(name, { mode: "encrypted", encryptionKey })), + ); + + const deps = { ...run.deps, sqlite }; + + sqlite.transaction(() => { + const currentSchema = getDbSchema(deps)(); + const dbIsInitialized = "evolu_version" in currentSchema.tables; + + const clock = createClock(deps)(dbIsInitialized); + + if (!dbIsInitialized) initializeDb(deps)(clock.get()); + + ensureDbSchema(deps)(dbSchema, currentSchema); + + tryApplyQuarantinedMessages(deps)(dbSchema); + }); + + _port.onMessage = (message) => { + _port.postMessage({ type: "OnMutate", requestId: message.requestId }); + }; + + // run.deps.leaderPort.onMessage({ + // // + // }) + + return ok(stack.move()); + + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to + // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu + // name and if silent for 10 seconds, it waits for another DB worker + // to announce itself alive and then routes requests to that worker. + }; + +interface Clock { + readonly get: () => Timestamp; + readonly save: (timestamp: Timestamp) => void; +} + +// interface ClockDep { +// readonly clock: Clock; +// } + +const createClock = + (deps: RandomBytesDep & SqliteDep) => + (dbIsInitialized: boolean): Clock => { + let currentTimestamp: Timestamp; + + if (dbIsInitialized) { + const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql` + select clock + from evolu_config + limit 1; + `); + assertNonEmptyReadonlyArray(rows); + currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock); + } else { + currentTimestamp = createInitialTimestamp(deps); + } + + return { + get: () => currentTimestamp, + + save: (timestamp) => { + currentTimestamp = timestamp; + + deps.sqlite.exec(sql.prepared` + update evolu_config + set "clock" = ${timestampToTimestampBytes(timestamp)}; + `); + }, + }; + }; + +const initializeDb = + ({ sqlite }: SqliteDep) => + (initialClock: Timestamp): void => { + for (const query of [ + sql` + create table evolu_version ( + "protocolVersion" integer not null + ) + strict; + `, + + sql` + insert into evolu_version ("protocolVersion") + values (${protocolVersion}); + `, + + sql` + create table evolu_config ( + "clock" blob not null + ) + strict; + `, + + sql` + insert into evolu_config ("clock") + values (${timestampToTimestampBytes(initialClock)}); + `, + + /** + * The History table stores all values per ownerId, timestamp, table, id, + * and column for conflict-free merging using last-write-win CRDT. + * Denormalizes Timestamp and DbChange for covering index performance. + * Time travel is available when last-write-win isn't desired. Future + * optimization will store history more efficiently. + */ + sql` + create table evolu_history ( + "ownerId" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "timestamp" blob not null, + "value" any + ) + strict; + `, + + // Index for reading database changes by owner and timestamp. + sql` + create index evolu_history_ownerId_timestamp on evolu_history ( + "ownerId", + "timestamp" + ); + `, + + sql` + create unique index evolu_history_ownerId_table_id_column_timestampDesc on evolu_history ( + "ownerId", + "table", + "id", + "column", + "timestamp" desc + ); + `, + + /** + * Stores messages with unknown schema in a quarantine table. + * + * When a device receives sync messages containing tables or columns that + * don't exist in its current schema (e.g., from a newer app version), + * those messages are stored here instead of being discarded. This enables + * forward compatibility: + * + * 1. Unknown data is preserved and can be applied when the app is updated + * 2. Messages are still propagated to other devices that may understand them + * 3. Partial messages work - known columns go to app tables, unknown to + * quarantine + * + * The `union all` query in `readDbChange` combines `evolu_history` and + * this table, ensuring all data (known and unknown) is included when + * syncing to other devices. + */ + sql` + create table evolu_message_quarantine ( + "ownerId" blob not null, + "timestamp" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "value" any, + primary key ("ownerId", "timestamp", "table", "id", "column") + ) + strict; + `, + ]) { + sqlite.exec(query); + } + + createBaseSqliteStorageTables({ sqlite }); + }; + +const tryApplyQuarantinedMessages = + (deps: SqliteDep) => + (dbSchema: DbSchema): void => { + const rows = deps.sqlite.exec<{ + readonly ownerId: OwnerIdBytes; + readonly timestamp: TimestampBytes; + readonly table: string; + readonly id: IdBytes; + readonly column: string; + readonly value: SqliteValue; + }>(sql` + select "ownerId", "timestamp", "table", "id", "column", "value" + from evolu_message_quarantine; + `); + + for (const row of rows.rows) { + if (!validateColumnValue(dbSchema, row.table, row.column, row.value)) + continue; + + applyColumnChange(deps)( + row.ownerId, + ownerIdBytesToOwnerId(row.ownerId), + row.table, + row.id, + idBytesToId(row.id), + row.column, + row.value, + row.timestamp, + ); + + deps.sqlite.exec(sql` + delete from evolu_message_quarantine + where + "ownerId" = ${row.ownerId} + and "timestamp" = ${row.timestamp} + and "table" = ${row.table} + and "id" = ${row.id} + and "column" = ${row.column}; + `); + } + }; + +const validateColumnValue = ( + dbSchema: DbSchema, + table: string, + column: string, + _value: SqliteValue, +): boolean => { + const schemaColumns = getProperty(dbSchema.tables, table); + return ( + schemaColumns != null && + (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column)) + ); +}; + +const systemColumnsWithoutOwnerId = systemColumns.difference( + new Set(["ownerId"]), +); + +const applyColumnChange = + (deps: SqliteDep) => + ( + ownerIdBytes: OwnerIdBytes, + ownerId: OwnerId, + table: string, + idBytes: IdBytes, + id: Id, + column: string, + value: SqliteValue, + timestampBytes: TimestampBytes, + ): void => { + deps.sqlite.exec(sql.prepared` + with + existingTimestamp as ( + select 1 + from evolu_history + where + "ownerId" = ${ownerIdBytes} + and "table" = ${table} + and "id" = ${idBytes} + and "column" = ${column} + and "timestamp" >= ${timestampBytes} + limit 1 + ) + insert into ${sql.identifier(table)} + ("ownerId", "id", ${sql.identifier(column)}) + select ${ownerId}, ${id}, ${value} + where not exists (select 1 from existingTimestamp) + on conflict ("ownerId", "id") do update + set ${sql.identifier(column)} = ${value} + where not exists (select 1 from existingTimestamp); + `); + + deps.sqlite.exec(sql.prepared` + insert into evolu_history + ("ownerId", "table", "id", "column", "value", "timestamp") + values + ( + ${ownerIdBytes}, + ${table}, + ${idBytes}, + ${column}, + ${value}, + ${timestampBytes} + ) + on conflict do nothing; + `); + }; + // import { // firstInArray, // isNonEmptyArray, @@ -27,7 +458,7 @@ // SqliteError, // } from "../Sqlite.js"; // import { TimeDep } from "../Time.js"; -// import { Id, Mnemonic, SimpleName } from "../Type.js"; +// import { Id, Mnemonic, Name } from "../Type.js"; // import { CreateWebSocketDep } from "../WebSocket.js"; // import { // createInitializedWorkerWithHandlers, @@ -68,7 +499,6 @@ // createSync, // SyncDep, // SyncOwner, -// tryApplyQuarantinedMessages, // } from "./Sync.js"; // import { // Timestamp, @@ -93,10 +523,10 @@ // * ### Example // * // * ```ts -// * // name: SimpleName.orThrow("MyApp") +// * // name: Name.orThrow("MyApp") // * ``` // */ -// readonly name: SimpleName; +// readonly name: Name; // /** // * Transport configuration for data sync and backup. Supports single transport @@ -180,13 +610,13 @@ // * // * // Local-only instance for device settings (no sync) // * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, { -// * name: SimpleName.orThrow("MyApp-Device"), +// * name: Name.orThrow("MyApp-Device"), // * transports: [], // No sync - stays local to device // * }); // * // * // Main synced instance for user data // * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, { -// * name: SimpleName.orThrow("MyApp"), +// * name: Name.orThrow("MyApp"), // * // Default transports for sync // * }); // * ``` @@ -215,7 +645,7 @@ // } // export const defaultDbConfig: DbConfig = { -// name: SimpleName.orThrow("Evolu"), +// name: Name.orThrow("Evolu"), // transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }], // maxDrift: 5 * 60 * 1000, // enableLogging: false, @@ -223,7 +653,7 @@ // export type DbWorker = Worker; -// export type CreateDbWorker = (name: SimpleName) => DbWorker; +// export type CreateDbWorker = (name: Name) => DbWorker; // export interface CreateDbWorkerDep { // readonly createDbWorker: CreateDbWorker; @@ -437,14 +867,6 @@ // }); // if (!sync.ok) return sync; -// { -// const result = tryApplyQuarantinedMessages({ -// ...deps, -// dbSchema: initMessage.dbSchema, -// })(); -// if (!result.ok) return result; -// } - // sync.value.useOwner(true, appOwner); // return ok({ diff --git a/packages/common/src/local-first/Error.ts b/packages/common/src/local-first/Error.ts index b3bf7429b..2efc7aa90 100644 --- a/packages/common/src/local-first/Error.ts +++ b/packages/common/src/local-first/Error.ts @@ -6,7 +6,6 @@ import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import type { SqliteError } from "../Sqlite.js"; import type { ProtocolError } from "./Protocol.js"; import type { TimestampError } from "./Timestamp.js"; @@ -14,6 +13,5 @@ import type { TimestampError } from "./Timestamp.js"; export type EvoluError = | DecryptWithXChaCha20Poly1305Error | ProtocolError - | SqliteError | TimestampError | UnknownError; diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index f4d82db5b..c40d3f9a9 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -654,7 +654,11 @@ export const createEvoluDeps = ( }; evoluWorker.port.postMessage( - { type: "InitTab", port: tabChannel.port1.native }, + { + type: "InitTab", + consoleLevel: console.getLevel(), + port: tabChannel.port1.native, + }, [tabChannel.port1.native], ); @@ -766,7 +770,7 @@ export const createEvolu = errorStore.set(createUnknownError(error)); }; - const dbSchema = evoluSchemaToDbSchema(schema as EvoluSchema, _indexes); + const dbSchema = evoluSchemaToDbSchema(schema, _indexes); const dbWorker = createDbWorkerClient(deps, name, setUnknownError); const storeAppOwner = async (nextAppOwner: AppOwner): Promise => { @@ -825,6 +829,7 @@ export const createEvolu = const mutateMicrotaskQueue: Array< [MutationChange, MutationOptions["onComplete"] | undefined] > = []; + let isProcessingMutationQueue = false; const createMutation = (kind: Kind): Mutation => @@ -903,36 +908,45 @@ export const createEvolu = }; const processMutationQueue = () => { - const changes: Array = []; - const onCompletes: Array> = []; - - for (const [change, onComplete] of mutateMicrotaskQueue) { - changes.push(change); - if (onComplete) onCompletes.push(onComplete); - } - - mutateMicrotaskQueue.length = 0; - - if (!isNonEmptyArray(changes)) return; + if (isProcessingMutationQueue) return; + isProcessingMutationQueue = true; void (async () => { try { - await dbReady; - const defaultOwnerId = (await getAppOwnerPromise()).id; - - for (const change of changes) { - const ownerId = change.ownerId ?? defaultOwnerId; - const statements = mutationChangeToStatements(change, ownerId); - for (const statement of statements) { - await dbWorker.mutate(statement.sql, statement.params); + while (mutateMicrotaskQueue.length > 0) { + const changes: Array = []; + const onCompletes: Array< + NonNullable + > = []; + + for (const [change, onComplete] of mutateMicrotaskQueue) { + changes.push(change); + if (onComplete) onCompletes.push(onComplete); } - } - await refreshLoadedQueries(); + mutateMicrotaskQueue.length = 0; + if (!isNonEmptyArray(changes)) continue; + + await dbReady; + const defaultOwnerId = (await getAppOwnerPromise()).id; + + for (const change of changes) { + const ownerId = change.ownerId ?? defaultOwnerId; + const statements = mutationChangeToStatements(change, ownerId); + for (const statement of statements) { + await dbWorker.mutate(statement.sql, statement.params); + } + } - for (const onComplete of onCompletes) onComplete(); + await refreshLoadedQueries(); + for (const onComplete of onCompletes) onComplete(); + } } catch (error) { setUnknownError(error); + } finally { + isProcessingMutationQueue = false; + if (mutateMicrotaskQueue.length > 0) + queueMicrotask(processMutationQueue); } })(); }; @@ -1281,12 +1295,14 @@ const createDbWorkerClient = ( } }; + // All Evolu instance traffic goes through SharedWorker, which is our + // single control point for observability and resilience behavior. deps.evoluWorker.port.postMessage( { type: "InitEvolu", name, - port: channel.port1.native, - brokerPort: brokerChannel.port1.native, + port1: channel.port1.native, + port2: brokerChannel.port1.native, }, [channel.port1.native, brokerChannel.port1.native], ); diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index ee6eb375e..050657524 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -189,6 +189,7 @@ import { hexToBytes, utf8ToBytes, } from "../Buffer.js"; +import type { ConsoleDep } from "../Console.js"; import { createPadmePadding, type DecryptWithXChaCha20Poly1305Error, @@ -526,7 +527,7 @@ export const createProtocolMessageFromCrdtMessages = /** Creates a {@link ProtocolMessage} for sync. */ export const createProtocolMessageForSync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag, @@ -538,16 +539,15 @@ export const createProtocolMessageForSync = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); const size = deps.storage.getSize(ownerIdBytes); - // Errors are handled by the storage. - if (size == null) return null; - splitRange(deps)( + const didSplitRange = splitRange(deps)( ownerIdBytes, NonNegativeInt.orThrow(0), size, InfiniteUpperBound, buffer, ); + if (!didSplitRange) return null; return buffer.unwrap(); }; @@ -994,9 +994,16 @@ export const applyProtocolMessageAsClient = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); if (isNonEmptyArray(messages)) { - const result = await run(storage.writeMessages(ownerIdBytes, messages)); - // Errors are handled by the Storage. Here we just stop syncing. - if (!result.ok) return ok({ type: "NoResponse" }); + try { + const result = await run( + storage.writeMessages(ownerIdBytes, messages), + ); + // Quota errors are handled by Storage; protocol just stops syncing. + if (!result.ok) return ok({ type: "NoResponse" }); + } catch (error) { + run.deps.console.error(error); + return ok({ type: "NoResponse" }); + } } // Now: No writeKey, no sync. @@ -1147,16 +1154,23 @@ export const applyProtocolMessageAsRelay = }); } - const result = await run(storage.writeMessages(ownerIdBytes, messages)); + try { + const result = await run( + storage.writeMessages(ownerIdBytes, messages), + ); - if (!result.ok) { - const errorCode = - result.error.type === "StorageWriteError" - ? ProtocolErrorCode.WriteError - : ProtocolErrorCode.QuotaError; + if (!result.ok) { + const message = createProtocolMessageBuffer(ownerId, { + messageType: MessageType.Response, + errorCode: ProtocolErrorCode.QuotaError, + }).unwrap(); + return ok({ type: "Response", message }); + } + } catch (error) { + run.deps.console.error(error); const message = createProtocolMessageBuffer(ownerId, { messageType: MessageType.Response, - errorCode, + errorCode: ProtocolErrorCode.WriteError, }).unwrap(); return ok({ type: "Response", message }); } @@ -1267,16 +1281,20 @@ const decodeMessages = ( }; const sync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ranges: NonEmptyReadonlyArray, output: ProtocolMessageBuffer, ownerIdBytes: OwnerIdBytes, ): Result => { const outputInitialSize = output.getSize(); - - const storageSize = deps.storage.getSize(ownerIdBytes); - if (storageSize == null) return err(ProtocolErrorCode.SyncError); + let storageSize: NonNegativeInt; + try { + storageSize = deps.storage.getSize(ownerIdBytes); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } let prevUpperBound: RangeUpperBound | null = null; let prevIndex = NonNegativeInt.orThrow(0); @@ -1316,12 +1334,17 @@ const sync = const addFingerprintForRemainingRange = ( begin: NonNegativeInt, ): boolean => { - const fingerprint = deps.storage.fingerprint( - ownerIdBytes, - begin, - storageSize, - ); - if (!fingerprint) return false; + let fingerprint: Fingerprint; + try { + fingerprint = deps.storage.fingerprint( + ownerIdBytes, + begin, + storageSize, + ); + } catch (error) { + deps.console.error(error); + return false; + } // There is always a space for a ramaining range. output.addRange({ type: RangeType.Fingerprint, @@ -1335,13 +1358,18 @@ const sync = const currentUpperBound = range.upperBound; const lower = prevIndex; - let upper = deps.storage.findLowerBound( - ownerIdBytes, - prevIndex, - storageSize, - currentUpperBound, - ); - if (upper == null) return err(ProtocolErrorCode.SyncError); + let upper: NonNegativeInt; + try { + upper = deps.storage.findLowerBound( + ownerIdBytes, + prevIndex, + storageSize, + currentUpperBound, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } switch (range.type) { case RangeType.Skip: { @@ -1350,25 +1378,34 @@ const sync = } case RangeType.Fingerprint: { - const ourFingerprint = deps.storage.fingerprint( - ownerIdBytes, - lower, - upper, - ); - if (ourFingerprint == null) return err(ProtocolErrorCode.SyncError); + let ourFingerprint: Fingerprint; + try { + ourFingerprint = deps.storage.fingerprint( + ownerIdBytes, + lower, + upper, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } if (eqArrayNumber(range.fingerprint, ourFingerprint)) { skipRange(range); } else { if (output.canSplitRange()) { coalesceSkipsBeforeAdd(); - splitRange(deps)( - ownerIdBytes, - lower, - upper, - currentUpperBound, - output, - ); + if ( + !splitRange(deps)( + ownerIdBytes, + lower, + upper, + currentUpperBound, + output, + ) + ) { + return err(ProtocolErrorCode.SyncError); + } } else { return addFingerprintForRemainingRange(upper) ? ok(true) @@ -1386,52 +1423,61 @@ const sync = ); const ourTimestamps = createTimestampsBuffer(); - let cantReadDbChange = false as boolean; let exceeded = false as boolean; + let iterateFailed = false as boolean; + + try { + deps.storage.iterate( + ownerIdBytes, + lower, + upper, + (timestamp, index) => { + const timestampString = timestamp.join(); + const timestampBinary = timestampBytesToTimestamp(timestamp); + + let message: EncryptedCrdtMessage | null = null; + + if (timestampsWeNeed.has(timestampString)) { + timestampsWeNeed.delete(timestampString); + } else { + try { + message = { + timestamp: timestampBinary, + change: deps.storage.readDbChange( + ownerIdBytes, + timestamp, + ), + }; + } catch (error) { + deps.console.error(error); + iterateFailed = true; + return false; + } + } - deps.storage.iterate( - ownerIdBytes, - lower, - upper, - (timestamp, index) => { - const timestampString = timestamp.join(); - const timestampBinary = timestampBytesToTimestamp(timestamp); - - let message: EncryptedCrdtMessage | null = null; - - if (timestampsWeNeed.has(timestampString)) { - timestampsWeNeed.delete(timestampString); - } else { - const dbChange = deps.storage.readDbChange( - ownerIdBytes, - timestamp, - ); - if (dbChange == null) { - cantReadDbChange = true; + if ( + !output.canAddTimestampsRangeAndMessage( + ourTimestamps, + message, + ) + ) { + exceeded = true; + endBound = timestamp; + upper = index; return false; } - message = { - timestamp: timestampBinary, - change: dbChange, - }; - } - if ( - !output.canAddTimestampsRangeAndMessage(ourTimestamps, message) - ) { - exceeded = true; - endBound = timestamp; - upper = index; - return false; - } - - ourTimestamps.add(timestampBinary); - if (message) output.addMessage(message); - return true; - }, - ); + ourTimestamps.add(timestampBinary); + if (message) output.addMessage(message); + return true; + }, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } - if (cantReadDbChange) { + if (iterateFailed) { return err(ProtocolErrorCode.SyncError); } @@ -1474,14 +1520,14 @@ const sync = }; const splitRange = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerIdBytes, lower: NonNegativeInt, upper: NonNegativeInt, upperBound: RangeUpperBound, buffer: ProtocolMessageBuffer, - ): void => { + ): boolean => { const itemCount = NonNegativeInt.orThrow(upper - lower); const buckets = computeBalancedBuckets(itemCount); @@ -1503,7 +1549,7 @@ const splitRange = ); buffer.addRange(range); - return; + return true; } // Check Storage.ts `fingerprint` and `fingerprintRanges` docs. @@ -1515,13 +1561,17 @@ const splitRange = ...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)), ]; - const fingerprintRanges = deps.storage.fingerprintRanges( - ownerId, - fingerprintRangesBuckets, - upperBound, - ); - // Errors are handled by the storage. - if (fingerprintRanges == null) return; + let fingerprintRanges: ReadonlyArray; + try { + fingerprintRanges = deps.storage.fingerprintRanges( + ownerId, + fingerprintRangesBuckets, + upperBound, + ); + } catch (error) { + deps.console.error(error); + return false; + } const rangesToUse = lower > 0 ? fingerprintRanges.slice(1) : fingerprintRanges; @@ -1529,6 +1579,7 @@ const splitRange = for (const range of rangesToUse) { buffer.addRange(range); } + return true; }; const decodeRanges = (buffer: Buffer): ReadonlyArray => { diff --git a/packages/common/src/local-first/Query.ts b/packages/common/src/local-first/Query.ts index d26ebbc37..70e4b9c49 100644 --- a/packages/common/src/local-first/Query.ts +++ b/packages/common/src/local-first/Query.ts @@ -10,12 +10,9 @@ import { createRandomBytes } from "../Crypto.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; import type { ReadonlyRecord } from "../Object.js"; import { createRecord, isPlainObject, objectToEntries } from "../Object.js"; -import type { Result } from "../Result.js"; -import { ok } from "../Result.js"; import type { SafeSql, SqliteDep, - SqliteError, SqliteQuery, SqliteQueryOptions, SqliteRow, @@ -195,18 +192,14 @@ export const createGetQueryRowsCache = (): GetQueryRowsCache => { export const loadQueries = (deps: GetQueryRowsCacheDep & SqliteDep) => - ( - tabId: Id, - queries: ReadonlyArray, - ): Result, SqliteError> => { + (tabId: Id, queries: ReadonlyArray): ReadonlyArray => { const queriesRows = []; for (const query of queries) { const sqlQuery = deserializeQuery(query); const result = deps.sqlite.exec(sqlQuery); - if (!result.ok) return result; - queriesRows.push([query, result.value.rows] as const); + queriesRows.push([query, result.rows] as const); } const queryRowsCache = deps.getQueryRowsCache(tabId); @@ -225,7 +218,7 @@ export const loadQueries = ), }), ); - return ok(queryPatchesArray); + return queryPatchesArray; }; export interface QueryPatches { diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index 5acd5444f..62e528e47 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -10,11 +10,12 @@ import { isNonEmptyArray, mapArray, } from "../Array.js"; +import { assert } from "../Assert.js"; import type { TimingSafeEqualDep } from "../Crypto.js"; import { createInstances } from "../Instances.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; import { sql } from "../Sqlite.js"; import type { Mutex } from "../Task.js"; import { createMutex } from "../Task.js"; @@ -27,7 +28,6 @@ import { ownerIdBytesToOwnerId, } from "./Owner.js"; import type { - CreateBaseSqliteStorageConfig, EncryptedDbChange, SqliteStorageDeps, Storage, @@ -110,8 +110,8 @@ export interface Relay extends AsyncDisposable {} export const createRelaySqliteStorage = (deps: SqliteStorageDeps & TimingSafeEqualDep) => - (config: CreateBaseSqliteStorageConfig): Storage => { - const sqliteStorageBase = createBaseSqliteStorage(deps)(config); + (config: StorageConfig): Storage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); /** Mutex instances cached per OwnerId to prevent concurrent writes. */ const ownerMutexes = createInstances(); @@ -136,42 +136,27 @@ export const createRelaySqliteStorage = where ownerId = ${ownerId}; `, ); - if (!selectWriteKey.ok) { - config.onStorageError(selectWriteKey.error); - return false; - } - - const { rows } = selectWriteKey.value; + const { rows } = selectWriteKey; if (isNonEmptyArray(rows)) { return deps.timingSafeEqual(rows[0].writeKey, writeKey); } - const insertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}); `); - if (!insertWriteKey.ok) { - config.onStorageError(insertWriteKey.error); - return false; - } return true; }, setWriteKey: (ownerId, writeKey) => { - const upsertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}) on conflict (ownerId) do update set writeKey = excluded.writeKey; `); - if (!upsertWriteKey.ok) { - config.onStorageError(upsertWriteKey.error); - return false; - } - - return true; }, writeMessages: (ownerIdBytes, messages) => async (run) => { @@ -184,109 +169,92 @@ export const createRelaySqliteStorage = const result = await run( ownerMutexes .ensure(ownerId, createMutex) - .withLock( - async (): Promise< - Result - > => { - const existingTimestampsResult = - sqliteStorageBase.getExistingTimestamps( - ownerIdBytes, - mapArray(messagesWithTimestampBytes, (m) => m.timestamp), - ); - if (!existingTimestampsResult.ok) - return existingTimestampsResult; - - const existingTimestampsSet = new Set( - existingTimestampsResult.value.map((t) => t.toString()), - ); - const newMessages = filterArray( - messagesWithTimestampBytes, - (m) => !existingTimestampsSet.has(m.timestamp.toString()), + .withLock(async (): Promise> => { + const existingTimestampsResult = + sqliteStorageBase.getExistingTimestamps( + ownerIdBytes, + mapArray(messagesWithTimestampBytes, (m) => m.timestamp), ); - // Nothing to write - if (!isNonEmptyArray(newMessages)) { - return ok(); + const existingTimestampsSet = new Set( + existingTimestampsResult.map((t) => t.toString()), + ); + const newMessages = filterArray( + messagesWithTimestampBytes, + (m) => !existingTimestampsSet.has(m.timestamp.toString()), + ); + + // Nothing to write + if (!isNonEmptyArray(newMessages)) { + return ok(); + } + + const usage = getOwnerUsage(deps)( + ownerIdBytes, + firstInArray(newMessages).timestamp, + ); + if (!usage.ok) return usage; + + const { storedBytes } = usage.value; + + const incomingBytes = newMessages.reduce( + (sum, m) => sum + m.change.length, + 0, + ); + const newStoredBytes = PositiveInt.orThrow( + (storedBytes ?? 0) + incomingBytes, + ); + + const quotaResult = config.isOwnerWithinQuota( + ownerId, + newStoredBytes, + ); + const isWithinQuota = isPromiseLike(quotaResult) + ? await quotaResult + : quotaResult; + if (!isWithinQuota) { + return err({ type: "StorageQuotaError", ownerId }); + } + + let { firstTimestamp, lastTimestamp } = usage.value; + + return deps.sqlite.transaction(() => { + for (const { timestamp, change } of newMessages) { + let strategy; + [strategy, firstTimestamp, lastTimestamp] = + getTimestampInsertStrategy( + timestamp, + firstTimestamp, + lastTimestamp, + ); + sqliteStorageBase.insertTimestamp( + ownerIdBytes, + timestamp, + strategy, + ); + deps.sqlite.exec(sql` + insert into evolu_message + ("ownerId", "timestamp", "change") + values (${ownerIdBytes}, ${timestamp}, ${change}) + on conflict do nothing; + `); } - const usage = getOwnerUsage(deps)( + updateOwnerUsage(deps)( ownerIdBytes, - firstInArray(newMessages).timestamp, - ); - if (!usage.ok) return usage; - - const { storedBytes } = usage.value; - - const incomingBytes = newMessages.reduce( - (sum, m) => sum + m.change.length, - 0, - ); - const newStoredBytes = PositiveInt.orThrow( - (storedBytes ?? 0) + incomingBytes, - ); - - const quotaResult = config.isOwnerWithinQuota( - ownerId, newStoredBytes, + firstTimestamp, + lastTimestamp, ); - const isWithinQuota = isPromiseLike(quotaResult) - ? await quotaResult - : quotaResult; - if (!isWithinQuota) { - return err({ type: "StorageQuotaError", ownerId }); - } - let { firstTimestamp, lastTimestamp } = usage.value; - - return deps.sqlite.transaction(() => { - for (const { timestamp, change } of newMessages) { - let strategy; - [strategy, firstTimestamp, lastTimestamp] = - getTimestampInsertStrategy( - timestamp, - firstTimestamp, - lastTimestamp, - ); - - { - const result = sqliteStorageBase.insertTimestamp( - ownerIdBytes, - timestamp, - strategy, - ); - if (!result.ok) return result; - } - - { - const result = deps.sqlite.exec(sql` - insert into evolu_message - ("ownerId", "timestamp", "change") - values (${ownerIdBytes}, ${timestamp}, ${change}) - on conflict do nothing; - `); - if (!result.ok) return result; - } - } - - return updateOwnerUsage(deps)( - ownerIdBytes, - newStoredBytes, - firstTimestamp, - lastTimestamp, - ); - }); - }, - ), + return ok(); + }); + }), ); - if (!result.ok && result.error.type !== "AbortError") { - switch (result.error.type) { - case "SqliteError": - config.onStorageError(result.error); - return err({ type: "StorageWriteError", ownerId }); - case "StorageQuotaError": - return err({ type: "StorageQuotaError", ownerId }); - } + if (!result.ok) { + if (result.error.type === "AbortError") return ok(); + return err(result.error); } return ok(); @@ -300,51 +268,33 @@ export const createRelaySqliteStorage = from evolu_message where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value.rows[0]?.change; + const row = result.rows[0]; + assert(row, "Every timestamp must have a change"); + return row.change; }, deleteOwner: (ownerId) => { - const transactionResult = deps.sqlite.transaction(() => { - const deleteWriteKey = deps.sqlite.exec(sql` + deps.sqlite.transaction(() => { + deps.sqlite.exec(sql` delete from evolu_writeKey where ownerId = ${ownerId}; `); - if (!deleteWriteKey.ok) return deleteWriteKey; - const deleteMessages = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message where ownerId = ${ownerId}; `); - if (!deleteMessages.ok) return deleteMessages; - const deleteUsage = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_usage where ownerId = ${ownerId}; `); - if (!deleteUsage.ok) return deleteUsage; - - const deleteBaseOwner = sqliteStorageBase.deleteOwner(ownerId); - if (!deleteBaseOwner) return err(null); - return ok(); + sqliteStorageBase.deleteOwner(ownerId); }); - - if (!transactionResult.ok) { - if (transactionResult.error) - config.onStorageError(transactionResult.error); - return false; - } - - return true; }, }; }; -export const createRelayStorageTables = ( - deps: SqliteDep, -): Result => { +export const createRelayStorageTables = (deps: SqliteDep): void => { for (const query of [ sql` create table evolu_writeKey ( @@ -365,9 +315,6 @@ export const createRelayStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index a66dc3c0c..0a1157adc 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -12,12 +12,10 @@ import { mapObject, type ReadonlyRecord, } from "../Object.js"; -import { ok, type Result } from "../Result.js"; import { type SafeSql, SqliteBoolean, type SqliteDep, - type SqliteError, type SqliteQuery, type SqliteQueryOptions, type SqliteValue, @@ -385,12 +383,14 @@ export const systemColumnsWithId = /*#__PURE__*/ readonly([ "id", ]); -export const evoluSchemaToDbSchema = ( - schema: EvoluSchema, +export const evoluSchemaToDbSchema = ( + schema: ValidateSchema extends never ? S : ValidateSchema, indexesConfig?: IndexesConfig, ): DbSchema => { + const validSchema = schema as EvoluSchema; + const tables = mapObject( - schema, + validSchema, (table) => new Set(Object.keys(table).filter((k) => k !== "id")), ); @@ -446,81 +446,68 @@ export const createQueryBuilder = /** Get the current database schema by reading SQLite metadata. */ export const getDbSchema = (deps: SqliteDep) => - ({ - allIndexes = false, - }: { - allIndexes?: boolean; - } = {}): Result => { + ({ allIndexes = false }: { allIndexes?: boolean } = {}): DbSchema => { const tables = createRecord>(); - const tableAndColumnInfoRows = deps.sqlite.exec(sql` - select - sqlite_master.name as tableName, - table_info.name as columnName - from - sqlite_master - join pragma_table_info(sqlite_master.name) as table_info; - `); - - if (!tableAndColumnInfoRows.ok) return tableAndColumnInfoRows; - - tableAndColumnInfoRows.value.rows.forEach((row) => { - const { tableName, columnName } = row as unknown as { - tableName: string; - columnName: string; - }; + const tableAndColumnInfoRows = deps.sqlite.exec<{ + tableName: string; + columnName: string; + }>(sql` + select + sqlite_master.name as tableName, + table_info.name as columnName + from + sqlite_master + join pragma_table_info(sqlite_master.name) as table_info; + `); + + tableAndColumnInfoRows.rows.forEach(({ tableName, columnName }) => { (tables[tableName] ??= new Set()).add(columnName); }); - const indexesRows = deps.sqlite.exec( + const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( allIndexes ? sql` - select name, sql - from sqlite_master - where type = 'index' and name not like 'sqlite_%'; - ` + select name, sql + from sqlite_master + where type = 'index' and name not like 'sqlite_%'; + ` : sql` - select name, sql - from sqlite_master - where - type = 'index' - and name not like 'sqlite_%' - and name not like 'evolu_%'; - `, + select name, sql + from sqlite_master + where + type = 'index' + and name not like 'sqlite_%' + and name not like 'evolu_%'; + `, ); - if (!indexesRows.ok) return indexesRows; - - const indexes = indexesRows.value.rows.map( - (row): DbIndex => ({ - name: row.name as string, - /** - * SQLite returns "CREATE INDEX" for "create index" for some reason. - * Other keywords remain unchanged. We have to normalize the casing for - * {@link indexesAreEqual} manually. - */ - sql: (row.sql as string) - .replace("CREATE INDEX", "create index") - .replace("CREATE UNIQUE INDEX", "create unique index"), - }), - ); + const indexes = indexesRows.rows.flatMap((row): Array => { + if (row.sql == null) return []; + return [ + { + name: row.name, + /** + * SQLite returns "CREATE INDEX" for "create index" for some reason. + * Other keywords remain unchanged. We have to normalize the casing for + * {@link indexesAreEqual} manually. + */ + sql: row.sql + .replace("CREATE INDEX", "create index") + .replace("CREATE UNIQUE INDEX", "create unique index"), + }, + ]; + }); - return ok({ tables, indexes }); + return { tables, indexes }; }; export const ensureDbSchema = (deps: SqliteDep) => - ( - newSchema: DbSchema, - currentSchema?: DbSchema, - ): Result => { + (newSchema: DbSchema, currentSchema?: DbSchema): void => { const queries: Array = []; - if (!currentSchema) { - const dbSchema = getDbSchema(deps)(); - if (!dbSchema.ok) return dbSchema; - currentSchema = dbSchema.value; - } + currentSchema ??= getDbSchema(deps)(); for (const [tableName, newColumns] of Object.entries(newSchema.tables)) { const currentColumns = getProperty(currentSchema.tables, tableName); @@ -561,10 +548,8 @@ export const ensureDbSchema = }); for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; // https://kysely.dev/docs/recipes/splitting-query-building-and-execution diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts new file mode 100644 index 000000000..73a17e7c6 --- /dev/null +++ b/packages/common/src/local-first/Shared.ts @@ -0,0 +1,267 @@ +/** + * Platform-agnostic Evolu Worker. + * + * @module + */ + +import type { NonEmptyReadonlyArray } from "../Array.js"; +import type { CallbackId } from "../Callbacks.js"; +import { createCallbacks } from "../Callbacks.js"; +import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; +import { exhaustiveCheck } from "../Function.js"; +import { ok } from "../Result.js"; +import { exponential, jitter, maxDelay } from "../Schedule.js"; +import { sleep, type Task, timeout } from "../Task.js"; +import type { Name } from "../Type.js"; +import type { + SharedWorker as CommonSharedWorker, + MessagePort, + NativeMessagePort, + SharedWorkerSelf, + WorkerDeps, +} from "../Worker.js"; +import type { DbWorkerLeaderInput, DbWorkerLeaderOutput } from "./Db.js"; +import type { EvoluError } from "./Error.js"; +import type { Query } from "./Query.js"; +import type { MutationChange } from "./Schema.js"; + +export type SharedWorker = CommonSharedWorker; + +export interface SharedWorkerDep { + readonly sharedWorker: SharedWorker; +} + +/** + * Messages sent from an Evolu instance to the worker-side Evolu port. + * + * Redesign status: currently only mutation dispatch is defined. Additional + * request variants will be added as query and owner flows are implemented. + */ +export interface EvoluInput { + readonly type: "Mutate"; + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + +export type SharedWorkerInput = + | { + /** Tab-level channel for broadcast outputs (console/error). */ + readonly type: "InitTab"; + readonly consoleLevel: ConsoleLevel; + readonly port: NativeMessagePort; + } + | { + /** Per-Evolu instance request channel. */ + readonly type: "InitEvolu"; + readonly name: Name; + readonly port1: NativeMessagePort; + readonly port2: NativeMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >; + }; + +export type EvoluTabOutput = + | { + readonly type: "ConsoleEntry"; + readonly entry: ConsoleEntry; + } + | { + readonly type: "EvoluError"; + readonly error: EvoluError; + }; + +export const initSharedWorker = + ( + self: SharedWorkerSelf, + ): Task => + async (run) => { + const { createMessagePort, consoleStoreOutputEntry } = run.deps; + const console = run.deps.console.child("SharedWorker"); + + // TODO: Use heartbeat to detect and prune dead ports. + const tabPorts = new Set>(); + const queuedTabOutputs: Array = []; + const leaderPorts = new Map< + Name, + MessagePort + >(); + const mutationQueues = new Map>(); + const queueProcessors = new Set(); + const onMutateCallbacks = createCallbacks(run.deps); + + const retrySchedule = jitter(0.5)(maxDelay("5s")(exponential("250ms"))); + + const postTabOutput = (output: EvoluTabOutput): void => { + if (tabPorts.size === 0) queuedTabOutputs.push(output); + else for (const port of tabPorts) port.postMessage(output); + }; + + const waitForAck = + (ackPromise: Promise): Task => + async () => { + await ackPromise; + return ok(); + }; + + const ensureMutationQueue = (name: Name): Array => { + let queue = mutationQueues.get(name); + if (!queue) { + queue = []; + mutationQueues.set(name, queue); + } + return queue; + }; + + const processMutationQueue = (name: Name): void => { + if ((mutationQueues.get(name)?.length ?? 0) === 0) return; + if (queueProcessors.has(name)) return; + queueProcessors.add(name); + + void run.daemon(async (run) => { + const retryStep = retrySchedule(run.deps); + + while ((mutationQueues.get(name)?.length ?? 0) > 0) { + const queue = mutationQueues.get(name); + if (!queue) break; + + const message = queue[0]; + const leaderPort = leaderPorts.get(name); + + if (!leaderPort) { + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + continue; + } + + if (!message.requestId) { + const { promise, resolve } = Promise.withResolvers(); + message.requestId = onMutateCallbacks.register(resolve); + message.ackPromise = promise; + } + + leaderPort.postMessage({ + type: "Mutate", + requestId: message.requestId, + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + + const ackPromise = message.ackPromise; + if (!ackPromise) continue; + + const ack = await run(timeout(waitForAck(ackPromise), "3s")); + + if (ack.ok) { + queue.shift(); + continue; + } + + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + } + + queueProcessors.delete(name); + processMutationQueue(name); + return ok(); + }); + }; + + await using stack = run.stack(); + + const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) postTabOutput({ type: "ConsoleEntry", entry }); + }); + + stack.defer(() => { + unsubscribeConsoleStore(); + return ok(); + }); + + console.info("initSharedWorker"); + + self.onConnect = (port) => { + console.debug("onConnect"); + + port.onMessage = (message) => { + switch (message.type) { + case "InitTab": { + console.setLevel(message.consoleLevel); + const tabPort = createMessagePort(message.port); + tabPorts.add(tabPort); + + if (queuedTabOutputs.length > 0) { + queuedTabOutputs.forEach(postTabOutput); + queuedTabOutputs.length = 0; + } + + break; + } + case "InitEvolu": { + const evoluName = message.name; + const evoluPort = createMessagePort( + message.port1, + ); + const leaderPort = createMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(message.port2); + + leaderPorts.set(evoluName, leaderPort); + + leaderPort.onMessage = (message) => { + switch (message.type) { + case "LeaderAcquired": { + leaderPorts.set(message.name, leaderPort); + console.info("leaderAcquired", { name: message.name }); + processMutationQueue(message.name); + break; + } + case "OnMutate": { + onMutateCallbacks.execute(message.requestId); + break; + } + case "ConsoleEntry": + case "EvoluError": { + postTabOutput(message); + break; + } + default: + exhaustiveCheck(message); + } + }; + + evoluPort.onMessage = (message) => { + const queue = ensureMutationQueue(evoluName); + queue.push({ + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + processMutationQueue(evoluName); + }; + break; + } + default: + exhaustiveCheck(message); + } + }; + }; + + return ok(stack.move()); + }; + +interface QueuedMutation { + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; + requestId?: CallbackId; + ackPromise?: Promise; +} diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 16be1c594..32650b8b9 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -14,7 +14,7 @@ import { decrement } from "../Number.js"; import type { RandomDep } from "../Random.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; import { SqliteValue, sql } from "../Sqlite.js"; import type { Task } from "../Task.js"; import type { InferType, Int64String, Typed, TypeError } from "../Type.js"; @@ -98,13 +98,13 @@ export interface StorageConfig { * itself remains synchronous. */ export interface Storage { - readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null; + readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt; readonly fingerprint: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ) => Fingerprint | null; + ) => Fingerprint; /** * Computes fingerprints with their upper bounds in one call. @@ -117,14 +117,14 @@ export interface Storage { ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound?: RangeUpperBound, - ) => ReadonlyArray | null; + ) => ReadonlyArray; readonly findLowerBound: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ) => NonNegativeInt | null; + ) => NonNegativeInt; readonly iterate: ( ownerId: OwnerIdBytes, @@ -147,7 +147,7 @@ export interface Storage { readonly setWriteKey: ( ownerId: OwnerIdBytes, writeKey: OwnerWriteKey, - ) => boolean; + ) => void; /** * Write encrypted {@link CrdtMessage}s to storage. @@ -158,31 +158,22 @@ export interface Storage { readonly writeMessages: ( ownerIdBytes: OwnerIdBytes, messages: NonEmptyReadonlyArray, - ) => Task; + ) => Task; /** Read encrypted {@link DbChange}s from storage. */ readonly readDbChange: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, - ) => EncryptedDbChange | null; + ) => EncryptedDbChange; - /** - * Delete all data for the given {@link Owner}. - * - * Returns `true` on success, `false` on failure. - */ - readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean; + /** Delete all data for the given {@link Owner}. */ + readonly deleteOwner: (ownerId: OwnerIdBytes) => void; } export interface StorageDep { readonly storage: Storage; } -/** Error indicating a serious write failure. */ -export interface StorageWriteError - extends OwnerError, - Typed<"StorageWriteError"> {} - /** Error when storage or billing quota is exceeded. */ export interface StorageQuotaError extends OwnerError, @@ -348,7 +339,7 @@ export interface BaseSqliteStorage ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, - ) => Result; + ) => void; /** * Efficiently checks which timestamps already exist in the database using a @@ -357,7 +348,7 @@ export interface BaseSqliteStorage readonly getExistingTimestamps: ( ownerIdBytes: OwnerIdBytes, timestampsBytes: NonEmptyReadonlyArray, - ) => Result, SqliteError>; + ) => ReadonlyArray; } export interface BaseSqliteStorageDep { @@ -366,10 +357,6 @@ export interface BaseSqliteStorageDep { export type SqliteStorageDeps = RandomDep & SqliteDep; -export interface CreateBaseSqliteStorageConfig extends StorageConfig { - onStorageError: (error: SqliteError) => void; -} - /** * Creates a {@link BaseSqliteStorage} implementation. * @@ -384,15 +371,14 @@ export interface CreateBaseSqliteStorageConfig extends StorageConfig { * environments yet, the stateless design should work well across them. */ export const createBaseSqliteStorage = - (deps: SqliteStorageDeps) => - (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => ({ + (deps: SqliteStorageDeps) => (): BaseSqliteStorage => ({ insertTimestamp: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, ) => { const level = randomSkiplistLevel(deps); - return insertTimestamp(deps)(ownerId, timestamp, level, strategy); + insertTimestamp(deps)(ownerId, timestamp, level, strategy); }, getExistingTimestamps: (ownerIdBytes, timestampsBytes) => { @@ -420,47 +406,20 @@ export const createBaseSqliteStorage = on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t; `); - if (!result.ok) return result; - - return ok(result.value.rows.map((row) => row.timestampBytes)); + return result.rows.map((row) => row.timestampBytes); }, - getSize: (ownerId) => { - const size = getSize(deps)(ownerId); - if (!size.ok) { - config.onStorageError(size.error); - return null; - } - return size.value; - }, + getSize: getSize(deps), fingerprint: (ownerId, begin, end) => { assertBeginEnd(begin, end); - const result = fingerprint(deps)(ownerId, begin, end); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value; + return fingerprint(deps)(ownerId, begin, end); }, - fingerprintRanges: (ownerId, buckets, upperBound) => { - const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound); - if (!ranges.ok) { - config.onStorageError(ranges.error); - return null; - } - return ranges.value; - }, + fingerprintRanges: fingerprintRanges(deps), - findLowerBound: (ownerId, begin, end, upperBound) => { - const lowerBound = findLowerBound(deps)(ownerId, begin, end, upperBound); - if (!lowerBound.ok) { - config.onStorageError(lowerBound.error); - return null; - } - return lowerBound.value; - }, + findLowerBound: (ownerId, begin, end, upperBound) => + findLowerBound(deps)(ownerId, begin, end, upperBound), iterate: (ownerId, begin, end, callback) => { assertBeginEnd(begin, end); @@ -469,12 +428,8 @@ export const createBaseSqliteStorage = // This is much faster than SQL limit with offset. const first = getTimestampByIndex(deps)(ownerId, begin); - if (!first.ok) { - config.onStorageError(first.error); - return; - } - if (!callback(first.value, begin)) return; + if (!callback(first, begin)) return; if (length === 1) return; /** @@ -490,30 +445,21 @@ export const createBaseSqliteStorage = const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql` select t from evolu_timestamp - where ownerId = ${ownerId} and t > ${first.value} + where ownerId = ${ownerId} and t > ${first} order by t limit ${length - 1}; `); - if (!result.ok) { - config.onStorageError(result.error); - return; - } - for (let i = 0; i < result.value.rows.length; i++) { + for (let i = 0; i < result.rows.length; i++) { const index = NonNegativeInt.orThrow(begin + 1 + i); - if (!callback(result.value.rows[i].t, index)) return; + if (!callback(result.rows[i].t, index)) return; } }, deleteOwner: (ownerId) => { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_timestamp where ownerId = ${ownerId}; `); - if (!result.ok) { - config.onStorageError(result.error); - return false; - } - return true; }, }); @@ -521,9 +467,7 @@ const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => { assert(begin <= end, "invalid begin or end"); }; -export const createBaseSqliteStorageTables = ( - deps: SqliteDep, -): Result => { +export const createBaseSqliteStorageTables = (deps: SqliteDep): void => { for (const query of [ /** * Creates the `evolu_timestamp` table for storing timestamps of multiple @@ -585,10 +529,8 @@ export const createBaseSqliteStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert"; @@ -638,7 +580,7 @@ const insertTimestamp = timestamp: TimestampBytes, level: PositiveInt, strategy: StorageInsertTimestampStrategy, - ): Result => { + ): void => { const [h1, h2] = fingerprintToSqliteFingerprint( timestampBytesToFingerprint(timestamp), ); @@ -1151,11 +1093,8 @@ const insertTimestamp = } for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; export const timestampBytesToFingerprint = ( @@ -1245,7 +1184,7 @@ const sqliteFingerprintToFingerprint = ([ const getSize = (deps: SqliteDep) => - (ownerId: OwnerIdBytes): Result => { + (ownerId: OwnerIdBytes): NonNegativeInt => { const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared` with ml(ml) as ( @@ -1281,8 +1220,7 @@ const getSize = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].size); + return result.rows[0].size; }; const findLowerBound = @@ -1292,11 +1230,11 @@ const findLowerBound = begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ): Result => { + ): NonNegativeInt => { assertBeginEnd(begin, end); if (end === 0 || begin === end || upperBound === InfiniteUpperBound) { - return ok(end); + return end; } const result = deps.sqlite.exec<{ @@ -1308,25 +1246,19 @@ const findLowerBound = order by t limit 1; `); - if (!result.ok) return result; - if (result.value.rows.length === 0) { - return ok(end); + if (result.rows.length === 0) { + return end; } - const count = getTimestampCount(deps)(ownerId, result.value.rows[0].t); - if (!count.ok) return count; - + const count = getTimestampCount(deps)(ownerId, result.rows[0].t); // `decrement` converts a count to an index. - return ok(NonNegativeInt.orThrow(decrement(count.value))); + return NonNegativeInt.orThrow(decrement(count)); }; const getTimestampCount = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - timestamp: TimestampBytes, - ): Result => { + (ownerId: OwnerIdBytes, timestamp: TimestampBytes): PositiveInt => { const result = deps.sqlite.exec<{ count: PositiveInt; }>(sql.prepared` @@ -1371,8 +1303,7 @@ const getTimestampCount = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].count); + return result.rows[0].count; }; /** @@ -1385,22 +1316,38 @@ const fingerprint = ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ): Result => { + ): Fingerprint => { // There is no need to fingerprint an empty range. if (end - begin === 0) { - return ok(zeroFingerprint); + return zeroFingerprint; } + const getFingerprintFromRanges = ( + ranges: ReadonlyArray, + index: number, + ): Fingerprint => { + const range = ranges[index]; + if (range) return range.fingerprint; + throw new Error( + [ + "Missing fingerprint range", + `ownerId=${globalThis.Array.from(ownerId).join(",")}`, + `begin=${begin}`, + `end=${end}`, + `index=${index}`, + `rangesLength=${ranges.length}`, + ].join(" "), + ); + }; + if (begin === 0) { - const result = fingerprintRanges(deps)(ownerId, [end]); - if (!result.ok) return result; - return ok(result.value[0].fingerprint); + const ranges = fingerprintRanges(deps)(ownerId, [end]); + return getFingerprintFromRanges(ranges, 0); } // We should have a param to skip the first result. - const result = fingerprintRanges(deps)(ownerId, [begin, end]); - if (!result.ok) return result; - return ok(result.value[1].fingerprint); + const ranges = fingerprintRanges(deps)(ownerId, [begin, end]); + return getFingerprintFromRanges(ranges, 1); }; /** @@ -1417,7 +1364,7 @@ const fingerprintRanges = ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound: RangeUpperBound = InfiniteUpperBound, - ): Result, SqliteError> => { + ): ReadonlyArray => { const bucketsJson = JSON.stringify(buckets); const result = deps.sqlite.exec<{ @@ -1535,21 +1482,25 @@ const fingerprintRanges = from c3; `); - if (!result.ok) return result; - - const fingerprintRanges = result.value.rows.map( - (row, i, arr): FingerprintRange => ({ - type: RangeType.Fingerprint, - // biome-ignore lint/style/noNonNullAssertion: Guaranteed by logic - upperBound: i === arr.length - 1 ? upperBound : row.b!, - fingerprint: sqliteFingerprintToFingerprint([ - row.h1, - row.h2, - ] as SqliteFingerprint), - }), + const fingerprintRanges = result.rows.map( + (row, i, arr): FingerprintRange => { + const nextUpperBound = i === arr.length - 1 ? upperBound : row.b; + assert( + nextUpperBound != null, + "Missing fingerprint upperBound row value", + ); + return { + type: RangeType.Fingerprint, + upperBound: nextUpperBound, + fingerprint: sqliteFingerprintToFingerprint([ + row.h1, + row.h2, + ] as SqliteFingerprint), + }; + }, ); - return ok(fingerprintRanges); + return fingerprintRanges; }; // XOR in SQLite @@ -1557,10 +1508,7 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`); export const getTimestampByIndex = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - index: NonNegativeInt, - ): Result => { + (ownerId: OwnerIdBytes, index: NonNegativeInt): TimestampBytes => { const result = deps.sqlite.exec<{ readonly pt: TimestampBytes; }>(sql.prepared` @@ -1635,8 +1583,7 @@ export const getTimestampByIndex = where ic == ${index + 1}; `); - if (!result.ok) return result; - return ok(result.value.rows[0].pt); + return result.rows[0].pt; }; /** Retrieves usage information for an owner from the evolu_usage table. */ @@ -1645,14 +1592,11 @@ export const getOwnerUsage = ( ownerIdBytes: OwnerIdBytes, initialTimestamp: TimestampBytes, - ): Result< - { - storedBytes: NonNegativeInt | null; - firstTimestamp: TimestampBytes; - lastTimestamp: TimestampBytes; - }, - SqliteError - > => { + ): Result<{ + storedBytes: NonNegativeInt | null; + firstTimestamp: TimestampBytes; + lastTimestamp: TimestampBytes; + }> => { const result = deps.sqlite.exec<{ storedBytes: NonNegativeInt; firstTimestamp: TimestampBytes | null; @@ -1662,9 +1606,8 @@ export const getOwnerUsage = from evolu_usage where ownerId = ${ownerIdBytes}; `); - if (!result.ok) return result; - if (!isNonEmptyArray(result.value.rows)) { + if (!isNonEmptyArray(result.rows)) { return ok({ storedBytes: null, firstTimestamp: initialTimestamp, @@ -1672,7 +1615,7 @@ export const getOwnerUsage = }); } - const row = firstInArray(result.value.rows); + const row = firstInArray(result.rows); assert(row.firstTimestamp, "not null"); assert(row.lastTimestamp, "not null"); @@ -1696,8 +1639,8 @@ export const updateOwnerUsage = storedBytes: PositiveInt, firstTimestamp: TimestampBytes, lastTimestamp: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql` + ): void => { + deps.sqlite.exec(sql` insert into evolu_usage ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp") values @@ -1708,6 +1651,4 @@ export const updateOwnerUsage = firstTimestamp = ${firstTimestamp}, lastTimestamp = ${lastTimestamp}; `); - if (!result.ok) return result; - return ok(); }; diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index fb67c09ef..536b87386 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -6,7 +6,7 @@ import type { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js"; import { appendToArray, firstInArray, isNonEmptyArray } from "../Array.js"; -import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import { assert, assertNonEmptyReadonlyArray } from "../Assert.js"; import type { Brand } from "../Brand.js"; import type { ConsoleDep } from "../Console.js"; import type { @@ -14,13 +14,13 @@ import type { RandomBytesDep, } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import { lazyFalse, lazyTrue, todo } from "../Function.js"; +import { lazyFalse, lazyVoid, todo } from "../Function.js"; import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; import { createResources } from "../Resources.js"; import type { Result } from "../Result.js"; -import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import { ok } from "../Result.js"; +import type { SqliteDep } from "../Sqlite.js"; import { booleanToSqliteBoolean, SqliteBoolean, @@ -68,12 +68,7 @@ import { } from "./Protocol.js"; import type { DbSchemaDep, MutationChange } from "./Schema.js"; import { systemColumns } from "./Schema.js"; -import type { - BaseSqliteStorage, - CrdtMessage, - Storage, - StorageWriteError, -} from "./Storage.js"; +import type { BaseSqliteStorage, CrdtMessage, Storage } from "./Storage.js"; import { createBaseSqliteStorage, DbChange, @@ -111,7 +106,6 @@ export interface Sync extends Disposable { changes: NonEmptyReadonlyArray, ) => Result< void, - | SqliteError | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError @@ -149,7 +143,6 @@ export interface SyncConfig { | ProtocolError | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -173,7 +166,7 @@ export const createSync = TimeDep & TimestampConfigDep, ) => - (config: SyncConfig): Result => { + (config: SyncConfig): Sync => { let isDisposed = false; /** Returns owner data only if actively assigned to at least one transport. */ @@ -182,14 +175,11 @@ export const createSync = return resources.getConsumer(ownerId); }; - const storageResult = createClientStorage({ + const storage = createClientStorage({ ...deps, getSyncOwner, })(config); - if (!storageResult.ok) return storageResult; - const storage = storageResult.value; - const createResource = (transport: OwnerTransport): WebSocket => { const transportKey = createTransportKey(transport); @@ -298,10 +288,10 @@ export const createSync = // The onOpen handler will sync it. if (!webSocket.isOpen()) return; - const message = createProtocolMessageForSync({ storage })( - owner.id, - SubscriptionFlags.Subscribe, - ); + const message = createProtocolMessageForSync({ + storage, + console: deps.console, + })(owner.id, SubscriptionFlags.Subscribe); if (message) webSocket.send(message); }, @@ -367,8 +357,7 @@ export const createSync = } for (const [ownerId, messages] of ownerMessages) { - const result = applyMessages({ ...deps, storage })(ownerId, messages); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(ownerId, messages); const owner = getSyncOwner(ownerId); if (!owner?.writeKey) continue; @@ -398,7 +387,8 @@ export const createSync = } } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }, [Symbol.dispose]: () => { @@ -408,7 +398,7 @@ export const createSync = }, }; - return ok(sync); + return sync; }; export interface ClockDep { @@ -417,7 +407,7 @@ export interface ClockDep { export interface Clock { readonly get: () => Timestamp; - readonly save: (timestamp: Timestamp) => Result; + readonly save: (timestamp: Timestamp) => void; } export const createClock = @@ -430,13 +420,10 @@ export const createClock = save: (timestamp) => { currentTimestamp = timestamp; - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` update evolu_config set "clock" = ${timestampToTimestampBytes(timestamp)}; `); - if (!result.ok) return result; - - return ok(); }, }; }; @@ -454,6 +441,7 @@ export interface ClientStorageDep { const createClientStorage = ( deps: ClockDep & + ConsoleDep & DbSchemaDep & GetSyncOwnerDep & RandomBytesDep & @@ -467,18 +455,14 @@ const createClientStorage = error: | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError, ) => void; onReceive: () => void; - }): Result => { - const sqliteStorageBase = createBaseSqliteStorage(deps)({ - onStorageError: config.onError, - isOwnerWithinQuota: lazyTrue, // Clients don't have quota limits - }); + }): ClientStorage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); // TODO: Mutex per OwnerId like in Relay to support more owners. const mutex = createMutex(); @@ -488,7 +472,7 @@ const createClientStorage = // Not implemented yet. validateWriteKey: lazyFalse, - setWriteKey: lazyFalse, + setWriteKey: lazyVoid, writeMessages: (ownerIdBytes, encryptedMessages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); @@ -499,7 +483,6 @@ const createClientStorage = boolean, | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -545,14 +528,11 @@ const createClientStorage = } if (isNonEmptyArray(messages)) { - const result = applyMessages({ ...deps, storage })( - owner.id, - messages, - ); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(owner.id, messages); } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }); if (!transaction.ok) return transaction; @@ -565,8 +545,9 @@ const createClientStorage = if (!result.ok) { if (result.error.type !== "AbortError") { config.onError(result.error); + throw new Error(result.error.type, { cause: result.error }); } - return err({ type: "StorageWriteError", ownerId }); + return ok(); } config.onReceive(); @@ -576,8 +557,7 @@ const createClientStorage = readDbChange: (ownerId, timestamp) => { const owner = deps.getSyncOwner(ownerIdBytesToOwnerId(ownerId)); - // Owner can be removed to stop syncing. - if (!owner) return null; + assert(owner, "Sync owner must exist while reading db change"); const result = deps.sqlite.exec<{ readonly table: string; @@ -594,12 +574,7 @@ const createClientStorage = where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onError(result.error); - return null; - } - - const { rows } = result.value; + const { rows } = result; assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows"); const firstRow = firstInArray(rows); @@ -640,7 +615,7 @@ const createClientStorage = }, }; - return ok(storage); + return storage; }; type TransportKey = string & Brand<"TransportKey">; @@ -669,45 +644,53 @@ const dbChangeToColumns = (change: DbChange, now: Millis) => { export const applyLocalOnlyChange = (deps: SqliteDep & TimeDep & AppOwnerDep) => - (change: MutationChange): Result => { + (change: MutationChange): void => { if (change.isDelete) { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from ${sql.identifier(change.table)} where id = ${change.id}; `); - if (!result.ok) return result; } else { const ownerId = deps.appOwner.id; const columns = dbChangeToColumns(change, deps.time.now()); for (const [column, value] of columns) { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into ${sql.identifier(change.table)} ("ownerId", "id", ${sql.identifier(column)}) values (${ownerId}, ${change.id}, ${value}) on conflict ("ownerId", "id") do update set ${sql.identifier(column)} = ${value}; `); - if (!result.ok) return result; } } - - return ok(); }; const applyMessages = - (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) => ( - ownerId: OwnerId, - messages: NonEmptyReadonlyArray, - ): Result => { + deps: ClientStorageDep & + ClockDep & + ConsoleDep & + DbSchemaDep & + RandomDep & + SqliteDep, + ) => + (ownerId: OwnerId, messages: NonEmptyReadonlyArray): void => { const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); - - const usage = getOwnerUsage(deps)( - ownerIdBytes, - timestampToTimestampBytes(firstInArray(messages).timestamp), + const firstMessageTimestamp = timestampToTimestampBytes( + firstInArray(messages).timestamp, ); - if (!usage.ok) return usage; + + const usage = getOwnerUsage(deps)(ownerIdBytes, firstMessageTimestamp); + if (!usage.ok) { + deps.console.error("[sync]", "applyMessages/getOwnerUsage failed", { + ownerId, + ownerIdBytes, + firstMessageTimestamp, + error: usage.error, + }); + return; + } let { firstTimestamp, lastTimestamp } = usage.value; @@ -718,7 +701,7 @@ const applyMessages = for (const [column, value] of columns) { if (validateColumnValue(deps)(change.table, column, value)) { - const result = applyColumnChange(deps)( + applyColumnChange(deps)( ownerIdBytes, ownerId, change.table, @@ -728,9 +711,8 @@ const applyMessages = value, timestampBytes, ); - if (!result.ok) return result; } else { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_message_quarantine ("ownerId", "timestamp", "table", "id", "column", "value") values @@ -744,7 +726,6 @@ const applyMessages = ) on conflict do nothing; `); - if (!result.ok) return result; } } @@ -755,19 +736,15 @@ const applyMessages = lastTimestamp, ); - const result = deps.storage.insertTimestamp( - ownerIdBytes, - timestampBytes, - strategy, - ); - if (!result.ok) return result; + // TODO: Rethink and maybe refactor. + deps.storage.insertTimestamp(ownerIdBytes, timestampBytes, strategy); } /** * TODO: Implement proper storedBytes tracking for client using received and * sent encrypted message sizes. */ - return updateOwnerUsage(deps)( + updateOwnerUsage(deps)( ownerIdBytes, 1 as PositiveInt, // Placeholder until proper tracking implemented firstTimestamp, @@ -804,8 +781,8 @@ const applyColumnChange = column: string, value: SqliteValue, timestampBytes: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql.prepared` + ): void => { + deps.sqlite.exec(sql.prepared` with existingTimestamp as ( select 1 @@ -826,10 +803,7 @@ const applyColumnChange = set ${sql.identifier(column)} = ${value} where not exists (select 1 from existingTimestamp); `); - if (!result.ok) return result; - - { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_history ("ownerId", "table", "id", "column", "value", "timestamp") values @@ -843,10 +817,6 @@ const applyColumnChange = ) on conflict do nothing; `); - if (!result.ok) return result; - } - - return ok(); }; /** @@ -855,7 +825,7 @@ const applyColumnChange = * don't exist in the current schema (e.g., from a newer app version). */ export const tryApplyQuarantinedMessages = - (deps: DbSchemaDep & SqliteDep) => (): Result => { + (deps: DbSchemaDep & SqliteDep) => (): void => { const rows = deps.sqlite.exec<{ readonly ownerId: OwnerIdBytes; readonly timestamp: TimestampBytes; @@ -867,13 +837,12 @@ export const tryApplyQuarantinedMessages = select "ownerId", "timestamp", "table", "id", "column", "value" from evolu_message_quarantine; `); - if (!rows.ok) return rows; - for (const row of rows.value.rows) { + for (const row of rows.rows) { if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue; - const result = applyColumnChange(deps)( + applyColumnChange(deps)( row.ownerId, ownerIdBytesToOwnerId(row.ownerId), row.table, @@ -883,10 +852,7 @@ export const tryApplyQuarantinedMessages = row.value, row.timestamp, ); - if (!result.ok) return result; - - { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message_quarantine where "ownerId" = ${row.ownerId} @@ -895,11 +861,7 @@ export const tryApplyQuarantinedMessages = and "id" = ${row.id} and "column" = ${row.column}; `); - if (!result.ok) return result; - } } - - return ok(); }; /** diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index 453dc126f..fd62c3fbc 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -4,7 +4,12 @@ * @module */ -import type { ConsoleEntry } from "../Console.js"; +import type { + ConsoleDep, + ConsoleEntry, + ConsoleLevel, + ConsoleStoreOutputEntryDep, +} from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -33,13 +38,17 @@ export interface EvoluWorkerDep { export type EvoluWorkerInput = | { readonly type: "InitTab"; - readonly port: NativeMessagePort; + readonly consoleLevel: ConsoleLevel; + readonly port: NativeMessagePort; } | { readonly type: "InitEvolu"; readonly name: SimpleName; - readonly port: NativeMessagePort; - readonly brokerPort: NativeMessagePort; + readonly port1: NativeMessagePort; + readonly port2: NativeMessagePort< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >; }; export type EvoluTabOutput = @@ -55,16 +64,20 @@ export type EvoluTabOutput = export interface RunDbWorkerPortDep { readonly runDbWorkerPort: (config: { readonly name: SimpleName; + readonly consoleLevel: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }) => void; } export const runEvoluWorkerScope = - (deps: CreateMessagePortDep & RunDbWorkerPortDep) => - (self: EvoluWorkerScope): void => { + (deps: CreateMessagePortDep & RunDbWorkerPortDep & ConsoleDep) => + ( + self: EvoluWorkerScope, + ): { readonly postTabOutput: (output: EvoluTabOutput) => void } => { const tabPorts = new Set>(); const queuedTabOutputs: Array = []; + let consoleLevel = deps.console.getLevel(); const postTabOutput = (output: EvoluTabOutput): void => { if (tabPorts.size === 0) { @@ -85,6 +98,8 @@ export const runEvoluWorkerScope = const tabPort = deps.createMessagePort( message.port, ); + consoleLevel = message.consoleLevel; + deps.console.setLevel(consoleLevel); tabPorts.add(tabPort); if (queuedTabOutputs.length > 0) { @@ -96,13 +111,18 @@ export const runEvoluWorkerScope = } case "InitEvolu": { const port = deps.createMessagePort( - message.port, + message.port1, ); const brokerPort = deps.createMessagePort< DbWorkerLeaderOutput, DbWorkerLeaderInput - >(message.brokerPort); - deps.runDbWorkerPort({ name: message.name, port, brokerPort }); + >(message.port2); + deps.runDbWorkerPort({ + name: message.name, + consoleLevel, + port, + brokerPort, + }); break; } default: @@ -110,6 +130,8 @@ export const runEvoluWorkerScope = } }; }; + + return { postTabOutput }; }; /** @@ -120,8 +142,26 @@ export const runEvoluWorkerScope = export const initEvoluWorker = ( self: EvoluWorkerScope, - ): Task => + ): Task< + void, + never, + CreateMessagePortDep & + RunDbWorkerPortDep & + ConsoleDep & + ConsoleStoreOutputEntryDep + > => (run) => { - runEvoluWorkerScope(run.deps)(self); + const { postTabOutput } = runEvoluWorkerScope(run.deps)(self); + + const unsubscribe = run.deps.consoleStoreOutputEntry.subscribe(() => { + const entry = run.deps.consoleStoreOutputEntry.get(); + if (entry) postTabOutput({ type: "ConsoleEntry", entry }); + }); + + run.defer(() => { + unsubscribe(); + return ok(); + }); + return ok(); }; diff --git a/packages/common/src/local-first/index.ts b/packages/common/src/local-first/index.ts index dc7eb0ebf..607c8593d 100644 --- a/packages/common/src/local-first/index.ts +++ b/packages/common/src/local-first/index.ts @@ -1,5 +1,26 @@ export * from "./Db.js"; -export * from "./DbWorkerProtocol.js"; +export { + type DbWorkerCloseMessage as ExperimentalDbWorkerCloseMessage, + type DbWorkerCloseResponseMessage as ExperimentalDbWorkerCloseResponseMessage, + type DbWorkerErrorMessage as ExperimentalDbWorkerErrorMessage, + type DbWorkerExportMessage as ExperimentalDbWorkerExportMessage, + type DbWorkerExportResponseMessage as ExperimentalDbWorkerExportResponseMessage, + type DbWorkerGetAppOwnerMessage as ExperimentalDbWorkerGetAppOwnerMessage, + type DbWorkerInitMessage as ExperimentalDbWorkerInitMessage, + type DbWorkerInitResponseMessage as ExperimentalDbWorkerInitResponseMessage, + type DbWorkerInput as ExperimentalDbWorkerInput, + type DbWorkerLeaderInput as ExperimentalDbWorkerLeaderInput, + type DbWorkerLeaderOutput as ExperimentalDbWorkerLeaderOutput, + type DbWorkerMutateMessage as ExperimentalDbWorkerMutateMessage, + type DbWorkerMutateResponseMessage as ExperimentalDbWorkerMutateResponseMessage, + type DbWorkerOutput as ExperimentalDbWorkerOutput, + type DbWorkerQueryMessage as ExperimentalDbWorkerQueryMessage, + type DbWorkerQueryResponseMessage as ExperimentalDbWorkerQueryResponseMessage, + type DbWorkerResetMessage as ExperimentalDbWorkerResetMessage, + type DbWorkerResetResponseMessage as ExperimentalDbWorkerResetResponseMessage, + dbWorkerLeaderHeartbeatIntervalMs as experimentalDbWorkerLeaderHeartbeatIntervalMs, + dbWorkerLeaderHeartbeatTimeoutMs as experimentalDbWorkerLeaderHeartbeatTimeoutMs, +} from "./DbWorkerProtocol.js"; export * from "./Error.js"; export * from "./Evolu.js"; export * from "./Owner.js"; @@ -7,7 +28,15 @@ export * from "./Protocol.js"; export * from "./Query.js"; export * from "./Relay.js"; export * from "./Schema.js"; +export * from "./Shared.js"; export * from "./Storage.js"; export * from "./Sync.js"; export * from "./Timestamp.js"; -export * from "./Worker.js"; +export { + type EvoluWorker, + type EvoluWorkerDep, + type EvoluWorkerInput, + initEvoluWorker, + type RunDbWorkerPortDep, + runEvoluWorkerScope, +} from "./Worker.js"; diff --git a/packages/common/test/Result.test.ts b/packages/common/test/Result.test.ts index c9d83d9d6..41859dc1e 100644 --- a/packages/common/test/Result.test.ts +++ b/packages/common/test/Result.test.ts @@ -16,6 +16,8 @@ import { anyResult, done, err, + getOk, + getOrNull, getOrThrow, isErr, isOk, @@ -131,6 +133,36 @@ describe("getOrThrow", () => { }); }); +describe("getOrNull", () => { + it("returns value for Ok", () => { + expect(getOrNull(ok(42))).toBe(42); + }); + + it("returns null for Err", () => { + expect(getOrNull(err("error"))).toBeNull(); + }); +}); + +describe("getOk", () => { + it("extracts value from Result with never error", () => { + const result = ok(42); + expect(getOk(result)).toBe(42); + }); + + it("rejects Result with possible error type", () => { + type IsAssignable = + Result extends Result ? true : false; + expectTypeOf().toEqualTypeOf(); + }); + + it("throws when invariant is violated at runtime", () => { + const invalid = err("fail") as unknown as Result; + expect(() => getOk(invalid)).toThrowErrorMatchingInlineSnapshot( + `[Error: Expected Ok result.]`, + ); + }); +}); + describe("trySync", () => { interface ParseError { readonly type: "ParseError"; diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index 070357d2f..20518f7d0 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -17,8 +17,7 @@ import { } from "../src/Sqlite.js"; import { sleep } from "../src/Task.js"; import { testCreateRun } from "../src/Test.js"; -import { testSimpleName } from "./_deps.js"; -import { testCreateRunWithSqlite } from "./_deps.nodejs.js"; +import { testCreateRunWithSqlite, testSimpleName } from "./_deps.js"; describe("eqSqliteValue", () => { test("equal Uint8Arrays return true", () => { @@ -56,13 +55,10 @@ test("basic DDL/DML works", async () => { await using run = await testCreateRunWithSqlite(); const { sqlite } = run.deps; - expect(sqlite.exec(sql`create table a (data);`).ok).toBe(true); - expect(sqlite.exec(sql`insert into a (data) values (${"foo"});`).ok).toBe( - true, - ); + sqlite.exec(sql`create table a (data);`); + sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.exec(sql`select * from a;`); - assert(result.ok); - expect(result.value.rows).toEqual([{ data: "foo" }]); + expect(result.rows).toEqual([{ data: "foo" }]); }); describe("transactions", () => { @@ -72,22 +68,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`), - ); + const action = () => + sqlite.transaction(() => + ok(sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`)), + ); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -111,8 +100,7 @@ describe("transactions", () => { } const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -125,15 +113,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into a (data) values (${"bar"});`), - ); + const result = sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"bar"});`); + return ok(); + }); expect(result.ok).toBe(true); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "bar" }]); + expect(rows.rows).toEqual([{ data: "bar" }]); }); test("transaction callback returns error", async () => { @@ -179,9 +167,8 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("should not reach")); + expect(() => sqlite.transaction(() => ok("should not reach"))).toThrow(); - expect(result.ok).toBe(false); expect(beginCalled).toBe(true); expect(rollbackCalled).toBe(false); }); @@ -192,26 +179,19 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => { - sqlite.exec(sql`insert into a (data) values (${"boom"});`); - throw new Error("Callback failed"); - }); + const action = () => + sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"boom"});`); + throw new Error("Callback failed"); + }); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); }); - test("rollback failure logs warning and returns both errors", async () => { + test("rollback failure throws rollback error", async () => { let rollbackCalled = false; const createFailingDriver: CreateSqliteDriver = () => () => { @@ -240,28 +220,11 @@ describe("transactions", () => { const sqliteResult = await run(createSqlite(testSimpleName)); assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const { console } = run.deps; - const result = sqlite.transaction(() => - sqlite.exec(sql`select * from users;`), - ); - - expect(result.ok).toBe(false); + expect(() => + sqlite.transaction(() => ok(sqlite.exec(sql`select * from users;`))), + ).toThrow(); expect(rollbackCalled).toBe(true); - - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - expect(result.error.rollbackError).toEqual( - expect.objectContaining({ - type: "UnknownError", - error: expect.objectContaining({ message: "Rollback failed" }), - }), - ); - } - - const entries = console.getEntriesSnapshot(); - const warnLogs = entries.filter((e) => e.method === "warn"); - expect(warnLogs.map((e) => e.args[0])).toContain("rollback failed"); }); test("transaction commit failure triggers rollback", async () => { @@ -297,9 +260,7 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("data")); - - expect(result.ok).toBe(false); + expect(() => sqlite.transaction(() => ok("data"))).toThrow(); expect(commitCalled).toBe(true); expect(rollbackCalled).toBe(true); }); @@ -314,12 +275,11 @@ describe("export", () => { sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.export(); - assert(result.ok); - expect(result.value).toBeInstanceOf(Uint8Array); - expect(result.value.length).toBeGreaterThan(0); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); }); - test("export failure returns SqliteError", async () => { + test("export failure throws", async () => { const createFailingDriver: CreateSqliteDriver = () => () => { const driver: SqliteDriver = { exec: () => ({ rows: [], changes: 0 }), @@ -338,11 +298,7 @@ describe("export", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.export(); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - } + expect(() => sqlite.export()).toThrow("Export failed"); }); }); @@ -376,7 +332,7 @@ describe("logExplainQueryPlan", () => { }; const result = sqlite.exec(query); - expect(result.ok).toBe(true); + expect(result.rows).toEqual([]); const entries = run.deps.console.getEntriesSnapshot(); const logEntries = entries.filter( diff --git a/packages/common/test/Worker.test.ts b/packages/common/test/Worker.test.ts new file mode 100644 index 000000000..76173186c --- /dev/null +++ b/packages/common/test/Worker.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { lazyVoid } from "../src/Function.js"; +import type { NativeMessagePort } from "../src/Worker.js"; +import { + testCreateMessageChannel, + testCreateMessagePort, + testCreateSharedWorker, + testCreateWorker, +} from "../src/Worker.js"; + +describe("testCreateMessageChannel", () => { + test("port1 postMessage delivers to port2 onMessage", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("hello"); + expect(received).toEqual(["hello"]); + }); + + test("port2 postMessage delivers to port1 onMessage", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port1.onMessage = (msg) => received.push(msg); + channel.port2.postMessage(42); + expect(received).toEqual([42]); + }); + + test("messages are queued until onMessage is assigned", () => { + const channel = testCreateMessageChannel(); + channel.port1.postMessage("a"); + channel.port1.postMessage("b"); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + expect(received).toEqual(["a", "b"]); + }); + + test("messages sent after onMessage is assigned are delivered immediately", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("first"); + channel.port1.postMessage("second"); + expect(received).toEqual(["first", "second"]); + }); + + test("setting onMessage to null stops delivery", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("delivered"); + channel.port2.onMessage = null; + channel.port1.postMessage("queued"); + expect(received).toEqual(["delivered"]); + }); + + test("dispose nulls out handlers", () => { + const channel = testCreateMessageChannel(); + channel.port1.onMessage = lazyVoid; + channel.port2.onMessage = lazyVoid; + expect(channel.port1.onMessage).not.toBeNull(); + expect(channel.port2.onMessage).not.toBeNull(); + channel[Symbol.dispose](); + expect(channel.port1.onMessage).toBeNull(); + expect(channel.port2.onMessage).toBeNull(); + }); + + test("each channel creates independent ports", () => { + const channel1 = testCreateMessageChannel(); + const channel2 = testCreateMessageChannel(); + expect(channel1.port1.native).not.toBe(channel2.port1.native); + expect(channel1.port2.native).not.toBe(channel2.port2.native); + }); + + test("bidirectional communication works", () => { + const channel = testCreateMessageChannel(); + const strings: Array = []; + const numbers: Array = []; + + channel.port2.onMessage = (msg) => strings.push(msg); + channel.port1.onMessage = (msg) => numbers.push(msg); + + channel.port1.postMessage("hello"); + channel.port2.postMessage(42); + + expect(strings).toEqual(["hello"]); + expect(numbers).toEqual([42]); + }); +}); + +describe("testCreateMessagePort", () => { + test("looks up port by native token from channel", () => { + const channel = testCreateMessageChannel(); + const port = testCreateMessagePort(channel.port1.native); + expect(port).toBe(channel.port1); + }); + + test("looks up port2 by native token", () => { + const channel = testCreateMessageChannel(); + const port = testCreateMessagePort(channel.port2.native); + expect(port).toBe(channel.port2); + }); + + test("throws for unknown native port", () => { + const unknownNative = Symbol("unknown") as unknown as NativeMessagePort; + expect(() => testCreateMessagePort(unknownNative)).toThrow( + "Unknown native port", + ); + }); +}); + +describe("testCreateWorker", () => { + test("worker and self communicate through ports", () => { + const { worker, self } = testCreateWorker(); + const workerReceived: Array = []; + const selfReceived: Array = []; + + worker.onMessage = (msg) => workerReceived.push(msg); + self.onMessage = (msg) => selfReceived.push(msg); + + worker.postMessage("to-self"); + self.postMessage(123); + + expect(selfReceived).toEqual(["to-self"]); + expect(workerReceived).toEqual([123]); + }); + + test("messages are queued until onMessage is assigned", () => { + const { worker, self } = testCreateWorker(); + worker.postMessage("queued"); + + const received: Array = []; + self.onMessage = (msg) => received.push(msg); + + expect(received).toEqual(["queued"]); + }); + + test("worker dispose clears handlers", () => { + const { worker, self } = testCreateWorker(); + worker.onMessage = lazyVoid; + self.onMessage = lazyVoid; + + worker[Symbol.dispose](); + + expect(worker.onMessage).toBeNull(); + expect(self.onMessage).toBeNull(); + }); +}); + +describe("testCreateSharedWorker", () => { + test("connect triggers onConnect with worker port", () => { + const { self, connect } = testCreateSharedWorker(); + let connected = false; + self.onConnect = () => { + connected = true; + }; + connect(); + expect(connected).toBe(true); + }); + + test("connect does nothing when onConnect is null", () => { + const { connect } = testCreateSharedWorker(); + expect(() => connect()).not.toThrow(); + }); + + test("worker and self communicate through ports", () => { + const { worker, self, connect } = testCreateSharedWorker(); + const workerReceived: Array = []; + const clientReceived: Array = []; + + self.onConnect = (port) => { + port.onMessage = (msg) => workerReceived.push(msg); + port.postMessage(99); + }; + + worker.port.onMessage = (msg) => clientReceived.push(msg); + connect(); + + worker.port.postMessage("hello"); + + expect(workerReceived).toEqual(["hello"]); + expect(clientReceived).toEqual([99]); + }); + + test("messages sent before connect are queued", () => { + const { worker, self, connect } = testCreateSharedWorker(); + worker.port.postMessage("before-connect"); + + const received: Array = []; + self.onConnect = (port) => { + port.onMessage = (msg) => received.push(msg); + }; + connect(); + + expect(received).toEqual(["before-connect"]); + }); + + test("worker dispose disposes channel", () => { + const { worker } = testCreateSharedWorker(); + worker.port.onMessage = lazyVoid; + expect(worker.port.onMessage).not.toBeNull(); + worker[Symbol.dispose](); + expect(worker.port.onMessage).toBeNull(); + }); + + test("self dispose nulls onConnect", () => { + const { self } = testCreateSharedWorker(); + self.onConnect = lazyVoid; + expect(self.onConnect).not.toBeNull(); + self[Symbol.dispose](); + expect(self.onConnect).toBeNull(); + }); +}); diff --git a/packages/common/test/_deps.nodejs.ts b/packages/common/test/_deps.nodejs.ts index cc1d24135..f4e444237 100644 --- a/packages/common/test/_deps.nodejs.ts +++ b/packages/common/test/_deps.nodejs.ts @@ -1,114 +1,2 @@ -import { timingSafeEqual } from "node:crypto"; -import BetterSQLite, { type Statement } from "better-sqlite3"; -import { assert } from "../src/Assert.js"; -import type { TimingSafeEqual } from "../src/Crypto.js"; -import { lazyTrue, lazyVoid } from "../src/Function.js"; -import { - createRelaySqliteStorage, - createRelayStorageTables, -} from "../src/local-first/Relay.js"; -import { - createBaseSqliteStorageTables, - type StorageConfig, - type StorageDep, -} from "../src/local-first/Storage.js"; -import { ok } from "../src/Result.js"; -import type { - CreateSqliteDriver, - CreateSqliteDriverDep, - SqliteDep, - SqliteDriver, - SqliteRow, -} from "../src/Sqlite.js"; -import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; -import type { Run } from "../src/Task.js"; -import { type TestDeps, testCreateRun } from "../src/Test.js"; -import { testSimpleName } from "./_deps.js"; - -export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; - -export const testCreateRunWithSqlite = async (): Promise< - Run -> => { - const run = testCreateRun({ - createSqliteDriver: testCreateSqliteDriver, - }); - - const sqlite = await run(createSqlite(testSimpleName)); - assert(sqlite.ok, "bug"); - - run.defer(() => { - sqlite.value[Symbol.dispose](); - return ok(); - }); - - return run.addDeps({ sqlite: sqlite.value }); -}; - -/** Creates a test Run with relay storage and SQLite deps. */ -export const testCreateRunWithSqliteAndRelayStorage = async ( - config?: Partial, -): Promise> => { - const run = await testCreateRunWithSqlite(); - - createBaseSqliteStorageTables(run.deps); - createRelayStorageTables(run.deps); - - const storage = createRelaySqliteStorage({ - ...run.deps, - timingSafeEqual: testTimingSafeEqual, - })({ - onStorageError: (error) => { - throw new Error(error.type); - }, - isOwnerWithinQuota: lazyTrue, - ...config, - }); - - return run.addDeps({ storage }); -}; - -/** In-memory better-sqlite3 driver for tests. */ -const testCreateSqliteDriver: CreateSqliteDriver = (name) => - createBetterSqliteDriver(name, { mode: "memory" }); - -// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it -// (nodejs depends on common — importing back would create a circular dependency). -const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { - const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; - const db = new BetterSQLite(filename); - let isDisposed = false; - - const cache = createPreparedStatementsCache( - (sql) => db.prepare(sql), - // Not needed. - // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement - lazyVoid, - ); - - const driver: SqliteDriver = { - exec: (query) => { - // Always prepare is recommended for better-sqlite3 - const prepared = cache.get(query, true); - - if (prepared.reader) { - const rows = prepared.all(query.parameters) as Array; - return { rows, changes: 0 }; - } - - const changes = prepared.run(query.parameters).changes; - return { rows: [], changes }; - }, - - export: () => db.serialize(), - - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - cache[Symbol.dispose](); - db.close(); - }, - }; - - return ok(driver); -}; +// Backward-compatible test helper path. Prefer importing from "./_deps.js". +export * from "./_deps.js"; diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index 19a76bd20..e9f498c3f 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -1,3 +1,113 @@ +import { timingSafeEqual } from "node:crypto"; +import BetterSQLite, { type Statement } from "better-sqlite3"; +import { assert } from "../src/Assert.js"; +import type { TimingSafeEqual } from "../src/Crypto.js"; +import { lazyTrue, lazyVoid } from "../src/Function.js"; +import { + createRelaySqliteStorage, + createRelayStorageTables, +} from "../src/local-first/Relay.js"; +import { + createBaseSqliteStorageTables, + type StorageConfig, + type StorageDep, +} from "../src/local-first/Storage.js"; +import { ok } from "../src/Result.js"; +import type { + CreateSqliteDriver, + CreateSqliteDriverDep, + SqliteDep, + SqliteDriver, + SqliteRow, +} from "../src/Sqlite.js"; +import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; +import type { Run } from "../src/Task.js"; +import { type TestDeps, testCreateRun } from "../src/Test.js"; import { SimpleName } from "../src/Type.js"; export const testSimpleName = /*#__PURE__*/ SimpleName.orThrow("Test"); + +export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; + +export const testCreateRunWithSqlite = async (): Promise< + Run +> => { + const run = testCreateRun({ + createSqliteDriver: testCreateSqliteDriver, + }); + + const sqlite = await run(createSqlite(testSimpleName)); + assert(sqlite.ok, "bug"); + + run.defer(() => { + sqlite.value[Symbol.dispose](); + return ok(); + }); + + return run.addDeps({ sqlite: sqlite.value }); +}; + +/** Creates a test Run with relay storage and SQLite deps. */ +export const testCreateRunWithSqliteAndRelayStorage = async ( + config?: Partial, +): Promise> => { + const run = await testCreateRunWithSqlite(); + + createBaseSqliteStorageTables(run.deps); + createRelayStorageTables(run.deps); + + const storage = createRelaySqliteStorage({ + ...run.deps, + timingSafeEqual: testTimingSafeEqual, + })({ + isOwnerWithinQuota: lazyTrue, + ...config, + }); + + return run.addDeps({ storage }); +}; + +/** In-memory better-sqlite3 driver for tests. */ +const testCreateSqliteDriver: CreateSqliteDriver = (name) => + createBetterSqliteDriver(name, { mode: "memory" }); + +// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it +// (nodejs depends on common — importing back would create a circular dependency). +const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { + const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; + const db = new BetterSQLite(filename); + let isDisposed = false; + + const cache = createPreparedStatementsCache( + (sql) => db.prepare(sql), + // Not needed. + // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement + lazyVoid, + ); + + const driver: SqliteDriver = { + exec: (query) => { + // Always prepare is recommended for better-sqlite3 + const prepared = cache.get(query, true); + + if (prepared.reader) { + const rows = prepared.all(query.parameters) as Array; + return { rows, changes: 0 }; + } + + const changes = prepared.run(query.parameters).changes; + return { rows: [], changes }; + }, + + export: () => db.serialize(), + + [Symbol.dispose]: () => { + if (isDisposed) return; + isDisposed = true; + cache[Symbol.dispose](); + db.close(); + }, + }; + + return ok(driver); +}; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 9f6d64850..203b2e6ff 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -96,13 +96,17 @@ const createMockMessageChannel = () => { }; }; -const createMockDeps = () => { +const createMockDeps = (options?: { + readonly mutateResponseDelayMs?: number; +}) => { const rows = new Map(); let persistedAppOwner: AppOwner | null = null; let reloadCount = 0; let resetCount = 0; let mutateCount = 0; let closeCount = 0; + let inFlightMutates = 0; + let maxInFlightMutates = 0; let tabPort: MockPort | null = null; const brokerInputs: Array = []; @@ -115,12 +119,12 @@ const createMockDeps = () => { port: { postMessage: ( message: - | { type: "InitTab"; port: unknown } + | { type: "InitTab"; consoleLevel: string; port: unknown } | { type: "InitEvolu"; name: SimpleName; - port: unknown; - brokerPort: unknown; + port1: unknown; + port2: unknown; }, _transfer?: ReadonlyArray, ) => { @@ -130,11 +134,11 @@ const createMockDeps = () => { } if (message.type !== "InitEvolu") return; - const dbPort = message.port as MockPort< + const dbPort = message.port1 as MockPort< DbWorkerOutput, DbWorkerInput >; - const brokerPort = message.brokerPort as MockPort< + const brokerPort = message.port2 as MockPort< DbWorkerLeaderOutput, DbWorkerLeaderInput >; @@ -169,41 +173,59 @@ const createMockDeps = () => { } case "DbWorkerMutate": { mutateCount += 1; + inFlightMutates += 1; + maxInFlightMutates = Math.max( + maxInFlightMutates, + inFlightMutates, + ); + + const respond = () => { + if ( + dbMessage.sql?.includes("__evolu_meta") && + dbMessage.params?.length === 1 + ) { + const appOwnerFromDb = dbMessage.params[0]; + if (typeof appOwnerFromDb === "string") { + persistedAppOwner = JSON.parse( + appOwnerFromDb, + ) as AppOwner; + } + } - if ( - dbMessage.sql?.includes("__evolu_meta") && - dbMessage.params?.length === 1 - ) { - const appOwnerFromDb = dbMessage.params[0]; - if (typeof appOwnerFromDb === "string") { - persistedAppOwner = JSON.parse(appOwnerFromDb) as AppOwner; + if ( + dbMessage.sql?.includes(`"title"`) && + dbMessage.params?.length === 3 + ) { + const [, id, title] = dbMessage.params; + rows.set(String(id), { + id: String(id), + title: String(title), + }); } - } - if ( - dbMessage.sql?.includes(`"title"`) && - dbMessage.params?.length === 3 - ) { - const [, id, title] = dbMessage.params; - rows.set(String(id), { - id: String(id), - title: String(title), - }); - } + if ( + dbMessage.sql?.startsWith("delete from") && + dbMessage.params?.length === 2 + ) { + const [, id] = dbMessage.params; + rows.delete(String(id)); + } - if ( - dbMessage.sql?.startsWith("delete from") && - dbMessage.params?.length === 2 - ) { - const [, id] = dbMessage.params; - rows.delete(String(id)); + dbPort.postMessage({ + type: "DbWorkerMutateResponse", + requestId: dbMessage.requestId, + changes: 1, + }); + inFlightMutates -= 1; + }; + + const mutateResponseDelayMs = + options?.mutateResponseDelayMs ?? 0; + if (mutateResponseDelayMs > 0) { + globalThis.setTimeout(respond, mutateResponseDelayMs); + } else { + respond(); } - - dbPort.postMessage({ - type: "DbWorkerMutateResponse", - requestId: dbMessage.requestId, - changes: 1, - }); break; } case "DbWorkerExport": { @@ -253,6 +275,7 @@ const createMockDeps = () => { resetCount, mutateCount, closeCount, + maxInFlightMutates, hasTabPort: tabPort !== null, brokerInputs: [...brokerInputs], rows: [...rows], @@ -464,6 +487,32 @@ describe("createEvolu", () => { const state = getState(); expect(state.closeCount).toBeGreaterThanOrEqual(2); }); + + test("mutation queue stays serialized across microtasks", async () => { + const { deps, getState } = createMockDeps({ mutateResponseDelayMs: 10 }); + const evoluDeps = createEvoluDeps(deps as any); + const evolu = createEvolu(evoluDeps)(Schema, { name: testSimpleName }); + const createQuery = createQueryBuilder(Schema); + const allTodos = createQuery((db) => + db.selectFrom("todo").select(["id", "title"]), + ); + + await evolu.appOwner; + + evolu.insert("todo", { title: NonEmptyString100.orThrow("A") }); + await Promise.resolve(); + evolu.insert("todo", { title: NonEmptyString100.orThrow("B") }); + + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 200); + }); + + const rows = await evolu.loadQuery(allTodos); + const state = getState(); + + expect(rows).toHaveLength(2); + expect(state.maxInFlightMutates).toBe(1); + }); }); // import { describe, expectTypeOf, test } from "vitest"; diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 7ec5c6bc8..0d69f431f 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -67,7 +67,7 @@ import { NonNegativeInt, PositiveInt, } from "../../src/Type.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { maxTimestamp, testAppOwner, @@ -1062,7 +1062,7 @@ describe("E2E sync", () => { relayStorage: Storage, rangesMaxSize = defaultProtocolMessageRangesMaxSize, ) => { - const clientStorageDep = { storage: clientStorage }; + const clientStorageDep = { storage: clientStorage, console: deps.console }; const relayStorageDep = { storage: relayStorage }; let message = createProtocolMessageForSync(clientStorageDep)( @@ -1112,7 +1112,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); expect( @@ -1121,7 +1121,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); } diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index c7bc5d7de..ec677abfc 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -17,7 +17,7 @@ import type { } from "../../src/local-first/Storage.js"; import { createInitialTimestamp } from "../../src/local-first/Timestamp.js"; import { testCreateDeps } from "../../src/Test.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { testAppOwner, testAppOwner2, @@ -64,8 +64,7 @@ test("deleteOwner", async () => { expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); - const deleteResult = storage.deleteOwner(testAppOwnerIdBytes); - expect(deleteResult).toBe(true); + storage.deleteOwner(testAppOwnerIdBytes); for (const table of ["evolu_timestamp", "evolu_message", "evolu_writeKey"]) { const countResult = sqlite.exec<{ count: number }>(sql` @@ -73,7 +72,7 @@ test("deleteOwner", async () => { from ${sql.raw(table)} where ownerid = ${testAppOwnerIdBytes}; `); - expect(countResult.ok && countResult.value.rows[0].count).toBe(0); + expect(countResult.rows[0].count).toBe(0); } }); @@ -92,8 +91,7 @@ describe("writeMessages", () => { from evolu_usage where ownerid = ${ownerId}; `); - assert(usageResult.ok); - return usageResult.value.rows[0].storedBytes as NonNegativeInt; + return usageResult.rows[0].storedBytes as NonNegativeInt; }; const message = createTestMessage(); @@ -137,8 +135,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(countResult.ok); - expect(countResult.value.rows[0].count).toBe(1); + expect(countResult.rows[0].count).toBe(1); }); test("mutex prevents concurrent writes for same owner", async () => { @@ -218,8 +215,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(messageCountResult.ok); - expect(messageCountResult.value.rows[0].count).toBe(0); + expect(messageCountResult.rows[0].count).toBe(0); const usageResult = sqlite.exec<{ count: number }>(sql` select count(*) as count @@ -227,8 +223,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(usageResult.ok); - expect(usageResult.value.rows[0].count).toBe(0); + expect(usageResult.rows[0].count).toBe(0); }); describe("isOwnerWithinQuota", () => { diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index 88025e334..bba63a1e9 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -14,7 +14,6 @@ import { ensureDbSchema, getDbSchema, } from "../../src/local-first/Schema.js"; -import { ok } from "../../src/Result.js"; import { SqliteBoolean } from "../../src/Sqlite.js"; import { Boolean, @@ -24,7 +23,7 @@ import { NonEmptyString100, nullOr, } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; const TodoId = id("Todo"); type TodoId = typeof TodoId.Type; @@ -263,21 +262,17 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.todo.has("id")).toBe(true); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("createdAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("updatedAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("isDeleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("ownerId")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.todo.has("id")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("createdAt")).toBe(true); + expect(dbSchema.tables.todo.has("updatedAt")).toBe(true); + expect(dbSchema.tables.todo.has("isDeleted")).toBe(true); + expect(dbSchema.tables.todo.has("ownerId")).toBe(true); }); test("adds new columns to existing tables", async () => { @@ -290,8 +285,7 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result1 = ensureDbSchema(run.deps)(initialSchema); - expect(result1).toEqual(ok()); + ensureDbSchema(run.deps)(initialSchema); const updatedSchema: DbSchema = { tables: { @@ -300,16 +294,12 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result2 = ensureDbSchema(run.deps)(updatedSchema); - expect(result2).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("priority")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("priority")).toBe(true); }); test("creates multiple tables", async () => { @@ -323,17 +313,13 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.category).toBeDefined(); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.category.has("name")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.category).toBeDefined(); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.category.has("name")).toBe(true); }); test("uses set difference to find new columns", async () => { @@ -355,20 +341,16 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(updatedSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - // Original columns still exist - expect(dbSchema.value.tables.todo.has("a")).toBe(true); - expect(dbSchema.value.tables.todo.has("b")).toBe(true); - expect(dbSchema.value.tables.todo.has("c")).toBe(true); + expect(dbSchema.tables.todo.has("a")).toBe(true); + expect(dbSchema.tables.todo.has("b")).toBe(true); + expect(dbSchema.tables.todo.has("c")).toBe(true); // New columns added via difference - expect(dbSchema.value.tables.todo.has("d")).toBe(true); - expect(dbSchema.value.tables.todo.has("e")).toBe(true); + expect(dbSchema.tables.todo.has("d")).toBe(true); + expect(dbSchema.tables.todo.has("e")).toBe(true); }); test("with currentSchema parameter skips getDbSchema call", async () => { @@ -392,13 +374,9 @@ describe("ensureDbSchema", () => { }; // Pass currentSchema to skip getDbSchema - const result = ensureDbSchema(run.deps)(newSchema, currentSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema, currentSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("description")).toBe(true); + expect(dbSchema.tables.todo.has("description")).toBe(true); }); }); diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts new file mode 100644 index 000000000..e7db6e329 --- /dev/null +++ b/packages/common/test/local-first/Shared.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, test } from "vitest"; +import type { ConsoleEntry } from "../../src/Console.js"; +import { testCreateConsole } from "../../src/Console.js"; +import type { + DbWorkerLeaderInput, + DbWorkerLeaderOutput, +} from "../../src/local-first/Db.js"; +import type { MutationChange } from "../../src/local-first/Schema.js"; +import { + type EvoluInput, + type EvoluTabOutput, + initSharedWorker, + type SharedWorkerInput, +} from "../../src/local-first/Shared.js"; +import type { ReadonlyStore } from "../../src/Store.js"; +import { createStore } from "../../src/Store.js"; +import { testCreateRun, testName } from "../../src/Test.js"; +import { + testCreateMessageChannel, + testCreateMessagePort, + testCreateSharedWorker, +} from "../../src/Worker.js"; + +describe("initSharedWorker", () => { + const setupWorker = async ( + consoleStoreOutputEntry: ReadonlyStore = createStore( + null, + ), + ) => { + const { worker, self, connect } = + testCreateSharedWorker(); + + await using run = testCreateRun({ + console: testCreateConsole(), + consoleStoreOutputEntry, + createMessagePort: testCreateMessagePort, + }); + + const initResult = await run(initSharedWorker(self)); + if (!initResult.ok) + throw new Error("initSharedWorker should always succeed"); + + connect(); + + return { + worker, + workerStack: initResult.value, + }; + }; + + test("replays queued console entries after first console port connects", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const firstEntry: ConsoleEntry = { + method: "info", + path: ["before"], + args: ["queued"], + }; + + consoleStoreOutputEntry.set(firstEntry); + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const secondEntry: ConsoleEntry = { + method: "info", + path: ["after"], + args: ["live"], + }; + + consoleStoreOutputEntry.set(secondEntry); + + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: firstEntry }, + { type: "ConsoleEntry", entry: secondEntry }, + ]); + }); + + test("forwards entries immediately when console port is already connected", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const liveEntry: ConsoleEntry = { + method: "info", + path: ["live"], + args: ["entry"], + }; + + consoleStoreOutputEntry.set(liveEntry); + + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: liveEntry }, + ]); + }); + + test("ignores null console store updates", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: ["before-null"], + args: ["value"], + }; + + consoleStoreOutputEntry.set(entry); + consoleStoreOutputEntry.set(null); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards typed console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const error = { type: "UnknownError", error: "boom" } as const; + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", error], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards untyped console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", "plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with one argument", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with no arguments", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: [], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("accepts InitEvolu message", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(); + + expect(() => { + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + }).not.toThrow(); + }); + + test("forwards DbWorker console entries from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const tabChannel = testCreateMessageChannel(); + tabChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + consoleLevel: "debug", + port: tabChannel.port1.native, + }); + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: ["DbWorker"], + args: ["initializeDb", { name: testName }], + }; + + leaderChannel.port2.postMessage({ type: "ConsoleEntry", entry }); + + expect(receivedOutputs).toContainEqual({ type: "ConsoleEntry", entry }); + }); + + test("accepts LeaderAcquired events from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ + type: "LeaderAcquired", + name: testName, + }); + }).not.toThrow(); + }); + + test("accepts Evolu input messages on evolu channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + evoluChannel.port2.postMessage({ + type: "Mutate", + changes: [{} as MutationChange], + onCompleteIds: [], + subscribedQueries: [], + }); + }).not.toThrow(); + }); + + test("throws for unknown leader channel message type", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ type: "Unknown" } as never); + }).toThrow(); + }); + + test("throws for unknown message type", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + expect(() => { + worker.port.postMessage({ + type: "Unknown", + } as never); + }).toThrow(); + }); +}); diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 590e93d22..9ff0313bf 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -1,6 +1,5 @@ import { sha256 } from "@noble/hashes/sha2.js"; import { assert, expect, test } from "vitest"; -import { lazyTrue } from "../../src/Function.js"; import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; import type { BaseSqliteStorageDep, @@ -25,13 +24,13 @@ import { } from "../../src/local-first/Timestamp.js"; import { computeBalancedBuckets } from "../../src/Number.js"; import { createRandom } from "../../src/Random.js"; -import { getOrThrow, ok } from "../../src/Result.js"; +import { ok } from "../../src/Result.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; import { testCreateDeps } from "../../src/Test.js"; import type { Millis } from "../../src/Time.js"; import { createId, NonNegativeInt, type PositiveInt } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; import { testAnotherTimestampsAsc, testAppOwner2, @@ -49,15 +48,9 @@ const createDeps = async (): Promise => { // Pseudo-random does not scale (randomness is limited). const random = createRandom(); - const result = createBaseSqliteStorageTables({ sqlite }); - assert(result.ok); + createBaseSqliteStorageTables({ sqlite }); - const storage = createBaseSqliteStorage({ sqlite, random })({ - onStorageError: (error) => { - throw new Error(error.type); - }, - isOwnerWithinQuota: lazyTrue, // Allow all writes in tests - }); + const storage = createBaseSqliteStorage({ sqlite, random })(); return { sqlite, storage }; }; @@ -136,16 +129,14 @@ const testTimestamps = async ( ? null : (fingerprintRanges[i].upperBound as TimestampBytes); - const { rows: timestampRows } = getOrThrow( - deps.sqlite.exec<{ t: TimestampBytes }>(sql` - select t - from evolu_timestamp - where - (${lower} is null or t >= ${lower}) - and (${upper} is null or t < ${upper}) - and ownerid = ${testAppOwnerIdBytes}; - `), - ); + const { rows: timestampRows } = deps.sqlite.exec<{ t: TimestampBytes }>(sql` + select t + from evolu_timestamp + where + (${lower} is null or t >= ${lower}) + and (${upper} is null or t < ${upper}) + and ownerid = ${testAppOwnerIdBytes}; + `); incrementalCounts.push(timestampRows.length); @@ -217,7 +208,7 @@ test("empty db", async () => { 0 as NonNegativeInt, 0 as NonNegativeInt, ); - expect(fingerprint?.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); + expect(fingerprint.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); const lowerBound = deps.storage.findLowerBound( testAppOwnerIdBytes, @@ -383,8 +374,7 @@ test("getTimestampByIndex", async () => { testAppOwnerIdBytes, i as NonNegativeInt, ); - assert(timestamp.ok); - expect(timestamp.value.join()).toBe(testTimestampsAsc[i].join()); + expect(timestamp.join()).toBe(testTimestampsAsc[i].join()); } }); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index b6e893dce..d3d60c2c2 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -1,5 +1,7 @@ import { expect, test, vi } from "vitest"; +import { createConsole } from "../../src/Console.js"; import { createUnknownError } from "../../src/Error.js"; +import type { ConsoleEntry } from "../../src/index.js"; import type { DbWorkerInput, DbWorkerLeaderInput, @@ -9,8 +11,11 @@ import type { import { type EvoluTabOutput, type EvoluWorkerInput, + initEvoluWorker, runEvoluWorkerScope, } from "../../src/local-first/Worker.js"; +import { createStore } from "../../src/Store.js"; +import { createRun } from "../../src/Task.js"; import { SimpleName } from "../../src/Type.js"; import type { CreateMessagePort, @@ -22,7 +27,7 @@ import type { const createTrackedPort = () => { let onMessage: ((message: Output) => void) | null = null; const sentMessages: Array = []; - const native = {} as NativeMessagePort; + const native = {} as NativeMessagePort; const port: MessagePort = { postMessage: (message) => { @@ -62,14 +67,21 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => const tabPort = createTrackedPort(); const createMessagePort: CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => { - if (nativePort === tabPort.native) + if ( + (nativePort as NativeMessagePort) === + (tabPort.native as NativeMessagePort) + ) return tabPort.port as unknown as MessagePort; throw new Error("Unexpected native port"); }; + const console = createConsole(); + const setLevel = vi.spyOn(console, "setLevel"); + runEvoluWorkerScope({ + console, createMessagePort, runDbWorkerPort: vi.fn(), })(workerScope); @@ -77,12 +89,14 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => workerScope.onConnect?.(workerConnection.port); workerConnection.emit({ type: "InitTab", + consoleLevel: "debug", port: tabPort.native, }); const error = createUnknownError(new Error("boom")); workerScope.onError?.(error); + expect(setLevel).toHaveBeenCalledWith("debug"); expect(tabPort.sentMessages).toEqual([{ type: "EvoluError", error }]); }); @@ -95,18 +109,26 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { DbWorkerLeaderInput >(); const runDbWorkerPort = vi.fn(); + const console = createConsole(); const createMessagePort: CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => { - if (nativePort === dbPort.native) + if ( + (nativePort as NativeMessagePort) === + (dbPort.native as NativeMessagePort) + ) return dbPort.port as unknown as MessagePort; - if (nativePort === brokerPort.native) + if ( + (nativePort as NativeMessagePort) === + (brokerPort.native as NativeMessagePort) + ) return brokerPort.port as unknown as MessagePort; throw new Error("Unexpected native port"); }; runEvoluWorkerScope({ + console, createMessagePort, runDbWorkerPort, })(workerScope); @@ -116,14 +138,60 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { workerConnection.emit({ type: "InitEvolu", name, - port: dbPort.native, - brokerPort: brokerPort.native, + port1: dbPort.native, + port2: brokerPort.native, }); expect(runDbWorkerPort).toHaveBeenCalledTimes(1); expect(runDbWorkerPort).toHaveBeenCalledWith({ name, + consoleLevel: "log", port: dbPort.port, brokerPort: brokerPort.port, }); }); + +test("initEvoluWorker forwards console store output to tab port", async () => { + const workerScope = createWorkerScope(); + const workerConnection = createTrackedPort(); + const tabPort = createTrackedPort(); + const entryStore = createStore(null); + + const createMessagePort: CreateMessagePort = ( + nativePort: NativeMessagePort, + ): MessagePort => { + if ( + (nativePort as NativeMessagePort) === + (tabPort.native as NativeMessagePort) + ) + return tabPort.port as unknown as MessagePort; + throw new Error("Unexpected native port"); + }; + + await using run = createRun({ + console: createConsole(), + consoleStoreOutputEntry: entryStore, + createMessagePort, + runDbWorkerPort: vi.fn(), + }); + + const initResult = await run(initEvoluWorker(workerScope)); + expect(initResult.ok).toBe(true); + if (!initResult.ok) return; + + workerScope.onConnect?.(workerConnection.port); + workerConnection.emit({ + type: "InitTab", + consoleLevel: "debug", + port: tabPort.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: [], + args: ["forwarded"], + }; + entryStore.set(entry); + + expect(tabPort.sentMessages).toContainEqual({ type: "ConsoleEntry", entry }); +}); diff --git a/packages/common/test/local-first/_utils.ts b/packages/common/test/local-first/_utils.ts index 5664fc2f9..d033ba288 100644 --- a/packages/common/test/local-first/_utils.ts +++ b/packages/common/test/local-first/_utils.ts @@ -1,4 +1,3 @@ -import { assert } from "../../src/Assert.js"; import { type DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; import type { CrdtMessage } from "../../src/local-first/Storage.js"; import { DbChange } from "../../src/local-first/Storage.js"; @@ -18,23 +17,21 @@ export interface DbSnapshot { export const getDbSnapshot = (deps: SqliteDep): DbSnapshot => { const schema = getDbSchema(deps)({ allIndexes: true }); - assert(schema.ok, "bug"); const tables = []; - for (const tableName in schema.value.tables) { + for (const tableName in schema.tables) { const result = deps.sqlite.exec(sql` select * from ${sql.identifier(tableName)}; `); - assert(result.ok, "bug"); tables.push({ name: tableName, - rows: result.value.rows, + rows: result.rows, }); } - return { schema: schema.value, tables }; + return { schema, tables }; }; export const createTestCrdtMessage = ( diff --git a/packages/nodejs/src/Worker.ts b/packages/nodejs/src/Worker.ts index 442974568..bdce4fb69 100644 --- a/packages/nodejs/src/Worker.ts +++ b/packages/nodejs/src/Worker.ts @@ -121,7 +121,7 @@ const wrap = ( else native.postMessage(message, toTransferList(transfer)); }, onMessage: null, - native: native as unknown as NativeMessagePort, + native: native as unknown as NativeMessagePort, [Symbol.dispose]: () => { port.onMessage = null; native.off("message", onNativeMessage); diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 0806efc58..946420499 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -1,18 +1,17 @@ import { existsSync } from "node:fs"; import { createServer } from "node:http"; import { - allResult, type CreateSqliteDriverDep, callback, createRandom, createRelation, createSqlite, + getOk, isPromiseLike, type OwnerId, ok, type RandomDep, SimpleName, - type SqliteError, type Task, type TimingSafeEqualDep, Uint8Array, @@ -57,7 +56,7 @@ export const createRelayDeps = (): RelayDeps => ({ * ```ts * const deps = { ...createRelayDeps(), console }; * - * await using run = createRunner(deps); + * await using run = createRun(deps); * await using stack = run.stack(); * * await stack.use(startRelay({ port: 4000 })); @@ -71,33 +70,22 @@ export const startRelay = name = SimpleName.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, - }: NodeJsRelayConfig): Task => + }: NodeJsRelayConfig): Task => async (_run) => { await using stack = _run.stack(); const console = _run.deps.console.child("relay"); const dbFileExists = existsSync(`${name}.db`); - const sqlite = await stack.use(createSqlite(name)); - if (!sqlite.ok) { - console.error(sqlite.error); - return sqlite; - } - const deps = { ..._run.deps, sqlite: sqlite.value }; + const sqlite = getOk(await stack.use(createSqlite(name))); + const deps = { ..._run.deps, sqlite }; if (!dbFileExists) { - const result = allResult([ - createBaseSqliteStorageTables(deps), - createRelayStorageTables(deps), - ]); - if (!result.ok) { - console.error(result.error); - return result; - } + createBaseSqliteStorageTables(deps); + createRelayStorageTables(deps); } const storage = createRelaySqliteStorage(deps)({ - onStorageError: console.error, isOwnerWithinQuota, }); @@ -221,7 +209,6 @@ export const startRelay = console.info("HTTP server closed"); ok(); }); - return undefined; }), ); @@ -233,7 +220,6 @@ export const startRelay = console.info("WebSocketServer closed"); ok(); }); - return undefined; }), ); @@ -246,7 +232,6 @@ export const startRelay = } } ok(); - return undefined; }), ); diff --git a/packages/nodejs/test/Sqlite.test.ts b/packages/nodejs/test/Sqlite.test.ts index aa52fd56a..e7d992f4a 100644 --- a/packages/nodejs/test/Sqlite.test.ts +++ b/packages/nodejs/test/Sqlite.test.ts @@ -23,8 +23,7 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -42,9 +41,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); - expect(rows.value.changes).toBe(0); + expect(rows.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); + expect(rows.changes).toBe(0); sqlite[Symbol.dispose](); }); @@ -62,9 +60,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -81,9 +78,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); }); @@ -113,12 +109,11 @@ describe("createBetterSqliteDriver", () => { // Execute the same query twice — both should succeed via cached statement const insert1 = sqlite.exec(sql`insert into t (name) values (${"A"});`); const insert2 = sqlite.exec(sql`insert into t (name) values (${"B"});`); - assert(insert1.ok); - assert(insert2.ok); + expect(insert1.changes).toBe(1); + expect(insert2.changes).toBe(1); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); diff --git a/packages/react-native/src/Worker.ts b/packages/react-native/src/Worker.ts index 843483d71..190ddcc7b 100644 --- a/packages/react-native/src/Worker.ts +++ b/packages/react-native/src/Worker.ts @@ -33,7 +33,7 @@ export const createWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { worker.onMessage = null; workerSelf?.[Symbol.dispose](); @@ -52,7 +52,7 @@ export const createWorker = ( worker.onMessage(message); }); }, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { if (workerSelf) workerSelf.onMessage = null; }, @@ -87,7 +87,7 @@ export const createSharedWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { clientPort.onMessage = null; }, @@ -104,7 +104,7 @@ export const createSharedWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { if (workerPort) workerPort.onMessage = null; }, @@ -160,7 +160,7 @@ export const createMessageChannel = (): MessageChannel< }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { port1.onMessage = null; }, @@ -177,7 +177,7 @@ export const createMessageChannel = (): MessageChannel< }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { port2.onMessage = null; }, @@ -200,6 +200,6 @@ export const createMessageChannel = (): MessageChannel< * wrapper, so this is a passthrough. */ export const createMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => nativePort as unknown as MessagePort; diff --git a/packages/react-native/src/exports/bare-op-sqlite.ts b/packages/react-native/src/exports/bare-op-sqlite.ts index 1fa5f6542..ecf6dddd8 100644 --- a/packages/react-native/src/exports/bare-op-sqlite.ts +++ b/packages/react-native/src/exports/bare-op-sqlite.ts @@ -10,6 +10,7 @@ import type { ReloadApp } from "@evolu/common"; import { DevSettings } from "react-native"; import { SensitiveInfo } from "react-native-sensitive-info"; import { createEvoluDeps, createSharedLocalAuth } from "../shared.js"; +import { createOpSqliteDriver } from "../sqlite-drivers/createOpSqliteDriver.js"; const reloadApp: ReloadApp = () => { if (process.env.NODE_ENV === "development") { @@ -19,6 +20,9 @@ const reloadApp: ReloadApp = () => { } }; -export const evoluReactNativeDeps = createEvoluDeps({ reloadApp }); +export const evoluReactNativeDeps = createEvoluDeps({ + createSqliteDriver: createOpSqliteDriver, + reloadApp, +}); export const localAuth = createSharedLocalAuth(SensitiveInfo); diff --git a/packages/react-native/src/exports/expo-sqlite.ts b/packages/react-native/src/exports/expo-sqlite.ts index 65d6949c9..d65b54bf5 100644 --- a/packages/react-native/src/exports/expo-sqlite.ts +++ b/packages/react-native/src/exports/expo-sqlite.ts @@ -8,17 +8,18 @@ import type { EvoluDeps } from "@evolu/common/local-first"; import * as Expo from "expo"; import { createEvoluDeps as createSharedEvoluDeps } from "../shared.js"; +import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; /** Creates Evolu dependencies for Expo. */ export const createEvoluDeps = (): EvoluDeps => createSharedEvoluDeps({ + createSqliteDriver: createExpoSqliteDriver, reloadApp: () => { void Expo.reloadAppAsync(); }, }); import { createExpoDeps } from "../createExpoDeps.js"; -import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ createSqliteDriver: createExpoSqliteDriver, diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index e85370d72..70e0c752f 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -1,4 +1,5 @@ import { + type CreateSqliteDriverDep, createLocalAuth, createRandomBytes, type LocalAuth, @@ -11,8 +12,9 @@ import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-fi const randomBytes = createRandomBytes(); /** Creates Evolu dependencies for React Native. */ -export const createEvoluDeps = (deps: ReloadAppDep): EvoluDeps => - createCommonEvoluDeps(deps); +export const createEvoluDeps = ( + deps: ReloadAppDep & CreateSqliteDriverDep, +): EvoluDeps => createCommonEvoluDeps(deps); export const createSharedLocalAuth = ( secureStorage: SecureStorage, diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index d86ef8307..566c70a9f 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -138,7 +138,7 @@ const wrap = ( else native.postMessage(message, [...transfer]); }, onMessage: null, - native: native as unknown as NativeMessagePort, + native: native as unknown as NativeMessagePort, [Symbol.dispose]: () => { native.onmessage = null; if (native instanceof globalThis.Worker) native.terminate(); diff --git a/packages/web/src/local-first/DbWorker.ts b/packages/web/src/local-first/DbWorker.ts index b1390d9eb..e74ff5a02 100644 --- a/packages/web/src/local-first/DbWorker.ts +++ b/packages/web/src/local-first/DbWorker.ts @@ -1,4 +1,5 @@ import type { + ConsoleLevel, MessagePort, SafeSql, SimpleName, @@ -8,13 +9,13 @@ import type { import { SimpleName as SimpleNameType } from "@evolu/common"; import type { AppOwner, - DbWorkerInput, - DbWorkerLeaderInput, - DbWorkerLeaderOutput, - DbWorkerOutput, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerLeaderInput as DbWorkerLeaderInput, + ExperimentalDbWorkerLeaderOutput as DbWorkerLeaderOutput, + ExperimentalDbWorkerOutput as DbWorkerOutput, Row, } from "@evolu/common/local-first"; -import { dbWorkerLeaderHeartbeatTimeoutMs as defaultHeartbeatTimeoutMs } from "@evolu/common/local-first"; +import { experimentalDbWorkerLeaderHeartbeatTimeoutMs as defaultHeartbeatTimeoutMs } from "@evolu/common/local-first"; import { createWasmSqliteDriver } from "../Sqlite.js"; import { createRun } from "../Task.js"; @@ -144,6 +145,7 @@ const releaseSharedDb = (dbName: string): void => { export const runWebDbWorkerPort = (config: { readonly name: SimpleName; + readonly consoleLevel?: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }): void => { @@ -153,6 +155,7 @@ export const runWebDbWorkerPort = (config: { export const runWebDbWorkerPortWithOptions = ( config: { readonly name: SimpleName; + readonly consoleLevel?: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }, diff --git a/packages/web/src/local-first/Worker.worker.ts b/packages/web/src/local-first/Worker.worker.ts index b3ee52e7a..4c63e99f8 100644 --- a/packages/web/src/local-first/Worker.worker.ts +++ b/packages/web/src/local-first/Worker.worker.ts @@ -2,12 +2,9 @@ declare const self: SharedWorkerGlobalScope; import { initEvoluWorker } from "@evolu/common/local-first"; -import { createRun } from "../Task.js"; -import { createMessagePort, createSharedWorkerScope } from "../Worker.js"; +import { createSharedWorkerScope, createWorkerRun } from "../Worker.js"; import { runWebDbWorkerPort } from "./DbWorker.js"; -await using run = createRun({ - createMessagePort, - runDbWorkerPort: runWebDbWorkerPort, -}); +await using baseRun = createWorkerRun(); +const run = baseRun.addDeps({ runDbWorkerPort: runWebDbWorkerPort }); await run(initEvoluWorker(createSharedWorkerScope(self))); diff --git a/packages/web/test/DbWorker.test.ts b/packages/web/test/DbWorker.test.ts index efaed1cf4..5276740da 100644 --- a/packages/web/test/DbWorker.test.ts +++ b/packages/web/test/DbWorker.test.ts @@ -1,10 +1,10 @@ import type { MessagePort } from "@evolu/common"; import { SimpleName } from "@evolu/common"; import type { - DbWorkerInput, - DbWorkerLeaderInput, - DbWorkerLeaderOutput, - DbWorkerOutput, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerLeaderInput as DbWorkerLeaderInput, + ExperimentalDbWorkerLeaderOutput as DbWorkerLeaderOutput, + ExperimentalDbWorkerOutput as DbWorkerOutput, } from "@evolu/common/local-first"; import { describe, expect, test } from "vitest"; import { diff --git a/packages/web/test/Sqlite.test.ts b/packages/web/test/Sqlite.test.ts index adc3acf39..de89afbc9 100644 --- a/packages/web/test/Sqlite.test.ts +++ b/packages/web/test/Sqlite.test.ts @@ -65,8 +65,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -84,9 +83,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -105,8 +103,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql.prepared`insert into t (name) values (${"B"});`); const rows = sqlite.exec(sql.prepared`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); @@ -123,9 +120,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); });