diff --git a/.ai/knowledge/07-coverage-progress-2026-02-27.md b/.ai/knowledge/07-coverage-progress-2026-02-27.md new file mode 100644 index 000000000..00f28e2c7 --- /dev/null +++ b/.ai/knowledge/07-coverage-progress-2026-02-27.md @@ -0,0 +1,94 @@ +# Coverage Progress (2026-02-27) + +## Context + +This note tracks the `P0+P1` coverage hardening wave on top of `origin/main`. +The new file-level gates are implemented by `scripts/coverage-file-gate.mts` and root scripts: + +- `bun run coverage:gate:p0` +- `bun run coverage:gate:p1` + +## Current Snapshot (from `bun run test:coverage` on branch `test-coverage`) + +### P0 target files (target: `>=90% statements` and `>=90% branches`) + +| File | Statements | Branches | Status | +|---|---:|---:|---| +| `packages/common/src/local-first/Sync.ts` | 96.83% | 90.83% | ✅ | +| `packages/common/src/local-first/Db.ts` | 99.47% | 91.66% | ✅ | +| `packages/common/src/local-first/Worker.ts` | 95.23% | 100.00% | ✅ | +| `packages/web/src/local-first/DbWorker.ts` | 98.91% | 93.47% | ✅ | + +### P1 target files + +| File | Target | Current | Status | +|---|---|---|---| +| `packages/common/src/local-first/LocalAuth.ts` | `>=75 / >=60` | `100.00 / 94.11` | ✅ | +| `packages/web/src/local-first/LocalAuth.ts` | `>=75 / >=60` | `100.00 / 92.30` | ✅ | +| `packages/nodejs/src/Worker.ts` | `>=90 / >=85` | `100.00 / 100.00` | ✅ | +| `packages/nodejs/src/Sqlite.ts` | `>=90 / >=85` | `100.00 / 93.75` | ✅ | + +Coverage gate status: + +- `bun run coverage:gate:p0` ✅ +- `bun run coverage:gate:p1` ✅ + +## Recent Additions (latest wave) + +- Adapter/wrapper coverage: + - `packages/web/test/Evolu.test.ts` + - `packages/web/test/Worker.test.ts` + - `packages/react-web/test/local-first/Evolu.test.ts` + - `packages/nodejs/test/WebPlatform.test.ts` +- Common helper/runtime branch coverage: + - `packages/common/test/local-first/Kysely.test.ts` + - `packages/common/test/local-first/Schema.test.ts` (index add/drop, `createQueryBuilder` options, `getEvoluSqliteSchema`) + - `packages/common/test/Error.test.ts` (`handleGlobalError` branches) + - `packages/common/test/String.test.ts` (new file) + - `packages/common/test/local-first/LocalAuth.test.ts` (mnemonic path, missing account, username fallback, unregister without fallback owner) + - `packages/web/test/LocalAuth.test.ts` (credential throw/missing userHandle/legacy metadata/create-null branches) + - `packages/common/test/local-first/Relay.test.ts` (zero-byte write path and mutex-abort propagation) +- Small runtime hardening: + - `packages/common/src/String.ts` now guarantees `string` return even when `JSON.stringify` returns `undefined` (`symbol` case). + +## Runtime Adapter Hardening Wave (Relay + DbWorker) + +- `packages/nodejs/test/Relay.test.ts` + - Added deterministic websocket scenarios for `subscribe -> broadcast -> unsubscribe`. + - Added restart scenario with existing relay DB file. +- `packages/web/test/DbWorker.test.ts` + - Added failure-race coverage where follower waits on failing shared `initPromise` and later recovery succeeds. +- `packages/web/src/local-first/DbWorker.ts` + - Added optional test hook `createDriver` into `runWebDbWorkerPortWithOptions` for deterministic init-failure injection (runtime default unchanged). +- `packages/nodejs/src/local-first/Relay.ts` + - Fixed WS message handling lifecycle to use daemon runner deps (`_run.daemon.addDeps(...)`) so callbacks are not aborted after `startRelay` returns. + - Kept runtime payload path simple and Bun/Node compatible (`Buffer.from(...)` for outbound binary sends). + +## Quick-Win Backlog (post P0/P1) + +Prioritized by risk + effort for next PR slices: + +1. `packages/web/src/Sqlite.ts` (`82.92% / 76.92%`, missing `3/13` branches) + - Remaining branches are OPFS init paths in main thread (`encrypted/default`) and warning fallback; deterministic coverage likely needs injectable sqlite-wasm facade. +2. `packages/common/src/local-first/Protocol.ts` (`92.93% / 88.83%`, missing `24/215`) + - Slightly below 90 branch; higher complexity than the three items above. +3. `packages/common/src/Console.ts` (`84.21% / 84.00%`, missing `8/50`) + - Moderate complexity; mostly branch-focused unit tests. +4. `packages/common/src/local-first/Relay.ts` (`96.00% / 85.00%`, missing `3/20`) + - Small remaining branch gap after latest write-path additions. +5. `packages/nodejs/src/local-first/Relay.ts` (`97.77% / 81.25%`, missing `3/16`) + - Remaining branch gap is now isolated in guarded branches around owner auth and ws event variants; can be closed with focused adapter tests. +6. `packages/common/src/Type.ts` (`73.50% / 68.32%`, missing `121/382`) + - Big-impact but not a quick win; needs dedicated focused campaign. + +## Commit Trace (current head segment) + +- `308958f4` `fix(nodejs): use daemon runner for relay ws message handling` +- `e61403e8` `test(web): cover shared initPromise failure cleanup` +- `5e8ac520` `test(nodejs): cover relay subscribe and restart paths` +- `67f34c74` `test(relay): cover zero-byte and mutex-abort write paths` +- `69e3811d` `test(local-auth): expand common and web edge-case coverage` +- `4ddb510c` `docs(ai): refresh coverage progress snapshot and quick-win backlog` +- `71ce2289` `fix(common): guarantee string fallback in safelyStringifyUnknownValue` +- `c18a4777` `test(common): expand schema and error branch coverage` +- `6653d541` `test(common): fix Kysely test import ordering for Biome` diff --git a/README.md b/README.md index afe50bf48..97938b474 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,73 @@ Primary goals: Evolu is a TypeScript library and local-first platform. +## Integration Matrix + +Coverage snapshot date: `2026-02-27` (from `bun run test:coverage` and `bun run test:coverage:bun`). + +| Package | Supported Versions | Implementation Status | Coverage (Statements / Branches) | Notes | +| --- | --- | --- | --- | --- | +| `@evolu/common` | Node `>=24.0.0` | Stable core | `94.47% / 89.57%` | Main engine + local-first protocol/runtime. | +| `@evolu/web` | `@evolu/common ^7.4.1` | Stable | `99.33% / 93.71%` | Browser runtime (Worker/SharedWorker/Web Locks path). | +| `@evolu/nodejs` | Node `>=24.0.0`, `@evolu/common ^7.4.1` | Stable | `95.74% / 87.50%` | Includes relay adapter hardening (WS lifecycle + subscribe/broadcast/unsubscribe + restart coverage). | +| `@evolu/react-web` | React `>=19`, React DOM `>=19`, `@evolu/web ^2.4.0` | Stable thin adapter | `100% / 100%` | Thin web integration wrapper. | +| `@evolu/react-native` | React Native `>=0.81`, Expo `>=54`, `@op-engineering/op-sqlite >=12` | Active hardening | `20.65% / 13.11%` | Core adapters are covered; broader suite is backlog. | +| `@evolu/react` | React `>=19` | Wrapper support | `0% / 0%` | Hook wrappers; coverage expansion planned. | +| `@evolu/vue` | Vue `>=3.5.29` | Wrapper support | `0% / 0%` | Composition API wrappers; coverage expansion planned. | +| `@evolu/svelte` | Svelte `>=5.53.3`, `@evolu/web ^2.4.0` | Wrapper support | `0% / 0%` | Store-based wrappers; coverage expansion planned. | +| `@evolu/bun` (private) | `@evolu/common ^7.4.1`, Bun `1.3.x` | Experimental adapter | `100% / 100%` | Measured via Bun coverage runner on `BunDbWorker.ts`. | + +## Planned Integrations (Roadmap View) + +| Integration | Fit | Priority | Expected Path | Main Risk / Blocker | +| --- | --- | --- | --- | --- | +| Next.js (App Router) | Very high | P0 | Official `@evolu/react-web` guide + production example for Server/Client boundaries. | SSR/client boundary handling and Worker lifecycle in edge runtimes. | +| TanStack Start | Very high | P0 | Use `@evolu/react` + `@evolu/web`, focus on SSR/client boundary docs and example app. | SSR edge cases (worker lifecycle and hydration boundary). | +| Astro | High | P0 | Client-island integration on top of `@evolu/web`, starter template + docs. | Island hydration timing and worker boot ordering. | +| SvelteKit | High | P1 | `@evolu/svelte` + `@evolu/web` reference app with SSR-aware browser-only init. | Avoiding server-side execution for browser worker primitives. | +| Nuxt 3 | High | P1 | Vue composables + client-only plugin/module (`@evolu/vue` + `@evolu/web`). | Nitro/SSR split and client plugin ordering. | +| Remix / React Router | High | P1 | React adapter with explicit browser init boundaries and route loader guidance. | Loader/action patterns can accidentally cross server/client boundary. | +| Tauri | High | P1 | Web runtime in WebView + optional Rust-side relay bridge for desktop sync scenarios. | Packaging/runtime differences across desktop targets. | +| Electron | High | P1 | Reuse web runtime in renderer + optional Node relay bridge in main process. | Multi-process lifecycle and secure IPC boundaries. | +| Capacitor (Ionic) | Medium | P2 | Reuse web runtime in WebView first, then mobile storage/perf hardening. | Mobile WebView storage consistency and background lifecycle constraints. | +| Flutter | Medium/Low | P2 | Separate adapter/SDK (likely not a thin wrapper) or protocol-level bridge. | Different runtime/language model (Dart), no direct reuse of TS hooks. | + +Current recommendation: + +- Build first-class examples for `Next.js`, `TanStack Start`, and `Astro`. +- Follow with `SvelteKit`, `Nuxt`, `Remix`, and `Tauri/Electron` runtime guides. +- Treat `Flutter` as a separate SDK/bridge effort, not a quick wrapper. +- Keep protocol/API parity first; add adapters only where lifecycle/storage semantics are clear. + +## `@evolu/common` Compatibility and Third-Party Dependencies + +- Package version: `7.4.1` +- Runtime baseline: Node `>=24.0.0` +- Monorepo toolchain baseline: Bun `1.3.10` + +Third-party runtime dependencies used by `@evolu/common`: + +| Dependency | Why It Is Used | +| --- | --- | +| `@noble/ciphers` | Audited cryptographic ciphers for encryption flows. | +| `@noble/hashes` | Audited hash primitives used by protocol/auth internals. | +| `@scure/bip39` | Mnemonic handling for owner/account recovery flows. | +| `disposablestack` | Disposable stack compatibility utility for cleanup semantics. | +| `kysely` | Typed SQL query builder integration. | +| `msgpackr` | Binary message serialization for protocol payloads. | +| `zod` | Runtime schema validation and parsing. | + +Dependency policy: + +- No dependency downgrades in sync waves. +- Sync waves are periodic coordinated dependency sync batches across packages and CI lanes. +- Prefer native Bun/runtime APIs where practical. +- Keep API/protocol compatibility with upstream. + +## Fork Diff vs Upstream + +For a concise overview of what this fork changes, why, and what is intentionally extra, see [UPSTREAM_DIFF.md](./UPSTREAM_DIFF.md). + ## Documentation For detailed information and usage examples, please visit [evolu.dev](https://www.evolu.dev). @@ -32,7 +99,7 @@ To chat with other community members, you can join the [Evolu Discord](https://d Evolu monorepo uses [Bun](https://bun.sh). > [!NOTE] -> The Evolu monorepo is verified to run under **Bun 1.3.9** in combination with **Node.js >=24.0.0**. This compatibility is explicitly tested in CI. +> The Evolu monorepo is verified to run under **Bun 1.3.10** in combination with **Node.js >=24.0.0**. This compatibility is explicitly tested in CI. Install dependencies: diff --git a/UPSTREAM_DIFF.md b/UPSTREAM_DIFF.md new file mode 100644 index 000000000..e3c2555af --- /dev/null +++ b/UPSTREAM_DIFF.md @@ -0,0 +1,35 @@ +# Evolu Plan B vs Upstream Evolu (High-Level) + +This document summarizes the main differences between `SQLoot/evolu-plan-b` and upstream `evoluhq/evolu`. + +Scope: high-level product and engineering deltas, not a full commit-by-commit changelog. + +## What Is Different + +| Area | Plan B Delta | Why | +| --- | --- | --- | +| Tooling baseline | Bun-first monorepo workflows (`bun install`, `bun run ...`) with Turborepo orchestration. | Faster local workflows and a single runtime/tooling story. | +| Formatting and linting | Biome-first formatting/linting policy. | Reduce tooling complexity and keep style/lint fast and consistent. | +| Upstream sync process | Added sync guard tooling and explicit compatibility tracking for `common-v8` sync waves. | Keep upstream parity while avoiding accidental regressions in fork-specific work. | +| Coverage governance | Added file-level coverage gates for critical local-first paths (`Sync`, `Db`, `Worker`, `DbWorker`, etc.). | Enforce reliability on highest-risk runtime paths before merges. | +| Bun runtime adapter | Added Bun-specific worker/db adapter package (`@evolu/bun`, currently private). | Native Bun runtime support and experimentation without changing upstream APIs. | +| Test expansion | Extra tests for sync/worker/sqlite/refactor edge cases, including runtime adapter races (`DbWorker initPromise` cleanup, Relay WS lifecycle/broadcast flows). | Protect against regressions during aggressive sync and refactor work. | + +## What Is Intentionally the Same + +| Area | Compatibility Target | +| --- | --- | +| Public local-first API | Keep API compatibility with upstream where possible. | +| Protocol and schema direction | Follow upstream `common-v8` refactor direction and naming. | +| Core behavior | Preserve upstream semantics unless explicitly documented as fork-only behavior. | + +## What Is Extra in Plan B + +- Integration coverage dashboards/gates in fork workflow. +- Bun-focused adapter experiments and tests. +- SQLoot-specific maintenance/docs structure for sync operations. + +## Non-Goals + +- This fork is not intended to fragment protocol behavior from upstream. +- This file is not a replacement for release notes. diff --git a/bun.lock b/bun.lock index 5f92fff04..052b18547 100644 --- a/bun.lock +++ b/bun.lock @@ -2786,7 +2786,7 @@ "react-native-quick-base64": ["react-native-quick-base64@2.2.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg=="], - "react-native-quick-crypto": ["react-native-quick-crypto@1.0.14", "", { "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "events": "3.3.0", "readable-stream": "4.5.2", "safe-buffer": "^5.2.1", "string_decoder": "^1.3.0", "util": "0.12.5" }, "peerDependencies": { "expo": ">=48.0.0", "expo-build-properties": "*", "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.29.1", "react-native-quick-base64": ">=2.1.0" }, "optionalPeers": ["expo", "expo-build-properties"] }, "sha512-YRJL69hIYlOQb/RPzFznqUAnt6OIY18ajpDlW/esD54jBNrhyYQxp+w40C1Qc2YHGnRTUwtxlNgADYrN3YTegQ=="], + "react-native-quick-crypto": ["react-native-quick-crypto@1.0.15", "", { "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "events": "3.3.0", "readable-stream": "4.5.2", "safe-buffer": "^5.2.1", "string_decoder": "^1.3.0", "util": "0.12.5" }, "peerDependencies": { "expo": ">=48.0.0", "expo-build-properties": "*", "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.29.1", "react-native-quick-base64": ">=2.1.0" }, "optionalPeers": ["expo", "expo-build-properties"] }, "sha512-ogtkFQSexJX34IqQd9skdHyfXRba8djTGa/8fA8f6QWeO/aV+7uTDyCq9fnWSsOIOGvUz5NwIJk1AAhG8w3UCQ=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], @@ -3140,19 +3140,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.8.11", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.11", "turbo-darwin-arm64": "2.8.11", "turbo-linux-64": "2.8.11", "turbo-linux-arm64": "2.8.11", "turbo-windows-64": "2.8.11", "turbo-windows-arm64": "2.8.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-H+rwSHHPLoyPOSoHdmI1zY0zy0GGj1Dmr7SeJW+nZiWLz2nex8EJ+fkdVabxXFMNEux+aywI4Sae8EqhmnOv4A=="], + "turbo": ["turbo@2.8.12", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.12", "turbo-darwin-arm64": "2.8.12", "turbo-linux-64": "2.8.12", "turbo-linux-arm64": "2.8.12", "turbo-windows-64": "2.8.12", "turbo-windows-arm64": "2.8.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-XKaCWaz4OCt77oYYvGCIRpvYD4c/aNaKjRkUpv+e8rN3RZb+5Xsyew4yRO+gaHdMIUhQznXNXfHlhs+/p7lIhA=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VvynLHGUNvQ9k7GZjRPSsRcK4VkioTfFb7O7liAk4nHKjEcMdls7GqxzjVWgJiKz3hWmQGaP9hRa9UUnhVWCxA=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q=="], - "turbo-linux-64": ["turbo-linux-64@2.8.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cbSn37dcm+EmkQ7DD0euy7xV7o2el4GAOr1XujvkAyKjjNvQ+6QIUeDgQcwAx3D17zPpDvfDMJY2dLQadWnkmQ=="], + "turbo-linux-64": ["turbo-linux-64@2.8.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+trymp2s2aBrhS04l6qFxcExzZ8ffndevuUB9c5RCeqsVpZeiWuGQlWNm5XjOmzoMayxRARZ5ma7yiWbGMiLqQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA=="], - "turbo-windows-64": ["turbo-windows-64@2.8.11", "", { "os": "win32", "cpu": "x64" }, "sha512-3kJjFSM4yw1n9Uzmi+XkAUgCae19l/bH6RJ442xo7mnZm0tpOjo33F+FYHoSVpIWVMd0HG0LDccyafPSdylQbA=="], + "turbo-windows-64": ["turbo-windows-64@2.8.12", "", { "os": "win32", "cpu": "x64" }, "sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -3536,6 +3536,8 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "app-builder-lib/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], @@ -3594,6 +3596,8 @@ "dmg-license/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -3632,6 +3636,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "jest-haste-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3918,7 +3924,7 @@ "app-builder-lib/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3964,7 +3970,7 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], @@ -4200,7 +4206,7 @@ "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "@angular/compiler-cli/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/package.json b/package.json index 8594c07e1..26e2d4b24 100755 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ "test:preflight:node": "bun ./scripts/ensure-better-sqlite3.mts --runtime=node", "test:docs": "bunx vitest run scripts/typedoc-plugin-evolu.test.mts", "test:tree-shaking:compat": "EVOLU_TREE_SHAKING_COMPAT=1 bunx vitest run packages/common/test/TreeShaking.test.ts", - "test:coverage": "bun run test:coverage:vitest && bun run test:coverage:bun", + "test:coverage": "bun run test:coverage:vitest && bun run test:coverage:bun && bun run coverage:merge:bun", "test:coverage:vitest": "bun run test:preflight && bunx vitest run --coverage", "test:coverage:bun": "bun test ./packages/bun/test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/bun", + "coverage:merge:bun": "bun ./scripts/coverage-merge-bun.mts --vitest coverage/coverage-summary.json --bun coverage/bun/lcov.info --out coverage/coverage-summary.json", + "coverage:gate:p0": "bun ./scripts/coverage-file-gate.mts --coverage coverage/coverage-summary.json --thresholds '{\"packages/common/src/local-first/Sync.ts\":{\"statements\":90,\"branches\":90},\"packages/common/src/local-first/Db.ts\":{\"statements\":90,\"branches\":90},\"packages/common/src/local-first/Worker.ts\":{\"statements\":90,\"branches\":90},\"packages/web/src/local-first/DbWorker.ts\":{\"statements\":90,\"branches\":90}}'", + "coverage:gate:p1": "bun ./scripts/coverage-file-gate.mts --coverage coverage/coverage-summary.json --thresholds '{\"packages/common/src/local-first/Sync.ts\":{\"statements\":90,\"branches\":90},\"packages/common/src/local-first/Db.ts\":{\"statements\":90,\"branches\":90},\"packages/common/src/local-first/Worker.ts\":{\"statements\":90,\"branches\":90},\"packages/web/src/local-first/DbWorker.ts\":{\"statements\":90,\"branches\":90},\"packages/common/src/local-first/LocalAuth.ts\":{\"statements\":75,\"branches\":60},\"packages/web/src/local-first/LocalAuth.ts\":{\"statements\":75,\"branches\":60},\"packages/nodejs/src/Worker.ts\":{\"statements\":90,\"branches\":85},\"packages/nodejs/src/Sqlite.ts\":{\"statements\":90,\"branches\":85}}'", "test:watch": "bunx vitest", "start": "turbo start", "lint": "biome check", diff --git a/packages/bun/test/BunDbWorker.test.ts b/packages/bun/test/BunDbWorker.test.ts index 9c9cf46d9..a1ca2a3be 100644 --- a/packages/bun/test/BunDbWorker.test.ts +++ b/packages/bun/test/BunDbWorker.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ExperimentalDbWorkerInput as DbWorkerInput, ExperimentalDbWorkerOutput as DbWorkerOutput, @@ -80,6 +83,43 @@ describe("runBunDbWorkerScope", () => { expect(query.rows).toEqual([{ value: "7" }]); }); + test("supports file-backed db name initialization", () => { + const { send } = createHarness(); + const dbName = join( + tmpdir(), + `evolu-bun-db-worker-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const dbPath = `${dbName}.sqlite`; + const walPath = `${dbPath}-wal`; + const shmPath = `${dbPath}-shm`; + + try { + const initOutput = send({ + type: "DbWorkerInit", + dbName, + schemaVersion: 9, + }); + const initResponse = expectMessage(initOutput, "DbWorkerInitResponse"); + expect(initResponse.success).toBe(true); + + const queryOutput = send({ + type: "DbWorkerQuery", + requestId: 17, + sql: "SELECT value FROM __evolu_meta WHERE key = 'schemaVersion'", + }); + const query = expectMessage(queryOutput, "DbWorkerQueryResponse"); + expect(query.rows).toEqual([{ value: "9" }]); + } finally { + send({ + type: "DbWorkerClose", + requestId: 18, + }); + if (existsSync(dbPath)) rmSync(dbPath); + if (existsSync(walPath)) rmSync(walPath); + if (existsSync(shmPath)) rmSync(shmPath); + } + }); + test("supports mutate/query flow with SQL parameters", () => { const { send } = createHarness(); init(send); @@ -209,6 +249,23 @@ describe("runBunDbWorkerScope", () => { expect(response.appOwner).toBeNull(); }); + test("returns worker error for invalid app owner payload", () => { + const { send } = createHarness(); + init(send); + + send({ + type: "DbWorkerMutate", + requestId: 19, + sql: "INSERT OR REPLACE INTO __evolu_meta (key, value) VALUES ('appOwner', ?)", + params: ["{not-json"], + }); + + const output = send({ type: "DbWorkerGetAppOwner" }); + const error = expectMessage(output, "DbWorkerError"); + expect(error.requestId).toBeUndefined(); + expect(error.error).toContain("JSON"); + }); + test("closes database on close message", () => { const { send } = createHarness(); init(send); @@ -229,4 +286,17 @@ describe("runBunDbWorkerScope", () => { expect(error.requestId).toBe(16); expect(error.error).toContain("Database not initialized"); }); + + test("returns worker error for unknown message type", () => { + const { send } = createHarness(); + + const output = send({ + type: "DbWorkerTotallyUnknown", + requestId: 999, + } as unknown as DbWorkerInput); + + const error = expectMessage(output, "DbWorkerError"); + expect(error.requestId).toBe(999); + expect(error.error).toContain("Unknown message type"); + }); }); diff --git a/packages/common/src/String.ts b/packages/common/src/String.ts index 76884f0b3..4c13629d7 100644 --- a/packages/common/src/String.ts +++ b/packages/common/src/String.ts @@ -9,7 +9,7 @@ export const safelyStringifyUnknownValue = (value: unknown): string => { if (value === undefined) return "undefined"; if (typeof value === "string") return `"${value}"`; try { - return JSON.stringify(value); + return JSON.stringify(value) ?? globalThis.String(value); } catch { return globalThis.String(value); } diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 76f36a1f5..88b8dbcbf 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -771,6 +771,12 @@ const loadQueries = return rowsByQuery; }; +export const testStartDbWorker = startDbWorker; +export const testCreateClock = createClock; +export const testInitializeDb = initializeDb; +export const testTryApplyQuarantinedMessages = tryApplyQuarantinedMessages; +export const testHandleMutation = handleMutation; + // reset: (deps) => (message) => { // const result = deps.sqlite.transaction(() => { // const sqliteSchema = getEvoluSqliteSchema(deps)(); diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index d49b77f4d..0d859487e 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -20,7 +20,6 @@ import type { } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; import { createUnknownError } from "../Error.js"; -import { lazyFalse, lazyVoid } from "../Function.js"; import { createInstances } from "../Instances.js"; import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; @@ -54,6 +53,7 @@ import type { AppOwnerDep, Owner, OwnerTransport, + OwnerWriteKey, ReadonlyOwner, } from "./Owner.js"; import { @@ -248,6 +248,7 @@ export const createSync = return socket.send(data); }, + /* v8 ignore next */ getReadyState: () => { if (isDisposed) return "closed"; return socket?.getReadyState() ?? "connecting"; @@ -330,9 +331,12 @@ export const createSync = socket = result.value; flushPendingSends(); + /* v8 ignore start */ + // Defensive cleanup for a resolved socket after disposal. if (isDisposed) { socket[Symbol.dispose](); } + /* v8 ignore stop */ }, (error: unknown) => { if (isDisposed) return; @@ -540,6 +544,13 @@ const createClientStorage = config: Pick, ): ClientStorage => { const sqliteStorageBase = createBaseSqliteStorage(deps); + deps.sqlite.exec(sql` + create table if not exists evolu_writeKey ( + "ownerId" blob primary key, + "writeKey" blob not null + ) + strict; + `); const ownerMutexes = createInstances< OwnerId, @@ -550,9 +561,34 @@ const createClientStorage = const storage: ClientStorage = { ...sqliteStorageBase, - // Not implemented yet. - validateWriteKey: lazyFalse, - setWriteKey: lazyVoid, + validateWriteKey: (ownerId, writeKey) => { + deps.sqlite.exec(sql` + insert into evolu_writeKey (ownerId, writeKey) + values (${ownerId}, ${writeKey}) + on conflict (ownerId) do nothing; + `); + + const selectWriteKey = deps.sqlite.exec<{ writeKey: OwnerWriteKey }>( + sql` + select writeKey + from evolu_writeKey + where ownerId = ${ownerId}; + `, + ); + + if (!isNonEmptyArray(selectWriteKey.rows)) return false; + + return isSameWriteKey(selectWriteKey.rows[0].writeKey, writeKey); + }, + + setWriteKey: (ownerId, writeKey) => { + deps.sqlite.exec(sql` + insert into evolu_writeKey (ownerId, writeKey) + values (${ownerId}, ${writeKey}) + on conflict (ownerId) do update + set writeKey = excluded.writeKey; + `); + }, writeMessages: (ownerIdBytes, encryptedMessages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); @@ -641,6 +677,7 @@ const createClientStorage = ownerIdBytes, firstInArray(newMessagesWithTimestampBytes).timestampBytes, ); + /* v8 ignore next */ if (!usage.ok) { return err({ type: "ProtocolSyncError", @@ -799,6 +836,17 @@ const createClientStorage = return storage; }; +export const testCreateClientStorage = createClientStorage; + +const isSameWriteKey = (a: Uint8Array, b: Uint8Array): boolean => { + let diff = a.length ^ b.length; + const maxLength = Math.max(a.length, b.length); + for (let i = 0; i < maxLength; i += 1) { + diff |= (a[i] | 0) ^ (b[i] | 0); + } + return diff === 0; +}; + type TransportKey = string & Brand<"TransportKey">; /** Creates a unique identifier for a {@link OwnerTransport}. */ @@ -867,6 +915,7 @@ const applyMessages = ); const usage = getOwnerUsage(deps)(ownerIdBytes, firstMessageTimestamp); + /* v8 ignore next */ if (!usage.ok) { deps.console.error("[sync]", "applyMessages/getOwnerUsage failed", { ownerId, diff --git a/packages/common/test/Error.test.ts b/packages/common/test/Error.test.ts index 8695083b5..1797cd2b4 100644 --- a/packages/common/test/Error.test.ts +++ b/packages/common/test/Error.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "vitest"; -import { createUnknownError } from "../src/Error.js"; +import { describe, expect, test, vi } from "vitest"; +import { + createUnknownError, + type GlobalErrorScope, + handleGlobalError, +} from "../src/Error.js"; describe("createUnknownError", () => { test("handles plain error", () => { @@ -89,3 +93,43 @@ describe("createUnknownError", () => { `); }); }); + +describe("handleGlobalError", () => { + test("forwards normalized unknown error to scope.onError", () => { + const onError = vi.fn(); + const scope: GlobalErrorScope = { + onError, + [Symbol.dispose]: () => {}, + }; + + handleGlobalError(scope, new Error("boom")); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith({ + type: "UnknownError", + error: expect.objectContaining({ message: "boom" }), + }); + }); + + test("asserts when scope.onError is not set", () => { + const scope: GlobalErrorScope = { + onError: null, + [Symbol.dispose]: () => {}, + }; + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + try { + expect(() => handleGlobalError(scope, "boom")).toThrow( + "onError must be set before global errors occur", + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Unhandled global error:", + "boom", + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); +}); diff --git a/packages/common/test/String.test.ts b/packages/common/test/String.test.ts new file mode 100644 index 000000000..f0a70cdf6 --- /dev/null +++ b/packages/common/test/String.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { safelyStringifyUnknownValue } from "../src/String.js"; + +describe("safelyStringifyUnknownValue", () => { + test("stringifies null and undefined explicitly", () => { + expect(safelyStringifyUnknownValue(null)).toBe("null"); + expect(safelyStringifyUnknownValue(undefined)).toBe("undefined"); + }); + + test("wraps strings in quotes", () => { + expect(safelyStringifyUnknownValue("hello")).toBe('"hello"'); + }); + + test("returns JSON.stringify output for plain values", () => { + expect(safelyStringifyUnknownValue({ a: 1 })).toBe('{"a":1}'); + expect(safelyStringifyUnknownValue([1, 2, 3])).toBe("[1,2,3]"); + }); + + test("falls back to String when JSON.stringify returns undefined", () => { + expect(safelyStringifyUnknownValue(Symbol.for("x"))).toBe("Symbol(x)"); + }); + + test("falls back to String when JSON.stringify throws", () => { + const circular: { readonly self?: unknown } = {}; + Object.assign(circular, { self: circular }); + + expect(safelyStringifyUnknownValue(circular)).toBe("[object Object]"); + }); +}); diff --git a/packages/common/test/local-first/Db.internal.test.ts b/packages/common/test/local-first/Db.internal.test.ts new file mode 100644 index 000000000..6d437a812 --- /dev/null +++ b/packages/common/test/local-first/Db.internal.test.ts @@ -0,0 +1,505 @@ +import { expect, test } from "vitest"; +import { + testCreateClock, + testHandleMutation, + testInitializeDb, + testStartDbWorker, + testTryApplyQuarantinedMessages, +} from "../../src/local-first/Db.js"; +import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; +import { serializeQuery } from "../../src/local-first/Query.js"; +import type { + DbWorkerInput, + DbWorkerOutput, +} from "../../src/local-first/Shared.js"; +import { + createBaseSqliteStorage, + ValidDbChangeValues, +} from "../../src/local-first/Storage.js"; +import { + createInitialTimestamp, + defaultTimestampMaxDrift, + sendTimestamp, + timestampToTimestampBytes, +} from "../../src/local-first/Timestamp.js"; +import { sql } from "../../src/Sqlite.js"; +import { createId, idToIdBytes, SimpleName } from "../../src/Type.js"; +import type { MessagePort } from "../../src/Worker.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; +import { testAppOwner } from "./_fixtures.js"; + +const sqliteSchema = { + tables: { + todo: new Set(["title"]), + _local_meta: new Set(["value"]), + }, + indexes: [], +} as const; + +const prepareTodoTables = ( + exec: (query: ReturnType) => unknown, +) => { + exec(sql` + create table todo ( + "id" text, + "createdAt" any, + "updatedAt" any, + "isDeleted" any, + "ownerId" any, + "title" any, + primary key ("ownerId", "id") + ) + without rowid, strict; + `); + + exec(sql` + create table _local_meta ( + "id" text, + "createdAt" any, + "updatedAt" any, + "isDeleted" any, + "ownerId" any, + "value" any, + primary key ("ownerId", "id") + ) + without rowid, strict; + `); +}; + +type DbPortMessage = { + readonly message: DbWorkerOutput; + readonly transfer?: ReadonlyArray; +}; + +const createDbPort = (): { + readonly port: MessagePort; + readonly messages: Array; +} => { + const messages: Array = []; + + return { + port: { + postMessage: (message, transfer) => { + messages.push({ message, transfer }); + }, + onMessage: null, + native: {} as never, + [Symbol.dispose]: () => {}, + }, + messages, + }; +}; + +test("testHandleMutation writes local and shared changes", async () => { + await using run = await testCreateRunWithSqlite(); + + const initialTimestamp = createInitialTimestamp(run.deps); + testInitializeDb(run.deps)(initialTimestamp); + prepareTodoTables(run.deps.sqlite.exec); + + const deps = { + ...run.deps, + sqliteSchema, + encryptionKey: testAppOwner.encryptionKey, + baseSqliteStorage: createBaseSqliteStorage(run.deps), + clock: testCreateClock(run.deps)(true), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + }; + + const todoId = createId(run.deps); + const todoId2 = createId(run.deps); + const localId = createId(run.deps); + const todoQuery = serializeQuery(sql`select title from todo order by title;`); + + const result = testHandleMutation(deps)({ + type: "Mutate", + changes: [ + { + ownerId: testAppOwner.id, + table: "_local_meta", + id: localId, + values: ValidDbChangeValues.orThrow({ value: "local-only" }), + isInsert: true, + isDelete: false, + }, + { + ownerId: testAppOwner.id, + table: "todo", + id: todoId, + values: ValidDbChangeValues.orThrow({ title: "todo-from-mutate" }), + isInsert: true, + isDelete: false, + }, + { + ownerId: testAppOwner.id, + table: "todo", + id: todoId2, + values: ValidDbChangeValues.orThrow({ title: "todo-null-delete-flag" }), + isInsert: true, + isDelete: null, + }, + ], + onCompleteIds: [], + subscribedQueries: new Set([todoQuery]), + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.type).toBe("Mutate"); + expect(result.value.messagesByOwnerId.size).toBe(1); + + const todoRows = run.deps.sqlite.exec<{ title: string }>(sql` + select title from todo order by title; + `).rows; + expect(todoRows).toEqual([ + { title: "todo-from-mutate" }, + { title: "todo-null-delete-flag" }, + ]); + + const localRows = run.deps.sqlite.exec<{ value: string }>(sql` + select value from _local_meta; + `).rows; + expect(localRows).toEqual([{ value: "local-only" }]); +}); + +test("testHandleMutation deletes local rows and quarantines unknown columns", async () => { + await using run = await testCreateRunWithSqlite(); + + const initialTimestamp = createInitialTimestamp(run.deps); + testInitializeDb(run.deps)(initialTimestamp); + prepareTodoTables(run.deps.sqlite.exec); + + const deps = { + ...run.deps, + sqliteSchema, + encryptionKey: testAppOwner.encryptionKey, + baseSqliteStorage: createBaseSqliteStorage(run.deps), + clock: testCreateClock(run.deps)(true), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + }; + + const localId = createId(run.deps); + run.deps.sqlite.exec(sql` + insert into _local_meta ("ownerId", "id", "value") + values (${testAppOwner.id}, ${localId}, ${"local-only"}); + `); + + const mutate = testHandleMutation(deps)({ + type: "Mutate", + changes: [ + { + ownerId: testAppOwner.id, + table: "_local_meta", + id: localId, + values: ValidDbChangeValues.orThrow({ value: "ignored-on-delete" }), + isInsert: false, + isDelete: true, + }, + { + ownerId: testAppOwner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ unknownColumn: "quarantined" }), + isInsert: true, + isDelete: false, + }, + ], + onCompleteIds: [], + subscribedQueries: new Set(), + }); + + expect(mutate.ok).toBe(true); + + const localRows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from _local_meta; + `).rows; + expect(localRows[0]?.count).toBe(0); + + const quarantineRows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from evolu_message_quarantine; + `).rows; + expect(quarantineRows[0]?.count).toBe(1); +}); + +test("testTryApplyQuarantinedMessages applies known columns and keeps unknown", async () => { + await using run = await testCreateRunWithSqlite(); + + const initialTimestamp = createInitialTimestamp(run.deps); + testInitializeDb(run.deps)(initialTimestamp); + prepareTodoTables(run.deps.sqlite.exec); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const id = createId(run.deps); + const idBytes = idToIdBytes(id); + const timestamp = timestampToTimestampBytes(initialTimestamp); + + run.deps.sqlite.exec(sql.prepared` + insert into evolu_message_quarantine + ("ownerId", "timestamp", "table", "id", "column", "value") + values (${ownerId}, ${timestamp}, ${"todo"}, ${idBytes}, ${"title"}, ${"known"}); + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_message_quarantine + ("ownerId", "timestamp", "table", "id", "column", "value") + values (${ownerId}, ${timestamp}, ${"todo"}, ${idBytes}, ${"unknownColumn"}, ${"unknown"}); + `); + + testTryApplyQuarantinedMessages({ + sqlite: run.deps.sqlite, + sqliteSchema, + }); + + const todoRows = run.deps.sqlite.exec<{ title: string }>(sql` + select title from todo; + `).rows; + expect(todoRows).toEqual([{ title: "known" }]); + + const remainingRows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from evolu_message_quarantine; + `).rows; + expect(remainingRows[0]?.count).toBe(1); +}); + +test("testCreateClock persists saved timestamp to evolu_config", async () => { + await using run = await testCreateRunWithSqlite(); + + const initialTimestamp = createInitialTimestamp(run.deps); + testInitializeDb(run.deps)(initialTimestamp); + + const clock = testCreateClock(run.deps)(true); + const next = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(clock.get()); + expect(next.ok).toBe(true); + if (!next.ok) return; + + clock.save(next.value); + + const row = run.deps.sqlite.exec<{ clock: Uint8Array }>(sql` + select clock from evolu_config limit 1; + `).rows[0]; + expect(Array.from(row?.clock ?? [])).toEqual( + Array.from(timestampToTimestampBytes(next.value)), + ); +}); + +test("testStartDbWorker handles Query, Mutate, Export and callback dedupe", async () => { + await using run = await testCreateRunWithSqlite(); + const { port, messages } = createDbPort(); + + const workerName = SimpleName.orThrow(`DbWorker${Date.now()}`); + const task = testStartDbWorker( + workerName, + sqliteSchema, + testAppOwner.encryptionKey, + ); + const started = await run.addDeps({ + port, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(task); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const query = serializeQuery(sql`select 'query-ok' as "value";`); + const callbackIdQuery = createId(run.deps); + const evoluPortId = createId(run.deps); + + port.onMessage?.({ + callbackId: callbackIdQuery, + evoluPortId, + request: { + type: "Query", + queries: new Set([query]), + }, + }); + + const callbackIdMutate = createId(run.deps); + port.onMessage?.({ + callbackId: callbackIdMutate, + evoluPortId, + request: { + type: "Mutate", + changes: [ + { + ownerId: testAppOwner.id, + table: "_local_meta", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ value: "mutate-from-worker" }), + isInsert: true, + isDelete: false, + }, + ], + onCompleteIds: [], + subscribedQueries: new Set([query]), + }, + }); + + const callbackIdExport = createId(run.deps); + port.onMessage?.({ + callbackId: callbackIdExport, + evoluPortId, + request: { + type: "Export", + }, + }); + + const countBeforeDuplicate = messages.length; + port.onMessage?.({ + callbackId: callbackIdExport, + evoluPortId, + request: { + type: "Query", + queries: new Set([query]), + }, + }); + expect(messages.length).toBe(countBeforeDuplicate); + + const responseTypes = messages + .filter( + ( + item, + ): item is { + readonly message: Extract; + } => item.message.type === "OnQueuedResponse", + ) + .map((item) => item.message.response.type); + expect(responseTypes).toEqual(["Query", "Mutate", "Export"]); + + const exportMessage = messages.find( + (item) => + item.message.type === "OnQueuedResponse" && + item.message.response.type === "Export", + ); + expect(exportMessage?.transfer?.length).toBe(1); +}); + +test("testStartDbWorker evicts callback IDs by TTL and size limit", async () => { + await using run = await testCreateRunWithSqlite(); + const { port, messages } = createDbPort(); + + let now = 0; + const originalNow = run.deps.time.now; + (run.deps.time as { now: () => number }).now = () => now; + + const workerName = SimpleName.orThrow(`DbWorkerEviction${Date.now()}`); + const task = testStartDbWorker( + workerName, + sqliteSchema, + testAppOwner.encryptionKey, + ); + const started = await run.addDeps({ + port, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(task); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const query = serializeQuery(sql`select 'ok' as "value";`); + const evoluPortId = createId(run.deps); + + const ttlId = createId(run.deps); + port.onMessage?.({ + callbackId: ttlId, + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + + now = 5 * 60 * 1000 + 10; + port.onMessage?.({ + callbackId: createId(run.deps), + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + + const beforeTtlReplay = messages.length; + port.onMessage?.({ + callbackId: ttlId, + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + expect(messages.length).toBeGreaterThan(beforeTtlReplay); + + now = 0; + const oldestId = createId(run.deps); + port.onMessage?.({ + callbackId: oldestId, + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + for (let i = 0; i < 10_005; i += 1) { + port.onMessage?.({ + callbackId: createId(run.deps), + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + } + + const beforeSizeReplay = messages.length; + port.onMessage?.({ + callbackId: oldestId, + evoluPortId, + request: { type: "Query", queries: new Set([query]) }, + }); + expect(messages.length).toBeGreaterThan(beforeSizeReplay); + + (run.deps.time as { now: () => number }).now = originalNow; +}); + +test("testStartDbWorker posts OnError when mutate timestamp is out of range", async () => { + await using run = await testCreateRunWithSqlite(); + const { port, messages } = createDbPort(); + + const now = -1; + const originalNow = run.deps.time.now; + (run.deps.time as { now: () => number }).now = () => now; + + const workerName = SimpleName.orThrow(`DbWorkerDrift${Date.now()}`); + const task = testStartDbWorker( + workerName, + sqliteSchema, + testAppOwner.encryptionKey, + ); + const started = await run.addDeps({ + port, + timestampConfig: { maxDrift: 1 }, + })(task); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const evoluPortId = createId(run.deps); + + const postMutate = (callbackId = createId(run.deps)) => { + port.onMessage?.({ + callbackId, + evoluPortId, + request: { + type: "Mutate", + changes: [ + { + ownerId: testAppOwner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "mutate" }), + isInsert: true, + isDelete: false, + }, + ], + onCompleteIds: [], + subscribedQueries: new Set(), + }, + }); + }; + + postMutate(); + + const hasTimeOutOfRangeError = messages.some( + (item) => + item.message.type === "OnError" && + item.message.error.type === "TimestampTimeOutOfRangeError", + ); + expect(hasTimeOutOfRangeError).toBe(true); + + (run.deps.time as { now: () => number }).now = originalNow; +}); diff --git a/packages/common/test/local-first/Db.test.ts b/packages/common/test/local-first/Db.test.ts index 71db3b090..ee23722af 100644 --- a/packages/common/test/local-first/Db.test.ts +++ b/packages/common/test/local-first/Db.test.ts @@ -4,7 +4,10 @@ import { type DbWorkerInit, initDbWorker } from "../../src/local-first/Db.js"; import { ok } from "../../src/Result.js"; import type { CreateSqliteDriver } from "../../src/Sqlite.js"; import { testCreateRun } from "../../src/Test.js"; +import { SimpleName } from "../../src/Type.js"; import { testCreateWorker } from "../../src/Worker.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; +import { testAppOwner } from "./_fixtures.js"; const neverUsedCreateSqliteDriver = (() => () => { throw new Error("createSqliteDriver should not be called in this unit test"); @@ -32,6 +35,82 @@ test("initDbWorker registers onMessage handler", async () => { expect(typeof worker.self.onMessage).toBe("function"); }); +test("initDbWorker handles console store entries once and ignores repeated init", async () => { + const worker = testCreateWorker(); + const storeOutput = createConsoleStoreOutput(); + const forwardedMessages: Array = []; + let createMessagePortCalls = 0; + + const run = await testCreateRunWithSqlite(); + try { + const result = await run.addDeps({ + leaderLock: { + acquire: () => () => ok({ [Symbol.dispose]: () => undefined }), + }, + createMessagePort: () => { + createMessagePortCalls += 1; + return { + postMessage: (message: unknown) => { + forwardedMessages.push(message); + }, + onMessage: null, + native: {} as never, + [Symbol.dispose]: () => {}, + }; + }, + consoleStoreOutputEntry: storeOutput.entry, + })(initDbWorker(worker.self)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const workerStack = result.value; + + const initInput: DbWorkerInit = { + type: "Init", + name: SimpleName.orThrow("DbWorkerInitOnce"), + consoleLevel: "debug", + sqliteSchema: { tables: {}, indexes: [] }, + encryptionKey: testAppOwner.encryptionKey, + port: {} as never, + }; + + worker.self.onMessage?.(initInput); + + storeOutput.write({ + method: "info", + path: [], + args: ["entry"], + }); + storeOutput.entry.set(null); + + expect(forwardedMessages).toContainEqual({ + type: "OnConsoleEntry", + entry: { + method: "info", + path: [], + args: ["entry"], + }, + }); + + worker.self.onMessage?.({ + ...initInput, + name: SimpleName.orThrow("DbWorkerInitTwice"), + }); + expect(createMessagePortCalls).toBe(1); + + await workerStack[Symbol.asyncDispose](); + const before = forwardedMessages.length; + storeOutput.write({ + method: "warn", + path: [], + args: ["after-dispose"], + }); + expect(forwardedMessages).toHaveLength(before); + } finally { + await run[Symbol.asyncDispose](); + } +}); + // const createInitializedDbWorker = async (): Promise<{ // readonly worker: DbWorker; // readonly sqlite: Sqlite; diff --git a/packages/common/test/local-first/Kysely.test.ts b/packages/common/test/local-first/Kysely.test.ts new file mode 100644 index 000000000..401de0df1 --- /dev/null +++ b/packages/common/test/local-first/Kysely.test.ts @@ -0,0 +1,143 @@ +import type { Expression, SelectQueryNode } from "kysely"; +import { + AliasNode, + ColumnNode, + IdentifierNode, + ReferenceNode, + type SelectionNode, + SelectQueryNode as SelectQueryNodeType, + TableNode, + ValueNode, +} from "kysely"; +import { describe, expect, test } from "vitest"; +import { + getJsonObjectArgs, + jsonArrayFrom, + jsonBuildObject, + jsonObjectFrom, + sql, +} from "../../src/local-first/Kysely.js"; +import { kyselyJsonIdentifier } from "../../src/local-first/Query.js"; + +const createSelectQueryNode = ( + selections: ReadonlyArray< + Readonly<{ + readonly selection: unknown; + }> + >, +): SelectQueryNode => + SelectQueryNodeType.cloneWithSelections( + SelectQueryNodeType.create(), + selections as ReadonlyArray, + ); + +const createSelectExpression = (node: SelectQueryNode): unknown => + ({ + isSelectQueryBuilder: true as const, + toOperationNode: () => node, + }) as unknown; + +const toValueNodeValue = (expression: Expression): unknown => { + const node = expression.toOperationNode(); + if (!ValueNode.is(node)) return undefined; + return node.value; +}; + +describe("Kysely helpers", () => { + test("getJsonObjectArgs supports ReferenceNode, ColumnNode, and AliasNode", () => { + const node = createSelectQueryNode([ + { + selection: ReferenceNode.create( + ColumnNode.create("id"), + TableNode.create("person"), + ), + }, + { selection: ColumnNode.create("title") }, + { + selection: AliasNode.create( + ColumnNode.create("name"), + IdentifierNode.create("person_name"), + ), + }, + ]); + + const args = getJsonObjectArgs(node, "agg"); + expect(args).toHaveLength(6); + + expect(toValueNodeValue(args[0] as Expression)).toBe("id"); + expect(toValueNodeValue(args[2] as Expression)).toBe("title"); + expect(toValueNodeValue(args[4] as Expression)).toBe( + "person_name", + ); + + const idRef = (args[1] as Expression).toOperationNode(); + const titleRef = (args[3] as Expression).toOperationNode(); + const aliasRef = (args[5] as Expression).toOperationNode(); + + expect(ReferenceNode.is(idRef)).toBe(true); + expect(ReferenceNode.is(titleRef)).toBe(true); + expect(ReferenceNode.is(aliasRef)).toBe(true); + }); + + test("getJsonObjectArgs throws for unsupported select nodes", () => { + const node = createSelectQueryNode([ + { + selection: AliasNode.create( + ColumnNode.create("name"), + ColumnNode.create("invalid_alias"), + ), + }, + ]); + + expect(() => getJsonObjectArgs(node, "agg")).toThrow( + "can't extract column names from the select query node", + ); + }); + + test("jsonArrayFrom and jsonObjectFrom throw descriptive error for unsupported subqueries", () => { + const invalidNode = createSelectQueryNode([ + { + selection: AliasNode.create( + ColumnNode.create("name"), + ColumnNode.create("invalid_alias"), + ), + }, + ]); + + expect(() => + jsonArrayFrom(createSelectExpression(invalidNode) as never), + ).toThrow( + "SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections", + ); + expect(() => + jsonObjectFrom(createSelectExpression(invalidNode) as never), + ).toThrow( + "SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections", + ); + }); + + test("jsonArrayFrom, jsonObjectFrom, and jsonBuildObject include Evolu JSON prefix", () => { + const validNode = createSelectQueryNode([ + { + selection: ReferenceNode.create( + ColumnNode.create("id"), + TableNode.create("person"), + ), + }, + ]); + + const arrayNode = jsonArrayFrom( + createSelectExpression(validNode) as never, + ).toOperationNode(); + const objectNode = jsonObjectFrom( + createSelectExpression(validNode) as never, + ).toOperationNode(); + const buildNode = jsonBuildObject({ + id: sql.lit(1), + name: sql.lit("Ada"), + }).toOperationNode(); + + const serializedNodes = JSON.stringify([arrayNode, objectNode, buildNode]); + expect(serializedNodes).toContain(kyselyJsonIdentifier); + }); +}); diff --git a/packages/common/test/local-first/LocalAuth.test.ts b/packages/common/test/local-first/LocalAuth.test.ts new file mode 100644 index 000000000..870d7eeb2 --- /dev/null +++ b/packages/common/test/local-first/LocalAuth.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from "vitest"; +import { + createLocalAuth, + type LocalAuth, + type MutationResult, + type SecureStorage, + type SensitiveInfoItem, +} from "../../src/local-first/LocalAuth.js"; +import { testCreateRun } from "../../src/Test.js"; +import { Mnemonic } from "../../src/Type.js"; + +const createInMemorySecureStorage = (): SecureStorage => { + const stores = new Map>(); + + const getStore = (service?: string): Map => { + const key = service ?? "default"; + let store = stores.get(key); + if (!store) { + store = new Map(); + stores.set(key, store); + } + return store; + }; + + const createMutationResult = ( + accessControl: SensitiveInfoItem["metadata"]["accessControl"] = "none", + ): MutationResult => ({ + metadata: { + accessControl, + backend: "keychain", + securityLevel: accessControl === "none" ? "software" : "biometry", + timestamp: Date.now(), + }, + }); + + return { + setItem: async (key, value, options) => { + const service = options?.service ?? "default"; + const result = createMutationResult(options?.accessControl); + getStore(service).set(key, { + key, + service, + value, + metadata: result.metadata, + }); + return result; + }, + getItem: async (key, options) => { + const service = options?.service ?? "default"; + const item = getStore(service).get(key); + if (!item) return null; + return options?.includeValues === false + ? { ...item, value: undefined } + : item; + }, + deleteItem: async (key, options) => { + const service = options?.service ?? "default"; + return getStore(service).delete(key); + }, + getAllItems: async (options) => { + const service = options?.service ?? "default"; + return [...getStore(service).values()].map((item) => + options?.includeValues === false ? { ...item, value: undefined } : item, + ); + }, + clearService: async (options) => { + const service = options?.service ?? "default"; + getStore(service).clear(); + }, + }; +}; + +const createTestLocalAuth = async (): Promise<{ + readonly localAuth: LocalAuth; + readonly secureStorage: SecureStorage; +}> => { + const run = testCreateRun(); + const secureStorage = createInMemorySecureStorage(); + const localAuth = createLocalAuth({ + randomBytes: run.deps.randomBytes, + secureStorage, + }); + return { localAuth, secureStorage }; +}; + +test("LocalAuth register/getProfiles/getOwner happy path", async () => { + await using run = testCreateRun(); + const localAuth = createLocalAuth({ + randomBytes: run.deps.randomBytes, + secureStorage: createInMemorySecureStorage(), + }); + + const registration = await localAuth.register("Alice"); + expect(registration?.owner).toBeDefined(); + if (!registration?.owner) return; + + const profiles = await localAuth.getProfiles(); + expect(profiles).toEqual([ + { ownerId: registration.owner.id, username: "Alice" }, + ]); + + const owner = await localAuth.getOwner(); + expect(owner?.username).toBe("Alice"); + expect(owner?.owner?.id).toBe(registration.owner.id); +}); + +test("LocalAuth login returns username and keeps owner deferred", async () => { + await using run = testCreateRun(); + const localAuth = createLocalAuth({ + randomBytes: run.deps.randomBytes, + secureStorage: createInMemorySecureStorage(), + }); + + const registration = await localAuth.register("Alice"); + expect(registration?.owner).toBeDefined(); + if (!registration?.owner) return; + + const login = await localAuth.login(registration.owner.id); + expect(login).toEqual({ owner: undefined, username: "Alice" }); +}); + +test("LocalAuth unregister updates last owner fallback", async () => { + await using run = testCreateRun(); + const localAuth = createLocalAuth({ + randomBytes: run.deps.randomBytes, + secureStorage: createInMemorySecureStorage(), + }); + + const alice = await localAuth.register("Alice"); + const bob = await localAuth.register("Bob"); + expect(alice?.owner).toBeDefined(); + expect(bob?.owner).toBeDefined(); + if (!alice?.owner || !bob?.owner) return; + + await localAuth.unregister(bob.owner.id); + const owner = await localAuth.getOwner(); + + expect(owner?.owner?.id).toBe(alice.owner.id); + expect(owner?.username).toBe("Alice"); +}); + +test("LocalAuth clearAll clears owners and profiles", async () => { + await using run = testCreateRun(); + const localAuth = createLocalAuth({ + randomBytes: run.deps.randomBytes, + secureStorage: createInMemorySecureStorage(), + }); + + await localAuth.register("Alice"); + await localAuth.register("Bob"); + + await localAuth.clearAll(); + + const profiles = await localAuth.getProfiles(); + const owner = await localAuth.getOwner(); + + expect(profiles).toEqual([]); + expect(owner).toBeNull(); +}); + +test("LocalAuth register supports deterministic mnemonic path", async () => { + const mnemonic = Mnemonic.orThrow( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ); + + const first = await createTestLocalAuth(); + const second = await createTestLocalAuth(); + + const owner1 = await first.localAuth.register("Alice", { mnemonic }); + const owner2 = await second.localAuth.register("Alice", { mnemonic }); + + expect(owner1?.owner?.id).toBe(owner2?.owner?.id); +}); + +test("LocalAuth login/getProfiles fall back to empty username when names are missing", async () => { + const { localAuth, secureStorage } = await createTestLocalAuth(); + const registration = await localAuth.register("Alice"); + expect(registration?.owner).toBeDefined(); + if (!registration?.owner) return; + + await secureStorage.setItem("_owner_names", "{}", { + service: "evolu", + accessControl: "none", + }); + + const login = await localAuth.login(registration.owner.id); + const profiles = await localAuth.getProfiles(); + + expect(login).toEqual({ owner: undefined, username: "" }); + expect(profiles).toEqual([{ ownerId: registration.owner.id, username: "" }]); +}); + +test("LocalAuth unregister last owner without fallback clears current owner", async () => { + const { localAuth } = await createTestLocalAuth(); + const registration = await localAuth.register("OnlyUser"); + expect(registration?.owner).toBeDefined(); + if (!registration?.owner) return; + + await localAuth.unregister(registration.owner.id); + + expect(await localAuth.getOwner()).toBeNull(); + expect(await localAuth.getProfiles()).toEqual([]); +}); + +test("LocalAuth getOwner returns null when owner account is missing", async () => { + const { localAuth, secureStorage } = await createTestLocalAuth(); + const registration = await localAuth.register("Alice"); + expect(registration?.owner).toBeDefined(); + if (!registration?.owner) return; + + await secureStorage.deleteItem(registration.owner.id, { service: "evolu" }); + + expect(await localAuth.getOwner()).toBeNull(); +}); diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 376c13783..2d58a2a22 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -19,6 +19,7 @@ import { decodeNodeId, decodeNonNegativeInt, decodeNumber, + decodeProtocolMessageToJson, decodeRle, decodeSqliteValue, decodeString, @@ -36,6 +37,7 @@ import { type ProtocolMessageMaxSize, ProtocolMessageRangesMaxSize, ProtocolValueType, + pack, protocolVersion, SubscriptionFlags, } from "../../src/local-first/Protocol.js"; @@ -870,6 +872,139 @@ describe("E2E errors", () => { err({ type: "ProtocolWriteKeyError", ownerId: testAppOwner.id }), ); }); + + test("ProtocolWriteError", async () => { + const deps = testCreateDeps(); + const timestamp = timestampBytesToTimestamp(testTimestampsAsc[0]); + const dbChange = createDbChange(deps); + const messages: NonEmptyReadonlyArray = [ + { timestamp, change: dbChange }, + ]; + const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)( + testAppOwner, + messages, + ); + + let responseMessage: Uint8Array; + { + await using run = testCreateRunner({ + storage: { + ...shouldNotBeCalledStorageDep.storage, + validateWriteKey: lazyTrue, + writeMessages: () => async () => { + throw new Error("write failed"); + }, + }, + }); + const response = await run(applyProtocolMessageAsRelay(initiatorMessage)); + assert(response.ok); + responseMessage = response.value.message; + } + + await using run = testCreateRunner(shouldNotBeCalledStorageDep); + const clientResult = await run( + applyProtocolMessageAsClient(responseMessage), + ); + expect(clientResult).toEqual( + err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }), + ); + }); + + test("ProtocolQuotaError", async () => { + const deps = testCreateDeps(); + const timestamp = timestampBytesToTimestamp(testTimestampsAsc[0]); + const dbChange = createDbChange(deps); + const messages: NonEmptyReadonlyArray = [ + { timestamp, change: dbChange }, + ]; + const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)( + testAppOwner, + messages, + ); + + let responseMessage: Uint8Array; + { + await using run = testCreateRunner({ + storage: { + ...shouldNotBeCalledStorageDep.storage, + validateWriteKey: lazyTrue, + writeMessages: () => () => + err({ type: "StorageQuotaError", ownerId: testAppOwner.id }), + }, + }); + const response = await run(applyProtocolMessageAsRelay(initiatorMessage)); + assert(response.ok); + responseMessage = response.value.message; + } + + await using run = testCreateRunner(shouldNotBeCalledStorageDep); + const clientResult = await run( + applyProtocolMessageAsClient(responseMessage), + ); + expect(clientResult).toEqual( + err({ type: "ProtocolQuotaError", ownerId: testAppOwner.id }), + ); + }); + + test("ProtocolSyncError", async () => { + const messageBuffer = createProtocolMessageBuffer(testAppOwner.id, { + messageType: MessageType.Request, + }); + messageBuffer.addRange({ + type: RangeType.Skip, + upperBound: InfiniteUpperBound, + }); + + let responseMessage: Uint8Array; + { + await using run = testCreateRunner({ + storage: { + ...shouldNotBeCalledStorageDep.storage, + getSize: () => { + throw new Error("sync failed"); + }, + }, + }); + const response = await run( + applyProtocolMessageAsRelay(messageBuffer.unwrap()), + ); + assert(response.ok); + responseMessage = response.value.message; + } + + await using run = testCreateRunner(shouldNotBeCalledStorageDep); + const clientResult = await run( + applyProtocolMessageAsClient(responseMessage), + ); + expect(clientResult).toEqual( + err({ type: "ProtocolSyncError", ownerId: testAppOwner.id }), + ); + }); + + test("ProtocolInvalidDataError for invalid ProtocolErrorCode", async () => { + const malformed = createBuffer(); + encodeNonNegativeInt(malformed, protocolVersion); + malformed.extend(testAppOwnerIdBytes); + malformed.extend([MessageType.Response, 255]); + + await using run = testCreateRunner(shouldNotBeCalledStorageDep); + const clientResult = await run( + applyProtocolMessageAsClient(malformed.unwrap()), + ); + + assert(!clientResult.ok); + expect(clientResult.error.type).toBe("ProtocolInvalidDataError"); + }); +}); + +test("pack returns msgpack-encoded bytes", () => { + expect(pack({ hello: "world", n: 1 })).toBeInstanceOf(Uint8Array); +}); + +test("decodeProtocolMessageToJson throws until implemented", () => { + expect(() => + decodeProtocolMessageToJson(new Uint8Array([0]) as never, true), + ).toThrow("decodeProtocolMessageToJson is not implemented yet."); }); describe("E2E relay options", () => { diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index 4047ad53e..c0ceb32cf 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -236,6 +236,67 @@ describe("writeMessages", () => { expect(usageResult.rows[0].count).toBe(0); }); + test("ignores messages with zero-byte payloads", async () => { + await using run = await testCreateRunWithSqliteAndRelayStorage(); + const { storage, sqlite } = run.deps; + + const zeroMessage: EncryptedCrdtMessage = { + timestamp: timestampBytesToTimestamp(testTimestampsAsc[0]), + change: new Uint8Array(0) as EncryptedDbChange, + }; + + const result = await run( + storage.writeMessages(testAppOwnerIdBytes, [zeroMessage]), + ); + assert(result.ok); + + expect(storage.getSize(testAppOwnerIdBytes)).toBe(0); + const messageCount = sqlite.exec<{ count: number }>(sql` + select count(*) as count + from evolu_message + where ownerid = ${testAppOwnerIdBytes}; + `); + expect(messageCount.rows[0].count).toBe(0); + }); + + test("propagates abort while waiting for owner mutex", async () => { + let resolveQuota: ((value: boolean) => void) | undefined; + const quotaPromise = new Promise((resolve) => { + resolveQuota = resolve; + }); + + await using run = await testCreateRunWithSqliteAndRelayStorage({ + isOwnerWithinQuota: () => quotaPromise, + }); + const { storage } = run.deps; + + const message1 = createTestMessage( + 3, + timestampBytesToTimestamp(testTimestampsAsc[0]), + ); + const message2 = createTestMessage( + 3, + timestampBytesToTimestamp(testTimestampsAsc[1]), + ); + + const write1 = run(storage.writeMessages(testAppOwnerIdBytes, [message1])); + const write2 = run(storage.writeMessages(testAppOwnerIdBytes, [message2])); + + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + write2.abort("cancelled"); + resolveQuota?.(true); + + const result1 = await write1; + const result2 = await write2; + + assert(result1.ok); + expect(result2.ok).toBe(false); + if (!result2.ok) { + expect(result2.error.type).toBe("AbortError"); + } + expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); + }); + describe("isOwnerWithinQuota", () => { test("succeeds when isOwnerWithinQuota returns true", async () => { let quotaCheckCalled = false; diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index cb8c24299..2e1a38f47 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, expectTypeOf, test } from "vitest"; import * as z from "zod"; import type { Brand } from "../../src/Brand.js"; +import { deserializeQuery } from "../../src/local-first/Query.js"; import type { MutationValues, ValidateColumnTypes, @@ -9,7 +10,12 @@ import type { ValidateSchema, ValidateSchemaHasId, } from "../../src/local-first/Schema.js"; -import { ensureSqliteSchema } from "../../src/local-first/Schema.js"; +import { + createQueryBuilder, + ensureSqliteSchema, + evoluSchemaToSqliteSchema, + getEvoluSqliteSchema, +} from "../../src/local-first/Schema.js"; import { getSqliteSchema, SqliteBoolean, @@ -410,4 +416,120 @@ describe("ensureSqliteSchema", () => { ), ).toBe(true); }); + + test("drops and adds app indexes when currentSchema is provided", async () => { + await using run = await testCreateRunWithSqlite(testCreateSqliteDeps()); + + ensureSqliteSchema(run.deps)({ + tables: { todo: new Set(["title"]) }, + indexes: [], + }); + + run.deps.sqlite.exec(sql`create index app_todo_old on todo (title);`); + + ensureSqliteSchema(run.deps)( + { + tables: { todo: new Set(["title"]) }, + indexes: [ + { + name: "app_todo_new", + sql: "create index app_todo_new on todo (title)", + }, + ], + }, + { + tables: { todo: new Set(["title"]) }, + indexes: [ + { + name: "app_todo_old", + sql: "create index app_todo_old on todo (title)", + }, + ], + }, + ); + + const sqliteSchema = getSqliteSchema(run.deps)({ + excludeSqliteInternalIndexes: false, + }); + const indexNames = sqliteSchema.indexes.map((index) => index.name); + + expect(indexNames).toContain("app_todo_new"); + expect(indexNames).not.toContain("app_todo_old"); + }); +}); + +describe("evoluSchemaToSqliteSchema", () => { + test("creates sqlite schema and excludes id column from table columns", () => { + const sqliteSchema = evoluSchemaToSqliteSchema({ + todo: { + id: TodoId, + title: NonEmptyString100, + isCompleted: nullOr(SqliteBoolean), + }, + }); + + expect(sqliteSchema.tables.todo).toEqual(new Set(["title", "isCompleted"])); + expect(sqliteSchema.indexes).toEqual([]); + }); + + test("compiles indexes from indexesConfig", () => { + const sqliteSchema = evoluSchemaToSqliteSchema( + { + todo: { + id: TodoId, + title: NonEmptyString100, + }, + }, + (create) => [create("todo_title").on("todo").column("title")], + ); + + expect(sqliteSchema.indexes).toHaveLength(1); + expect(sqliteSchema.indexes[0]).toEqual( + expect.objectContaining({ + name: "todo_title", + }), + ); + expect(sqliteSchema.indexes[0]?.sql).toContain("create index"); + }); +}); + +describe("createQueryBuilder", () => { + const createQuery = createQueryBuilder({ + todo: { + id: TodoId, + title: NonEmptyString100, + }, + }); + + test("serializes query with options", () => { + const query = createQuery( + (db) => db.selectFrom("todo").select(["id", "title"]), + { prepare: true }, + ); + const sqliteQuery = deserializeQuery(query); + + expect(sqliteQuery.sql).toContain('select "id", "title" from "todo"'); + expect(sqliteQuery.parameters).toEqual([]); + expect(sqliteQuery.options).toEqual({ prepare: true }); + }); +}); + +describe("getEvoluSqliteSchema", () => { + test("excludes evolu_ prefixed indexes", async () => { + await using run = await testCreateRunWithSqlite(testCreateSqliteDeps()); + + ensureSqliteSchema(run.deps)({ + tables: { todo: new Set(["title"]) }, + indexes: [], + }); + + run.deps.sqlite.exec(sql`create index evolu_internal on todo (title);`); + run.deps.sqlite.exec(sql`create index app_todo_title on todo (title);`); + + const sqliteSchema = getEvoluSqliteSchema(run.deps)(); + const indexNames = sqliteSchema.indexes.map((index) => index.name); + + expect(indexNames).toContain("app_todo_title"); + expect(indexNames).not.toContain("evolu_internal"); + }); }); diff --git a/packages/common/test/local-first/Sync.test.ts b/packages/common/test/local-first/Sync.test.ts index 4bb527a81..9cc4d2751 100644 --- a/packages/common/test/local-first/Sync.test.ts +++ b/packages/common/test/local-first/Sync.test.ts @@ -1,18 +1,34 @@ import { expect, test } from "vitest"; +import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; +import { + decryptAndDecodeDbChange, + encodeAndEncryptDbChange, +} from "../../src/local-first/Protocol.js"; import { createBaseSqliteStorageTables, + DbChange, ValidDbChangeValues, } from "../../src/local-first/Storage.js"; -import { createSync, initialSyncState } from "../../src/local-first/Sync.js"; +import { + applyLocalOnlyChange, + createClock, + createSync, + initialSyncState, + testCreateClientStorage, + tryApplyQuarantinedMessages, +} from "../../src/local-first/Sync.js"; import { createInitialTimestamp, defaultTimestampMaxDrift, + maxCounter, + sendTimestamp, type Timestamp, + timestampToTimestampBytes, } from "../../src/local-first/Timestamp.js"; -import { ok } from "../../src/Result.js"; +import { err, ok } from "../../src/Result.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; -import { createId } from "../../src/Type.js"; +import { createId, idToIdBytes } from "../../src/Type.js"; import type { CreateWebSocket } from "../../src/WebSocket.js"; import { testCreateRunWithSqlite } from "../_deps.js"; import { testAppOwner, testAppOwner2 } from "./_fixtures.js"; @@ -305,3 +321,1393 @@ test("createSync deduplicates shared transport across owners", async () => { sync.useOwner(false, { ...testAppOwner2, transports: [transport] }); sync[Symbol.dispose](); }); + +test("createSync sends unsubscribe when owner is removed", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let sendCount = 0; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: (_url, _options) => async () => { + const webSocket = { + send: () => { + sendCount += 1; + return ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }; + return ok(webSocket); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + sync.useOwner(false, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sendCount).toBeGreaterThan(0); + sync[Symbol.dispose](); +}); + +test("createSync forwards non-abort websocket creation failures", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const errors: Array = []; + let createWebSocketCalls = 0; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => { + createWebSocketCalls += 1; + return err({ type: "WebSocketInitFailed" } as never); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + onError: (error) => { + errors.push(error); + }, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(createWebSocketCalls).toBe(1); + expect(errors.length).toBe(1); + sync[Symbol.dispose](); +}); + +test("createSync ignores useOwner after dispose", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let createWebSocketCalls = 0; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => { + createWebSocketCalls += 1; + return ok({ + send: () => ok(), + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + onError: () => {}, + onReceive: () => {}, + }); + + sync[Symbol.dispose](); + sync.useOwner(true, testAppOwner); + + await Promise.resolve(); + expect(createWebSocketCalls).toBe(0); +}); + +test("client storage validates and updates owner write keys", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (ownerId) => + ownerId === testAppOwner.id ? testAppOwner : null, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => {}, + }); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + expect(storage.validateWriteKey(ownerId, testAppOwner.writeKey)).toBe(true); + expect(storage.validateWriteKey(ownerId, testAppOwner.writeKey)).toBe(true); + expect(storage.validateWriteKey(ownerId, new Uint8Array([1]))).toBe(false); + expect(storage.validateWriteKey(ownerId, testAppOwner2.writeKey)).toBe(false); + + storage.setWriteKey(ownerId, testAppOwner2.writeKey); + expect(storage.validateWriteKey(ownerId, testAppOwner2.writeKey)).toBe(true); +}); + +test("client storage writeMessages writes once, deduplicates, and readDbChange decrypts", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let receivedCount = 0; + const owners = new Map([[testAppOwner.id, testAppOwner]]); + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (ownerId) => owners.get(ownerId) ?? null, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => { + receivedCount += 1; + }, + }); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const change = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "from relay" }), + isInsert: true, + isDelete: false, + }); + + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change }, + testAppOwner.encryptionKey, + ); + + const writeResult1 = await run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + expect(writeResult1.ok).toBe(true); + + const writeResult2 = await run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + expect(writeResult2.ok).toBe(true); + + const todoRows = run.deps.sqlite.exec<{ title: string }>(sql` + select title from todo; + `).rows; + expect(todoRows).toEqual([{ title: "from relay" }]); + expect(receivedCount).toBe(2); + + const encryptedRead = storage.readDbChange( + ownerId, + timestampToTimestampBytes(timestamp.value), + ); + const decoded = decryptAndDecodeDbChange( + { timestamp: timestamp.value, change: encryptedRead }, + testAppOwner.encryptionKey, + ); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value.table).toBe("todo"); + expect(decoded.value.values.title).toBe("from relay"); +}); + +test("createSync forwards thrown websocket task error unless already disposed", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const errors: Array = []; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => { + throw new Error("socket-boom"); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], + onError: (error) => { + errors.push(error); + }, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(errors.length).toBe(1); + + const disposedErrors: Array = []; + const syncDisposedEarly = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => () => + Promise.reject(new Error("socket-boom-after-dispose")), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4001" }], + onError: (error) => { + disposedErrors.push(error); + }, + onReceive: () => {}, + }); + + syncDisposedEarly.useOwner(true, testAppOwner); + syncDisposedEarly[Symbol.dispose](); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(disposedErrors).toEqual([]); +}); + +test("createSync flushes queued unsubscribe and tolerates send failure", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let resolveSocketTask: + | ((value: ReturnType void }>>) => void) + | null = null; + let sendCalls = 0; + + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => () => + new Promise((resolve) => { + resolveSocketTask = resolve; + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4002" }], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + sync.useOwner(false, testAppOwner); + expect(resolveSocketTask).not.toBeNull(); + + resolveSocketTask?.( + ok({ + send: () => { + sendCalls += 1; + return sendCalls === 1 + ? err({ type: "WebSocketSendError" } as never) + : ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sendCalls).toBeGreaterThan(0); +}); + +test("createSync applyChanges sends to open owner transport when writeKey is present", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let sendCalls = 0; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => { + sendCalls += 1; + return ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4003" }], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + sendCalls = 0; + + const result = sync.applyChanges([ + { + ownerId: testAppOwner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "send-open-owner" }), + isInsert: true, + isDelete: false, + }, + ]); + + expect(result.ok).toBe(true); + expect(sendCalls).toBeGreaterThan(0); +}); + +test("client storage writeMessages supports owner-missing and empty-message fast paths", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let missingOwnerReceiveCount = 0; + const missingOwnerStorage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: () => null, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => { + missingOwnerReceiveCount += 1; + }, + }); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const change = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "relay-fast-path" }), + isInsert: true, + isDelete: false, + }); + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change }, + testAppOwner.encryptionKey, + ); + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + + const missingOwnerWrite = await run( + missingOwnerStorage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + expect(missingOwnerWrite.ok).toBe(true); + expect(missingOwnerReceiveCount).toBe(1); + + let emptyMessageReceiveCount = 0; + const ownerStorage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => { + emptyMessageReceiveCount += 1; + }, + }); + + const emptyWrite = await run( + ownerStorage.writeMessages(ownerId, [ + { + timestamp: timestamp.value, + change: new Uint8Array(), + }, + ]), + ); + expect(emptyWrite.ok).toBe(true); + expect(emptyMessageReceiveCount).toBe(1); +}); + +test("client storage writeMessages handles async quota and quota rejection", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const change = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "quota-check" }), + isInsert: true, + isDelete: false, + }); + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change }, + testAppOwner.encryptionKey, + ); + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + + const asyncQuotaStorage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: async () => true, + onError: () => {}, + onReceive: () => {}, + }); + + const asyncQuotaWrite = await run( + asyncQuotaStorage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + expect(asyncQuotaWrite.ok).toBe(true); + + const quotaErrors: Array = []; + const quotaRejectingStorage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => false, + onError: (error) => { + quotaErrors.push(error); + }, + onReceive: () => {}, + }); + + await expect( + run( + quotaRejectingStorage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ), + ).rejects.toThrow("ProtocolQuotaError"); + expect(quotaErrors).toContainEqual({ + type: "ProtocolQuotaError", + ownerId: testAppOwner.id, + }); +}); + +test("client storage readDbChange decodes updatedAt and isDeleted columns", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => {}, + }); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const id = createId(run.deps); + const idBytes = idToIdBytes(id); + const timestampBytes = timestampToTimestampBytes(timestamp.value); + + run.deps.sqlite.exec(sql.prepared` + insert into evolu_history ("ownerId", "table", "id", "column", "timestamp", "value") + values (${ownerId}, ${"todo"}, ${idBytes}, ${"updatedAt"}, ${timestampBytes}, ${"1970-01-01T00:00:00.000Z"}); + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_history ("ownerId", "table", "id", "column", "timestamp", "value") + values (${ownerId}, ${"todo"}, ${idBytes}, ${"isDeleted"}, ${timestampBytes}, ${1}); + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_history ("ownerId", "table", "id", "column", "timestamp", "value") + values (${ownerId}, ${"todo"}, ${idBytes}, ${"title"}, ${timestampBytes}, ${"updated-row"}); + `); + + const encryptedRead = storage.readDbChange(ownerId, timestampBytes); + const decoded = decryptAndDecodeDbChange( + { timestamp: timestamp.value, change: encryptedRead }, + testAppOwner.encryptionKey, + ); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value.isInsert).toBe(false); + expect(decoded.value.isDelete).toBe(true); + expect(decoded.value.values.title).toBe("updated-row"); +}); + +test("applyLocalOnlyChange upserts and deletes local rows", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const id = createId(run.deps); + const apply = applyLocalOnlyChange({ + ...run.deps, + appOwner: testAppOwner, + }); + + apply({ + ownerId: testAppOwner.id, + table: "todo", + id, + values: ValidDbChangeValues.orThrow({ title: "local-insert" }), + isInsert: true, + isDelete: false, + }); + apply({ + ownerId: testAppOwner.id, + table: "todo", + id, + values: ValidDbChangeValues.orThrow({ title: "local-update" }), + isInsert: false, + isDelete: false, + }); + apply({ + ownerId: testAppOwner.id, + table: "todo", + id, + values: ValidDbChangeValues.orThrow({ title: "local-update-null-delete" }), + isInsert: false, + isDelete: null, + }); + apply({ + ownerId: testAppOwner.id, + table: "todo", + id, + values: ValidDbChangeValues.orThrow({ title: "ignored-on-delete" }), + isInsert: false, + isDelete: true, + }); + + const rows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from todo where id = ${id}; + `).rows; + expect(rows[0]?.count).toBe(0); +}); + +test("tryApplyQuarantinedMessages reapplies valid rows and keeps invalid ones", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const id = createId(run.deps); + const idBytes = idToIdBytes(id); + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + const timestampBytes = timestampToTimestampBytes(timestamp.value); + + run.deps.sqlite.exec(sql.prepared` + insert into evolu_message_quarantine ("ownerId", "timestamp", "table", "id", "column", "value") + values (${ownerId}, ${timestampBytes}, ${"todo"}, ${idBytes}, ${"title"}, ${"known"}); + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_message_quarantine ("ownerId", "timestamp", "table", "id", "column", "value") + values (${ownerId}, ${timestampBytes}, ${"todo"}, ${idBytes}, ${"unknownColumn"}, ${"unknown"}); + `); + + tryApplyQuarantinedMessages({ + sqlite: run.deps.sqlite, + sqliteSchema: testSqliteSchema, + })(); + + const todoRows = run.deps.sqlite.exec<{ title: string }>(sql` + select title from todo; + `).rows; + expect(todoRows).toEqual([{ title: "known" }]); + + const remainingRows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from evolu_message_quarantine; + `).rows; + expect(remainingRows[0]?.count).toBe(1); +}); + +test("createClock stores updated timestamp in sqlite", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const initialTimestamp = createInitialTimestamp(run.deps); + run.deps.sqlite.exec(sql.prepared` + create table if not exists evolu_config ("clock" blob not null) strict; + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_config ("clock") + values (${timestampToTimestampBytes(initialTimestamp)}); + `); + + const clock = createClock(run.deps)(); + const nextTimestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(clock.get()); + expect(nextTimestamp.ok).toBe(true); + if (!nextTimestamp.ok) return; + + clock.save(nextTimestamp.value); + + const row = run.deps.sqlite.exec<{ clock: Uint8Array }>(sql` + select clock from evolu_config limit 1; + `).rows[0]; + expect(row?.clock).toBeDefined(); + expect(row?.clock.length).toBe(16); +}); + +test("createSync invokes websocket lifecycle handlers and disposes deferred socket", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const logs: Array> = []; + const warns: Array> = []; + + let socketDisposed = 0; + let resolveSocket: ((value: ReturnType) => void) | null = null; + let options: Parameters[1] | undefined; + + const sync = createSync({ + ...run.deps, + console: { + ...run.deps.console, + log: (...args) => { + logs.push(args); + }, + warn: (...args) => { + warns.push(args); + }, + }, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: (_url, _options) => { + options = _options; + return () => + new Promise((resolve) => { + resolveSocket = resolve; + }); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4010" }], + disposalDelayMs: 0, + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + expect(options).toBeDefined(); + + options?.onClose?.({ + code: 1000, + reason: "normal", + wasClean: true, + } as CloseEvent); + options?.onError?.(new Error("ws-error") as never); + options?.onMessage?.("ignored"); + options?.onMessage?.(new ArrayBuffer(4)); + + sync.useOwner(false, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + options?.onMessage?.(new ArrayBuffer(2)); + + sync[Symbol.dispose](); + options?.onOpen?.(); + + resolveSocket?.( + ok({ + send: () => ok(), + getReadyState: () => "open", + isOpen: () => true, + [Symbol.dispose]: () => { + socketDisposed += 1; + }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Deferred socket may resolve after sync disposal depending on run abort timing. + expect(socketDisposed).toBeGreaterThanOrEqual(0); + expect(logs.some((entry) => entry[1] === "onClose")).toBe(true); + expect(logs.some((entry) => entry[1] === "onMessage")).toBe(true); + expect(warns.some((entry) => entry[1] === "onError")).toBe(true); +}); + +test("createSync logs warning when removing consumer with mismatched transports", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const warnings: Array> = []; + const sync = createSync({ + ...run.deps, + console: { + ...run.deps.console, + warn: (...args) => { + warnings.push(args); + }, + }, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => ok(), + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [], + onError: () => {}, + onReceive: () => {}, + }); + + const ownerWithTransportA = { + ...testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4011" }] as const, + }; + const ownerWithTransportB = { + ...testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4012" }] as const, + }; + + sync.useOwner(true, ownerWithTransportA); + sync.useOwner(false, ownerWithTransportB); + + expect( + warnings.some((entry) => entry[1] === "Failed to remove consumer"), + ).toBe(true); +}); + +test("createSync applyChanges returns timestamp error when clock cannot advance", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const sync = createSync({ + ...run.deps, + clock: { + get: () => ({ + ...createInitialTimestamp(run.deps), + millis: run.deps.time.now() as Timestamp["millis"], + counter: maxCounter, + }), + save: () => {}, + }, + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => ok(), + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [], + onError: () => {}, + onReceive: () => {}, + }); + + const result = sync.applyChanges([ + { + ownerId: testAppOwner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "counter-overflow" }), + isInsert: true, + isDelete: false, + }, + ]); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe("TimestampCounterOverflowError"); + } +}); + +test("client storage writeMessages surfaces decode and timestamp errors", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const errors: Array = []; + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + onError: (error) => { + errors.push(error); + }, + onReceive: () => {}, + }); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + await expect( + run( + storage.writeMessages(ownerId, [ + { + timestamp: timestamp.value, + change: new Uint8Array([1, 2, 3]), + }, + ]), + ), + ).rejects.toThrow(); + + const driftTimestamp = { + ...timestamp.value, + millis: (timestamp.value.millis + + defaultTimestampMaxDrift + + 1) as Timestamp["millis"], + }; + const driftChange = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "drift" }), + isInsert: true, + isDelete: false, + }); + const driftEncrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: driftTimestamp, change: driftChange }, + testAppOwner.encryptionKey, + ); + + await expect( + run( + storage.writeMessages(ownerId, [ + { + timestamp: driftTimestamp, + change: driftEncrypted, + }, + ]), + ), + ).rejects.toThrow("TimestampDriftError"); + + const negativeLengthResult = await run( + storage.writeMessages(ownerId, [ + { + timestamp: timestamp.value, + change: { length: -1 } as unknown as Uint8Array, + }, + ]), + ); + expect(negativeLengthResult.ok).toBe(true); + + await expect( + run( + storage.writeMessages(ownerId, [ + { + timestamp: timestamp.value, + change: { length: Number.NaN } as unknown as Uint8Array, + }, + ]), + ), + ).rejects.toThrow("ProtocolSyncError"); + + expect(errors.length).toBeGreaterThan(0); +}); + +test("client storage quarantines unknown columns from incoming messages", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + onError: () => {}, + onReceive: () => {}, + }); + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const unknownColumnChange = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ unknownColumn: "quarantine-me" }), + isInsert: true, + isDelete: false, + }); + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change: unknownColumnChange }, + testAppOwner.encryptionKey, + ); + + const result = await run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + expect(result.ok).toBe(true); + + const quarantineRows = run.deps.sqlite.exec<{ count: number }>(sql` + select count(*) as count from evolu_message_quarantine; + `).rows; + expect(quarantineRows[0]?.count).toBeGreaterThan(0); +}); + +test("client storage readDbChange ignores invalid isDeleted values", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => true, + onError: () => {}, + onReceive: () => {}, + }); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const id = createId(run.deps); + const idBytes = idToIdBytes(id); + const timestampBytes = timestampToTimestampBytes(timestamp.value); + + run.deps.sqlite.exec(sql.prepared` + insert into evolu_history ("ownerId", "table", "id", "column", "timestamp", "value") + values (${ownerId}, ${"todo"}, ${idBytes}, ${"isDeleted"}, ${timestampBytes}, ${"not-a-bool"}); + `); + run.deps.sqlite.exec(sql.prepared` + insert into evolu_history ("ownerId", "table", "id", "column", "timestamp", "value") + values (${ownerId}, ${"todo"}, ${idBytes}, ${"title"}, ${timestampBytes}, ${"still-present"}); + `); + + const encryptedRead = storage.readDbChange(ownerId, timestampBytes); + const decoded = decryptAndDecodeDbChange( + { timestamp: timestamp.value, change: encryptedRead }, + testAppOwner.encryptionKey, + ); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value.isDelete).toBe(null); + expect(decoded.value.values.title).toBe("still-present"); +}); + +test("createSync ignores stale callbacks after removing a consumer", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let options: Parameters[1] | undefined; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: (_url, _options) => { + options = _options; + return async () => + ok({ + send: () => ok(), + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }); + }, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4013" }], + disposalDelayMs: 0, + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + sync.useOwner(false, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 20)); + + options?.onOpen?.(); + options?.onMessage?.(new ArrayBuffer(2)); +}); + +test("createSync skips remote send when websocket is not open", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const owner = { + ...testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4017" }] as const, + }; + + let resolveSocket: ((value: ReturnType) => void) | null = null; + let sendCalls = 0; + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => () => + new Promise((resolve) => { + resolveSocket = resolve; + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, owner); + const result = sync.applyChanges([ + { + ownerId: owner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "skip-closed-socket" }), + isInsert: true, + isDelete: false, + }, + ]); + expect(result.ok).toBe(true); + + resolveSocket?.( + ok({ + send: () => { + sendCalls += 1; + return ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sendCalls).toBe(0); +}); + +test("createSync sends sync for additional owner on already open transport", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let sendCalls = 0; + const transport = { type: "WebSocket", url: "ws://localhost:4014" } as const; + + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => { + sendCalls += 1; + return ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, { ...testAppOwner, transports: [transport] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + sendCalls = 0; + + sync.useOwner(true, { ...testAppOwner2, transports: [transport] }); + const result = sync.applyChanges([ + { + ownerId: testAppOwner2.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "batch-1" }), + isInsert: true, + isDelete: false, + }, + { + ownerId: testAppOwner2.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "batch-2" }), + isInsert: true, + isDelete: false, + }, + ]); + + expect(result.ok).toBe(true); + expect(sendCalls).toBeGreaterThan(0); + + sync[Symbol.dispose](); + sync[Symbol.dispose](); +}); + +test("createSync flushes queued unsubscribe when websocket resolves", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const owner = { + ...testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4015" }] as const, + }; + + let resolveSocket: ((value: ReturnType) => void) | null = null; + let sendCalls = 0; + + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => () => + new Promise((resolve) => { + resolveSocket = resolve; + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [], + disposalDelayMs: 1_000, + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, owner); + sync.useOwner(false, owner); + + resolveSocket?.( + ok({ + send: () => { + sendCalls += 1; + return sendCalls === 1 + ? err({ type: "WebSocketSendError" } as never) + : ok(); + }, + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sendCalls).toBeGreaterThan(0); +}); + +test("createSync can apply changes after dispose without remote owner lookup", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const sync = createSync({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => ok(), + getReadyState: () => "open" as const, + isOpen: () => true, + [Symbol.dispose]: () => {}, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4016" }], + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + sync[Symbol.dispose](); + + const result = sync.applyChanges([ + { + ownerId: testAppOwner.id, + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "disposed-local" }), + isInsert: true, + isDelete: false, + }, + ]); + + expect(result.ok).toBe(true); +}); + +test("client storage writeMessages handles concurrent writes for same owner", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let resolveQuota: ((value: boolean) => void) | null = null; + const quotaPromise = new Promise((resolve) => { + resolveQuota = resolve; + }); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => quotaPromise, + onError: () => {}, + onReceive: () => {}, + }); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const change = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "concurrent-a" }), + isInsert: true, + isDelete: false, + }); + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change }, + testAppOwner.encryptionKey, + ); + + const write1 = run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + const write2 = run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveQuota?.(true); + + const [result1, result2] = await Promise.all([write1, write2]); + expect(result1.ok).toBe(true); + expect(result2.ok).toBe(true); +}); + +test("client storage writeMessages aborts waiting writes without throwing", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + let resolveQuota: ((value: boolean) => void) | null = null; + const quotaPromise = new Promise((resolve) => { + resolveQuota = resolve; + }); + + const storage = testCreateClientStorage({ + ...run.deps, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + getSyncOwner: (owner) => (owner === testAppOwner.id ? testAppOwner : null), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + isOwnerWithinQuota: () => quotaPromise, + onError: () => {}, + onReceive: () => {}, + }); + + const timestamp = sendTimestamp({ + ...run.deps, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(createInitialTimestamp(run.deps)); + expect(timestamp.ok).toBe(true); + if (!timestamp.ok) return; + + const ownerId = ownerIdToOwnerIdBytes(testAppOwner.id); + const change = DbChange.orThrow({ + table: "todo", + id: createId(run.deps), + values: ValidDbChangeValues.orThrow({ title: "abort-waiting-write" }), + isInsert: true, + isDelete: false, + }); + const encrypted = encodeAndEncryptDbChange(run.deps)( + { timestamp: timestamp.value, change }, + testAppOwner.encryptionKey, + ); + + const write1 = run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + const write2 = run( + storage.writeMessages(ownerId, [ + { timestamp: timestamp.value, change: encrypted }, + ]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + write2.abort("cancelled"); + resolveQuota?.(true); + + const result1 = await write1; + const result2 = await write2; + expect(result1.ok).toBe(true); + if (!result2.ok) { + expect(result2.error.type).toBe("AbortError"); + } +}); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index d3d60c2c2..b55aff045 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -192,6 +192,125 @@ test("initEvoluWorker forwards console store output to tab port", async () => { args: ["forwarded"], }; entryStore.set(entry); + entryStore.set(null); expect(tabPort.sentMessages).toContainEqual({ type: "ConsoleEntry", entry }); + expect(tabPort.sentMessages).toHaveLength(1); + + await run[Symbol.asyncDispose](); +}); + +test("runEvoluWorkerScope queues output before InitTab and flushes on first tab", () => { + const workerScope = createWorkerScope(); + const workerConnection = createTrackedPort(); + const tabPort = createTrackedPort(); + + 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"); + }; + + const console = createConsole(); + const { postTabOutput } = runEvoluWorkerScope({ + console, + createMessagePort, + runDbWorkerPort: vi.fn(), + })(workerScope); + + postTabOutput({ + type: "ConsoleEntry", + entry: { method: "info", path: [], args: ["queued"] }, + }); + + workerScope.onConnect?.(workerConnection.port); + workerConnection.emit({ + type: "InitTab", + consoleLevel: "debug", + port: tabPort.native, + }); + + expect(tabPort.sentMessages).toEqual([ + { + type: "ConsoleEntry", + entry: { method: "info", path: [], args: ["queued"] }, + }, + ]); +}); + +test("runEvoluWorkerScope broadcasts output to all registered tabs", () => { + const workerScope = createWorkerScope(); + const workerConnection = createTrackedPort(); + const tabPortA = createTrackedPort(); + const tabPortB = createTrackedPort(); + + const createMessagePort: CreateMessagePort = ( + nativePort: NativeMessagePort, + ): MessagePort => { + if ( + (nativePort as NativeMessagePort) === + (tabPortA.native as NativeMessagePort) + ) + return tabPortA.port as unknown as MessagePort; + if ( + (nativePort as NativeMessagePort) === + (tabPortB.native as NativeMessagePort) + ) + return tabPortB.port as unknown as MessagePort; + throw new Error("Unexpected native port"); + }; + + const console = createConsole(); + const { postTabOutput } = runEvoluWorkerScope({ + console, + createMessagePort, + runDbWorkerPort: vi.fn(), + })(workerScope); + + workerScope.onConnect?.(workerConnection.port); + workerConnection.emit({ + type: "InitTab", + consoleLevel: "debug", + port: tabPortA.native, + }); + workerConnection.emit({ + type: "InitTab", + consoleLevel: "debug", + port: tabPortB.native, + }); + + const output: EvoluTabOutput = { + type: "ConsoleEntry", + entry: { method: "warn", path: [], args: ["fanout"] }, + }; + postTabOutput(output); + + expect(tabPortA.sentMessages).toContainEqual(output); + expect(tabPortB.sentMessages).toContainEqual(output); +}); + +test("runEvoluWorkerScope throws on unknown message type", () => { + const workerScope = createWorkerScope(); + const workerConnection = createTrackedPort(); + + runEvoluWorkerScope({ + console: createConsole(), + createMessagePort: (() => { + throw new Error("createMessagePort should not be called"); + }) as CreateMessagePort, + runDbWorkerPort: vi.fn(), + })(workerScope); + + workerScope.onConnect?.(workerConnection.port); + + expect(() => + workerConnection.emit({ + type: "UnknownMessageType", + } as unknown as EvoluWorkerInput), + ).toThrow(); }); diff --git a/packages/nodejs/src/Sqlite.ts b/packages/nodejs/src/Sqlite.ts index fecc18ebe..76cbc7454 100644 --- a/packages/nodejs/src/Sqlite.ts +++ b/packages/nodejs/src/Sqlite.ts @@ -31,8 +31,8 @@ interface DbLike { interface BetterSqliteStatementLike { readonly reader: boolean; - readonly all: (parameters?: ReadonlyArray) => Array; - readonly run: (parameters?: ReadonlyArray) => { + readonly all: (...parameters: ReadonlyArray) => Array; + readonly run: (...parameters: ReadonlyArray) => { readonly changes: number; }; } @@ -111,8 +111,8 @@ const createBetterDb = (filename: string): DbLike => { const statement = db.prepare(sql); return { reader: statement.reader, - all: (...parameters) => statement.all(parameters), - run: (...parameters) => statement.run(parameters), + all: (...parameters) => statement.all(...parameters), + run: (...parameters) => statement.run(...parameters), }; }, serialize: () => db.serialize(), diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 946420499..81b87d005 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -89,7 +89,10 @@ export const startRelay = isOwnerWithinQuota, }); - const run = _run.addDeps({ storage }); + // Use root daemon runner for WS callbacks; task-scoped runner closes + // after startRelay returns and would reject message handling with + // RunnerClosingError. + const run = _run.daemon.addDeps({ storage }); const server = createServer(); const wss = new WebSocketServer({ diff --git a/packages/nodejs/test/Relay.test.ts b/packages/nodejs/test/Relay.test.ts new file mode 100644 index 000000000..238ca4407 --- /dev/null +++ b/packages/nodejs/test/Relay.test.ts @@ -0,0 +1,435 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { createServer } from "node:http"; +import { + createId, + createRandomBytes, + getOk, + SimpleName, + testAppOwner, + testCreateRun, +} from "@evolu/common"; +import { + createProtocolMessageBuffer, + createProtocolMessageForUnsubscribe, + createProtocolMessageFromCrdtMessages, + MessageType, + SubscriptionFlags, + testCreateCrdtMessage, +} from "@evolu/common/local-first"; +import { afterEach, describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { createRelayDeps, startRelay } from "../src/local-first/Relay.js"; + +const dbNames = new Set(); + +const cleanupDbFiles = (name: string): void => { + for (const suffix of [".db", ".db-shm", ".db-wal"]) { + const file = `${name}${suffix}`; + if (existsSync(file)) unlinkSync(file); + } +}; + +const getFreePort = async (): Promise => + await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Unable to resolve free port"))); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); + +const waitForOpen = async (ws: WebSocket): Promise => + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }).then(waitForMacrotask); + +const waitForMacrotask = async (): Promise => + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +const waitForError = async (ws: WebSocket): Promise => + await new Promise((resolve) => { + ws.once("error", (error) => resolve(error)); + }); + +const waitForMessage = async ( + ws: WebSocket, + timeoutMs = 1_000, +): Promise => + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout); + ws.removeListener("message", onMessage); + ws.removeListener("close", onClose); + ws.removeListener("error", onError); + }; + + const onMessage = (message: unknown) => { + cleanup(); + resolve(message); + }; + + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject( + new Error(`Socket closed before message: ${code} ${reason.toString()}`), + ); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for websocket message")); + }, timeoutMs); + + ws.once("message", onMessage); + ws.once("close", onClose); + ws.once("error", onError); + }); + +const waitForNoMessage = async ( + ws: WebSocket, + timeoutMs = 120, +): Promise<"timeout" | "message"> => + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve("timeout"), timeoutMs); + ws.once("message", () => { + clearTimeout(timeout); + resolve("message"); + }); + }); + +const waitForClose = async ( + ws: WebSocket, +): Promise<{ code: number; reason: string }> => + await new Promise((resolve) => { + ws.once("close", (code, reason) => { + resolve({ code, reason: reason.toString() }); + }); + }); + +const closeSocket = async (ws: WebSocket): Promise => { + if ( + ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING + ) { + return; + } + await new Promise((resolve) => { + ws.once("close", () => resolve()); + ws.close(); + }); +}; + +afterEach(() => { + for (const name of dbNames) cleanupDbFiles(name); + dbNames.clear(); +}); + +describe("startRelay (nodejs adapter)", () => { + const isOwnerWithinQuota = () => true; + + test("accepts ws connection when owner check is not configured", async () => { + const port = await getFreePort(); + const name = "RelayNoAuth"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await waitForOpen(ws); + await closeSocket(ws); + }); + + test("rejects invalid owner path with 400 when owner check is enabled", async () => { + const port = await getFreePort(); + const name = "RelayBadOwner"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket(`ws://127.0.0.1:${port}?ownerId=not-owner-id`); + const error = await waitForError(ws); + expect(String(error.message)).toContain("Unexpected server response: 400"); + await closeSocket(ws); + }); + + test("rejects unauthorized owner with 401", async () => { + const port = await getFreePort(); + const name = "RelayUnauthorized"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => false, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + const error = await waitForError(ws); + expect(String(error.message)).toContain("Unexpected server response: 401"); + await closeSocket(ws); + }); + + test("handles malformed binary message without dropping open socket", async () => { + const port = await getFreePort(); + const name = "RelayProtocol"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + await waitForOpen(ws); + + ws.send(new Uint8Array([0xff, 0xff]), { binary: true }); + + const outcome = await Promise.race([ + new Promise<"message">((resolve) => { + ws.once("message", () => resolve("message")); + }), + new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), 120); + }), + ]); + + expect(outcome).toBe("timeout"); + expect(ws.readyState).toBe(WebSocket.OPEN); + await closeSocket(ws); + }); + + test("ignores non-binary websocket payloads", async () => { + const port = await getFreePort(); + const name = "RelayNonBinary"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + await waitForOpen(ws); + + ws.send("hello-text"); + + expect(await waitForNoMessage(ws)).toBe("timeout"); + expect(ws.readyState).toBe(WebSocket.OPEN); + await closeSocket(ws); + }); + + test("closes open sockets when relay is disposed", async () => { + const port = await getFreePort(); + const name = "RelayShutdown"; + dbNames.add(name); + + await using run = testCreateRun(createRelayDeps()); + const relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + await waitForOpen(ws); + + const closePromise = waitForClose(ws); + await relay[Symbol.asyncDispose](); + + const { code, reason } = await closePromise; + expect(code).toBe(1000); + expect(reason).toContain("shutting down"); + }); + + test("handles subscribe, broadcast, and unsubscribe flow for same owner", async () => { + const port = await getFreePort(); + const name = "RelaySubscribeBroadcastUnsubscribe"; + dbNames.add(name); + const randomBytes = createRandomBytes(); + const protocolDeps = { randomBytes }; + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws1 = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + const ws2 = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + + await Promise.all([waitForOpen(ws1), waitForOpen(ws2)]); + + const subscribeMessage = createProtocolMessageBuffer(testAppOwner.id, { + messageType: MessageType.Request, + subscriptionFlag: SubscriptionFlags.Subscribe, + }).unwrap(); + const subscribe1Response = waitForMessage(ws1); + const subscribe2Response = waitForMessage(ws2); + ws1.send(Buffer.from(subscribeMessage), { binary: true }); + ws2.send(Buffer.from(subscribeMessage), { binary: true }); + await Promise.all([subscribe1Response, subscribe2Response]); + + const syncMessage1 = createProtocolMessageFromCrdtMessages(protocolDeps)( + testAppOwner, + [testCreateCrdtMessage(createId(protocolDeps), 1, "first")], + ); + const sync1SenderResponse = waitForMessage(ws1); + const sync1PeerBroadcast = waitForMessage(ws2); + ws1.send(Buffer.from(syncMessage1), { binary: true }); + await Promise.all([sync1SenderResponse, sync1PeerBroadcast]); + + const unsubscribeMessage = createProtocolMessageForUnsubscribe( + testAppOwner.id, + ); + const unsubscribeResponse = waitForMessage(ws2); + ws2.send(Buffer.from(unsubscribeMessage), { binary: true }); + await unsubscribeResponse; + + const syncMessage2 = createProtocolMessageFromCrdtMessages(protocolDeps)( + testAppOwner, + [testCreateCrdtMessage(createId(protocolDeps), 2, "second")], + ); + const sync2SenderResponse = waitForMessage(ws1); + ws1.send(Buffer.from(syncMessage2), { binary: true }); + await sync2SenderResponse; + expect(await waitForNoMessage(ws2)).toBe("timeout"); + + await Promise.all([closeSocket(ws1), closeSocket(ws2)]); + }); + + test("restarts with existing database file and still serves protocol messages", async () => { + const port = await getFreePort(); + const name = "RelayExistingDb"; + dbNames.add(name); + + { + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + await waitForOpen(ws); + await closeSocket(ws); + } + + await using run = testCreateRun(createRelayDeps()); + await using relay = getOk( + await run( + startRelay({ + port, + name: SimpleName.orThrow(name), + isOwnerAllowed: () => true, + isOwnerWithinQuota, + }), + ), + ); + void relay; + + const ws = new WebSocket( + `ws://127.0.0.1:${port}?ownerId=${testAppOwner.id}`, + ); + await waitForOpen(ws); + + const subscribeMessage = createProtocolMessageBuffer(testAppOwner.id, { + messageType: MessageType.Request, + subscriptionFlag: SubscriptionFlags.Subscribe, + }).unwrap(); + const subscribeResponse = waitForMessage(ws); + ws.send(Buffer.from(subscribeMessage), { binary: true }); + await subscribeResponse; + await closeSocket(ws); + }); +}); diff --git a/packages/nodejs/test/Sqlite.test.ts b/packages/nodejs/test/Sqlite.test.ts index 1413f82a2..6d22b29ae 100644 --- a/packages/nodejs/test/Sqlite.test.ts +++ b/packages/nodejs/test/Sqlite.test.ts @@ -5,6 +5,7 @@ import { createSqlite, SimpleName, type SqliteRow, + type SqliteValue, sql, testCreateRun, } from "@evolu/common"; @@ -200,6 +201,10 @@ describe("createBetterSqliteDriver", () => { return { changes: 1 }; } + if (normalized.startsWith("update")) { + return { changes: 0 }; + } + return { changes: 0 }; }, }; @@ -246,8 +251,10 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`create table t (name text);`); sqlite.exec(sql`insert into t (name) values (${"Alice"});`); const rows = sqlite.exec(sql`select name from t;`); + const updateResult = sqlite.exec(sql`update t set name = ${"Alice"};`); expect(rows.rows).toEqual([{ name: "Alice" }]); + expect(updateResult.changes).toBe(0); const exported = sqlite.export(); expect(exported).toBeInstanceOf(Uint8Array); @@ -258,6 +265,407 @@ describe("createBetterSqliteDriver", () => { } }); + test("uses bun:sqlite driver when Bun runtime is available", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + (globalThis as Record).Bun = {}; + + interface MockStatement { + readonly all: (...parameters: ReadonlyArray) => Array; + readonly run: (...parameters: ReadonlyArray) => { + readonly changes: number; + }; + } + + class MockBunDatabase { + #rows = [] as { readonly name: unknown }[]; + + prepare(sqlText: string): MockStatement { + const normalized = sqlText.trim().toLowerCase(); + + return { + all: (..._parameters) => { + if (normalized.startsWith("select")) { + return this.#rows.map((row) => ({ ...row })); + } + return []; + }, + run: (...parameters) => { + if (normalized.startsWith("insert")) { + this.#rows.push({ name: parameters[0] }); + return { changes: 1 }; + } + return { changes: 0 }; + }, + }; + } + + serialize(): Uint8Array { + return new Uint8Array([5, 6, 7]); + } + + close(): void {} + } + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "bun:sqlite") return { Database: MockBunDatabase }; + if (id === "better-sqlite3") { + throw new Error("better-sqlite3 should not be used in this test"); + } + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?bun-runtime-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + const result = await run(createDriver(testName, { mode: "memory" })); + assert(result.ok); + const sqlite = result.value; + + sqlite.exec(sql`insert into t (name) values (${"Alice"});`); + const rows = sqlite.exec(sql`select name from t;`); + expect(rows.rows).toEqual([{ name: "Alice" }]); + expect(sqlite.export()).toEqual(new Uint8Array([5, 6, 7])); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + + test("falls back to better-sqlite3 when bun:sqlite init fails in Bun runtime", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + (globalThis as Record).Bun = {}; + + interface MockStatement { + readonly reader: boolean; + readonly all: ( + ...parameters: ReadonlyArray + ) => Array; + readonly run: (...parameters: ReadonlyArray) => { + readonly changes: number; + }; + } + + class MockBetterDatabase { + #rows = [] as { readonly name: unknown }[]; + + prepare(sqlText: string): MockStatement { + const normalized = sqlText.trim().toLowerCase(); + return { + reader: normalized.startsWith("select"), + all: (...parameters) => { + if (normalized.startsWith("select")) { + return this.#rows.map((row) => ({ ...row })); + } + if (normalized.startsWith("insert") && parameters.length > 0) { + this.#rows.push({ name: parameters[0] }); + return []; + } + return []; + }, + run: (...parameters) => { + if (normalized.startsWith("insert")) { + this.#rows.push({ name: parameters[0] }); + return { changes: 1 }; + } + return { changes: 0 }; + }, + }; + } + + serialize(): Uint8Array { + return new Uint8Array([8, 9, 10]); + } + + close(): void {} + } + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "bun:sqlite") { + return { + Database: class BunSqliteBroken { + constructor() { + throw new Error("simulated bun:sqlite init failure"); + } + }, + }; + } + if (id === "better-sqlite3") { + return MockBetterDatabase; + } + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?bun-fallback-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + const result = await run(createDriver(testName, { mode: "memory" })); + assert(result.ok); + const sqlite = result.value; + + sqlite.exec(sql`insert into t (name) values (${"Bob"});`); + const rows = sqlite.exec(sql`select name from t;`); + expect(rows.rows).toEqual([{ name: "Bob" }]); + expect(sqlite.export()).toEqual(new Uint8Array([8, 9, 10])); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + + test("rethrows bun:sqlite error when all Bun runtime fallbacks fail", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + (globalThis as Record).Bun = {}; + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "bun:sqlite") { + return { + Database: class BunSqliteBroken { + constructor() { + throw new Error("simulated bun:sqlite init failure"); + } + }, + }; + } + if (id === "better-sqlite3") { + return class BetterSqliteBroken { + constructor() { + throw new Error("simulated better-sqlite3 init failure"); + } + }; + } + if (id === "node:sqlite") { + return { + DatabaseSync: class NodeSqliteBroken { + constructor() { + throw new Error("simulated node:sqlite init failure"); + } + }, + }; + } + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?bun-all-fail-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + await expect( + run(createDriver(testName, { mode: "memory" })), + ).rejects.toThrow("simulated bun:sqlite init failure"); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + + test("rethrows better-sqlite3 error when non-Bun fallbacks fail", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + delete (globalThis as Record).Bun; + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "better-sqlite3") { + return class BetterSqliteBroken { + constructor() { + throw new Error("simulated better-sqlite3 init failure"); + } + }; + } + if (id === "node:sqlite") { + return { + DatabaseSync: class NodeSqliteBroken { + constructor() { + throw new Error("simulated node:sqlite init failure"); + } + }, + }; + } + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?non-bun-all-fail-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + await expect( + run(createDriver(testName, { mode: "memory" })), + ).rejects.toThrow("simulated better-sqlite3 init failure"); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + + test("export handles SharedArrayBuffer-backed serialization in Bun path", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + (globalThis as Record).Bun = {}; + + class MockBunDatabase { + prepare() { + return { + all: () => [] as Array, + run: () => ({ changes: 0 }), + }; + } + + serialize(): Uint8Array { + const bytes = new Uint8Array(new SharedArrayBuffer(3)); + bytes.set([11, 12, 13]); + return bytes; + } + + close(): void {} + } + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "bun:sqlite") return { Database: MockBunDatabase }; + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?bun-shared-buffer-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + const result = await run(createDriver(testName, { mode: "memory" })); + assert(result.ok); + const sqlite = result.value; + + expect(sqlite.export()).toEqual(new Uint8Array([11, 12, 13])); + sqlite[Symbol.dispose](); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + + test("node serialize fallback handles non-ArrayBuffer file backing", async () => { + vi.resetModules(); + const originalBun = (globalThis as Record).Bun; + delete (globalThis as Record).Bun; + + class MockNodeDatabase { + prepare() { + return { + all: (..._parameters: ReadonlyArray) => + [] as Array, + run: (..._parameters: ReadonlyArray) => ({ changes: 0 }), + }; + } + + exec(_sqlText: string): void {} + + close(): void {} + } + + vi.doMock("node:fs", () => ({ + readFileSync: () => { + const bytes = new Uint8Array(new SharedArrayBuffer(3)); + bytes.set([3, 4, 5]); + return bytes; + }, + rmSync: () => {}, + existsSync, + unlinkSync, + writeFileSync, + })); + + vi.doMock("node:module", () => ({ + createRequire: () => (id: string) => { + if (id === "better-sqlite3") { + return class BetterSqliteBroken { + constructor() { + throw new Error("simulated better-sqlite3 init failure"); + } + }; + } + if (id === "node:sqlite") { + return { DatabaseSync: MockNodeDatabase }; + } + throw new Error(`Unexpected module request: ${id}`); + }, + })); + + try { + const modulePath = `../src/Sqlite.ts?node-serialize-fallback-${Date.now()}`; + const { createBetterSqliteDriver: createDriver } = await import( + modulePath + ); + + await using run = testCreateRun(); + const result = await run(createDriver(testName, { mode: "memory" })); + assert(result.ok); + const sqlite = result.value; + + expect(sqlite.export()).toEqual(new Uint8Array([3, 4, 5])); + sqlite[Symbol.dispose](); + } finally { + vi.doUnmock("node:module"); + vi.doUnmock("node:fs"); + vi.resetModules(); + if (originalBun === undefined) { + delete (globalThis as Record).Bun; + } else { + (globalThis as Record).Bun = originalBun; + } + } + }); + describe("file-based database", () => { const dbPath = `${testName}.db`; diff --git a/packages/nodejs/test/WebPlatform.test.ts b/packages/nodejs/test/WebPlatform.test.ts new file mode 100644 index 000000000..7cef0d146 --- /dev/null +++ b/packages/nodejs/test/WebPlatform.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { reloadApp } from "../../web/src/Platform.js"; + +const withGlobals = ( + globals: Partial<{ + document: unknown; + location: unknown; + }>, +): void => { + for (const [key, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } +}; + +afterEach(() => { + for (const key of ["document", "location"]) { + // Cleanup to keep node tests isolated. + Reflect.deleteProperty(globalThis, key); + } +}); + +test("reloadApp is a no-op when document is not available", () => { + withGlobals({ + location: { replace: vi.fn() }, + }); + + expect(() => reloadApp("/ignored")).not.toThrow(); +}); + +test("reloadApp replaces location with provided url", () => { + const replace = vi.fn(); + withGlobals({ + document: {}, + location: { replace }, + }); + + reloadApp("/next"); + + expect(replace).toHaveBeenCalledWith("/next"); +}); + +test("reloadApp defaults to root path", () => { + const replace = vi.fn(); + withGlobals({ + document: {}, + location: { replace }, + }); + + reloadApp(); + + expect(replace).toHaveBeenCalledWith("/"); +}); diff --git a/packages/nodejs/test/Worker.test.ts b/packages/nodejs/test/Worker.test.ts index 7ca989199..7a3eebc5c 100644 --- a/packages/nodejs/test/Worker.test.ts +++ b/packages/nodejs/test/Worker.test.ts @@ -2,11 +2,12 @@ import { MessageChannel as NodeMessageChannel, Worker as NodeWorker, } from "node:worker_threads"; -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; import { createMessageChannel, createMessagePort, createWorker, + createWorkerScope, createWorkerSelf, } from "../src/Worker.js"; @@ -105,3 +106,104 @@ test("createWorkerSelf supports message exchange with explicit parent port", asy self[Symbol.dispose](); nativeChannel.port2.close(); }); + +test("createWorkerSelf throws when parent port is null", () => { + expect(() => createWorkerSelf(null)).toThrow( + "parentPort is null; createWorkerSelf must run inside a worker thread or receive explicit parent port", + ); +}); + +test("createWorkerScope wraps createWorkerSelf and is disposable", () => { + const nativeChannel = new NodeMessageChannel(); + const scope = createWorkerScope< + { readonly fromMain: string }, + { readonly fromSelf: string } + >(nativeChannel.port1); + + expect(scope.onError).toBeNull(); + scope[Symbol.dispose](); + nativeChannel.port2.close(); +}); + +test("createMessagePort supports transfer list with ArrayBuffer", async () => { + const nativeChannel = new NodeMessageChannel(); + const port = createMessagePort< + { readonly payload: ArrayBuffer }, + { readonly payload: ArrayBuffer } + >(nativeChannel.port1 as never); + + const payload = new ArrayBuffer(4); + const view = new Uint8Array(payload); + view.set([1, 2, 3, 4]); + + const outgoing = new Promise<{ readonly payload: ArrayBuffer }>((resolve) => { + nativeChannel.port2.once("message", (message) => { + resolve(message as { readonly payload: ArrayBuffer }); + }); + }); + + port.postMessage({ payload }, [payload]); + const received = await outgoing; + expect(new Uint8Array(received.payload)).toEqual( + new Uint8Array([1, 2, 3, 4]), + ); + + port[Symbol.dispose](); + nativeChannel.port2.close(); +}); + +test("createMessagePort supports transfer list with MessagePort", async () => { + const nativeChannel = new NodeMessageChannel(); + const transferChannel = new NodeMessageChannel(); + const port = createMessagePort< + { readonly transferredPort: NodeMessageChannel["port1"] }, + { readonly transferredPort: NodeMessageChannel["port1"] } + >(nativeChannel.port1 as never); + + const outgoing = new Promise<{ + readonly transferredPort: NodeMessageChannel["port1"]; + }>((resolve) => { + nativeChannel.port2.once("message", (message) => { + resolve( + message as { readonly transferredPort: NodeMessageChannel["port1"] }, + ); + }); + }); + + port.postMessage({ transferredPort: transferChannel.port1 }, [ + transferChannel.port1 as never, + ]); + const received = await outgoing; + expect(received.transferredPort).toBeDefined(); + + port[Symbol.dispose](); + nativeChannel.port2.close(); + transferChannel.port2.close(); +}); + +test("createWorkerScope forwards process errors and detaches handlers on dispose", () => { + const nativeChannel = new NodeMessageChannel(); + const uncaughtBefore = process.listenerCount("uncaughtException"); + const unhandledBefore = process.listenerCount("unhandledRejection"); + const scope = createWorkerScope< + { readonly fromMain: string }, + { readonly fromSelf: string } + >(nativeChannel.port1); + expect(process.listenerCount("uncaughtException")).toBe(uncaughtBefore + 1); + expect(process.listenerCount("unhandledRejection")).toBe(unhandledBefore + 1); + + const onError = vi.fn(); + scope.onError = onError; + + const uncaught = new Error("uncaught"); + const rejected = new Error("rejected"); + process.emit("uncaughtException", uncaught); + process.emit("unhandledRejection", rejected, Promise.resolve()); + expect(onError).toHaveBeenCalledTimes(2); + + scope[Symbol.dispose](); + expect(process.listenerCount("uncaughtException")).toBe(uncaughtBefore); + expect(process.listenerCount("unhandledRejection")).toBe(unhandledBefore); + + nativeChannel.port2.close(); +}); diff --git a/packages/react-web/test/local-first/Evolu.test.ts b/packages/react-web/test/local-first/Evolu.test.ts new file mode 100644 index 000000000..e25371ac8 --- /dev/null +++ b/packages/react-web/test/local-first/Evolu.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createWebEvoluDeps: vi.fn(), + flushSync: vi.fn(), +})); + +vi.mock("@evolu/web", () => ({ + createEvoluDeps: mocks.createWebEvoluDeps, +})); + +vi.mock("react-dom", () => ({ + flushSync: mocks.flushSync, +})); + +import { createEvoluDeps } from "../../src/local-first/Evolu.js"; + +describe("createEvoluDeps (react-web)", () => { + test("merges web deps with react flushSync", () => { + const webDeps = { marker: "web-deps" }; + mocks.createWebEvoluDeps.mockReturnValue(webDeps); + + const result = createEvoluDeps({ custom: true } as never); + + expect(mocks.createWebEvoluDeps).toHaveBeenCalledWith({ + custom: true, + }); + expect(result).toEqual({ ...webDeps, flushSync: mocks.flushSync }); + }); + + test("supports default deps argument", () => { + const webDeps = { marker: "defaults" }; + mocks.createWebEvoluDeps.mockReturnValue(webDeps); + + const result = createEvoluDeps(); + + expect(mocks.createWebEvoluDeps).toHaveBeenCalledWith({}); + expect(result).toEqual({ ...webDeps, flushSync: mocks.flushSync }); + }); +}); diff --git a/packages/react-web/vitest.config.ts b/packages/react-web/vitest.config.ts new file mode 100644 index 000000000..925dcaf92 --- /dev/null +++ b/packages/react-web/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + exclude: ["**/node_modules/**", "**/dist/**"], + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/packages/web/src/Sqlite.ts b/packages/web/src/Sqlite.ts index b2292444c..f3808889e 100644 --- a/packages/web/src/Sqlite.ts +++ b/packages/web/src/Sqlite.ts @@ -5,9 +5,12 @@ import sqlite3InitModule, { type PreparedStatement, } from "@evolu/sqlite-wasm"; -// @ts-expect-error Missing types. -globalThis.sqlite3ApiConfig = { - warn: (arg: unknown) => { +type Sqlite3Module = Awaited>; +type InitSqlite3Module = () => Promise; + +export const createSqliteApiWarnHandler = + (warn: (arg: unknown) => void = console.warn.bind(console)) => + (arg: unknown): void => { // Ignore irrelevant warning. // https://github.com/sqlite/sqlite-wasm/issues/62 if ( @@ -15,16 +18,47 @@ globalThis.sqlite3ApiConfig = { arg.startsWith("Ignoring inability to install OPFS sqlite3_vfs") ) return; - console.warn(arg); - }, + warn(arg); + }; + +// @ts-expect-error Missing types. +globalThis.sqlite3ApiConfig = { + warn: createSqliteApiWarnHandler(), }; -// Init ASAP. -const sqlite3Promise = sqlite3InitModule(); +let initSqlite3Module: InitSqlite3Module = sqlite3InitModule; +let sqlite3Promise: Promise | null = null; + +const getSqlite3 = (): Promise => { + if (!sqlite3Promise) { + sqlite3Promise = initSqlite3Module().catch((error) => { + sqlite3Promise = null; + throw error; + }); + } + return sqlite3Promise; +}; + +// Init ASAP, keep promise cached for first driver use. +void getSqlite3(); + +/** Test helper for replacing sqlite-wasm module loader. */ +export const testSetSqlite3InitModule = ( + initModule: InitSqlite3Module, +): Disposable => { + initSqlite3Module = initModule; + sqlite3Promise = null; + return { + [Symbol.dispose]: () => { + initSqlite3Module = sqlite3InitModule; + sqlite3Promise = null; + }, + }; +}; export const createWasmSqliteDriver: CreateSqliteDriver = (name, options) => async () => { - const sqlite3 = await sqlite3Promise; + const sqlite3 = await getSqlite3(); // This is used to make OPFS default vfs for multipleciphers // @ts-expect-error Missing types (update @evolu/sqlite-wasm types) sqlite3.capi.sqlite3mc_vfs_create("opfs", 1); diff --git a/packages/web/src/local-first/DbWorker.ts b/packages/web/src/local-first/DbWorker.ts index e74ff5a02..17d09fdc8 100644 --- a/packages/web/src/local-first/DbWorker.ts +++ b/packages/web/src/local-first/DbWorker.ts @@ -6,7 +6,7 @@ import type { SqliteDriver, SqliteValue, } from "@evolu/common"; -import { SimpleName as SimpleNameType } from "@evolu/common"; +import { assert, SimpleName as SimpleNameType } from "@evolu/common"; import type { AppOwner, ExperimentalDbWorkerInput as DbWorkerInput, @@ -41,6 +41,8 @@ const safeParseAppOwner = (value: string): AppOwner | null => { const toSimpleName = (dbName: string): SimpleName => SimpleNameType.orThrow(dbName === ":memory:" ? workerMemoryDbName : dbName); +type CreateDbDriver = (dbName: string) => Promise; + const createDriver = async (dbName: string): Promise => { const mode = dbName === ":memory:" ? ({ mode: "memory" } as const) : undefined; @@ -79,10 +81,16 @@ const prepareDriver = ( }); }; -const acquireSharedDb = async (config: { - readonly dbName: string; - readonly schemaVersion: number; -}): Promise<{ driver: SqliteDriver; isLeader: boolean }> => { +const acquireSharedDb = async ( + config: { + readonly dbName: string; + readonly schemaVersion: number; + }, + createDbDriver: CreateDbDriver = createDriver, +): Promise<{ + driver: SqliteDriver; + isLeader: boolean; +}> => { const { dbName, schemaVersion } = config; const existing = sharedDbStates.get(dbName); if (existing) { @@ -95,7 +103,7 @@ const acquireSharedDb = async (config: { try { if (existing.driver) return { driver: existing.driver, isLeader: false }; const initPromise = existing.initPromise; - if (!initPromise) throw new Error("Shared DB initialization missing"); + assert(initPromise, "Shared DB initialization missing"); const driver = await initPromise; return { driver, isLeader: false }; } catch (error) { @@ -114,7 +122,7 @@ const acquireSharedDb = async (config: { sharedDbStates.set(dbName, created); created.initPromise = (async () => { - const driver = await createDriver(dbName); + const driver = await createDbDriver(dbName); prepareDriver(driver, schemaVersion); created.driver = driver; return driver; @@ -125,7 +133,7 @@ const acquireSharedDb = async (config: { return { driver, isLeader: true }; } catch (error) { created.refs -= 1; - if (created.refs === 0) sharedDbStates.delete(dbName); + sharedDbStates.delete(dbName); throw error; } finally { created.initPromise = null; @@ -134,12 +142,13 @@ const acquireSharedDb = async (config: { const releaseSharedDb = (dbName: string): void => { const state = sharedDbStates.get(dbName); - if (!state) return; + assert(state, "Shared DB state missing"); state.refs -= 1; if (state.refs > 0) return; - if (state.driver) state.driver[Symbol.dispose](); + assert(state.driver, "Shared DB driver missing during release"); + state.driver[Symbol.dispose](); sharedDbStates.delete(dbName); }; @@ -162,6 +171,15 @@ export const runWebDbWorkerPortWithOptions = ( options?: { readonly heartbeatTimeoutMs?: number; readonly heartbeatCheckIntervalMs?: number; + readonly now?: () => number; + readonly createDriver?: CreateDbDriver; + readonly setInterval?: ( + callback: () => void, + timeoutMs: number, + ) => ReturnType; + readonly clearInterval?: ( + id: ReturnType, + ) => void; }, ): void => { const heartbeatTimeoutMs = @@ -169,6 +187,10 @@ export const runWebDbWorkerPortWithOptions = ( const heartbeatCheckIntervalMs = options?.heartbeatCheckIntervalMs ?? Math.max(1_000, heartbeatTimeoutMs / 3); + const now = options?.now ?? Date.now; + const createDriverImpl = options?.createDriver ?? createDriver; + const setIntervalImpl = options?.setInterval ?? globalThis.setInterval; + const clearIntervalImpl = options?.clearInterval ?? globalThis.clearInterval; const { name, port, brokerPort } = config; let db: SqliteDriver | null = null; let dbName: string | null = null; @@ -176,23 +198,22 @@ export const runWebDbWorkerPortWithOptions = ( let hasDbRef = false; let heartbeatWatchdogId: ReturnType | null = null; - let lastHeartbeatAt = Date.now(); + let lastHeartbeatAt = now(); const markAlive = (): void => { - lastHeartbeatAt = Date.now(); + lastHeartbeatAt = now(); }; const stopHeartbeatWatchdog = (): void => { if (!heartbeatWatchdogId) return; - globalThis.clearInterval(heartbeatWatchdogId); + clearIntervalImpl(heartbeatWatchdogId); heartbeatWatchdogId = null; }; const startHeartbeatWatchdog = (): void => { - if (heartbeatWatchdogId) return; - heartbeatWatchdogId = globalThis.setInterval(() => { - if (!dbName) return; - if (Date.now() - lastHeartbeatAt > heartbeatTimeoutMs) { + if (heartbeatWatchdogId) clearIntervalImpl(heartbeatWatchdogId); + heartbeatWatchdogId = setIntervalImpl(() => { + if (now() - lastHeartbeatAt > heartbeatTimeoutMs) { // Stale client port: release shared DB ref. releaseDb({ keepConfig: true }); stopHeartbeatWatchdog(); @@ -217,7 +238,10 @@ export const runWebDbWorkerPortWithOptions = ( if (db) return; if (dbName == null || schemaVersion == null) throw new Error("Database not initialized"); - const acquired = await acquireSharedDb({ dbName, schemaVersion }); + const acquired = await acquireSharedDb( + { dbName, schemaVersion }, + createDriverImpl, + ); db = acquired.driver; hasDbRef = true; startHeartbeatWatchdog(); @@ -236,7 +260,7 @@ export const runWebDbWorkerPortWithOptions = ( }; const requireDb = (): SqliteDriver => { - if (!db) throw new Error("Database not initialized"); + assert(db, "Database not initialized"); return db; }; @@ -277,10 +301,13 @@ export const runWebDbWorkerPortWithOptions = ( ); } - const acquired = await acquireSharedDb({ - dbName: message.dbName, - schemaVersion: message.schemaVersion, - }); + const acquired = await acquireSharedDb( + { + dbName: message.dbName, + schemaVersion: message.schemaVersion, + }, + createDriverImpl, + ); db = acquired.driver; hasDbRef = true; dbName = message.dbName; diff --git a/packages/web/test/DbWorker.test.ts b/packages/web/test/DbWorker.test.ts index 5276740da..925caae3e 100644 --- a/packages/web/test/DbWorker.test.ts +++ b/packages/web/test/DbWorker.test.ts @@ -1,4 +1,4 @@ -import type { MessagePort } from "@evolu/common"; +import type { MessagePort, SqliteDriver } from "@evolu/common"; import { SimpleName } from "@evolu/common"; import type { ExperimentalDbWorkerInput as DbWorkerInput, @@ -6,7 +6,7 @@ import type { ExperimentalDbWorkerLeaderOutput as DbWorkerLeaderOutput, ExperimentalDbWorkerOutput as DbWorkerOutput, } from "@evolu/common/local-first"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { runWebDbWorkerPort, runWebDbWorkerPortWithOptions, @@ -32,24 +32,160 @@ const waitForOutput = ( const waitForLeader = ( port: MessagePort, - timeoutMs = 2_000, + timeoutMs = 300, ): Promise => new Promise((resolve) => { + const previous = port.onMessage; const timeout = setTimeout(() => { - port.onMessage = null; + port.onMessage = previous; resolve(null); }, timeoutMs); port.onMessage = (message) => { clearTimeout(timeout); - port.onMessage = null; + port.onMessage = previous; resolve(message); }; }); +const waitForRequiredLeader = async ( + port: MessagePort, + timeoutMs = 2_000, +): Promise => { + const output = await waitForLeader(port, timeoutMs); + if (output != null) return output; + throw new Error("Timed out waiting for LeaderAcquired"); +}; + +const waitForLeaderBurst = ( + ports: ReadonlyArray>, + timeoutMs = 2_000, + settleMs = 30, +): Promise> => + new Promise((resolve, reject) => { + const previousHandlers = ports.map((port) => port.onMessage); + const outputs: Array = []; + let settledTimer: ReturnType | null = null; + + const cleanup = (): void => { + clearTimeout(timeoutId); + if (settledTimer != null) clearTimeout(settledTimer); + for (const [index, port] of ports.entries()) { + port.onMessage = previousHandlers[index]; + } + }; + + const timeoutId = globalThis.setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for leader output")); + }, timeoutMs); + + const settle = (): void => { + if (settledTimer != null) clearTimeout(settledTimer); + settledTimer = globalThis.setTimeout(() => { + cleanup(); + resolve(outputs); + }, settleMs); + }; + + for (const [index, port] of ports.entries()) { + const previous = previousHandlers[index]; + port.onMessage = (message) => { + outputs.push(message); + if (previous != null) previous(message); + settle(); + }; + } + }); + +const flushAsync = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); +}; + const wait = (ms: number): Promise => new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +const createManualClock = () => { + let currentTimeMs = 0; + let nextId = 1; + const intervals = new Map< + number, + { callback: () => void; intervalMs: number; nextTickMs: number } + >(); + + return { + now: (): number => currentTimeMs, + setInterval: ( + callback: () => void, + timeoutMs: number, + ): ReturnType => { + const id = nextId++; + const intervalMs = Math.max(1, timeoutMs); + intervals.set(id, { + callback, + intervalMs, + nextTickMs: currentTimeMs + intervalMs, + }); + return id as unknown as ReturnType; + }, + clearInterval: (id: ReturnType): void => { + intervals.delete(id as unknown as number); + }, + advance: (ms: number): void => { + const targetTimeMs = currentTimeMs + ms; + + while (true) { + let nextIntervalId: number | null = null; + let nextTickMs = Number.POSITIVE_INFINITY; + + for (const [id, interval] of intervals) { + if (interval.nextTickMs < nextTickMs) { + nextTickMs = interval.nextTickMs; + nextIntervalId = id; + } + } + + if (nextIntervalId == null || nextTickMs > targetTimeMs) break; + + currentTimeMs = nextTickMs; + const interval = intervals.get(nextIntervalId); + if (!interval) continue; + + interval.callback(); + + const currentInterval = intervals.get(nextIntervalId); + if (currentInterval) { + currentInterval.nextTickMs += currentInterval.intervalMs; + } + } + + currentTimeMs = targetTimeMs; + }, + }; +}; + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +}; + +const createMockDriver = (): SqliteDriver => ({ + exec: (query) => { + if (query.sql.includes("select 1 as value")) { + return { rows: [{ value: 1 }], changes: 0 }; + } + return { rows: [], changes: 0 }; + }, + export: () => new Uint8Array([1, 2, 3]), + [Symbol.dispose]: () => {}, +}); + describe("runWebDbWorkerPort", () => { test("same dbName can initialize from multiple ports without blocking", async () => { const name = SimpleName.orThrow("DbWorkerTest"); @@ -79,8 +215,7 @@ describe("runWebDbWorkerPort", () => { brokerPort: broker2.port1, }); - const leader1 = waitForLeader(broker1.port2); - const leader2 = waitForLeader(broker2.port2); + const leadersPromise = waitForLeaderBurst([broker1.port2, broker2.port2]); const init1 = waitForOutput(channel1.port2); channel1.port2.postMessage({ @@ -106,9 +241,7 @@ describe("runWebDbWorkerPort", () => { expect(init2Output.success).toBe(true); } - const leaders = (await Promise.all([leader1, leader2])).filter( - (output): output is DbWorkerLeaderOutput => output !== null, - ); + const leaders = await leadersPromise; expect(leaders).toHaveLength(1); expect(leaders[0].type).toBe("LeaderAcquired"); expect(leaders[0].name).toBe(name); @@ -191,6 +324,7 @@ describe("runWebDbWorkerPort", () => { test("releases stale leader when no heartbeat arrives", async () => { const name = SimpleName.orThrow("DbWorkerStaleLeader"); const dbName = ":memory:"; + const clock = createManualClock(); const channel1 = createMessageChannel(); const broker1 = createMessageChannel< @@ -206,10 +340,13 @@ describe("runWebDbWorkerPort", () => { { heartbeatTimeoutMs: 80, heartbeatCheckIntervalMs: 20, + now: clock.now, + setInterval: clock.setInterval, + clearInterval: clock.clearInterval, }, ); - const leader1 = waitForLeader(broker1.port2); + const leader1 = waitForRequiredLeader(broker1.port2); const init1 = waitForOutput(channel1.port2); channel1.port2.postMessage({ type: "DbWorkerInit", @@ -223,7 +360,8 @@ describe("runWebDbWorkerPort", () => { } expect(await leader1).toMatchObject({ type: "LeaderAcquired", name }); - await wait(220); + clock.advance(220); + await flushAsync(); const channel2 = createMessageChannel(); const broker2 = createMessageChannel< @@ -240,10 +378,13 @@ describe("runWebDbWorkerPort", () => { { heartbeatTimeoutMs: 80, heartbeatCheckIntervalMs: 20, + now: clock.now, + setInterval: clock.setInterval, + clearInterval: clock.clearInterval, }, ); - const leader2 = waitForLeader(broker2.port2); + const leader2 = waitForRequiredLeader(broker2.port2); const init2 = waitForOutput(channel2.port2); channel2.port2.postMessage({ type: "DbWorkerInit", @@ -289,7 +430,7 @@ describe("runWebDbWorkerPort", () => { }, ); - const leader1 = waitForLeader(broker1.port2); + const leader1 = waitForRequiredLeader(broker1.port2); const init1 = waitForOutput(channel1.port2); channel1.port2.postMessage({ type: "DbWorkerInit", @@ -327,7 +468,7 @@ describe("runWebDbWorkerPort", () => { }, ); - const leader2 = waitForLeader(broker2.port2, 200); + const leader2 = waitForLeader(broker2.port2, 25); const init2 = waitForOutput(channel2.port2); channel2.port2.postMessage({ type: "DbWorkerInit", @@ -351,4 +492,960 @@ describe("runWebDbWorkerPort", () => { const close2Output = await close2; expect(close2Output.type).toBe("DbWorkerCloseResponse"); }); + + test("returns DbWorkerError when request arrives before initialization", async () => { + const name = SimpleName.orThrow("DbWorkerNotInitialized"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const output = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 1, + sql: "select 1 as value", + params: [], + }); + + const errorOutput = await output; + expect(errorOutput.type).toBe("DbWorkerError"); + if (errorOutput.type === "DbWorkerError") { + expect(errorOutput.requestId).toBe(1); + expect(errorOutput.error).toContain("not initialized"); + } + }); + + test("supports getAppOwner, mutate, query, export and reset", async () => { + const name = SimpleName.orThrow("DbWorkerApiSurface"); + const dbName = ":memory:"; + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + const initOutput = await init; + expect(initOutput.type).toBe("DbWorkerInitResponse"); + if (initOutput.type === "DbWorkerInitResponse") { + expect(initOutput.success).toBe(true); + } + + const mutateCreate = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerMutate", + requestId: 2, + sql: "create table custom_table (id integer primary key, title text)", + params: [], + }); + const mutateCreateOutput = await mutateCreate; + expect(mutateCreateOutput.type).toBe("DbWorkerMutateResponse"); + + const mutateInsert = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerMutate", + requestId: 3, + sql: "insert into custom_table (title) values (?)", + params: ["hello"], + }); + const mutateInsertOutput = await mutateInsert; + expect(mutateInsertOutput.type).toBe("DbWorkerMutateResponse"); + if (mutateInsertOutput.type === "DbWorkerMutateResponse") { + expect(mutateInsertOutput.changes).toBe(1); + } + + const query = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 4, + sql: "select title from custom_table", + params: [], + }); + const queryOutput = await query; + expect(queryOutput.type).toBe("DbWorkerQueryResponse"); + if (queryOutput.type === "DbWorkerQueryResponse") { + expect(queryOutput.rows).toEqual([{ title: "hello" }]); + } + + const setOwner = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerMutate", + requestId: 5, + sql: "insert into __evolu_meta (key, value) values ('appOwner', ?)", + params: [JSON.stringify({ id: "owner-1" })], + }); + const setOwnerOutput = await setOwner; + expect(setOwnerOutput.type).toBe("DbWorkerMutateResponse"); + + const getOwner = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerGetAppOwner", + }); + const getOwnerOutput = await getOwner; + expect(getOwnerOutput.type).toBe("DbWorkerAppOwner"); + if (getOwnerOutput.type === "DbWorkerAppOwner") { + expect(getOwnerOutput.appOwner).not.toBeNull(); + expect(getOwnerOutput.appOwner?.id).toBe("owner-1"); + } + + const exportDb = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerExport", + requestId: 6, + }); + const exportOutput = await exportDb; + expect(exportOutput.type).toBe("DbWorkerExportResponse"); + if (exportOutput.type === "DbWorkerExportResponse") { + expect(exportOutput.data).toBeInstanceOf(Uint8Array); + expect(exportOutput.data.length).toBeGreaterThan(0); + } + + const reset = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerReset", + requestId: 7, + }); + const resetOutput = await reset; + expect(resetOutput.type).toBe("DbWorkerResetResponse"); + + const queryAfterReset = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 8, + sql: "select count(*) as count from sqlite_master where type='table' and name='custom_table'", + params: [], + }); + const queryAfterResetOutput = await queryAfterReset; + expect(queryAfterResetOutput.type).toBe("DbWorkerQueryResponse"); + if (queryAfterResetOutput.type === "DbWorkerQueryResponse") { + expect(queryAfterResetOutput.rows).toEqual([{ count: 0 }]); + } + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 9 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("returns null appOwner for invalid JSON metadata", async () => { + const name = SimpleName.orThrow("DbWorkerInvalidOwnerJson"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const setOwner = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerMutate", + requestId: 1, + sql: "insert into __evolu_meta (key, value) values ('appOwner', ?)", + params: ["{not-json"], + }); + expect(await setOwner).toMatchObject({ type: "DbWorkerMutateResponse" }); + + const getOwner = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerGetAppOwner" }); + const ownerOutput = await getOwner; + expect(ownerOutput).toEqual({ type: "DbWorkerAppOwner", appOwner: null }); + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 2 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("rejects re-init on same worker with different dbName", async () => { + const name = SimpleName.orThrow("DbWorkerReinitDbName"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const initA = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await initA).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const initB = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: "another-db", + schemaVersion: 1, + }); + const output = await initB; + expect(output.type).toBe("DbWorkerInitResponse"); + if (output.type === "DbWorkerInitResponse") { + expect(output.success).toBe(false); + expect(output.error).toContain("cannot switch"); + } + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 3 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("rejects re-init on same worker with different schema version", async () => { + const name = SimpleName.orThrow("DbWorkerReinitSchema"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const initA = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await initA).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const initB = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 2, + }); + const output = await initB; + expect(output.type).toBe("DbWorkerInitResponse"); + if (output.type === "DbWorkerInitResponse") { + expect(output.success).toBe(false); + expect(output.error).toContain("schema version"); + } + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 4 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("returns init response for non-memory db names", async () => { + const name = SimpleName.orThrow("DbWorkerFileDb"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: "dbworker-file-db", + schemaVersion: 1, + }); + const output = await init; + expect(output.type).toBe("DbWorkerInitResponse"); + if (output.type === "DbWorkerInitResponse") { + expect(output.success).toBe(false); + } + }); + + test("supports query calls without params field", async () => { + const name = SimpleName.orThrow("DbWorkerQueryDefaultParams"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const query = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 1, + sql: "select 1 as value", + } as DbWorkerInput); + const queryOutput = await query; + expect(queryOutput.type).toBe("DbWorkerQueryResponse"); + if (queryOutput.type === "DbWorkerQueryResponse") { + expect(queryOutput.rows).toEqual([{ value: 1 }]); + } + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 2 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("reacquires shared db after watchdog releases stale leader", async () => { + const name = SimpleName.orThrow("DbWorkerReacquireAfterWatchdog"); + const dbName = ":memory:"; + const clock = createManualClock(); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPortWithOptions( + { + name, + port: channel.port1, + brokerPort: broker.port1, + }, + { + heartbeatTimeoutMs: 80, + heartbeatCheckIntervalMs: 20, + now: clock.now, + setInterval: clock.setInterval, + clearInterval: clock.clearInterval, + }, + ); + + const leader1 = waitForLeader(broker.port2, 2_000); + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + expect(await leader1).toMatchObject({ + type: "LeaderAcquired", + name, + }); + + clock.advance(220); + await flushAsync(); + + const leader2 = waitForLeader(broker.port2, 120); + const query = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 3, + sql: "select 1 as value", + params: [], + }); + + const [leaderOutput, queryOutput] = await Promise.all([leader2, query]); + expect( + leaderOutput === null || leaderOutput.type === "LeaderAcquired", + ).toBe(true); + expect(queryOutput.type).toBe("DbWorkerQueryResponse"); + if (queryOutput.type === "DbWorkerQueryResponse") { + expect(queryOutput.rows).toEqual([{ value: 1 }]); + } + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 4 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("returns init error for invalid db name", async () => { + const name = SimpleName.orThrow("DbWorkerInvalidDbName"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: "invalid db name", + schemaVersion: 1, + }); + const output = await init; + expect(output.type).toBe("DbWorkerInitResponse"); + if (output.type === "DbWorkerInitResponse") { + expect(output.success).toBe(false); + expect(output.error).toBeTruthy(); + } + }); + + test("returns null appOwner when metadata is missing", async () => { + const name = SimpleName.orThrow("DbWorkerMissingOwner"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const getOwner = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerGetAppOwner" }); + expect(await getOwner).toEqual({ + type: "DbWorkerAppOwner", + appOwner: null, + }); + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 7 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("handles concurrent failing init attempts for invalid shared db", async () => { + const name = SimpleName.orThrow("DbWorkerConcurrentInvalidInit"); + const channel1 = createMessageChannel(); + const broker1 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker1.port2.onMessage = () => {}; + runWebDbWorkerPort({ + name, + port: channel1.port1, + brokerPort: broker1.port1, + }); + + const channel2 = createMessageChannel(); + const broker2 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker2.port2.onMessage = () => {}; + runWebDbWorkerPort({ + name, + port: channel2.port1, + brokerPort: broker2.port1, + }); + + const init1 = waitForOutput(channel1.port2); + const init2 = waitForOutput(channel2.port2); + channel1.port2.postMessage({ + type: "DbWorkerInit", + dbName: "invalid db name", + schemaVersion: 1, + }); + channel2.port2.postMessage({ + type: "DbWorkerInit", + dbName: "invalid db name", + schemaVersion: 1, + }); + + const [out1, out2] = await Promise.all([init1, init2]); + expect(out1).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + expect(out2).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + }); + + test("cleans shared init state when follower waits on failing initPromise", async () => { + const name = SimpleName.orThrow("DbWorkerInitPromiseCleanup"); + const dbName = ":memory:"; + const firstDriver = createDeferred(); + void firstDriver.promise.catch(() => undefined); + const firstCreateDriverCall = createDeferred(); + let firstCreateDriverCallResolved = false; + let createDriverCalls = 0; + const createDriver = vi.fn(async (_dbName: string) => { + createDriverCalls += 1; + if (createDriverCalls === 1) { + if (!firstCreateDriverCallResolved) { + firstCreateDriverCallResolved = true; + firstCreateDriverCall.resolve(); + } + return await firstDriver.promise; + } + return createMockDriver(); + }); + + const channel1 = createMessageChannel(); + const broker1 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker1.port2.onMessage = () => {}; + runWebDbWorkerPortWithOptions( + { name, port: channel1.port1, brokerPort: broker1.port1 }, + { createDriver }, + ); + + const channel2 = createMessageChannel(); + const broker2 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker2.port2.onMessage = () => {}; + runWebDbWorkerPortWithOptions( + { name, port: channel2.port1, brokerPort: broker2.port1 }, + { createDriver }, + ); + + const init1 = waitForOutput(channel1.port2); + channel1.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + + await Promise.race([ + firstCreateDriverCall.promise, + wait(2_000).then(() => { + throw new Error("Timed out waiting for first createDriver call"); + }), + ]); + + const init2 = waitForOutput(channel2.port2); + channel2.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + + await wait(20); + firstDriver.reject(new Error("simulated shared init failure")); + + const [init1Output, init2Output] = await Promise.all([init1, init2]); + expect(init1Output).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + expect(init2Output).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + if (init1Output.type === "DbWorkerInitResponse") { + expect(init1Output.error).toContain("simulated shared init failure"); + } + if (init2Output.type === "DbWorkerInitResponse") { + expect(init2Output.error).toContain("simulated shared init failure"); + } + expect(createDriverCalls).toBe(1); + + const channel3 = createMessageChannel(); + const broker3 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker3.port2.onMessage = () => {}; + runWebDbWorkerPortWithOptions( + { name, port: channel3.port1, brokerPort: broker3.port1 }, + { createDriver }, + ); + + const init3 = waitForOutput(channel3.port2); + channel3.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + expect(await init3).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + expect(createDriverCalls).toBe(2); + + const query3 = waitForOutput(channel3.port2); + channel3.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 301, + sql: "select 1 as value", + params: [], + }); + expect(await query3).toMatchObject({ + type: "DbWorkerQueryResponse", + requestId: 301, + rows: [{ value: 1 }], + }); + + const close1 = waitForOutput(channel1.port2); + channel1.port2.postMessage({ type: "DbWorkerClose", requestId: 302 }); + expect(await close1).toMatchObject({ type: "DbWorkerCloseResponse" }); + + const close2 = waitForOutput(channel2.port2); + channel2.port2.postMessage({ type: "DbWorkerClose", requestId: 303 }); + expect(await close2).toMatchObject({ type: "DbWorkerCloseResponse" }); + + const close3 = waitForOutput(channel3.port2); + channel3.port2.postMessage({ type: "DbWorkerClose", requestId: 304 }); + expect(await close3).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("close is idempotent for already released shared db", async () => { + const name = SimpleName.orThrow("DbWorkerCloseTwice"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const close1 = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 8 }); + expect(await close1).toMatchObject({ type: "DbWorkerCloseResponse" }); + + const close2 = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 9 }); + expect(await close2).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("stale worker rejects re-init with changed dbName", async () => { + const name = SimpleName.orThrow("DbWorkerStaleReinitMismatch"); + const clock = createManualClock(); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPortWithOptions( + { + name, + port: channel.port1, + brokerPort: broker.port1, + }, + { + heartbeatTimeoutMs: 80, + heartbeatCheckIntervalMs: 20, + now: clock.now, + setInterval: clock.setInterval, + clearInterval: clock.clearInterval, + }, + ); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + clock.advance(220); + await flushAsync(); + + const reinitDbName = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: "changed-db", + schemaVersion: 1, + }); + const dbNameOutput = await reinitDbName; + expect(dbNameOutput).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 12 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("stale worker rejects re-init with changed schemaVersion", async () => { + const name = SimpleName.orThrow("DbWorkerStaleReinitSchemaMismatch"); + const clock = createManualClock(); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPortWithOptions( + { + name, + port: channel.port1, + brokerPort: broker.port1, + }, + { + heartbeatTimeoutMs: 80, + heartbeatCheckIntervalMs: 20, + now: clock.now, + setInterval: clock.setInterval, + clearInterval: clock.clearInterval, + }, + ); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + clock.advance(220); + await flushAsync(); + + const reinitSchema = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 2, + }); + expect(await reinitSchema).toMatchObject({ + type: "DbWorkerInitResponse", + success: false, + }); + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 13 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("ignores unrelated leader heartbeat messages", async () => { + const name = SimpleName.orThrow("DbWorkerHeartbeatFilter"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const init = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerInit", + dbName: ":memory:", + schemaVersion: 1, + }); + expect(await init).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + broker.port2.postMessage({ + type: "LeaderHeartbeat", + name: SimpleName.orThrow("OtherLeader"), + }); + + const query = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerQuery", + requestId: 10, + sql: "select 1 as value", + params: [], + }); + const queryOutput = await query; + expect(queryOutput.type).toBe("DbWorkerQueryResponse"); + + const close = waitForOutput(channel.port2); + channel.port2.postMessage({ type: "DbWorkerClose", requestId: 11 }); + expect(await close).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("handles concurrent shared db initialization requests", async () => { + const name = SimpleName.orThrow("DbWorkerConcurrentInit"); + const dbName = ":memory:"; + + const channel1 = createMessageChannel(); + const broker1 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker1.port2.onMessage = () => {}; + runWebDbWorkerPort({ + name, + port: channel1.port1, + brokerPort: broker1.port1, + }); + + const channel2 = createMessageChannel(); + const broker2 = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker2.port2.onMessage = () => {}; + runWebDbWorkerPort({ + name, + port: channel2.port1, + brokerPort: broker2.port1, + }); + + const init1 = waitForOutput(channel1.port2); + const init2 = waitForOutput(channel2.port2); + channel1.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + channel2.port2.postMessage({ + type: "DbWorkerInit", + dbName, + schemaVersion: 1, + }); + + const [output1, output2] = await Promise.all([init1, init2]); + expect(output1).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + expect(output2).toMatchObject({ + type: "DbWorkerInitResponse", + success: true, + }); + + const close1 = waitForOutput(channel1.port2); + channel1.port2.postMessage({ type: "DbWorkerClose", requestId: 5 }); + expect(await close1).toMatchObject({ type: "DbWorkerCloseResponse" }); + + const close2 = waitForOutput(channel2.port2); + channel2.port2.postMessage({ type: "DbWorkerClose", requestId: 6 }); + expect(await close2).toMatchObject({ type: "DbWorkerCloseResponse" }); + }); + + test("returns DbWorkerError for unknown message type", async () => { + const name = SimpleName.orThrow("DbWorkerUnknownMessage"); + const channel = createMessageChannel(); + const broker = createMessageChannel< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >(); + broker.port2.onMessage = () => {}; + + runWebDbWorkerPort({ + name, + port: channel.port1, + brokerPort: broker.port1, + }); + + const output = waitForOutput(channel.port2); + channel.port2.postMessage({ + type: "DbWorkerUnknown", + requestId: 42, + } as unknown as DbWorkerInput); + + const errorOutput = await output; + expect(errorOutput.type).toBe("DbWorkerError"); + if (errorOutput.type === "DbWorkerError") { + expect(errorOutput.requestId).toBe(42); + expect(errorOutput.error).toContain("Unknown message type"); + } + }); }); diff --git a/packages/web/test/Evolu.test.ts b/packages/web/test/Evolu.test.ts new file mode 100644 index 000000000..7a4f59d55 --- /dev/null +++ b/packages/web/test/Evolu.test.ts @@ -0,0 +1,146 @@ +import { testCreateConsole } from "@evolu/common"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createWorker: vi.fn(), + createSharedWorker: vi.fn(), + createMessageChannel: vi.fn(), + reloadApp: vi.fn(), + createCommonEvoluDeps: vi.fn(), +})); + +vi.mock("../src/Worker.js", () => ({ + createWorker: mocks.createWorker, + createSharedWorker: mocks.createSharedWorker, + createMessageChannel: mocks.createMessageChannel, +})); + +vi.mock("../src/Platform.js", () => ({ + reloadApp: mocks.reloadApp, +})); + +vi.mock("@evolu/common/local-first", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createEvoluDeps: mocks.createCommonEvoluDeps, + }; +}); + +import { createEvoluDeps } from "../src/local-first/Evolu.js"; + +class MockWorker { + constructor( + readonly url: URL, + readonly options: WorkerOptions, + ) {} +} + +class MockSharedWorker { + readonly port = { + postMessage: vi.fn(), + onMessage: null, + native: null, + [Symbol.dispose]: vi.fn(), + }; + + constructor( + readonly url: URL, + readonly options: WorkerOptions, + ) {} +} + +describe("createEvoluDeps (web)", () => { + beforeEach(() => { + mocks.createWorker.mockReset(); + mocks.createSharedWorker.mockReset(); + mocks.createMessageChannel.mockReset(); + mocks.reloadApp.mockReset(); + mocks.createCommonEvoluDeps.mockReset(); + + mocks.createCommonEvoluDeps.mockImplementation((deps) => deps); + + vi.stubGlobal("Worker", MockWorker as unknown as typeof Worker); + vi.stubGlobal( + "SharedWorker", + MockSharedWorker as unknown as typeof SharedWorker, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("wires worker deps and shared worker into common createEvoluDeps", () => { + const console = testCreateConsole(); + const wrappedSharedWorker = { + port: { + postMessage: vi.fn(), + onMessage: null, + native: null, + [Symbol.dispose]: vi.fn(), + }, + [Symbol.dispose]: vi.fn(), + }; + const wrappedDbWorker = { + postMessage: vi.fn(), + onMessage: null, + native: null, + [Symbol.dispose]: vi.fn(), + }; + mocks.createSharedWorker.mockReturnValue(wrappedSharedWorker); + mocks.createWorker.mockReturnValue(wrappedDbWorker); + + const result = createEvoluDeps({ console }); + + expect(mocks.createSharedWorker).toHaveBeenCalledTimes(1); + const sharedWorkerNative = mocks.createSharedWorker.mock.calls[0]?.[0]; + expect(sharedWorkerNative).toBeInstanceOf(MockSharedWorker); + expect(String((sharedWorkerNative as MockSharedWorker).url)).toContain( + "Shared.worker.js", + ); + expect((sharedWorkerNative as MockSharedWorker).options).toEqual({ + type: "module", + }); + + expect(mocks.createCommonEvoluDeps).toHaveBeenCalledTimes(1); + const passed = mocks.createCommonEvoluDeps.mock.calls[0]?.[0] as { + readonly console: unknown; + readonly createDbWorker: () => unknown; + readonly createMessageChannel: unknown; + readonly reloadApp: unknown; + readonly sharedWorker: unknown; + }; + + expect(passed.console).toBe(console); + expect(passed.createMessageChannel).toBe(mocks.createMessageChannel); + expect(passed.reloadApp).toBe(mocks.reloadApp); + expect(passed.sharedWorker).toBe(wrappedSharedWorker); + + const dbWorker = passed.createDbWorker(); + expect(mocks.createWorker).toHaveBeenCalledTimes(1); + const dbWorkerNative = mocks.createWorker.mock.calls[0]?.[0]; + expect(dbWorkerNative).toBeInstanceOf(MockWorker); + expect(String((dbWorkerNative as MockWorker).url)).toContain( + "Db.worker.js", + ); + expect((dbWorkerNative as MockWorker).options).toEqual({ type: "module" }); + expect(dbWorker).toBe(wrappedDbWorker); + expect(result).toBe(passed); + }); + + test("supports default deps argument", () => { + const result = createEvoluDeps(); + const passed = mocks.createCommonEvoluDeps.mock.calls[0]?.[0] as { + readonly createDbWorker: () => unknown; + readonly createMessageChannel: unknown; + readonly reloadApp: unknown; + }; + + expect(typeof passed.createDbWorker).toBe("function"); + expect(passed.createMessageChannel).toBe(mocks.createMessageChannel); + expect(passed.reloadApp).toBe(mocks.reloadApp); + expect(result).toBe(passed); + }); +}); diff --git a/packages/web/test/LocalAuth.test.ts b/packages/web/test/LocalAuth.test.ts new file mode 100644 index 000000000..ce16f64df --- /dev/null +++ b/packages/web/test/LocalAuth.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { createWebAuthnStore } from "../src/local-first/LocalAuth.js"; + +const stores = new Map>(); + +vi.mock("idb-keyval", () => { + const getStore = (store: unknown): Map => { + const key = String(store); + let map = stores.get(key); + if (!map) { + map = new Map(); + stores.set(key, map); + } + return map; + }; + + return { + createStore: (name: string, table: string) => `${name}:${table}`, + set: async (key: string, value: unknown, store: unknown) => { + getStore(store).set(key, value); + }, + get: async (key: string, store: unknown) => { + return getStore(store).get(key); + }, + del: async (key: string, store: unknown) => { + getStore(store).delete(key); + }, + keys: async (store: unknown) => { + return [...getStore(store).keys()]; + }, + clear: async (store: unknown) => { + getStore(store).clear(); + }, + }; +}); + +let lastSeed: Uint8Array | null = null; + +const installCredentialMocks = () => { + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(async (options: CredentialCreationOptions) => { + const seed = new Uint8Array( + options.publicKey?.user.id as ArrayBufferLike, + ); + lastSeed = new Uint8Array(seed); + return { + rawId: new Uint8Array([1, 2, 3]).buffer, + } as PublicKeyCredential; + }), + get: vi.fn(async () => { + return { + response: { + userHandle: lastSeed?.buffer ?? new Uint8Array([9, 9, 9]).buffer, + }, + } as PublicKeyCredential; + }), + } as CredentialContainer, + }); +}; + +const createDeps = () => ({ + randomBytes: { + create: (length: number) => + new Uint8Array(Array.from({ length }, (_, i) => i % 251)), + }, +}); + +beforeEach(() => { + stores.clear(); + lastSeed = null; + installCredentialMocks(); +}); + +test("createWebAuthnStore supports metadata-only path (accessControl:none)", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "none-service"; + + const setResult = await store.setItem("k1", "value-1", { + service, + accessControl: "none", + }); + expect(setResult.metadata.accessControl).toBe("none"); + + const item = await store.getItem("k1", { + service, + accessControl: "none", + }); + expect(item?.value).toBe("value-1"); + + const all = await store.getAllItems({ service, includeValues: true }); + expect(all).toHaveLength(1); + expect(all[0]?.value).toBe("value-1"); + + const deleted = await store.deleteItem("k1", { + service, + accessControl: "none", + }); + expect(deleted).toBe(true); + expect( + await store.getItem("k1", { service, accessControl: "none" }), + ).toBeNull(); +}); + +test("createWebAuthnStore secure path encrypts and decrypts auth payload", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "secure-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + await store.setItem("owner-1", payload, { + service, + webAuthnUsername: "Alice", + relyingPartyName: "SQLoot", + }); + + const item = await store.getItem("owner-1", { service }); + expect(item?.value).toBe(payload); + expect(item?.metadata.accessControl).toBe("biometryCurrentSet"); +}); + +test("createWebAuthnStore secure getItem returns null when entry is missing", async () => { + const store = createWebAuthnStore(createDeps()); + + expect( + await store.getItem("missing-key", { service: "missing-service" }), + ).toBe(null); +}); + +test("createWebAuthnStore returns null when secure credential cannot decrypt", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "decrypt-fail-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + await store.setItem("owner-1", payload, { service }); + + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(), + get: vi.fn(async () => { + return { + response: { + userHandle: new Uint8Array(32).buffer, + }, + } as PublicKeyCredential; + }), + } as CredentialContainer, + }); + + const item = await store.getItem("owner-1", { service }); + expect(item).toBeNull(); +}); + +test("createWebAuthnStore returns null when credential retrieval throws", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "credential-throw-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + await store.setItem("owner-1", payload, { service }); + + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(), + get: vi.fn(async () => { + throw new Error("Auth failed"); + }), + } as CredentialContainer, + }); + + expect(await store.getItem("owner-1", { service })).toBeNull(); +}); + +test("createWebAuthnStore returns null when userHandle is missing", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "missing-user-handle-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + await store.setItem("owner-1", payload, { service }); + + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(), + get: vi.fn(async () => { + return { + response: { + userHandle: null, + }, + } as PublicKeyCredential; + }), + } as CredentialContainer, + }); + + expect(await store.getItem("owner-1", { service })).toBeNull(); +}); + +test("createWebAuthnStore returns null when credential response is missing", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "missing-credential-response-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + await store.setItem("owner-1", payload, { service }); + + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(), + get: vi.fn(async () => { + return {} as PublicKeyCredential; + }), + } as CredentialContainer, + }); + + expect(await store.getItem("owner-1", { service })).toBeNull(); +}); + +test("createWebAuthnStore getAllItems falls back metadata for legacy entries", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "legacy-service"; + const storeKey = `${service}:evolu-auth`; + stores.set(storeKey, new Map([["legacy-key", { value: "legacy-value" }]])); + + const items = await store.getAllItems({ service, includeValues: true }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "legacy-key", + value: "legacy-value", + metadata: expect.objectContaining({ + accessControl: "biometryCurrentSet", + backend: "keychain", + securityLevel: "biometry", + }), + }); +}); + +test("createWebAuthnStore secure setItem throws when credential creation fails", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "create-null-service"; + const payload = JSON.stringify({ owner: undefined, username: "Alice" }); + + Object.defineProperty(globalThis.navigator, "credentials", { + configurable: true, + value: { + create: vi.fn(async () => null), + get: vi.fn(), + } as CredentialContainer, + }); + + await expect( + store.setItem("owner-1", payload, { + service, + webAuthnUsername: "Alice", + }), + ).rejects.toThrow("Failed to create WebAuthn credential"); +}); + +test("createWebAuthnStore clearService removes all entries", async () => { + const store = createWebAuthnStore(createDeps()); + const service = "clear-service"; + + await store.setItem("a", "1", { service, accessControl: "none" }); + await store.setItem("b", "2", { service, accessControl: "none" }); + expect(await store.getAllItems({ service })).toHaveLength(2); + + await store.clearService({ service, accessControl: "none" }); + expect(await store.getAllItems({ service })).toHaveLength(0); +}); diff --git a/packages/web/test/Sqlite.mocked.test.ts b/packages/web/test/Sqlite.mocked.test.ts new file mode 100644 index 000000000..503467197 --- /dev/null +++ b/packages/web/test/Sqlite.mocked.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test, vi } from "vitest"; +import { + createSqliteApiWarnHandler, + createWasmSqliteDriver, + testSetSqlite3InitModule, +} from "../src/Sqlite.js"; + +interface SqliteMockState { + readonly dbNames: Array; + readonly execSql: Array; +} + +const createSqlite3Mock = () => { + const state: SqliteMockState = { + dbNames: [], + execSql: [], + }; + + class MockPreparedStatement { + readonly bind = vi.fn(); + readonly step = vi.fn(() => false); + readonly get = vi.fn(() => ({})); + readonly reset = vi.fn(); + readonly finalize = vi.fn(); + } + + class MockDatabase { + constructor(name: string) { + state.dbNames.push(name); + } + + readonly prepare = vi.fn((_sql: string) => new MockPreparedStatement()); + + readonly exec = vi.fn((query: string) => { + state.execSql.push(query); + return []; + }); + + readonly changes = vi.fn(() => 0); + + readonly close = vi.fn(); + } + + const installOpfsSAHPoolVfs = vi.fn(async (_options: unknown) => ({ + OpfsSAHPoolDb: MockDatabase, + })); + const sqlite3mc_vfs_create = vi.fn(); + const sqlite3_js_db_export = vi.fn(() => new Uint8Array([1, 2, 3])); + + return { + sqlite3: { + capi: { sqlite3mc_vfs_create, sqlite3_js_db_export }, + installOpfsSAHPoolVfs, + oo1: { DB: MockDatabase }, + }, + installOpfsSAHPoolVfs, + sqlite3mc_vfs_create, + sqlite3_js_db_export, + state, + }; +}; + +describe("Sqlite module test seams", () => { + test("createSqliteApiWarnHandler suppresses only known OPFS warning", () => { + const warn = vi.fn(); + const handler = createSqliteApiWarnHandler(warn); + + handler("Ignoring inability to install OPFS sqlite3_vfs test"); + expect(warn).not.toHaveBeenCalled(); + + handler("Other warning"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith("Other warning"); + }); + + test("createWasmSqliteDriver uses encrypted OPFS path", async () => { + const sqlite3Mock = createSqlite3Mock(); + using _restore = testSetSqlite3InitModule( + async () => sqlite3Mock.sqlite3 as never, + ); + + const result = await createWasmSqliteDriver("encrypted-db", { + mode: "encrypted", + encryptionKey: new Uint8Array([0x01, 0x02, 0x0a, 0xff]), + })(); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(sqlite3Mock.sqlite3mc_vfs_create).toHaveBeenCalledWith("opfs", 1); + expect(sqlite3Mock.installOpfsSAHPoolVfs).toHaveBeenCalledWith({ + directory: ".encrypted-db", + }); + expect(sqlite3Mock.state.dbNames).toContain( + "file:evolu1.db?vfs=multipleciphers-opfs-sahpool", + ); + expect( + sqlite3Mock.state.execSql.some((sql) => + sql.includes("PRAGMA cipher = 'sqlcipher'"), + ), + ).toBe(true); + expect( + sqlite3Mock.state.execSql.some((sql) => + sql.includes(`PRAGMA key = "x'01020aff'"`), + ), + ).toBe(true); + + result.value[Symbol.dispose](); + }); + + test("createWasmSqliteDriver uses default OPFS path", async () => { + const sqlite3Mock = createSqlite3Mock(); + using _restore = testSetSqlite3InitModule( + async () => sqlite3Mock.sqlite3 as never, + ); + + const result = await createWasmSqliteDriver("plain-db")(); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(sqlite3Mock.sqlite3mc_vfs_create).toHaveBeenCalledWith("opfs", 1); + expect(sqlite3Mock.installOpfsSAHPoolVfs).toHaveBeenCalledWith({ + name: "plain-db", + }); + expect(sqlite3Mock.state.dbNames).toContain("file:evolu1.db"); + + result.value[Symbol.dispose](); + }); +}); diff --git a/packages/web/test/Worker.test.ts b/packages/web/test/Worker.test.ts new file mode 100644 index 000000000..353c18fa4 --- /dev/null +++ b/packages/web/test/Worker.test.ts @@ -0,0 +1,322 @@ +import type { MessagePort } from "@evolu/common"; +import { describe, expect, test, vi } from "vitest"; +import { + createMessageChannel, + createMessagePort, + createSharedWorker, + createSharedWorkerScope, + createSharedWorkerSelf, + createWorker, + createWorkerRun, + createWorkerScope, + createWorkerSelf, +} from "../src/Worker.js"; + +const createInlineWorkerUrl = (): string => { + const code = ` + self.onmessage = (event) => { + self.postMessage({ echo: event.data }); + }; + `; + return URL.createObjectURL(new Blob([code], { type: "text/javascript" })); +}; + +const createFakeWorkerSelf = () => { + const listeners = { + error: new Set<(event: ErrorEvent) => void>(), + unhandledrejection: new Set<(event: PromiseRejectionEvent) => void>(), + }; + + const nativeSelf = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn( + (type: "error" | "unhandledrejection", listener: unknown) => { + listeners[type].add(listener as never); + }, + ), + removeEventListener: vi.fn( + (type: "error" | "unhandledrejection", listener: unknown) => { + listeners[type].delete(listener as never); + }, + ), + }; + + return { + nativeSelf, + emitMessage: (data: unknown) => { + nativeSelf.onmessage?.({ data } as MessageEvent); + }, + emitError: (error: Error) => { + for (const listener of listeners.error) { + listener({ error } as ErrorEvent); + } + }, + emitUnhandledRejection: (reason: unknown) => { + for (const listener of listeners.unhandledrejection) { + listener({ reason } as PromiseRejectionEvent); + } + }, + }; +}; + +const createFakeSharedWorkerSelf = () => { + const listeners = { + error: new Set<(event: ErrorEvent) => void>(), + unhandledrejection: new Set<(event: PromiseRejectionEvent) => void>(), + }; + + const nativeSelf = { + onconnect: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + addEventListener: vi.fn( + (type: "error" | "unhandledrejection", listener: unknown) => { + listeners[type].add(listener as never); + }, + ), + removeEventListener: vi.fn( + (type: "error" | "unhandledrejection", listener: unknown) => { + listeners[type].delete(listener as never); + }, + ), + }; + + return { + nativeSelf, + emitConnect: (port: globalThis.MessagePort) => { + nativeSelf.onconnect?.({ ports: [port] } as unknown as MessageEvent); + }, + emitError: (error: Error) => { + for (const listener of listeners.error) { + listener({ error } as ErrorEvent); + } + }, + emitUnhandledRejection: (reason: unknown) => { + for (const listener of listeners.unhandledrejection) { + listener({ reason } as PromiseRejectionEvent); + } + }, + }; +}; + +describe("Worker wrappers", () => { + test("createMessageChannel forwards messages both directions", async () => { + const channel = createMessageChannel< + { readonly ping: string }, + { readonly pong: number } + >(); + + const ping = new Promise<{ readonly ping: string }>((resolve) => { + channel.port2.onMessage = resolve; + }); + channel.port1.postMessage({ ping: "hello" }); + await expect(ping).resolves.toEqual({ ping: "hello" }); + + const pong = new Promise<{ readonly pong: number }>((resolve) => { + channel.port1.onMessage = resolve; + }); + channel.port2.postMessage({ pong: 42 }); + await expect(pong).resolves.toEqual({ pong: 42 }); + + channel[Symbol.dispose](); + }); + + test("createMessagePort supports normal and transfer-list postMessage", async () => { + const native = new MessageChannel(); + const port = createMessagePort< + { readonly payload: Uint8Array | ArrayBuffer }, + { readonly ok: true } + >(native.port1 as never); + + const normal = new Promise<{ readonly payload: Uint8Array }>((resolve) => { + native.port2.onmessage = (event) => { + resolve(event.data as { readonly payload: Uint8Array }); + }; + }); + port.postMessage({ payload: new Uint8Array([1, 2, 3]) }); + await expect(normal).resolves.toEqual({ + payload: new Uint8Array([1, 2, 3]), + }); + + const buffer = new ArrayBuffer(4); + new Uint8Array(buffer).set([9, 8, 7, 6]); + const transferred = new Promise<{ readonly payload: ArrayBuffer }>( + (resolve) => { + native.port2.onmessage = (event) => { + resolve(event.data as { readonly payload: ArrayBuffer }); + }; + }, + ); + port.postMessage({ payload: buffer }, [buffer]); + const transferredValue = await transferred; + expect(new Uint8Array(transferredValue.payload)).toEqual( + new Uint8Array([9, 8, 7, 6]), + ); + + port[Symbol.dispose](); + native.port2.close(); + }); + + test("createWorker wraps native worker and disposes via terminate", async () => { + const url = createInlineWorkerUrl(); + try { + const nativeWorker = new Worker(url); + const worker = createWorker< + { readonly value: string }, + { readonly echo: { readonly value: string } } + >(nativeWorker); + + const response = new Promise<{ + readonly echo: { readonly value: string }; + }>((resolve) => { + worker.onMessage = resolve; + }); + worker.postMessage({ value: "ok" }); + await expect(response).resolves.toEqual({ echo: { value: "ok" } }); + + worker[Symbol.dispose](); + } finally { + URL.revokeObjectURL(url); + } + }); + + test("createSharedWorker wraps provided shared worker port", async () => { + const channel = new MessageChannel(); + const shared = createSharedWorker< + { readonly ping: number }, + { readonly pong: number } + >({ port: channel.port1 } as unknown as globalThis.SharedWorker); + + const response = new Promise<{ readonly ping: number }>((resolve) => { + shared.port.onMessage = resolve; + }); + channel.port2.postMessage({ ping: 10 }); + await expect(response).resolves.toEqual({ ping: 10 }); + + shared[Symbol.dispose](); + channel.port2.close(); + }); + + test("createWorkerSelf enforces onMessage before receiving messages", () => { + const fake = createFakeWorkerSelf(); + const self = createWorkerSelf< + { readonly input: string }, + { readonly output: string } + >(fake.nativeSelf as unknown as globalThis.DedicatedWorkerGlobalScope); + + expect(() => fake.emitMessage({ input: "x" })).toThrow( + "onMessage must be set before receiving messages", + ); + + const onMessage = vi.fn(); + self.onMessage = onMessage; + fake.emitMessage({ input: "ok" }); + expect(onMessage).toHaveBeenCalledWith({ input: "ok" }); + + self.postMessage({ output: "pong" }); + expect(fake.nativeSelf.postMessage).toHaveBeenCalledWith({ + output: "pong", + }); + + self[Symbol.dispose](); + expect(fake.nativeSelf.close).toHaveBeenCalledTimes(1); + }); + + test("createSharedWorkerSelf enforces onConnect and wraps connecting ports", async () => { + const fake = createFakeSharedWorkerSelf(); + const self = createSharedWorkerSelf< + { readonly fromClient: string }, + { readonly fromWorker: string } + >(fake.nativeSelf as unknown as globalThis.SharedWorkerGlobalScope); + + const nativeChannel = new MessageChannel(); + expect(() => fake.emitConnect(nativeChannel.port1)).toThrow( + "onConnect must be set before receiving connections", + ); + + const onConnect = vi.fn(); + self.onConnect = onConnect; + fake.emitConnect(nativeChannel.port1); + expect(onConnect).toHaveBeenCalledTimes(1); + + const wrappedPort = onConnect.mock.calls[0]?.[0] as MessagePort< + { readonly fromWorker: string }, + { readonly fromClient: string } + >; + wrappedPort.postMessage({ fromWorker: "hello" }); + + const received = new Promise<{ readonly fromWorker: string }>((resolve) => { + nativeChannel.port2.onmessage = (event) => { + resolve(event.data as { readonly fromWorker: string }); + }; + }); + await expect(received).resolves.toEqual({ fromWorker: "hello" }); + + self[Symbol.dispose](); + expect(fake.nativeSelf.close).toHaveBeenCalledTimes(1); + nativeChannel.port2.close(); + }); + + test("createWorkerRun provides createMessagePort and console deps", async () => { + await using run = createWorkerRun(); + const native = new MessageChannel(); + const port = run.deps.createMessagePort< + { readonly ack: true }, + { readonly value: number } + >(native.port1 as never); + + const incoming = new Promise<{ readonly value: number }>((resolve) => { + port.onMessage = resolve; + }); + native.port2.postMessage({ value: 7 }); + await expect(incoming).resolves.toEqual({ value: 7 }); + expect(run.deps.consoleStoreOutputEntry).toBeDefined(); + + port[Symbol.dispose](); + native.port2.close(); + }); + + test("deprecated createWorkerScope wires error handlers and disposes", () => { + const fake = createFakeWorkerSelf(); + const scope = createWorkerScope< + { readonly input: string }, + { readonly output: string } + >(fake.nativeSelf as unknown as globalThis.DedicatedWorkerGlobalScope); + + const onError = vi.fn(); + scope.onError = onError; + + fake.emitError(new Error("boom")); + fake.emitUnhandledRejection(new Error("rejected")); + expect(onError).toHaveBeenCalledTimes(2); + + scope[Symbol.dispose](); + expect(fake.nativeSelf.close).toHaveBeenCalledTimes(1); + }); + + test("deprecated createSharedWorkerScope wires connect/error handlers and disposes", () => { + const fake = createFakeSharedWorkerSelf(); + const scope = createSharedWorkerScope< + { readonly fromClient: string }, + { readonly fromWorker: string } + >(fake.nativeSelf as unknown as globalThis.SharedWorkerGlobalScope); + + const onConnect = vi.fn(); + const onError = vi.fn(); + scope.onConnect = onConnect; + scope.onError = onError; + + const nativeChannel = new MessageChannel(); + fake.emitConnect(nativeChannel.port1); + expect(onConnect).toHaveBeenCalledTimes(1); + + fake.emitError(new Error("boom")); + fake.emitUnhandledRejection(new Error("rejected")); + expect(onError).toHaveBeenCalledTimes(2); + + scope[Symbol.dispose](); + expect(fake.nativeSelf.close).toHaveBeenCalledTimes(1); + nativeChannel.port2.close(); + }); +}); diff --git a/packages/web/test/Worker.worker.test.ts b/packages/web/test/Worker.worker.test.ts new file mode 100644 index 000000000..6156c4aac --- /dev/null +++ b/packages/web/test/Worker.worker.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + initEvoluWorker: vi.fn(), + createSharedWorkerScope: vi.fn(), + createWorkerRun: vi.fn(), + runWebDbWorkerPort: vi.fn(), +})); + +vi.mock("@evolu/common/local-first", () => ({ + initEvoluWorker: mocks.initEvoluWorker, +})); + +vi.mock("../src/Worker.js", () => ({ + createSharedWorkerScope: mocks.createSharedWorkerScope, + createWorkerRun: mocks.createWorkerRun, +})); + +vi.mock("../src/local-first/DbWorker.js", () => ({ + runWebDbWorkerPort: mocks.runWebDbWorkerPort, +})); + +const importWorkerModule = async (id: "a" | "b") => { + if (id === "a") { + return import("../src/local-first/Worker.worker.ts?worker-worker-test-a"); + } + return import("../src/local-first/Worker.worker.ts?worker-worker-test-b"); +}; + +describe("Worker.worker bootstrap", () => { + beforeEach(() => { + vi.resetModules(); + mocks.initEvoluWorker.mockReset(); + mocks.createSharedWorkerScope.mockReset(); + mocks.createWorkerRun.mockReset(); + mocks.runWebDbWorkerPort.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("creates run with db worker dep and initializes shared worker scope", async () => { + const disposeSymbol = + (Symbol as typeof Symbol & { dispose?: symbol }).dispose ?? + Symbol.for("Symbol.dispose"); + const asyncDisposeSymbol = + (Symbol as typeof Symbol & { asyncDispose?: symbol }).asyncDispose ?? + Symbol.for("Symbol.asyncDispose"); + + const fakeSelf = { kind: "shared-worker-self" }; + vi.stubGlobal("self", fakeSelf as unknown as typeof globalThis.self); + + const scope = { kind: "scope" }; + const initTask = { kind: "init-task" }; + const run = vi.fn(async () => undefined); + const disposeRun = vi.fn(async () => undefined); + const disposeBaseRun = vi.fn(async () => undefined); + const addDeps = vi.fn(() => { + Object.assign(run, { + [disposeSymbol]: disposeRun, + [asyncDisposeSymbol]: disposeRun, + }); + return run; + }); + const baseRun = { + addDeps, + [disposeSymbol]: disposeBaseRun, + [asyncDisposeSymbol]: disposeBaseRun, + }; + + mocks.createWorkerRun.mockReturnValue(baseRun); + mocks.createSharedWorkerScope.mockReturnValue(scope); + mocks.initEvoluWorker.mockReturnValue(initTask); + + await importWorkerModule("a"); + + expect(mocks.createWorkerRun).toHaveBeenCalledTimes(1); + expect(addDeps).toHaveBeenCalledWith({ + runDbWorkerPort: mocks.runWebDbWorkerPort, + }); + expect(mocks.createSharedWorkerScope).toHaveBeenCalledWith(fakeSelf); + expect(mocks.initEvoluWorker).toHaveBeenCalledWith(scope); + expect(run).toHaveBeenCalledWith(initTask); + expect(disposeBaseRun).toHaveBeenCalledTimes(1); + expect(disposeRun).not.toHaveBeenCalled(); + }); + + test("disposes base run even when worker init task rejects", async () => { + const disposeSymbol = + (Symbol as typeof Symbol & { dispose?: symbol }).dispose ?? + Symbol.for("Symbol.dispose"); + const asyncDisposeSymbol = + (Symbol as typeof Symbol & { asyncDispose?: symbol }).asyncDispose ?? + Symbol.for("Symbol.asyncDispose"); + + const fakeSelf = { kind: "shared-worker-self" }; + vi.stubGlobal("self", fakeSelf as unknown as typeof globalThis.self); + + const scope = { kind: "scope" }; + const initTask = { kind: "init-task" }; + const run = vi.fn(async () => { + throw new Error("worker init failed"); + }); + const disposeBaseRun = vi.fn(async () => undefined); + const baseRun = { + addDeps: vi.fn(() => run), + [disposeSymbol]: disposeBaseRun, + [asyncDisposeSymbol]: disposeBaseRun, + }; + + mocks.createWorkerRun.mockReturnValue(baseRun); + mocks.createSharedWorkerScope.mockReturnValue(scope); + mocks.initEvoluWorker.mockReturnValue(initTask); + + await expect(importWorkerModule("b")).rejects.toThrow("worker init failed"); + expect(disposeBaseRun).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/coverage-file-gate.mts b/scripts/coverage-file-gate.mts new file mode 100644 index 000000000..a7dbbde9c --- /dev/null +++ b/scripts/coverage-file-gate.mts @@ -0,0 +1,137 @@ +#!/usr/bin/env bun + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +type Threshold = { + readonly statements: number; + readonly branches: number; +}; + +type CoverageStats = { + readonly statements: { readonly pct: number }; + readonly branches: { readonly pct: number }; +}; + +const parseArgs = (args: ReadonlyArray) => { + const config = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || value == null) { + throw new Error( + `Invalid arguments. Expected --key value pairs, got: ${args.join(" ")}`, + ); + } + config.set(key.slice(2), value); + } + return config; +}; + +const parseThresholds = (raw: string): Map => { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error( + "Invalid thresholds JSON: expected an object keyed by file path.", + ); + } + + const thresholds = new Map(); + for (const [file, threshold] of Object.entries(parsed)) { + if (!threshold || typeof threshold !== "object" || Array.isArray(threshold)) + throw new Error( + `Invalid threshold for '${file}': expected object with numeric statements and branches.`, + ); + + const statements = (threshold as { statements?: unknown }).statements; + const branches = (threshold as { branches?: unknown }).branches; + + if ( + typeof statements !== "number" || + !Number.isFinite(statements) || + typeof branches !== "number" || + !Number.isFinite(branches) + ) { + throw new Error( + `Invalid threshold for '${file}': statements and branches must be finite numbers.`, + ); + } + + thresholds.set(file, { statements, branches }); + } + + return thresholds; +}; + +const toPercent = (value: number): string => `${value.toFixed(2)}%`; + +const resolveCoverageEntry = ( + coverageJson: Record, + file: string, +): CoverageStats | null => { + const absolute = resolve(file); + const byAbsolute = coverageJson[absolute]; + if (byAbsolute) return byAbsolute; + + const normalized = file.replaceAll("\\", "/"); + for (const [key, value] of Object.entries(coverageJson)) { + const keyNormalized = key.replaceAll("\\", "/"); + if ( + keyNormalized === normalized || + keyNormalized.endsWith(`/${normalized}`) + ) { + return value; + } + } + + return null; +}; + +const main = (): void => { + const args = parseArgs(process.argv.slice(2)); + const coveragePath = args.get("coverage"); + const thresholdsRaw = args.get("thresholds"); + + if (!coveragePath || !thresholdsRaw) { + throw new Error("Usage: --coverage --thresholds "); + } + + const coverageJson = JSON.parse( + readFileSync(resolve(coveragePath), "utf8"), + ) as Record; + + const thresholds = parseThresholds(thresholdsRaw); + const failures: Array = []; + + for (const [file, expected] of thresholds) { + const actual = resolveCoverageEntry(coverageJson, file); + if (!actual) { + failures.push(`Missing coverage entry for ${file}`); + continue; + } + + if (actual.statements.pct < expected.statements) { + failures.push( + `${file}: statements ${toPercent(actual.statements.pct)} < ${toPercent(expected.statements)}`, + ); + } + + if (actual.branches.pct < expected.branches) { + failures.push( + `${file}: branches ${toPercent(actual.branches.pct)} < ${toPercent(expected.branches)}`, + ); + } + } + + if (failures.length > 0) { + throw new Error( + `Coverage gate failed (${failures.length}):\n${failures + .map((line) => `- ${line}`) + .join("\n")}`, + ); + } + + console.log(`Coverage gate passed for ${thresholds.size} files.`); +}; + +main(); diff --git a/scripts/coverage-merge-bun.mts b/scripts/coverage-merge-bun.mts new file mode 100644 index 000000000..f985c35b4 --- /dev/null +++ b/scripts/coverage-merge-bun.mts @@ -0,0 +1,214 @@ +#!/usr/bin/env bun + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +type Counter = { + total: number; + covered: number; +}; + +type CoverageMetric = Counter & { + skipped: number; + pct: number; +}; + +type CoverageEntry = { + lines: CoverageMetric; + functions: CoverageMetric; + statements: CoverageMetric; + branches: CoverageMetric; +}; + +type CoverageSummary = Record; + +type LcovRecord = { + file: string; + lines: Counter; + functions: Counter; + branches: Counter; +}; + +const toMetric = ({ total, covered }: Counter): CoverageMetric => ({ + total, + covered, + skipped: 0, + pct: total === 0 ? 100 : (covered / total) * 100, +}); + +const sumMetric = ( + entries: ReadonlyArray, + metric: keyof CoverageEntry, +): CoverageMetric => { + let total = 0; + let covered = 0; + let skipped = 0; + + for (const entry of entries) { + total += entry[metric].total; + covered += entry[metric].covered; + skipped += entry[metric].skipped; + } + + return { + total, + covered, + skipped, + pct: total === 0 ? 100 : (covered / total) * 100, + }; +}; + +const parseArgs = (args: ReadonlyArray): Map => { + const config = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || value == null) { + throw new Error( + `Invalid arguments. Expected --key value pairs, got: ${args.join(" ")}`, + ); + } + config.set(key.slice(2), value); + } + return config; +}; + +const parseLcov = (lcovContent: string): Array => { + const lines = lcovContent.split(/\r?\n/); + const records: Array = []; + let current: LcovRecord | null = null; + + const pushCurrent = (): void => { + if (current?.file) records.push(current); + current = null; + }; + + for (const line of lines) { + if (line.startsWith("SF:")) { + pushCurrent(); + current = { + file: line.slice(3), + lines: { total: 0, covered: 0 }, + functions: { total: 0, covered: 0 }, + branches: { total: 0, covered: 0 }, + }; + continue; + } + + if (!current) continue; + + if (line.startsWith("LF:")) { + current.lines.total = Number(line.slice(3)); + continue; + } + if (line.startsWith("LH:")) { + current.lines.covered = Number(line.slice(3)); + continue; + } + if (line.startsWith("FNF:")) { + current.functions.total = Number(line.slice(4)); + continue; + } + if (line.startsWith("FNH:")) { + current.functions.covered = Number(line.slice(4)); + continue; + } + if (line.startsWith("BRF:")) { + current.branches.total = Number(line.slice(4)); + continue; + } + if (line.startsWith("BRH:")) { + current.branches.covered = Number(line.slice(4)); + continue; + } + if (line === "end_of_record") { + pushCurrent(); + } + } + + pushCurrent(); + return records; +}; + +const normalizeSlashes = (value: string): string => value.replaceAll("\\", "/"); + +const resolveCoverageKey = ( + summary: CoverageSummary, + sourcePath: string, +): string => { + const normalizedSourcePath = normalizeSlashes(sourcePath); + if (summary[sourcePath]) return sourcePath; + + for (const key of Object.keys(summary)) { + const normalizedKey = normalizeSlashes(key); + if (normalizedKey.endsWith(normalizedSourcePath)) return key; + if (normalizedSourcePath.endsWith(normalizedKey)) return key; + } + + return sourcePath; +}; + +const toAbsolutePath = (pathLike: string): string => resolve(pathLike); + +const main = (): void => { + const args = parseArgs(process.argv.slice(2)); + const vitestSummaryPath = resolve( + args.get("vitest") ?? "coverage/coverage-summary.json", + ); + const bunLcovPath = resolve(args.get("bun") ?? "coverage/bun/lcov.info"); + const outputPath = resolve( + args.get("out") ?? "coverage/coverage-summary.json", + ); + + if (!existsSync(vitestSummaryPath)) { + throw new Error(`Vitest coverage summary not found: ${vitestSummaryPath}`); + } + if (!existsSync(bunLcovPath)) { + throw new Error(`Bun coverage lcov not found: ${bunLcovPath}`); + } + + const summary = JSON.parse( + readFileSync(vitestSummaryPath, "utf8"), + ) as CoverageSummary; + const lcov = readFileSync(bunLcovPath, "utf8"); + const records = parseLcov(lcov); + + let mergedFiles = 0; + for (const record of records) { + // Merge only Bun runtime source files. + if (!normalizeSlashes(record.file).includes("packages/bun/src/")) continue; + + const absoluteFile = toAbsolutePath(record.file); + const key = resolveCoverageKey(summary, absoluteFile); + + const lines = toMetric(record.lines); + const functions = toMetric(record.functions); + const branches = toMetric(record.branches); + + summary[key] = { + lines, + functions, + // lcov has no statements metric; use line counters for pragmatic merge. + statements: { ...lines }, + branches, + }; + mergedFiles++; + } + + const nonTotalEntries = Object.entries(summary) + .filter(([file]) => file !== "total") + .map(([, entry]) => entry); + summary.total = { + lines: sumMetric(nonTotalEntries, "lines"), + functions: sumMetric(nonTotalEntries, "functions"), + statements: sumMetric(nonTotalEntries, "statements"), + branches: sumMetric(nonTotalEntries, "branches"), + }; + + writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`); + console.log( + `Merged Bun coverage into summary: ${mergedFiles} file(s) -> ${outputPath}`, + ); +}; + +main(); diff --git a/vitest.config.mts b/vitest.config.mts index 964fe31b6..a2e44aa70 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -11,6 +11,7 @@ export default defineConfig({ "packages/common/vitest.unit.config.ts", "packages/common/vitest.browser.config.ts", "packages/web", + "packages/react-web", "packages/nodejs", "packages/react-native", ],