diff --git a/.changeset/structural-map-uint8array.md b/.changeset/structural-map-uint8array.md new file mode 100644 index 000000000..8957edad3 --- /dev/null +++ b/.changeset/structural-map-uint8array.md @@ -0,0 +1,7 @@ +--- +"@evolu/common": minor +--- + +Added `StructuralMap`, a `Map`-like collection for registries and coordination tables where callers have immutable JSON-like keys or `Uint8Array` keys and do not want to maintain a separate string id. + +`StructuralMap` works by deriving a canonical structural id for each key and storing entries in a native `Map` keyed by that id. Repeated lookups of the same object or array instance reuse cached ids through a `WeakMap`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ec034812..44fd9ba9f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -241,7 +241,9 @@ const result = await run(fetchUser("123")); ## Test-driven development -- Write a test before implementing a new feature or fixing a bug +- Tests are required for new features and bug fixes +- Prefer writing or updating tests before implementation when behavior or API shape is still being clarified +- When the implementation is straightforward from established patterns, implementation may come first - Test files are in `packages/*/test/*.test.ts` - Use `testNames` parameter to run specific tests — uses **substring matching**, so unique names avoid running unrelated tests - Run only changed/affected tests, not entire describe blocks diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81334a580..73e512a53 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -128,14 +128,14 @@ jobs: - name: Relay smoke tests (Bun runtime) if: matrix.runtime == 'bun' run: > - bunx vitest run --project unit + bunx vitest run --project "@evolu/common (nodejs)" packages/common/test/WebSocket.test.ts packages/common/test/local-first/Relay.test.ts - name: Relay smoke tests (Node runtime) if: matrix.runtime == 'node' run: > - node ./node_modules/vitest/vitest.mjs run --project unit + node ./node_modules/vitest/vitest.mjs run --project "@evolu/common (nodejs)" packages/common/test/WebSocket.test.ts packages/common/test/local-first/Relay.test.ts diff --git a/.github/workflows/ws-browser-nightly.yaml b/.github/workflows/ws-browser-nightly.yaml index 1aeaf7e03..56c126975 100644 --- a/.github/workflows/ws-browser-nightly.yaml +++ b/.github/workflows/ws-browser-nightly.yaml @@ -42,7 +42,7 @@ jobs: - name: Run browser WebSocket tests run: | set -o pipefail - bunx vitest run --project browser packages/common/test/WebSocket.test.ts --reporter=verbose 2>&1 | tee ws-browser-nightly.log + bunx vitest run --project "@evolu/common" packages/common/test/WebSocket.test.ts --reporter=verbose 2>&1 | tee ws-browser-nightly.log - name: Upload browser WebSocket logs if: always() diff --git a/.gitignore b/.gitignore index 304fb8bd8..6e6a8b642 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ storybook-static out test-identicons coverage +.vitest-attachments tmp __screenshots__ *.tsBuildInfo diff --git a/apps/relay/package.json b/apps/relay/package.json index 0b1168ea2..33ac234a5 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@evolu/tsconfig": "workspace:*", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "typescript": "^5.9.3" }, "engines": { diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index cd4fd08f3..73a3d164b 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -1,7 +1,6 @@ import { mkdirSync } from "node:fs"; import { createConsole, createConsoleFormatter } from "@evolu/common"; -import { createRelayDeps, createRunner, startRelay } from "@evolu/nodejs"; -import { startBunRelay } from "./startBunRelay.js"; +import { createRelayDeps, createRun, startRelay } from "@evolu/nodejs"; // Ensure the database is created in a predictable location for Docker. mkdirSync("data", { recursive: true }); @@ -16,24 +15,23 @@ const console = createConsole({ const deps = { ...createRelayDeps(), console }; -await using run = createRunner(deps); -await using stack = run.stack(); +await using run = createRun(deps); +await using stack = new AsyncDisposableStack(); -const isBunRuntime = (globalThis as { readonly Bun?: unknown }).Bun != null; -const startRelayTask = isBunRuntime ? startBunRelay : startRelay; +stack.use( + await run.orThrow( + startRelay({ + port: 4000, -await stack.use( - startRelayTask({ - port: 4000, + // Note: Relay requires URL in format ws://host:port/ + // isOwnerAllowed: (_ownerId) => true, - // Note: Relay requires URL in format ws://host:port/ - // isOwnerAllowed: (_ownerId) => true, - - isOwnerWithinQuota: (_ownerId, requiredBytes) => { - const maxBytes = 1024 * 1024; // 1MB - return requiredBytes <= maxBytes; - }, - }), + isOwnerWithinQuota: (_ownerId, requiredBytes) => { + const maxBytes = 1024 * 1024; // 1MB + return requiredBytes <= maxBytes; + }, + }), + ), ); await run.deps.shutdown; diff --git a/apps/relay/src/startBunRelay.ts b/apps/relay/src/startBunRelay.ts index 1b4e1ce8f..9a9f2b49a 100644 --- a/apps/relay/src/startBunRelay.ts +++ b/apps/relay/src/startBunRelay.ts @@ -1,8 +1,6 @@ import { type CreateSqliteDriverDep, - callback, createSqlite, - getOrThrow, isPromiseLike, type OwnerId, ok, @@ -95,11 +93,11 @@ export const startBunRelay = throw new Error("startBunRelay requires Bun runtime."); } - await using stack = _run.stack(); + await using stack = new AsyncDisposableStack(); const console = _run.deps.console.child("relay"); const relayName = name ?? SimpleName.orThrow("evolu-relay"); - const sqlite = getOrThrow(await stack.use(createSqlite(relayName))); + const sqlite = stack.use(await _run.orThrow(createSqlite(relayName))); const deps = { ..._run.deps, sqlite }; createBaseSqliteStorageTables(deps); @@ -255,17 +253,13 @@ export const startBunRelay = stack.defer(() => { console.info("Shutdown complete"); - return ok(); }); - stack.defer( - callback(({ ok }) => { - console.info("Shutting down..."); - server.stop(true); - console.info("Bun server stopped"); - ok(); - }), - ); + stack.defer(() => { + console.info("Shutting down..."); + server.stop(true); + console.info("Bun server stopped"); + }); console.info(`Started on port ${port} (Bun native runtime)`); diff --git a/biome.json b/biome.json index 88b0acb36..7462c2d64 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index a271cd95a..6ec807643 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "": { "name": "@evolu/monorepo", "devDependencies": { - "@biomejs/biome": "^2.4.5", + "@biomejs/biome": "^2.4.7", "@changesets/cli": "^2.29.8", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", - "turbo": "^2.8.16", + "turbo": "^2.8.17", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.10.0", "typescript": "^5.9.3", @@ -27,7 +27,7 @@ }, "devDependencies": { "@evolu/tsconfig": "workspace:*", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "typescript": "^5.9.3", }, }, @@ -35,15 +35,15 @@ "name": "@example/angular-vite-pwa", "version": "0.0.0", "dependencies": { - "@angular/core": "^21.2.2", - "@angular/platform-browser": "^21.2.2", + "@angular/core": "^21.2.4", + "@angular/platform-browser": "^21.2.4", "@evolu/common": "workspace:*", "@evolu/web": "workspace:*", }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.3.1", - "@angular/build": "^21.2.1", - "@angular/compiler-cli": "^21.2.2", + "@angular/build": "^21.2.2", + "@angular/compiler-cli": "^21.2.4", "@tailwindcss/vite": "^4.2.1", "@vite-pwa/assets-generator": "^1.0.2", "tailwindcss": "^4.2.1", @@ -56,13 +56,14 @@ "name": "@example/astro", "version": "0.0.0", "dependencies": { - "@astrojs/react": "^4.4.2", + "@astrojs/react": "^5.0.0", "@evolu/astro": "workspace:*", - "astro": "^5.14.5", + "astro": "^6.0.5", "react": "19.2.4", "react-dom": "19.2.4", }, "devDependencies": { + "@astrojs/check": "^0.9.8", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "typescript": "^5.9.3", @@ -81,12 +82,12 @@ "devDependencies": { "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "electron": "40.7.0", "electron-builder": "^26.8.1", "typescript": "^5.9.3", "vite": "^7.3.1", - "vite-plugin-electron": "^0.29.0", + "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.6", }, }, @@ -101,12 +102,12 @@ "@expo/metro-runtime": "^55.0.6", "@expo/vector-icons": "^15.1.1", "abort-signal-polyfill": "^1.0.0", - "babel-plugin-module-resolver": "^5.0.2", - "expo": "^55.0.5", + "babel-plugin-module-resolver": "^5.0.3", + "expo": "^55.0.6", "expo-constants": "^55.0.7", "expo-font": "^55.0.4", "expo-linking": "^55.0.7", - "expo-router": "^55.0.4", + "expo-router": "^55.0.5", "expo-secure-store": "~55.0.8", "expo-splash-screen": "~55.0.10", "expo-sqlite": "~55.0.10", @@ -133,7 +134,7 @@ "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-modules-commonjs": "^7.28.6", "@types/react": "~19.2.14", - "babel-preset-expo": "^55.0.10", + "babel-preset-expo": "^55.0.11", "typescript": "^5.9.3", }, }, @@ -146,14 +147,14 @@ "@evolu/react-web": "workspace:*", "@tabler/icons-react": "^3.37.1", "clsx": "^2.1.1", - "next": "^16.1.3", + "next": "^16.1.7", "react": "19.2.4", "react-dom": "19.2.4", }, "devDependencies": { "@tailwindcss/forms": "^0.5.11", "@tailwindcss/postcss": "^4.2.1", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "postcss": "^8.5.8", @@ -180,7 +181,7 @@ "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vite-pwa/assets-generator": "^1.0.2", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-pwa": "^1.2.0", @@ -197,7 +198,7 @@ "@evolu/web": "workspace:*", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.10", + "svelte": "^5.53.12", "svelte-check": "^4.4.3", "tslib": "^2.8.1", "typescript": "^5.9.3", @@ -210,14 +211,14 @@ "version": "0.0.0", "dependencies": { "@evolu/tanstack-start": "workspace:*", - "@tanstack/react-router": "^1.166.2", + "@tanstack/react-router": "^1.167.4", "react": "19.2.4", "react-dom": "19.2.4", }, "devDependencies": { "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1", }, @@ -235,7 +236,7 @@ "@tauri-apps/cli": "^2.10.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1", }, @@ -252,7 +253,7 @@ }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", - "@vitejs/plugin-vue": "^6.0.4", + "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.9.0", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -292,7 +293,7 @@ "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "@scure/bip39": "^2.0.1", - "kysely": "^0.28.11", + "kysely": "^0.28.12", "msgpackr": "^1.11.9", "zod": "^4.3.6", }, @@ -300,7 +301,7 @@ "@bokuweb/zstd-wasm": "^0.0.27", "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.18.1", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.8.0", "fast-check": "^4.6.0", "playwright": "^1.58.2", "typescript": "^5.9.3", @@ -311,14 +312,14 @@ "name": "@evolu/nodejs", "version": "2.4.0", "dependencies": { - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.8.0", "ws": "^8.19.0", }, "devDependencies": { "@evolu/common": "workspace:*", "@evolu/tsconfig": "workspace:*", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/ws": "^8.18.1", "typescript": "^5.9.3", }, @@ -358,9 +359,9 @@ "@evolu/common": "workspace:*", "@evolu/react": "workspace:*", "@evolu/tsconfig": "workspace:*", - "@op-engineering/op-sqlite": "^15.2.2", + "@op-engineering/op-sqlite": "^15.2.7", "@types/react": "~19.2.14", - "expo": "^55.0.5", + "expo": "^55.0.6", "expo-secure-store": "~55.0.8", "expo-sqlite": "~55.0.10", "react": "19.2.4", @@ -423,14 +424,14 @@ "@evolu/web": "workspace:*", "@sveltejs/package": "^2.5.7", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.10", + "svelte": "^5.53.12", "svelte-check": "^4.4.3", "typescript": "^5.9.3", }, "peerDependencies": { "@evolu/common": "^7.4.1", "@evolu/web": "^2.4.0", - "svelte": ">=5.53.10", + "svelte": ">=5.53.12", }, }, "packages/tanstack-start": { @@ -521,36 +522,42 @@ "@analogjs/vite-plugin-angular": ["@analogjs/vite-plugin-angular@2.3.1", "", { "dependencies": { "tinyglobby": "^0.2.14", "ts-morph": "^21.0.0" }, "peerDependencies": { "@angular-devkit/build-angular": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0", "@angular/build": "^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0" }, "optionalPeers": ["@angular-devkit/build-angular", "@angular/build"] }, "sha512-6ttSrMFBYwvS5JfovagfhkLaje1RjzztIniBWtH5G8wc6vrud77/sRJWVaVC4Ri4XRBTQ2kG5thSDumccX1B7g=="], - "@angular-devkit/architect": ["@angular-devkit/architect@0.2102.1", "", { "dependencies": { "@angular-devkit/core": "21.2.1", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w=="], + "@angular-devkit/architect": ["@angular-devkit/architect@0.2102.2", "", { "dependencies": { "@angular-devkit/core": "21.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-CDvFtXwyBtMRkTQnm+LfBNLL0yLV8ZGskrM1T6VkcGwXGFDott1FxUdj96ViodYsYL5fbJr0MNA6TlLcanV3kQ=="], - "@angular-devkit/core": ["@angular-devkit/core@21.2.1", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.3", "rxjs": "7.8.2", "source-map": "0.7.6" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg=="], + "@angular-devkit/core": ["@angular-devkit/core@21.2.2", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.3", "rxjs": "7.8.2", "source-map": "0.7.6" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-xUeKGe4BDQpkz0E6fnAPIJXE0y0nqtap0KhJIBhvN7xi3NenIzTmoi6T9Yv5OOBUdLZbOm4SOel8MhdXiIBpAQ=="], - "@angular/build": ["@angular/build@21.2.1", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2102.1", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "5.1.21", "@vitejs/plugin-basic-ssl": "2.1.4", "beasties": "0.4.1", "browserslist": "^4.26.0", "esbuild": "0.27.3", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", "listr2": "9.0.5", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.3", "piscina": "5.1.4", "rolldown": "1.0.0-rc.4", "sass": "1.97.3", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.15", "undici": "7.22.0", "vite": "7.3.1", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.1" }, "peerDependencies": { "@angular/compiler": "^21.0.0", "@angular/compiler-cli": "^21.0.0", "@angular/core": "^21.0.0", "@angular/localize": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", "@angular/ssr": "^21.2.1", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=5.9 <6.0", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ=="], + "@angular/build": ["@angular/build@21.2.2", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2102.2", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "5.1.21", "@vitejs/plugin-basic-ssl": "2.1.4", "beasties": "0.4.1", "browserslist": "^4.26.0", "esbuild": "0.27.3", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", "listr2": "9.0.5", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.3", "piscina": "5.1.4", "rolldown": "1.0.0-rc.4", "sass": "1.97.3", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.15", "undici": "7.22.0", "vite": "7.3.1", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.1" }, "peerDependencies": { "@angular/compiler": "^21.0.0", "@angular/compiler-cli": "^21.0.0", "@angular/core": "^21.0.0", "@angular/localize": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", "@angular/ssr": "^21.2.2", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=5.9 <6.0", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-Vq2eIneNxzhHm1MwEmRqEJDwHU9ODfSRDaMWwtysGMhpoMQmLdfTqkQDmkC2qVUr8mV8Z1i5I+oe5ZJaMr/PlQ=="], "@angular/common": ["@angular/common@21.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg=="], "@angular/compiler": ["@angular/compiler@21.2.1", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FxWaSaii1vfHIFA+JksqQ8NGB2frfqCrs7Ju50a44kbwR4fmanfn/VsiS/CbwBp9vcyT/Br9X/jAG4RuK/U2nw=="], - "@angular/compiler-cli": ["@angular/compiler-cli@21.2.2", "", { "dependencies": { "@babel/core": "7.29.0", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^18.0.0" }, "peerDependencies": { "@angular/compiler": "21.2.2", "typescript": ">=5.9 <6.1" }, "optionalPeers": ["typescript"], "bin": { "ngc": "bundles/src/bin/ngc.js", "ng-xi18n": "bundles/src/bin/ng_xi18n.js" } }, "sha512-TFg2wXUZ1FdUikNyR27PxuCXuqqlJhL6Mr/cBYuc4HbtBfgKw5FLffbI/iLubBEs55W5ApuYpBVuXKGoZp9SRQ=="], + "@angular/compiler-cli": ["@angular/compiler-cli@21.2.4", "", { "dependencies": { "@babel/core": "7.29.0", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^18.0.0" }, "peerDependencies": { "@angular/compiler": "21.2.4", "typescript": ">=5.9 <6.1" }, "optionalPeers": ["typescript"], "bin": { "ngc": "bundles/src/bin/ngc.js", "ng-xi18n": "bundles/src/bin/ng_xi18n.js" } }, "sha512-vGjd7DZo/Ox50pQCm5EycmBu91JclimPtZoyNXu/2hSxz3oAkzwiHCwlHwk2g58eheSSp+lYtYRLmHAqSVZLjg=="], - "@angular/core": ["@angular/core@21.2.2", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "21.2.2", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-ljiyiFjR6dgK27CNlOcMrjsDPYKFf2Rl89WLwGEGMOj0cJg/PSLQqpW/fbSkSB3SDgwG/WhXQ4Wrw525OKMupg=="], + "@angular/core": ["@angular/core@21.2.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "21.2.4", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-2+gd67ZuXHpGOqeb2o7XZPueEWEP81eJza2tSHkT5QMV8lnYllDEmaNnkPxnIjSLGP1O3PmiXxo4z8ibHkLZwg=="], - "@angular/platform-browser": ["@angular/platform-browser@21.2.2", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "21.2.2", "@angular/common": "21.2.2", "@angular/core": "21.2.2" }, "optionalPeers": ["@angular/animations"] }, "sha512-6cHfHi/lRCUPNGO0eJeYRIpu8vM+CMMS2Wv/psOUwvl/5+RC92hfBEZxzQiF/5X9A170bJabaMFQC5fA7pkF2g=="], + "@angular/platform-browser": ["@angular/platform-browser@21.2.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "21.2.4", "@angular/common": "21.2.4", "@angular/core": "21.2.4" }, "optionalPeers": ["@angular/animations"] }, "sha512-1A9e/cQVu+3BkRCktLcO3RZGuw8NOTHw1frUUrpAz+iMyvIT4sDRFbL+U1g8qmOCZqRNC1Pi1HZfZ1kl6kvrcQ=="], "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], - "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], + "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], - "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + "@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="], - "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], - "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.5", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w=="], - "@astrojs/react": ["@astrojs/react@4.4.2", "", { "dependencies": { "@vitejs/plugin-react": "^4.7.0", "ultrahtml": "^1.6.0", "vite": "^6.4.1" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ=="], + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jTAXHPy45L7o1ljH4jYV+ShtOHtyQUa1mGp3a5fJp1soX8lInuTJQ6ihmldHzVM4Q7QptU4SzIDIcKbBJO7sXQ=="], + + "@astrojs/prism": ["@astrojs/prism@4.0.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-NndtNPpxaGinRpRytljGBvYHpTOwHycSZ/c+lQi5cHvkqqrHKWdkPEhImlODBNmbuB+vyQUNUDXyjzt66CihJg=="], + + "@astrojs/react": ["@astrojs/react@5.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@vitejs/plugin-react": "^5.1.4", "devalue": "^5.6.3", "ultrahtml": "^1.6.0", "vite": "^7.3.1" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-OuM+0QFsoPkvv8ZB57kVLxKOqvR+84GR/Em9lh/tAL4fV4CnpBPDxc++0vd1CipH4o99Is7GribuTHFy3doF6g=="], "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.3", "", { "dependencies": { "yaml": "^2.8.2" } }, "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -803,23 +810,25 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.7", "@biomejs/cli-darwin-x64": "2.4.7", "@biomejs/cli-linux-arm64": "2.4.7", "@biomejs/cli-linux-arm64-musl": "2.4.7", "@biomejs/cli-linux-x64": "2.4.7", "@biomejs/cli-linux-x64-musl": "2.4.7", "@biomejs/cli-win32-arm64": "2.4.7", "@biomejs/cli-win32-x64": "2.4.7" }, "bin": { "biome": "bin/biome" } }, "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="], + + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], "@blazejkustra/react-native-alert": ["@blazejkustra/react-native-alert@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bgvKlnhfS39vz38BSBdHk1smVME0Nf5tJEzgoQOIPpci8KuTBcOORa93B2PV3/S5a0QkR1d8nW2yw76HDj6zqQ=="], @@ -863,6 +872,10 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@craftzdog/react-native-buffer": ["@craftzdog/react-native-buffer@6.1.0", "", { "dependencies": { "ieee754": "^1.2.1", "react-native-quick-base64": "^2.0.5" } }, "sha512-lJXdjZ7fTllLbzDrwg/FrJLjQ5sBcAgwcqgAB6OPpXTHdCenEhHZblQpfmBLLe7/S7m0yKXL3kN3jpwOEkpjGg=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], @@ -883,6 +896,20 @@ "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -993,7 +1020,7 @@ "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.25", "", {}, "sha512-MlwOpcYPLYu2+aDAwqv29l3sknNNxA36Jcu07Tg9+MTEvXk2SPcO8eQmwwDeVBbv5Wb6ToD1LmE+e0lLv/9WvA=="], - "@expo/cli": ["@expo/cli@55.0.15", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~55.0.8", "@expo/config-plugins": "~55.0.6", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.1", "@expo/image-utils": "^0.8.12", "@expo/json-file": "^10.0.12", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", "@expo/metro-config": "~55.0.9", "@expo/osascript": "^2.4.2", "@expo/package-manager": "^1.10.3", "@expo/plist": "^0.5.2", "@expo/prebuild-config": "^55.0.8", "@expo/require-utils": "^55.0.2", "@expo/router-server": "^55.0.9", "@expo/schema-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.0", "@react-native/dev-middleware": "0.83.2", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.3", "expo-server": "^55.0.6", "fetch-nodeshim": "^0.4.6", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.0", "multitars": "^0.2.3", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.3", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-Qd4aF2+wT9LtdV7G/gULbx/t8FJ/OVtwuNkLcZt1XlosQ5XX/C/3ywZXYl+/bYcHUmuO1TBD3Fg05bNlmL6vrw=="], + "@expo/cli": ["@expo/cli@55.0.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~55.0.8", "@expo/config-plugins": "~55.0.6", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.1", "@expo/image-utils": "^0.8.12", "@expo/json-file": "^10.0.12", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", "@expo/metro-config": "~55.0.9", "@expo/osascript": "^2.4.2", "@expo/package-manager": "^1.10.3", "@expo/plist": "^0.5.2", "@expo/prebuild-config": "^55.0.8", "@expo/require-utils": "^55.0.2", "@expo/router-server": "^55.0.10", "@expo/schema-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.0", "@react-native/dev-middleware": "0.83.2", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.3", "expo-server": "^55.0.6", "fetch-nodeshim": "^0.4.6", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.0", "multitars": "^0.2.3", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.3", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-rp1mBnA5msGDPTfFuqVl+9RsJOtuA0cXsWSJpHdvsIxcSVg0oJyF/rgvrwsFrNQCLXzkMXm+o3CsY9iL1D/CDA=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], @@ -1011,7 +1038,7 @@ "@expo/env": ["@expo/env@2.1.1", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg=="], - "@expo/fingerprint": ["@expo/fingerprint@0.16.5", "", { "dependencies": { "@expo/env": "^2.0.11", "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-mLrcymtgkW9IJ/G1e8MH1Xt2VIb1MOS86ePY0ePcnV3nVyJqm7gfa/AXD1Hk+eZXvf8XhioYz6QZaamBdEzR3A=="], + "@expo/fingerprint": ["@expo/fingerprint@0.16.6", "", { "dependencies": { "@expo/env": "^2.0.11", "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ=="], "@expo/image-utils": ["@expo/image-utils@0.8.12", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A=="], @@ -1037,7 +1064,7 @@ "@expo/require-utils": ["@expo/require-utils@55.0.2", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0" }, "optionalPeers": ["typescript"] }, "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw=="], - "@expo/router-server": ["@expo/router-server@55.0.9", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^55.0.6", "expo": "*", "expo-constants": "^55.0.7", "expo-font": "^55.0.4", "expo-router": "*", "expo-server": "^55.0.6", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-LcCFi+P1qfZOsw0DO4JwNKRxtWt4u2bjTYj0PUe4WVf9NVG/NfUetAXYRbBS6P+gupfM6SC+/bdzdqCWQh7j8g=="], + "@expo/router-server": ["@expo/router-server@55.0.10", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^55.0.6", "expo": "*", "expo-constants": "^55.0.7", "expo-font": "^55.0.4", "expo-router": "*", "expo-server": "^55.0.6", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-NZQzHwkaedufNPayVfPxsZGEMngOD3gDvYx9lld4sitRexrKDx5sHmmNHi6IByGbmCb4jwLXub5sIyWh6z1xPQ=="], "@expo/schema-utils": ["@expo/schema-utils@55.0.2", "", {}, "sha512-QZ5WKbJOWkCrMq0/kfhV9ry8te/OaS34YgLVpG8u9y2gix96TlpRTbxM/YATjNcUR2s4fiQmPCOxkGtog4i37g=="], @@ -1225,23 +1252,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + "@next/env": ["@next/env@16.1.7", "", {}, "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], @@ -1257,7 +1284,7 @@ "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], - "@op-engineering/op-sqlite": ["@op-engineering/op-sqlite@15.2.5", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Vmgwt0AzY7qoge3X6EONhsb5NlM2yoQUF0/lseUWBelfc9BUili7/DFsFsS73cvtYWlwPpqeTGOoce5mzHozBw=="], + "@op-engineering/op-sqlite": ["@op-engineering/op-sqlite@15.2.7", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rXpzEt6FL0syAHa2QqBaB7DhhXwxEOs05cTIhv0RI+iur66XcuM73SpUSD8gnh8opHJu+Hw6Nu3IM4EDWxEdng=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -1467,14 +1494,16 @@ "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], - "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], @@ -1543,13 +1572,13 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], - "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], - "@tanstack/react-router": ["@tanstack/react-router@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.167.4", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.4", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-VpbZh382zX3WF4+X2Z+EUyd8eJhJyjg9C6ByYwrVZiWbhgbMK4+zQQIG2+lCAlIlDi7SV8fDcGL09NA8Z2kpGQ=="], "@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg=="], + "@tanstack/router-core": ["@tanstack/router-core@1.167.4", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Gk5V9Zr5JFJ4SbLyCheQLJ3MnXddccENPA+DJRz+9g3QxtN8DJB8w8KCUCgDeYlWp4LvmO4nX3fy3tupqVP2Pw=="], "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], @@ -1627,7 +1656,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1665,38 +1694,48 @@ "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.1.4", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], - "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg=="], - "@vitest/browser": ["@vitest/browser@4.0.18", "", { "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.18.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng=="], + "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], - "@vitest/browser-playwright": ["@vitest/browser-playwright@4.0.18", "", { "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.0.18" } }, "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g=="], + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.0", "", { "dependencies": { "@vitest/browser": "4.1.0", "@vitest/mocker": "4.1.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.0" } }, "sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ=="], - "@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@4.0.18", "", { "dependencies": { "@istanbuljs/schema": "^0.1.3", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/trace-mapping": "0.3.31", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-instrument": "^6.0.3", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-0OhjP30owEDihYTZGWuq20rNtV1RjjJs1Mv4MaZIKcFBmiLUXX7HJLX4fU7wE+Mrc3lQxI2HKq6WrSXi5FGuCQ=="], + "@vitest/coverage-istanbul": ["@vitest/coverage-istanbul@4.1.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@istanbuljs/schema": "^0.1.3", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/trace-mapping": "0.3.31", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-0+67gA94YToxd+Pc3XgIA/2c8HN2hXNSg3T+1FI4HW7W/2gPitYCtktsY6Ke7vrt5caboMq3TUf0/vwbHRb0og=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.0", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.0", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.1.0", "vitest": "4.1.0" }, "optionalPeers": ["@vitest/browser"] }, "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ=="], - "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + "@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="], - "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="], - "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + "@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="], - "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + "@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], - "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="], + + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="], "@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="], @@ -1735,6 +1774,8 @@ "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -1743,8 +1784,6 @@ "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], - "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], @@ -1781,11 +1820,11 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - "astro": ["astro@5.18.1", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g=="], + "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -1809,7 +1848,7 @@ "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], - "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.3", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-h8h6H71ZvdLJZxZrYkaeR30BojTaV7O9GfqacY14SNj5CNB8ocL9tydNzTC0JrnNN7vY3eJhwCmkDj7tuEUaqQ=="], "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.16", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw=="], @@ -1827,7 +1866,7 @@ "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], - "babel-preset-expo": ["babel-preset-expo@55.0.10", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.83.2", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^55.0.2", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-aRtW7qJKohGU2V0LUJ6IeP7py3+kVUo9zcc8+v1Kix8jGGuIvqvpo9S6W1Fmn9VFP2DBwkFDLiyzkCZS85urVA=="], + "babel-preset-expo": ["babel-preset-expo@55.0.11", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.83.2", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.32.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^55.0.4", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-ti8t4xufD6gUQQh+qY+b+VT/1zyA0n1PBnwOzCkPUyEDiIVBpaOixR+BzVH68hqu9mH2wDfzoFuGgv+2LfRdqw=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], @@ -1835,8 +1874,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], @@ -1847,7 +1884,7 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], + "better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], @@ -1859,8 +1896,6 @@ "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], - "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], @@ -1901,7 +1936,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], @@ -1931,8 +1966,6 @@ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], - "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -1969,7 +2002,7 @@ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -2015,8 +2048,6 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -2075,9 +2106,7 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], - - "devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="], + "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], @@ -2129,6 +2158,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -2161,7 +2192,7 @@ "es-map": ["es-map@1.0.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-get-iterator": "^1.1.3", "es-set-tostringtag": "^2.0.3", "for-each": "^0.3.3", "get-intrinsic": "^1.2.4", "globalthis": "^1.0.4", "has-symbols": "^1.0.3", "internal-slot": "^1.0.7", "object.entries": "^1.1.8" } }, "sha512-JWn54TeYJe93WHhduTh9MUqEkjhbCjU8vqq1q9FyCrOle/utr1i8PmzK2vXnP6BeLsBLvb1zV4fSkpVnT4LW1A=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], @@ -2203,7 +2234,7 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "expo": ["expo@55.0.5", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "55.0.15", "@expo/config": "~55.0.8", "@expo/config-plugins": "~55.0.6", "@expo/devtools": "55.0.2", "@expo/fingerprint": "0.16.5", "@expo/local-build-cache-provider": "55.0.6", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", "@expo/metro-config": "55.0.9", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~55.0.10", "expo-asset": "~55.0.8", "expo-constants": "~55.0.7", "expo-file-system": "~55.0.10", "expo-font": "~55.0.4", "expo-keep-awake": "~55.0.4", "expo-modules-autolinking": "55.0.8", "expo-modules-core": "55.0.14", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.1" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-toVYbRU0gH50QSlIyrAswXD87RKi2pcJcHZpBDuqU3mIQZzJkTcWgRLWN/2R/wnd3kuJTtW5xlr5ndVG6xEWxQ=="], + "expo": ["expo@55.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "55.0.16", "@expo/config": "~55.0.8", "@expo/config-plugins": "~55.0.6", "@expo/devtools": "55.0.2", "@expo/fingerprint": "0.16.6", "@expo/local-build-cache-provider": "55.0.6", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", "@expo/metro-config": "55.0.9", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~55.0.11", "expo-asset": "~55.0.8", "expo-constants": "~55.0.7", "expo-file-system": "~55.0.10", "expo-font": "~55.0.4", "expo-keep-awake": "~55.0.4", "expo-modules-autolinking": "55.0.9", "expo-modules-core": "55.0.15", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.1" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-gaF8bh5beWmrptz3d4Gr138CiPoLJtzjNbqNSOQ8kdQm3wMW8lJGT1dsY5NPJTZ7MNJBTN+pcRwshr4BMK4OiA=="], "expo-asset": ["expo-asset@55.0.8", "", { "dependencies": { "@expo/image-utils": "^0.8.12", "expo-constants": "~55.0.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yEz2svDX67R0yiW2skx6dJmcE0q7sj9ECpGMcxBExMCbctc+nMoZCnjUuhzPl5vhClUsO5HFFXS5vIGmf1bgHQ=="], @@ -2213,7 +2244,7 @@ "expo-font": ["expo-font@55.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg=="], - "expo-glass-effect": ["expo-glass-effect@55.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-G7Q9rUaEY0YC36fGE6irDljfsfvzz/y49zagARAKvSJSyQMUSrhR25WOr5LK5Cw7gQNNBEy9U1ctlr7yCay/fQ=="], + "expo-glass-effect": ["expo-glass-effect@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IvUjHb/4t6r2H/LXDjcQ4uDoHrmO2cLOvEb9leLavQ4HX5+P4LRtQrMDMlkWAn5Wo5DkLcG8+1CrQU2nqgogTA=="], "expo-image": ["expo-image@55.0.6", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TKuu0uBmgTZlhd91Glv+V4vSBMlfl0bdQxfl97oKKZUo3OBC13l3eLik7v3VNLJN7PZbiwOAiXkZkqSOBx/Xsw=="], @@ -2221,11 +2252,11 @@ "expo-linking": ["expo-linking@55.0.7", "", { "dependencies": { "expo-constants": "~55.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ=="], - "expo-modules-autolinking": ["expo-modules-autolinking@55.0.8", "", { "dependencies": { "@expo/require-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-nrWB1pkNp7bR8ECUTgYUiJ2Pyh6AvxCBXZ+lyPlfl1TzEIGhwU1Yqr+d78eJDueXaW+9zKeE0HqrTZoLS3ve4A=="], + "expo-modules-autolinking": ["expo-modules-autolinking@55.0.9", "", { "dependencies": { "@expo/require-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-OXIrxSYKlT/1Av1AMyUWeSTW1GChGofWV14sB73o5eFbfuz6ocv18fnKx+Ji67ZC7a0RztDctcZTuEQK84S4iw=="], - "expo-modules-core": ["expo-modules-core@55.0.14", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eAerOnnhbZitUAKbY7B61kIudiabAz/m/oMGINms2+GeY1DRhdvrm5aAkhkHHmykPrg58PPryXtmF14YAYWViw=="], + "expo-modules-core": ["expo-modules-core@55.0.15", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MAGz1SYSVgQbwVeUysWgPtLh8ozbBwORatXoA4w0NZqZBZzEyBgUQNhuwaroaIi9W8Ir3wy1McmZcDYDJNGmVw=="], - "expo-router": ["expo-router@55.0.4", "", { "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^55.0.7", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.2.1", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "55.0.7", "@react-navigation/drawer": "^7.7.2", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^55.0.7", "expo-linking": "^55.0.7", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wLKxc9l3IaE96UJFvwXKi2YYYjYK/VUttwAwcnljaUA2dLgDruNGmjsBS9A+g3aK3lt2/JJRu+cec7ZLJ9r6Wg=="], + "expo-router": ["expo-router@55.0.5", "", { "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^55.0.8", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.2.1", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "55.0.7", "@react-navigation/drawer": "^7.7.2", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^55.0.7", "expo-linking": "^55.0.7", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-PzN545wLtznKuVQmJXnAKB/JFjSJJIPHatsjJe4Cl6bRADr/MbWv5d2fqOpqFD/C0ZGCRHY1uBalq7mb5IQ3ZQ=="], "expo-secure-store": ["expo-secure-store@55.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-8w9tQe8U6oRo5YIzqCqVhRrOnfoODNDoitBtLXEx+zS6WLUnkRq5kH7ViJuOgiM7PzLr9pvAliRiDOKyvFbTuQ=="], @@ -2449,8 +2480,6 @@ "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -2625,7 +2654,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], + "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="], "lan-network": ["lan-network@0.2.0", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw=="], @@ -2895,7 +2924,7 @@ "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], - "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "next": ["next@16.1.7", "", { "dependencies": { "@next/env": "16.1.7", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.7", "@next/swc-darwin-x64": "16.1.7", "@next/swc-linux-arm64-gnu": "16.1.7", "@next/swc-linux-arm64-musl": "16.1.7", "@next/swc-linux-x64-gnu": "16.1.7", "@next/swc-linux-x64-musl": "16.1.7", "@next/swc-win32-arm64-msvc": "16.1.7", "@next/swc-win32-x64-msvc": "16.1.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -2977,15 +3006,15 @@ "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], - "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], - "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + "p-queue": ["p-queue@9.1.0", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw=="], - "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], @@ -3037,8 +3066,6 @@ "piscina": ["piscina@5.1.4", "", { "optionalDependencies": { "@napi-rs/nice": "^1.0.4" } }, "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg=="], - "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], - "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], @@ -3201,6 +3228,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -3325,7 +3354,7 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -3399,7 +3428,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -3449,7 +3478,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svelte": ["svelte@5.53.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-UcNfWzbrjvYXYSk+U2hME25kpb87oq6/WVLeBF4khyQrb3Ob/URVlN23khal+RbdCUTMfg4qWjI9KZjCNFtYMQ=="], + "svelte": ["svelte@5.53.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA=="], "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], @@ -3495,6 +3524,8 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -3533,19 +3564,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="], + "turbo": ["turbo@2.8.17", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.17", "turbo-darwin-arm64": "2.8.17", "turbo-linux-64": "2.8.17", "turbo-linux-arm64": "2.8.17", "turbo-windows-64": "2.8.17", "turbo-windows-arm64": "2.8.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw=="], - "turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="], + "turbo-linux-64": ["turbo-linux-64@2.8.17", "", { "os": "linux", "cpu": "x64" }, "sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg=="], - "turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="], + "turbo-windows-64": ["turbo-windows-64@2.8.17", "", { "os": "win32", "cpu": "x64" }, "sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -3563,8 +3594,12 @@ "typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.10.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-psrg8Rtnv4HPWCsoxId+MzEN8TVK5jeKCnTbnGAbTBqcDapR9hM41bJT/9eAyKn9C2MDG9Qjh3MkltAYuLDoXg=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], @@ -3669,7 +3704,7 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "vite-plugin-electron": ["vite-plugin-electron@0.29.0", "", { "peerDependencies": { "vite-plugin-electron-renderer": "*" }, "optionalPeers": ["vite-plugin-electron-renderer"] }, "sha512-HP0DI9Shg41hzt55IKYVnbrChWXHX95QtsEQfM+szQBpWjVhVGMlqRjVco6ebfQjWNr+Ga+PeoBjMIl8zMaufw=="], + "vite-plugin-electron": ["vite-plugin-electron@0.29.1", "", { "peerDependencies": { "vite-plugin-electron-renderer": "*" }, "optionalPeers": ["vite-plugin-electron-renderer"] }, "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w=="], "vite-plugin-electron-renderer": ["vite-plugin-electron-renderer@0.14.6", "", {}, "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw=="], @@ -3677,10 +3712,42 @@ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], - "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], + + "volar-service-html": ["volar-service-html@0.0.70", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.70", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="], @@ -3721,8 +3788,6 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], - "workbox-background-sync": ["workbox-background-sync@7.4.0", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.0" } }, "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w=="], "workbox-broadcast-update": ["workbox-broadcast-update@7.4.0", "", { "dependencies": { "workbox-core": "7.4.0" } }, "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA=="], @@ -3779,28 +3844,22 @@ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], - - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - - "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@angular-devkit/core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -3809,9 +3868,9 @@ "@angular/compiler-cli/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - "@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -3955,6 +4014,12 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@shikijs/core/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -3991,6 +4056,10 @@ "@vitejs/plugin-vue/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + "@vitest/utils/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -4021,10 +4090,6 @@ "astro/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "astro/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - - "astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -4039,16 +4104,10 @@ "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "boxen/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - - "boxen/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "builder-util/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=="], + "bun-types/@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4161,8 +4220,6 @@ "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "jest-validate/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "jest-worker/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -4289,6 +4346,14 @@ "sharp-ico/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + + "shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -4337,8 +4402,6 @@ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "workbox-build/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "workbox-build/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -4357,21 +4420,19 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "yaml-language-server/prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - "@angular/compiler-cli/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - - "@angular/compiler-cli/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], - "@angular/compiler-cli/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], - "@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@astrojs/react/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "@angular/compiler-cli/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@angular/compiler-cli/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@astrojs/react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -4577,24 +4638,12 @@ "astro/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "astro/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "better-opn/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "boxen/wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "builder-util/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "builder-util/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -4811,10 +4860,6 @@ "unifont/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "widest-line/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "workbox-build/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -4835,58 +4880,6 @@ "@angular/compiler-cli/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@astrojs/react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@astrojs/react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@astrojs/react/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@astrojs/react/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@astrojs/react/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@astrojs/react/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@astrojs/react/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@astrojs/react/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@astrojs/react/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@astrojs/react/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@astrojs/react/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@astrojs/react/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@astrojs/react/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@astrojs/react/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@electron/rebuild/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "@expo/cli/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], @@ -4921,62 +4914,6 @@ "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "astro/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "astro/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "astro/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "astro/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "astro/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "astro/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "astro/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "astro/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "astro/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "astro/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "astro/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "astro/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "astro/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "astro/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "astro/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "astro/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "astro/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "astro/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "astro/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "astro/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "astro/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "astro/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "astro/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "astro/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "boxen/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -5005,8 +4942,6 @@ "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "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/examples/angular-vite-pwa/package.json b/examples/angular-vite-pwa/package.json index afd94224d..187bb62d4 100644 --- a/examples/angular-vite-pwa/package.json +++ b/examples/angular-vite-pwa/package.json @@ -9,15 +9,15 @@ "generate-pwa-assets": "pwa-assets-generator" }, "dependencies": { - "@angular/core": "^21.2.2", - "@angular/platform-browser": "^21.2.2", + "@angular/core": "^21.2.4", + "@angular/platform-browser": "^21.2.4", "@evolu/common": "workspace:*", "@evolu/web": "workspace:*" }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.3.1", - "@angular/build": "^21.2.1", - "@angular/compiler-cli": "^21.2.2", + "@angular/build": "^21.2.2", + "@angular/compiler-cli": "^21.2.4", "@tailwindcss/vite": "^4.2.1", "@vite-pwa/assets-generator": "^1.0.2", "tailwindcss": "^4.2.1", diff --git a/examples/astro/package.json b/examples/astro/package.json index afd957532..1cdebffef 100644 --- a/examples/astro/package.json +++ b/examples/astro/package.json @@ -10,13 +10,14 @@ "check": "astro check" }, "dependencies": { - "@astrojs/react": "^4.4.2", + "@astrojs/react": "^5.0.0", "@evolu/astro": "workspace:*", - "astro": "^5.14.5", + "astro": "^6.0.5", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@astrojs/check": "^0.9.8", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "typescript": "^5.9.3" diff --git a/examples/react-electron/package.json b/examples/react-electron/package.json index 8db825bae..3c458eacf 100644 --- a/examples/react-electron/package.json +++ b/examples/react-electron/package.json @@ -19,12 +19,12 @@ "devDependencies": { "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "electron": "40.7.0", "electron-builder": "^26.8.1", "typescript": "^5.9.3", "vite": "^7.3.1", - "vite-plugin-electron": "^0.29.0", + "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.6" }, "main": "dist-electron/main.js" diff --git a/examples/react-expo/package.json b/examples/react-expo/package.json index 23bf78211..2bacb87c7 100644 --- a/examples/react-expo/package.json +++ b/examples/react-expo/package.json @@ -25,12 +25,12 @@ "@expo/metro-runtime": "^55.0.6", "@expo/vector-icons": "^15.1.1", "abort-signal-polyfill": "^1.0.0", - "babel-plugin-module-resolver": "^5.0.2", - "expo": "^55.0.5", + "babel-plugin-module-resolver": "^5.0.3", + "expo": "^55.0.6", "expo-constants": "^55.0.7", "expo-font": "^55.0.4", "expo-linking": "^55.0.7", - "expo-router": "^55.0.4", + "expo-router": "^55.0.5", "expo-secure-store": "~55.0.8", "expo-splash-screen": "~55.0.10", "expo-sqlite": "~55.0.10", @@ -57,7 +57,7 @@ "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-modules-commonjs": "^7.28.6", "@types/react": "~19.2.14", - "babel-preset-expo": "^55.0.10", + "babel-preset-expo": "^55.0.11", "typescript": "^5.9.3" } } diff --git a/examples/react-nextjs/package.json b/examples/react-nextjs/package.json index 7d5e8221c..874cfa150 100644 --- a/examples/react-nextjs/package.json +++ b/examples/react-nextjs/package.json @@ -14,14 +14,14 @@ "@evolu/react-web": "workspace:*", "@tabler/icons-react": "^3.37.1", "clsx": "^2.1.1", - "next": "^16.1.3", + "next": "^16.1.7", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/forms": "^0.5.11", "@tailwindcss/postcss": "^4.2.1", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "postcss": "^8.5.8", diff --git a/examples/react-vite-pwa/package.json b/examples/react-vite-pwa/package.json index 7b1efbab4..24a326e10 100644 --- a/examples/react-vite-pwa/package.json +++ b/examples/react-vite-pwa/package.json @@ -26,7 +26,7 @@ "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vite-pwa/assets-generator": "^1.0.2", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-pwa": "^1.2.0", diff --git a/examples/svelte-vite-pwa/package.json b/examples/svelte-vite-pwa/package.json index a06b0f12b..33048fd4b 100644 --- a/examples/svelte-vite-pwa/package.json +++ b/examples/svelte-vite-pwa/package.json @@ -16,7 +16,7 @@ "@evolu/web": "workspace:*", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.10", + "svelte": "^5.53.12", "svelte-check": "^4.4.3", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/examples/tanstack-start/package.json b/examples/tanstack-start/package.json index 0b3c0298b..237f30781 100644 --- a/examples/tanstack-start/package.json +++ b/examples/tanstack-start/package.json @@ -10,14 +10,14 @@ }, "dependencies": { "@evolu/tanstack-start": "workspace:*", - "@tanstack/react-router": "^1.166.2", + "@tanstack/react-router": "^1.167.4", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1" } diff --git a/examples/tauri/package.json b/examples/tauri/package.json index 827252752..fa579f723 100644 --- a/examples/tauri/package.json +++ b/examples/tauri/package.json @@ -21,7 +21,7 @@ "@tauri-apps/cli": "^2.10.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", "vite": "^7.3.1" } diff --git a/examples/vue-vite-pwa/package.json b/examples/vue-vite-pwa/package.json index b5ca76219..03bc64ef9 100644 --- a/examples/vue-vite-pwa/package.json +++ b/examples/vue-vite-pwa/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@vite-pwa/assets-generator": "^1.0.2", - "@vitejs/plugin-vue": "^6.0.4", + "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.9.0", "typescript": "^5.9.3", "vite": "^7.3.1", diff --git a/package.json b/package.json index ccac9d8ce..cdebeaa68 100755 --- a/package.json +++ b/package.json @@ -77,13 +77,13 @@ "docs:sync:website": "bun ./scripts/docs-sync-website.mts" }, "devDependencies": { - "@biomejs/biome": "^2.4.5", + "@biomejs/biome": "^2.4.7", "@changesets/cli": "^2.29.8", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", - "turbo": "^2.8.16", + "turbo": "^2.8.17", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.10.0", "typescript": "^5.9.3", diff --git a/packages/common/package.json b/packages/common/package.json index cdfa717f8..b91a2c957 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -32,10 +32,19 @@ "source": "./src/local-first/index.ts", "import": "./dist/src/local-first/index.js", "default": "./dist/src/local-first/index.js" + }, + "./polyfills": { + "types": "./dist/src/Polyfills.d.ts", + "source": "./src/Polyfills.ts", + "import": "./dist/src/Polyfills.js", + "default": "./dist/src/Polyfills.js" } }, "typesVersions": { "*": { + "polyfills": [ + "./dist/src/Polyfills.d.ts" + ], "local-first": [ "./dist/src/local-first/index.d.ts" ] @@ -56,7 +65,7 @@ "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "@scure/bip39": "^2.0.1", - "kysely": "^0.28.11", + "kysely": "^0.28.12", "msgpackr": "^1.11.9", "zod": "^4.3.6" }, @@ -71,7 +80,7 @@ "@bokuweb/zstd-wasm": "^0.0.27", "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.18.1", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.8.0", "fast-check": "^4.6.0", "playwright": "^1.58.2", "typescript": "^5.9.3", diff --git a/packages/common/src/Assert.ts b/packages/common/src/Assert.ts index 3b5b23dee..a6d33e2a1 100644 --- a/packages/common/src/Assert.ts +++ b/packages/common/src/Assert.ts @@ -4,6 +4,8 @@ * @module */ +import type { Ok, Result } from "./Result.js"; +import type { AbortError } from "./Task.js"; import type { AnyType, InferType, Type } from "./Type.js"; /** @@ -102,3 +104,50 @@ export const assertType: ( ) => asserts value is InferType = (type, value, message) => { assert(type.is(value), message ?? `Expected ${type.name}.`); }; + +/** + * Asserts that a {@link Result} did not fail with `AbortError`. + * + * Use when abort would indicate a programmer error rather than ordinary control + * flow. A typical case is code that uses `unabortable` for work that must + * survive ordinary cancellation, but still wants to fail fast if that work was + * started on an already-stopped Run. + */ +export function assertNotAborted( + result: Result, + message?: string, +): asserts result is Ok; +export function assertNotAborted( + result: Result, + message?: string, +): asserts result is Result; +export function assertNotAborted( + result: Result, + message = "Expected result to not be aborted.", +): asserts result is Result { + const isAbortError = + !result.ok && + (result.error as { readonly type?: unknown }).type === "AbortError"; + + assert(!isAbortError, message); +} + +/** + * Guards synchronous methods on objects that may be called after disposal. + * + * Use when an API must fail fast before touching already-disposed state. + * + * ### Example + * + * ```ts + * const stack = new globalThis.AsyncDisposableStack(); + * assertNotDisposed(stack); // no-op + * await stack.disposeAsync(); + * assertNotDisposed(stack); // throws Error + * ``` + */ +export const assertNotDisposed = ( + value: globalThis.DisposableStack | globalThis.AsyncDisposableStack, +): void => { + assert(!value.disposed, "Expected value to not be disposed."); +}; diff --git a/packages/common/src/Function.ts b/packages/common/src/Function.ts index 10614f1c7..ebc19878d 100644 --- a/packages/common/src/Function.ts +++ b/packages/common/src/Function.ts @@ -4,9 +4,6 @@ * @module */ -import type { NonEmptyArray, NonEmptyReadonlyArray } from "./Array.js"; -import type { ReadonlyRecord } from "./Object.js"; - /** * Helper function to ensure exhaustive matching in a switch statement. Throws * an error if an unhandled case is encountered. @@ -111,67 +108,6 @@ export const exhaustiveCheck = (value: never): never => { */ export const identity = (a: A): A => a; -/** - * Casts an array, set, record, or map to its readonly counterpart. - * - * Zero runtime cost — returns the same value with a readonly type. Use this to - * enforce immutability at the type level. Preserves {@link NonEmptyArray} as - * {@link NonEmptyReadonlyArray}. - * - * ### Example - * - * ```ts - * // Array literals become NonEmptyReadonlyArray - * const items = readonly([1, 2, 3]); - * // Type: NonEmptyReadonlyArray - * - * // NonEmptyArray is preserved as NonEmptyReadonlyArray - * const nonEmpty: NonEmptyArray = [1, 2, 3]; - * const readonlyNonEmpty = readonly(nonEmpty); - * // Type: NonEmptyReadonlyArray - * - * // Regular arrays become ReadonlyArray - * const arr: Array = getNumbers(); - * const readonlyArr = readonly(arr); - * // Type: ReadonlyArray - * - * // Sets, Records, and Maps - * const ids = readonly(new Set(["a", "b"])); - * // Type: ReadonlySet - * - * const users: Record = { ... }; - * const readonlyUsers = readonly(users); - * // Type: ReadonlyRecord - * - * const lookup = readonly(new Map([["key", "value"]])); - * // Type: ReadonlyMap - * - * // ES2025 iterator chains: use .toArray() then readonly - * const doubled = readonly([1, 2, 3].values().map((x) => x * 2).toArray()); - * // Type: ReadonlyArray - * ``` - */ -export function readonly(array: NonEmptyArray): NonEmptyReadonlyArray; -/** Array overload. */ -export function readonly(array: Array): ReadonlyArray; -/** Set overload. */ -export function readonly(set: Set): ReadonlySet; -/** Map overload. */ -export function readonly(map: Map): ReadonlyMap; -/** Record overload. */ -export function readonly( - record: Record, -): ReadonlyRecord; -export function readonly( - value: Array | Set | Map | Record, -): - | ReadonlyArray - | ReadonlySet - | ReadonlyMap - | ReadonlyRecord { - return value; -} - /** * A function that takes no arguments and returns a value of type T. Also known * as a thunk. diff --git a/packages/common/src/Listeners.ts b/packages/common/src/Listeners.ts deleted file mode 100644 index f2eaf6d7c..000000000 --- a/packages/common/src/Listeners.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Simple publish-subscribe mechanism for event broadcasting. - * - * @module - */ - -/** A callback invoked on notification. */ -export type Listener = (value: T) => void; - -/** Unsubscribe function to remove a listener. */ -export type Unsubscribe = () => void; - -/** - * A simple publish-subscribe mechanism for broadcasting notifications. - * - * Use Listeners when you need to notify multiple listeners about events. The - * generic parameter allows typed payloads. - * - * ### Example - * - * ```ts - * // Without payload (default) - * const listeners = createListeners(); - * listeners.subscribe(() => console.log("notified")); - * listeners.notify(); - * - * // With typed payload - * const listeners = createListeners<{ id: string }>(); - * listeners.subscribe((event) => console.log(event.id)); - * listeners.notify({ id: "123" }); - * ``` - */ -export interface Listeners extends Disposable { - /** Registers a listener and returns an unsubscribe function. */ - readonly subscribe: (listener: Listener) => Unsubscribe; - - /** Notifies all registered listeners. */ - readonly notify: (value: T) => void; -} - -/** Creates a {@link Listeners} instance for managing subscriptions. */ -export const createListeners = (): Listeners => { - let listeners: Set> | null = null; - - return { - subscribe: (listener) => { - listeners ??= new Set(); - listeners.add(listener); - return () => listeners?.delete(listener); - }, - - notify: (value) => { - if (listeners) for (const listener of listeners) listener(value); - }, - - [Symbol.dispose]: () => { - listeners?.clear(); - }, - }; -}; diff --git a/packages/common/src/Random.ts b/packages/common/src/Random.ts index e7490f620..5aa7049c8 100644 --- a/packages/common/src/Random.ts +++ b/packages/common/src/Random.ts @@ -6,6 +6,13 @@ import type { Brand } from "./Brand.js"; +interface Arc4RngSnapshot { + readonly seed: string | number; + readonly i: number; + readonly j: number; + readonly state: ReadonlyArray; +} + const arc4StartDenom = 281_474_976_710_656; const arc4Significance = 4_503_599_627_370_496; const arc4Overflow = 9_007_199_254_740_992; @@ -47,23 +54,8 @@ class Arc4Rng { private j = 0; private readonly state: Array; - constructor(seed?: string | number); - constructor(snapshot?: { - readonly seed: string | number; - readonly i: number; - readonly j: number; - readonly state: ReadonlyArray; - }); constructor( - seedOrSnapshot: - | string - | number - | { - readonly seed: string | number; - readonly i: number; - readonly j: number; - readonly state: ReadonlyArray; - } = createDefaultSeed(), + seedOrSnapshot: string | number | Arc4RngSnapshot = createDefaultSeed(), ) { if (typeof seedOrSnapshot === "object") { this.seed = seedOrSnapshot.seed; diff --git a/packages/common/src/Ref.ts b/packages/common/src/Ref.ts index 58a0f31ad..79375c083 100644 --- a/packages/common/src/Ref.ts +++ b/packages/common/src/Ref.ts @@ -1,5 +1,5 @@ /** - * Mutable reference. + * Mutable reference to an immutable value. * * @module */ @@ -7,13 +7,16 @@ import type { Store } from "./Store.js"; /** - * Mutable reference. + * Mutable reference to an immutable value. * - * `Ref` holds a mutable value and exposes explicit `get`, `set`, `update`, and - * `modify` operations. Use it when mutable state needs to be passed around as a - * value. + * `Ref` holds the current value and exposes explicit `get`, `set`, `update`, + * and `modify` operations. The reference is mutable, but the value inside it + * must be immutable and replaced with a new value rather than mutated in place. + * Storing a mutable value in `Ref` does not make sense, because callers could + * mutate that value directly and pass it around without `Ref`. * - * For reactive state with subscriptions, see {@link Store}. + * Use it when mutable ownership of a value needs to be passed around as a + * value. For reactive state with subscriptions, see {@link Store}. * * ### Example * @@ -33,73 +36,73 @@ import type { Store } from "./Store.js"; * ``` */ export interface Ref { - /** Returns the current state. */ + /** Returns the current value. */ readonly get: () => T; - /** Sets the state. */ - readonly set: (state: T) => void; + /** Sets the current value. */ + readonly set: (value: T) => void; - /** Sets the state and returns the previous state. */ - readonly getAndSet: (state: T) => T; + /** Sets the current value and returns the previous value. */ + readonly getAndSet: (value: T) => T; - /** Sets the state and returns the current state after the update. */ - readonly setAndGet: (state: T) => T; + /** Sets the current value and returns it. */ + readonly setAndGet: (value: T) => T; - /** Updates the state. */ + /** Updates the current value. */ readonly update: (updater: (current: T) => T) => void; - /** Updates the state and returns the previous state. */ + /** Updates the current value and returns the previous value. */ readonly getAndUpdate: (updater: (current: T) => T) => T; - /** Updates the state and returns the current state after the update. */ + /** Updates the current value and returns it. */ readonly updateAndGet: (updater: (current: T) => T) => T; - /** Modifies the state and returns a computed result from the transition. */ + /** Modifies the current value and returns a computed result. */ readonly modify: ( - updater: (current: T) => readonly [result: R, nextState: T], + updater: (current: T) => readonly [result: R, nextValue: T], ) => R; } -/** Creates a {@link Ref} with the given initial state. */ -export const createRef = (initialState: T): Ref => { - let currentState = initialState; +/** Creates a {@link Ref} with the given initial immutable value. */ +export const createRef = (initialValue: T): Ref => { + let currentValue = initialValue; return { - get: () => currentState, + get: () => currentValue, - set: (state) => { - currentState = state; + set: (value) => { + currentValue = value; }, - getAndSet: (state) => { - const previousState = currentState; - currentState = state; - return previousState; + getAndSet: (value) => { + const previousValue = currentValue; + currentValue = value; + return previousValue; }, - setAndGet: (state) => { - currentState = state; - return currentState; + setAndGet: (value) => { + currentValue = value; + return currentValue; }, update: (updater) => { - currentState = updater(currentState); + currentValue = updater(currentValue); }, getAndUpdate: (updater) => { - const previousState = currentState; - currentState = updater(currentState); - return previousState; + const previousValue = currentValue; + currentValue = updater(currentValue); + return previousValue; }, updateAndGet: (updater) => { - currentState = updater(currentState); - return currentState; + currentValue = updater(currentValue); + return currentValue; }, modify: (updater) => { - const [result, nextState] = updater(currentState); - currentState = nextState; + const [result, nextValue] = updater(currentValue); + currentValue = nextValue; return result; }, }; diff --git a/packages/common/src/Resource.ts b/packages/common/src/Resource.ts new file mode 100644 index 000000000..512025e81 --- /dev/null +++ b/packages/common/src/Resource.ts @@ -0,0 +1,698 @@ +/** + * Resource lifecycle primitives. + * + * @module + */ + +import { assert, assertNotAborted } from "./Assert.js"; +import { ok } from "./Result.js"; +import { createStructuralMap, type StructuralKey } from "./StructuralMap.js"; +import { + type AbortError, + createMutex, + createMutexByKey, + type Fiber, + type MutexRef, + sleep, + type Task, + unabortable, +} from "./Task.js"; +import type { Duration } from "./Time.js"; +import { NonNegativeInt } from "./Type.js"; + +/** + * Disposable resource. + * + * A resource is any object that implements {@link Disposable} or + * {@link AsyncDisposable}. + * + * @see {@link ResourceRef} + * @see {@link createResourceRef} + */ +export type Resource = Disposable | AsyncDisposable; + +/** + * Borrowed {@link Resource}. + * + * A borrowed resource is a {@link Resource} without disposal methods. + * + * Another abstraction owns the resource and controls its lifecycle. Exposing + * disposal would break that ownership and allow callers to dispose a resource + * they do not own. + */ +export type BorrowedResource = Omit< + T, + typeof Symbol.dispose | typeof Symbol.asyncDispose +>; + +/** + * {@link Resource} reference. + * + * A {@link MutexRef}-like reference for resources. `ResourceRef` controls the + * resource lifecycle. + * + * Callers get the current resource as {@link BorrowedResource} to ensure only + * the `ResourceRef` can dispose it. + * + * Setting a new resource first disposes the current one and then sets the next. + * The create Task must not fail. If it could fail, the current resource would + * be disposed without the next resource installed. + */ +export interface ResourceRef + extends AsyncDisposable { + /** Returns the current resource. */ + readonly get: Task, never, D>; + + /** Disposes the current resource and then creates and sets the next. */ + readonly set: (create: Task) => Task; +} + +/** Creates {@link ResourceRef}. */ +export const createResourceRef = ( + create: Task, +): Task, never, D> => + unabortable, never, D>(async (run) => { + const resourceRefRun = run.create(); + + await using stack = new AsyncDisposableStack(); + stack.use(resourceRefRun); + + const initial = await resourceRefRun(create); + if (!initial.ok) return initial; + + let current = createOwnedResource(initial.value); + + const mutex = stack.use(createMutex()); + stack.defer(() => current.stack.disposeAsync()); + // Register as the last so disposal aborts further calls first. + // Repeated registration is safe because disposal is idempotent. + stack.use(resourceRefRun); + + const moved = stack.move(); + + return ok({ + get: () => resourceRefRun(mutex.withLock(() => ok(current.resource))), + + set: (create: Task): Task => + unabortable(() => + resourceRefRun( + mutex.withLock(async (run) => { + await current.stack.disposeAsync(); + const next = await run(create); + if (!next.ok) return next; + current = createOwnedResource(next.value); + return ok(); + }), + ), + ), + + [Symbol.asyncDispose]: () => moved.disposeAsync(), + }); + }); + +/** + * Shared {@link Resource}. + * + * Lazily acquires the underlying resource on the first + * {@link SharedResource.acquire | acquire} call, shares it across callers, and + * disposes it when the last caller {@link SharedResource.release | releases} + * it. + * + * Calls to {@link SharedResource.release | release} must be balanced with + * successful calls to {@link SharedResource.acquire | acquire}. Releasing more + * times than acquired is a programmer error checked with {@link assert}. + */ +export interface SharedResource + extends AsyncDisposable { + /** + * Acquires a shared reference. + * + * The first call lazily creates the resource. Later calls reuse the same + * resource until the final {@link SharedResource.release | release} starts the + * final disposal path. Disposal happens immediately by default, or after + * {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter} elapses + * when configured. + */ + readonly acquire: Task, never, D>; + + /** + * Releases one previously acquired shared reference. + * + * When the last acquired reference is released, the current resource is + * disposed immediately by default. If + * {@link SharedResourceOptions.idleDisposeAfter | idleDisposeAfter} is set, + * disposal is scheduled instead and a new acquire during that delay reuses + * the current resource. + */ + readonly release: Task; + + /** Returns the current acquire count. */ + readonly getCount: Task; +} + +/** Options for {@link createSharedResource}. */ +export interface SharedResourceOptions { + /** + * Keeps the resource alive briefly after the last release. + * + * This avoids immediate disposal when the resource is expensive to create and + * likely to be acquired again soon. A new acquire during this delay cancels + * the pending disposal and reuses the current resource. + */ + readonly idleDisposeAfter?: Duration | undefined; + + /** Called after the current resource is disposed and cleared. */ + readonly onDisposed?: () => void; +} + +/** Creates {@link SharedResource}. */ +export const createSharedResource = ( + create: Task, + { idleDisposeAfter, onDisposed }: SharedResourceOptions = {}, +): Task, never, D> => + unabortable, never, D>((run) => { + const sharedResourceRun = run.create(); + + let acquireCount = NonNegativeInt.orThrow(0); + let current: OwnedResource | undefined; + let idleDisposeFiber: Fiber | undefined; + + const stack = new AsyncDisposableStack(); + + const mutex = stack.use(createMutex()); + + const disposeCurrent = async () => { + if (!current) return; + const toDispose = current; + current = undefined; + try { + await toDispose.stack.disposeAsync(); + } finally { + onDisposed?.(); + } + }; + stack.defer(disposeCurrent); + + stack.defer(() => idleDisposeFiber?.abort()); + // Register as the last so disposal aborts further calls first. + stack.use(sharedResourceRun); + + const moved = stack.move(); + + return ok({ + acquire: unabortable, never, D>(() => + sharedResourceRun( + mutex.withLock(async (run) => { + if (idleDisposeFiber) { + idleDisposeFiber.abort(); + idleDisposeFiber = undefined; + } + + if (!current) { + const resource = await run(create); + if (!resource.ok) return resource; + current = createOwnedResource(resource.value); + } + + acquireCount = NonNegativeInt.orThrow(acquireCount + 1); + return ok(current.resource); + }), + ), + ), + + release: unabortable(() => + sharedResourceRun( + mutex.withLock(async () => { + assert( + acquireCount > 0, + "Release must not be called more times than acquire.", + ); + + acquireCount = NonNegativeInt.orThrow(acquireCount - 1); + if (acquireCount > 0) return ok(); + + if (!idleDisposeAfter) { + await disposeCurrent(); + return ok(); + } + + idleDisposeFiber = sharedResourceRun(async (run) => { + const slept = await run(sleep(idleDisposeAfter)); + if (!slept.ok) return slept; + + return run( + mutex.withLock(async () => { + idleDisposeFiber = undefined; + await disposeCurrent(); + return ok(); + }), + ); + }); + + return ok(); + }), + ), + ), + + getCount: () => sharedResourceRun(mutex.withLock(() => ok(acquireCount))), + + [Symbol.asyncDispose]: () => moved.disposeAsync(), + }); + }); + +/** + * Shared {@link Resource}s keyed by {@link StructuralKey}. + * + * A map-like registry of {@link SharedResource}s. Each key owns at most one + * current resource instance. + * + * The first {@link SharedResourceByKey.acquire | acquire} for a key lazily + * creates that key's resource. Later acquires for the same key reuse the same + * resource until the final {@link SharedResourceByKey.release | release} starts + * the final disposal path for that key. Disposal and registry removal happen + * immediately by default, or after + * {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} elapses + * when configured. + * + * Different keys are independent and may progress concurrently. Operations for + * the same key are serialized. Calls to + * {@link SharedResourceByKey.release | release} must be balanced with successful + * calls to {@link SharedResourceByKey.acquire | acquire}. Releasing more times + * than acquired is a programmer error checked with {@link assert}. + */ +export interface SharedResourceByKey< + K extends StructuralKey, + T extends Resource, + D = unknown, +> extends AsyncDisposable { + /** Acquires the shared resource for `key`, creating it on first use. */ + readonly acquire: (key: K) => Task, never, D>; + + /** + * Releases one previously acquired shared reference for `key`. + * + * When the last acquired reference for `key` is released, that key's current + * resource is disposed and removed from the registry immediately by default. + * If {@link SharedResourceByKeyOptions.idleDisposeAfter | idleDisposeAfter} is + * set, disposal and registry removal are scheduled instead and a new acquire + * for the same key during that delay reuses the current resource. + */ + readonly release: (key: K) => Task; + + /** Returns the current acquire count for `key`. Missing keys return `0`. */ + readonly getCount: (key: K) => Task; +} + +/** Options for {@link createSharedResourceByKey}. */ +export interface SharedResourceByKeyOptions + extends Pick { + /** Called after `key`'s current resource is disposed and cleared. */ + readonly onDisposed?: (key: K) => void; +} + +/** + * Creates {@link SharedResourceByKey}. + * + * The `create` Task is scoped to one key. It must not fail, for the same reason + * as {@link createSharedResource}: callers should never observe a key in a + * partially replaced state. + */ +export const createSharedResourceByKey = < + K extends StructuralKey, + T extends Resource, + D, +>( + create: (key: K) => Task, + { idleDisposeAfter, onDisposed }: SharedResourceByKeyOptions = {}, +): Task, never, D> => + unabortable, never, D>((rootRun) => { + const sharedResourceByKeyRun = rootRun.create(); + const sharedResourcesByKey = createStructuralMap>(); + + const stack = new AsyncDisposableStack(); + const mutexByKey = stack.use(createMutexByKey()); + stack.defer(async () => { + await disposeResources(sharedResourcesByKey.values()); + sharedResourcesByKey.clear(); + }); + // Register as the last so disposal aborts further calls first. + stack.use(sharedResourceByKeyRun); + + const moved = stack.move(); + + return ok({ + acquire: (key) => + unabortable, never, D>(() => + sharedResourceByKeyRun( + mutexByKey.withLock(key, async (run) => { + let sharedResource = sharedResourcesByKey.get(key); + + if (!sharedResource) { + const sharedResourceResult = await run( + createSharedResource(create(key), { + idleDisposeAfter, + onDisposed: () => { + const disposedSharedResource = sharedResource; + if (!disposedSharedResource) return; + + void rootRun.asUnabortableDaemon( + mutexByKey.withLock(key, async (cleanupRun) => { + if ( + sharedResourcesByKey.get(key) !== + disposedSharedResource + ) { + return ok(); + } + + const countResult = await cleanupRun( + disposedSharedResource.getCount, + ); + if (!countResult.ok || countResult.value > 0) { + return ok(); + } + + sharedResourcesByKey.delete(key); + onDisposed?.(key); + return ok(); + }), + ); + }, + }), + ); + assertNotAborted(sharedResourceResult); + + sharedResource = sharedResourceResult.value; + sharedResourcesByKey.set(key, sharedResource); + } + + return run(sharedResource.acquire); + }), + ), + ), + + release: (key) => + unabortable(() => + sharedResourceByKeyRun( + mutexByKey.withLock(key, async () => { + const sharedResource = sharedResourcesByKey.get(key); + assert( + sharedResource, + "Release must not be called more times than acquire.", + ); + return sharedResourceByKeyRun(sharedResource.release); + }), + ), + ), + + getCount: (key) => () => + sharedResourceByKeyRun( + mutexByKey.withLock(key, async (run) => { + const sharedResource = sharedResourcesByKey.get(key); + if (!sharedResource) return ok(NonNegativeInt.orThrow(0)); + return run(sharedResource.getCount); + }), + ), + + [Symbol.asyncDispose]: () => moved.disposeAsync(), + }); + }); + +interface OwnedResource { + readonly resource: BorrowedResource; + readonly stack: AsyncDisposableStack; +} + +const createOwnedResource = ( + resource: T, +): OwnedResource => { + const stack = new AsyncDisposableStack(); + stack.use(resource); + return { resource, stack }; +}; + +/** + * Disposes resources via a temporary stack so one disposal failure does not + * prevent later resources from being attempted. + */ +const disposeResources = async ( + resources: Iterable, +): Promise => { + const stack = new AsyncDisposableStack(); + for (const resource of resources) stack.use(resource); + await stack.disposeAsync(); +}; + +// /** +// * +// * Tracks which consumers use which shared resources and keeps resources alive +// * while at least one consumer is attached. +// * +// * ### Example +// * +// * ```ts +// * interface TransportConfig { +// * readonly url: UrlString; +// * } +// * +// * interface Owner { +// * readonly id: OwnerId; +// * } +// * +// * const resources = createResources< +// * WebSocket, +// * UrlString, +// * TransportConfig, +// * Owner, +// * OwnerId +// * >({ +// * createResource: async (transport) => { +// * const { createWebSocket } = run.deps; +// * return await run.orThrow( +// * createWebSocket(transport.url, { +// * onOpen: handleWebSocketOpen(transport.url), +// * }), +// * ); +// * }, +// * getResourceId: (transportConfig) => transportConfig.url, +// * getConsumerId: (owner) => owner.id, +// * }); +// * +// * const handleWebSocketOpen = (transportUrl: UrlString) => (): void => { +// * const ownerIds = resources.getConsumerIdsForResource(transportUrl); +// * dbWorker.postMessage({ type: "CreateSyncMessages", ownerIds }); +// * }; +// * +// * dbWorker.onMessage = (message) => { +// * switch (message.type) { +// * case "OnSyncMessage": +// * for (const [ownerId, syncMessage] of message.messagesByOwnerId) { +// * const webSockets = resources.getResourcesForConsumerId(ownerId); +// * for (const webSocket of webSockets) { +// * if (webSocket.isOpen()) webSocket.send(syncMessage); +// * } +// * } +// * } +// * }; +// * +// * await run( +// * resources.addConsumer({ id: "owner-1" as OwnerId }, [ +// * { url: "wss://server1.com" as UrlString }, +// * { url: "wss://server2.com" as UrlString }, +// * ]), +// * ); +// * +// * await run( +// * resources.addConsumer({ id: "owner-2" as OwnerId }, [ +// * { url: "wss://server1.com" as UrlString }, +// * ]), +// * ); +// * +// * await run( +// * resources.removeConsumer({ id: "owner-1" as OwnerId }, [ +// * { url: "wss://server1.com" as UrlString }, +// * { url: "wss://server2.com" as UrlString }, +// * ]), +// * ); +// * +// * // The WebSocket for wss://server2.com is disposed because it has no consumers. +// * // The WebSocket for wss://server1.com stays alive because owner-2 still uses it. +// * ``` +// */ +// export interface Resources< +// TResource extends Disposable | AsyncDisposable, +// TResourceId extends string, +// TResourceConfig, +// TConsumer, +// TConsumerId extends string, +// > extends AsyncDisposable { +// /** Attaches a consumer to resources. */ +// readonly addConsumer: ( +// consumer: TConsumer, +// resourceConfigs: ReadonlyArray, +// ) => Task; + +// /** Detaches a consumer from resources. */ +// readonly removeConsumer: ( +// consumer: TConsumer, +// resourceConfigs: ReadonlyArray, +// ) => Task; + +// readonly getConsumerIdsForResource: ( +// resourceId: TResourceId, +// ) => ReadonlySet; + +// readonly getResourcesForConsumerId: ( +// consumerId: TConsumerId, +// ) => ReadonlySet; +// } + +// /** Creates {@link Resources}. */ +// export const createResources = < +// TResource extends Disposable | AsyncDisposable, +// TResourceId extends string, +// TResourceConfig, +// TConsumer, +// TConsumerId extends string, +// >({ +// createResource, +// getResourceId, +// getConsumerId, +// }: { +// /** Creates a resource for the provided configuration. */ +// createResource: (resourceConfig: TResourceConfig) => Promise; + +// /** Maps a resource configuration to its shared resource identifier. */ +// getResourceId: (resourceConfig: TResourceConfig) => TResourceId; + +// /** Maps a consumer value to its stable consumer identifier. */ +// getConsumerId: (consumer: TConsumer) => TConsumerId; +// }): Resources< +// TResource, +// TResourceId, +// TResourceConfig, +// TConsumer, +// TConsumerId +// > => { +// const resourcesById = new Map(); +// const consumerRefCountsByResourceId = new Map< +// TResourceId, +// RefCount +// >(); +// const consumerIdsByResourceId = createRelation(); +// const mutexByResourceId = createMutexByKey(); + +// return { +// addConsumer: (consumer, resourceConfigs) => async (run) => { +// const consumerId = getConsumerId(consumer); + +// for (const resourceConfig of resourceConfigs) { +// const resourceId = getResourceId(resourceConfig); + +// const result = await run( +// unabortable( +// mutexByResourceId.withLock(resourceId, async () => { +// let resource = resourcesById.get(resourceId); +// if (!resource) { +// resource = await createResource(resourceConfig); +// resourcesById.set(resourceId, resource); +// } + +// let consumerRefCountsByConsumerId = +// consumerRefCountsByResourceId.get(resourceId); +// if (!consumerRefCountsByConsumerId) { +// consumerRefCountsByConsumerId = createRefCount(); +// consumerRefCountsByResourceId.set( +// resourceId, +// consumerRefCountsByConsumerId, +// ); +// } + +// const nextCount = +// consumerRefCountsByConsumerId.increment(consumerId); + +// if (nextCount === 1) { +// consumerIdsByResourceId.add(resourceId, consumerId); +// } + +// return ok(); +// }), +// ), +// ); +// assert(result.ok, "Unabortable addConsumer lock must not abort"); +// } + +// return ok(); +// }, + +// removeConsumer: (consumer, resourceConfigs) => async (run) => { +// const consumerId = getConsumerId(consumer); + +// for (const resourceConfig of resourceConfigs) { +// const resourceId = getResourceId(resourceConfig); + +// const result = await run( +// unabortable( +// mutexByResourceId.withLock(resourceId, () => { +// const consumerRefCountsByConsumerId = +// consumerRefCountsByResourceId.get(resourceId); +// if (!consumerRefCountsByConsumerId) { +// assert( +// !consumerIdsByResourceId.hasA(resourceId) && +// !resourcesById.has(resourceId), +// "Ref counts, relation, and resources must stay symmetric", +// ); +// return ok(); +// } + +// const nextCount = +// consumerRefCountsByConsumerId.decrement(consumerId); +// if (isNone(nextCount)) return ok(); + +// if (nextCount.value === 0) { +// consumerIdsByResourceId.remove(resourceId, consumerId); +// } + +// if (!consumerIdsByResourceId.hasA(resourceId)) { +// consumerRefCountsByResourceId.delete(resourceId); +// const resource = resourcesById.get(resourceId); +// assert( +// resource, +// "Resource must exist when last consumer reference is removed", +// ); +// resourcesById.delete(resourceId); +// // await disposeResource(resource); +// } + +// return ok(); +// }), +// ), +// ); +// assert(result.ok, "Unabortable removeConsumer lock must not abort"); +// } + +// return ok(); +// }, + +// getConsumerIdsForResource: (resourceId) => +// new Set(consumerIdsByResourceId.iterateB(resourceId)), + +// getResourcesForConsumerId: (consumerId) => { +// const resources = new Set(); +// for (const resourceId of consumerIdsByResourceId.iterateA(consumerId)) { +// resources.add(resourcesById.get(resourceId)!); +// } + +// return resources; +// }, + +// [Symbol.asyncDispose]: async () => { +// for (const resource of resourcesById.values()) { +// await disposeResource(resource); +// } +// resourcesById.clear(); +// consumerRefCountsByResourceId.clear(); +// consumerIdsByResourceId.clear(); +// mutexByResourceId[Symbol.dispose](); +// }, +// }; +// }; diff --git a/packages/common/src/Resources.ts b/packages/common/src/Resources.ts index a96688e43..4d42b2dd8 100644 --- a/packages/common/src/Resources.ts +++ b/packages/common/src/Resources.ts @@ -19,6 +19,19 @@ import { } from "./Time.js"; import { PositiveInt, type Typed } from "./Type.js"; +type AsyncOrSyncDisposable = Disposable | AsyncDisposable; + +const disposeResource = async ( + resource: AsyncOrSyncDisposable, +): Promise => { + if (Symbol.asyncDispose in resource) { + await resource[Symbol.asyncDispose](); + return; + } + + resource[Symbol.dispose](); +}; + /** * Async reference-counted resource management. * @@ -26,7 +39,7 @@ import { PositiveInt, type Typed } from "./Type.js"; * while at least one consumer is attached. */ export interface Resources< - TResource extends Disposable, + TResource extends AsyncOrSyncDisposable, TResourceId extends string, TResourceConfig, TConsumer, @@ -59,7 +72,7 @@ export interface Resources< /** Configuration for async {@link Resources}. */ export interface AsyncResourcesConfig< - TResource extends Disposable, + TResource extends AsyncOrSyncDisposable, TResourceId extends string, TResourceConfig, TConsumer, @@ -166,7 +179,7 @@ export interface LegacyResourcesConfig< } const createAsyncResources = < - TResource extends Disposable, + TResource extends AsyncOrSyncDisposable, TResourceId extends string, TResourceConfig, TConsumer, @@ -220,7 +233,7 @@ const createAsyncResources = < await using run = createRun(); const result = await run( unabortable( - mutexByResourceId.withLock(resourceId, () => { + mutexByResourceId.withLock(resourceId, async () => { disposalTimeoutByResourceId.delete(resourceId); if (consumerIdsByResourceId.hasA(resourceId)) return ok(); @@ -230,7 +243,7 @@ const createAsyncResources = < if (!resource) return ok(); resourcesById.delete(resourceId); - resource[Symbol.dispose](); + await disposeResource(resource); return ok(); }), ), @@ -403,7 +416,7 @@ const createAsyncResources = < } for (const resource of resourcesById.values()) { - resource[Symbol.dispose](); + await disposeResource(resource); } resourcesById.clear(); consumerRefCountsByResourceId.clear(); @@ -767,7 +780,7 @@ export function createResources< TConsumerId >; export function createResources< - TResource extends Disposable, + TResource extends AsyncOrSyncDisposable, TResourceId extends string, TResourceConfig, TConsumer, @@ -781,41 +794,27 @@ export function createResources< TConsumerId >, ): Resources; -export function createResources< - TResource extends Disposable, - TResourceId extends string, - TResourceConfig, - TConsumer, - TConsumerId extends string, ->( +export function createResources( configOrDeps: | TimeDep | AsyncResourcesConfig< - TResource, - TResourceId, - TResourceConfig, - TConsumer, - TConsumerId + AsyncOrSyncDisposable, + string, + unknown, + unknown, + string >, -): - | Resources - | (( +): unknown { + if (isTimeDep(configOrDeps)) { + return ( config: LegacyResourcesConfig< - TResource, - TResourceId, - TResourceConfig, - TConsumer, - TConsumerId + Disposable, + string, + unknown, + unknown, + string >, - ) => LegacyResources< - TResource, - TResourceId, - TResourceConfig, - TConsumer, - TConsumerId - >) { - if (isTimeDep(configOrDeps)) { - return (config) => createLegacyResources(configOrDeps, config); + ) => createLegacyResources(configOrDeps, config); } return createAsyncResources(configOrDeps); diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index f672d4e5a..f9e0f2b82 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -152,8 +152,8 @@ import type { Typed } from "./Type.js"; * combinators for every sequential pattern because that would duplicate plain * control flow and create API ambiguity. Use helpers when they add semantics * over ordinary control flow, such as operating on collections of results. - * While this can look verbose, it is explicit, transparent, debuggable, and - * avoids pipes and nested helper chains. + * While it may seem verbose, it is explicit, transparent, and avoids pipes and + * nested helpers, which are harder to debug. * * ## Composition * @@ -322,15 +322,20 @@ export const isErr = (result: Result): result is Err => !result.ok; /** - * Extracts the value from a {@link Result} if it is an `Ok`, or throws an error - * if it is an `Err`. + * Returns the value from an `Ok` {@link Result}, or throws if it is an `Err`. * - * **Intended usage:** + * Use this where failure should crash the current flow instead of being handled + * locally. + * + * **When to use:** + * + * - Application startup or composition-root setup where errors must stop the + * program immediately + * - Module-level constants + * - Test setup with values that are expected to be valid * - * - For critical code paths (e.g., app startup, config values) where failure - * should crash the app. - * - Not recommended for general error handling in application logic—prefer - * explicit checks. + * Prefer an explicit `if (!result.ok)` check in ordinary application logic + * where the caller can recover, retry, or choose a different flow. * * ### Example * diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index 082c9da07..55546b5d7 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -4,6 +4,7 @@ * @module */ +import { assertNotDisposed } from "./Assert.js"; import type { Brand } from "./Brand.js"; import type { EncryptionKey } from "./Crypto.js"; import type { Eq } from "./Eq.js"; @@ -33,7 +34,7 @@ import { * {@link https://github.com/WiseLibs/better-sqlite3/issues/262 | better concurrency} * for SQLite. */ -export interface Sqlite extends Disposable { +export interface Sqlite extends AsyncDisposable { readonly exec: ( query: SqliteQuery, ) => SqliteExecResult; @@ -56,17 +57,9 @@ export interface Sqlite extends Disposable { * Exported databases are forwarded through worker `postMessage` transfer * lists, which require transferable `ArrayBuffer` backing. */ - readonly export: () => SqliteExportFile; + readonly export: () => Uint8Array; } -/** - * Exported SQLite bytes with transferable ArrayBuffer backing. - * - * Kept as an intersection instead of `Uint8Array` for broader - * TypeScript compatibility. - */ -export type SqliteExportFile = Uint8Array & { readonly buffer: ArrayBuffer }; - export interface SqliteDep { readonly sqlite: Sqlite; } @@ -157,7 +150,7 @@ export interface SqliteDriver extends Disposable { * Exported databases are forwarded through worker `postMessage` transfer * lists, which require transferable `ArrayBuffer` backing. */ - readonly export: () => SqliteExportFile; + readonly export: () => Uint8Array; } /** Creates a {@link SqliteDriver}. */ @@ -194,16 +187,20 @@ export const createSqlite = async (run) => { const { createSqliteDriver } = run.deps; const console = run.deps.console.child("sql"); + const stack = new AsyncDisposableStack(); const driverResult = await run(createSqliteDriver(name, options)); if (!driverResult.ok) return driverResult; - const driver = driverResult.value; + const driver = stack.use(driverResult.value); console.debug("SQLite driver created"); - let isDisposed = false; + const moved = stack.move(); + const daemonSignal = run.daemon.signal; + let disposePromise: Promise | null = null; const sqlite: Sqlite = { exec: (query: SqliteQuery) => { + assertNotDisposed(moved); console.debug({ query }); const label = @@ -232,6 +229,7 @@ export const createSqlite = }, transaction: ((callback: () => Result | undefined) => { + assertNotDisposed(moved); console.debug("begin"); driver.exec(sql`begin;`); @@ -254,29 +252,28 @@ export const createSqlite = return result; }) as SqliteTransaction, - export: () => driver.export(), + export: () => { + assertNotDisposed(moved); + return driver.export(); + }, + + [Symbol.asyncDispose]: () => { + if (disposePromise) return disposePromise; - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; daemonSignal.removeEventListener("abort", disposeOnAbort); - driver[Symbol.dispose](); + disposePromise = moved.disposeAsync(); + return disposePromise; }, }; - const daemonSignal = run.daemon.signal; const disposeOnAbort = () => { - sqlite[Symbol.dispose](); + void sqlite[Symbol.asyncDispose](); }; - // Ensure Sqlite never outlives the root run even when callers forget to - // dispose it explicitly. When Sqlite is disposed manually, remove the abort - // listener so long-lived root runs do not accumulate stale hooks. + daemonSignal.addEventListener("abort", disposeOnAbort, { once: true }); if (daemonSignal.aborted) { - disposeOnAbort(); + await sqlite[Symbol.asyncDispose](); return err({ type: "AbortError", reason: daemonSignal.reason }); - } else { - daemonSignal.addEventListener("abort", disposeOnAbort, { once: true }); } return ok(sqlite); @@ -507,11 +504,8 @@ export const getSqliteSchema = * SQLite query. */ excludeIndexNamePrefix?: string; - /** - * Excludes SQLite internal indexes prefixed with `sqlite_`. - * - * @default true + * Excludes SQLite internal indexes like sqlite_autoindex_* by default. */ excludeSqliteInternalIndexes?: boolean; } = {}): SqliteSchema => { @@ -533,60 +527,52 @@ export const getSqliteSchema = (tables[tableName] ??= new Set()).add(columnName); }); - const escapeLikePattern = (s: string) => - s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); - - const indexesRows = - excludeIndexNamePrefix != null - ? deps.sqlite.exec<{ name: string; sql: string | null }>( - sql` - select name, sql - from sqlite_master - where - type = 'index' - ${sql.raw( - excludeSqliteInternalIndexes - ? "and name not like 'sqlite_%'" - : "", - )} - and name not like ${`${escapeLikePattern(excludeIndexNamePrefix)}%`} escape '\\'; - `, - ) - : deps.sqlite.exec<{ name: string; sql: string | null }>( - sql` - select name, sql - from sqlite_master - where - type = 'index' - ${sql.raw( - excludeSqliteInternalIndexes - ? "and name not like 'sqlite_%'" - : "", - )}; - `, - ); - - const indexes = indexesRows.rows.flatMap((row): Array => { - if (row.sql == null) return []; - return [ - { + const escapedIndexNamePrefix = + excludeIndexNamePrefix == null + ? null + : `${excludeIndexNamePrefix + .replaceAll("\\", "\\\\") + .replaceAll("%", "\\%") + .replaceAll("_", "\\_")}%`; + + const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( + sql` + select name, sql + from sqlite_master + where + type = 'index' + ${sql.raw( + excludeSqliteInternalIndexes ? "and name not like 'sqlite_%'" : "", + )} + ${sql.raw(escapedIndexNamePrefix == null ? "" : "and name not like ")} + ${escapedIndexNamePrefix ?? sql.raw("")} + ${sql.raw(escapedIndexNamePrefix == null ? "" : "ESCAPE '\\'")}; + `, + ); + + const indexes = indexesRows.rows + .filter((row): row is { name: string; sql: string } => row.sql != null) + .map( + (row): SqliteIndex => ({ name: row.name, /** * SQLite returns "CREATE INDEX" for "create index" for some reason. - * Other keywords remain unchanged. We have to normalize the casing - * for schema comparison manually. + * Other keywords remain unchanged. We have to normalize the casing for + * schema comparison manually. */ sql: row.sql .replace("CREATE INDEX", "create index") .replace("CREATE UNIQUE INDEX", "create unique index"), - }, - ]; - }); + }), + ); return { tables, indexes }; }; -/** Returns schema and full table contents for inspection and testing. */ +/** + * Returns {@link SqliteSchema} and full {@link SqliteRow} table contents for + * inspection and testing. + */ export interface SqliteSnapshot { readonly schema: SqliteSchema; readonly tables: Array<{ @@ -595,9 +581,25 @@ export interface SqliteSnapshot { }>; } -export const getSqliteSnapshot = (deps: SqliteDep): SqliteSnapshot => { +/** + * Captures a full {@link SqliteSnapshot} for testing and diagnostics. + * + * The snapshot includes current {@link SqliteSchema} and all rows from every + * discovered table. Table order follows `schema.tables` iteration order. + */ +export const getSqliteSnapshot = ( + deps: SqliteDep, + { + excludeIndexNamePrefix, + excludeSqliteInternalIndexes = false, + }: { + excludeIndexNamePrefix?: string; + excludeSqliteInternalIndexes?: boolean; + } = {}, +): SqliteSnapshot => { const schema = getSqliteSchema(deps)({ - excludeSqliteInternalIndexes: false, + ...(excludeIndexNamePrefix === undefined ? {} : { excludeIndexNamePrefix }), + excludeSqliteInternalIndexes, }); const tables: SqliteSnapshot["tables"] = []; diff --git a/packages/common/src/Store.ts b/packages/common/src/Store.ts index 1b1b75d99..871cbe8a9 100644 --- a/packages/common/src/Store.ts +++ b/packages/common/src/Store.ts @@ -6,11 +6,19 @@ import type { Eq } from "./Eq.js"; import { eqStrict } from "./Eq.js"; -import type { Listener, Unsubscribe } from "./Listeners.js"; -import { createListeners } from "./Listeners.js"; import type { Ref } from "./Ref.js"; import { createRef } from "./Ref.js"; +/** + * A store for managing state with change notifications. Like a {@link Ref} with + * subscriptions. + * + * Store is a valid dependency in Evolu's [Dependency + * Injection](https://evolu.dev/docs/dependency-injection) pattern—use it when + * functions need shared mutable state with subscriptions. + */ +export interface Store extends ReadonlyStore, Ref {} + /** * A read-only view of a {@link Store} that provides state access and change * notifications without allowing modifications. @@ -29,15 +37,14 @@ export interface ReadonlyStore extends Disposable { readonly subscribe: (listener: Listener) => Unsubscribe; } +/** Callback used by {@link ReadonlyStore.subscribe} to observe changes. */ +export type Listener = () => void; + /** - * A store for managing state with change notifications. Like a {@link Ref} with - * subscriptions. - * - * Store is a valid dependency in Evolu's [Dependency - * Injection](https://evolu.dev/docs/dependency-injection) pattern—use it when - * functions need shared mutable state with subscriptions. + * Function returned by {@link ReadonlyStore.subscribe} to stop observing + * changes. */ -export interface Store extends ReadonlyStore, Ref {} +export type Unsubscribe = () => void; /** * Creates a store with the given initial state. The store encapsulates its @@ -48,17 +55,23 @@ export interface Store extends ReadonlyStore, Ref {} * provide a custom equality function as the second argument. */ export const createStore = (initialState: T, eq?: Eq): Store => { - const listeners = createListeners(); const equality = eq ?? eqStrict; const ref = createRef(initialState); + const listeners = new Set(); const notifyIfChanged = (previousState: T): void => { - if (!equality(previousState, ref.get())) listeners.notify(); + if (!equality(previousState, ref.get())) { + const currentListeners = Array.from(listeners); + for (const listener of currentListeners) listener(); + } }; return { get: ref.get, - subscribe: listeners.subscribe, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, set: (state) => { const previousState = ref.get(); @@ -107,6 +120,8 @@ export const createStore = (initialState: T, eq?: Eq): Store => { return result; }, - [Symbol.dispose]: listeners[Symbol.dispose], + [Symbol.dispose]: () => { + listeners.clear(); + }, }; }; diff --git a/packages/common/src/StructuralMap.ts b/packages/common/src/StructuralMap.ts new file mode 100644 index 000000000..926794752 --- /dev/null +++ b/packages/common/src/StructuralMap.ts @@ -0,0 +1,231 @@ +/** + * Map with structural keys. + * + * @module + */ + +import { assert } from "./Assert.js"; +import { isPlainObject } from "./Object.js"; +import { type JsonValue, uint8ArrayToBase64Url } from "./Type.js"; + +/** + * Immutable structural key. + * + * Structural keys support {@link JsonValue} plus `Uint8Array`. + * + * This is for keys that are JSON-like or can reasonably travel through + * `postMessage`, not for arbitrary JavaScript objects. + * + * Keys are compared by structural value rather than object identity, so arrays, + * plain objects, and `Uint8Array` values must not be mutated. + * + * @see {@link StructuralMap} + */ +export type StructuralKey = + | string + | number + | boolean + | null + | Uint8Array + | StructuralArrayInput + | StructuralObjectInput; + +export interface StructuralObjectInput { + readonly [key: string]: StructuralKey; +} + +export type StructuralArrayInput = ReadonlyArray; + +/** + * `Map`-like collection keyed by {@link StructuralKey}. + * + * Use this when keys should compare by structural value instead of identity. + * + * Structurally equal arrays and plain objects address the same entry even when + * they are different JavaScript instances. + * + * This is intended for small to medium registries and coordination tables where + * callers naturally already have immutable JSON-like keys and do not want to + * maintain a separate canonical id. + * + * The implementation derives a canonical structural id for each key and stores + * entries in a native `Map` keyed by that id. For repeated lookups of the same + * object or array instance, the derived id is cached in a `WeakMap` so + * subsequent access can reuse it without recomputing the full structure. + * + * This favors simplicity and predictable behavior over maximum scale. Each + * operation still needs to derive the structural id, so cost is proportional to + * key size before the final native `Map` lookup. For collections with many + * distinct keys or very hot paths, prefer native stable keys with a native + * `Map`. + */ +export interface StructuralMap + extends Iterable { + readonly size: number; + readonly clear: () => void; + readonly delete: (key: K) => boolean; + readonly entries: () => IterableIterator; + readonly forEach: ( + callback: (value: V, key: K, map: StructuralMap) => void, + ) => void; + readonly get: (key: K) => V | undefined; + readonly has: (key: K) => boolean; + readonly keys: () => IterableIterator; + readonly set: (key: K, value: V) => StructuralMap; + readonly values: () => IterableIterator; +} + +/** Creates {@link StructuralMap}. */ +export const createStructuralMap = < + K extends StructuralKey, + V, +>(): StructuralMap => { + const entriesById = new Map>(); + const keyIdByObject = new WeakMap(); + + const getKeyId = (key: K): string => + serializeStructuralKey(key, keyIdByObject, new Set()); + + const map: StructuralMap = { + get size() { + return entriesById.size; + }, + + clear: () => { + entriesById.clear(); + }, + + delete: (key) => entriesById.delete(getKeyId(key)), + + entries: function* () { + for (const entry of entriesById.values()) { + yield [entry.key, entry.value] as const; + } + }, + + forEach: (callback) => { + for (const entry of entriesById.values()) { + callback(entry.value, entry.key, map); + } + }, + + get: (key) => entriesById.get(getKeyId(key))?.value, + + has: (key) => entriesById.has(getKeyId(key)), + + keys: function* () { + for (const entry of entriesById.values()) { + yield entry.key; + } + }, + + set: (key, value) => { + entriesById.set(getKeyId(key), { key, value }); + return map; + }, + + values: function* () { + for (const entry of entriesById.values()) { + yield entry.value; + } + }, + + [Symbol.iterator]: () => map.entries(), + }; + + return map; +}; + +interface Entry { + readonly key: K; + readonly value: V; +} + +const isUint8Array = (value: object): value is Uint8Array => + value instanceof globalThis.Uint8Array || + Object.prototype.toString.call(value) === "[object Uint8Array]"; + +const formatUnsupportedStructuralKeyType = (value: unknown): string => { + if (typeof value === "object" && value !== null) { + return value.constructor?.name ?? Object.prototype.toString.call(value); + } + + return typeof value; +}; + +const createUnsupportedStructuralKeyError = (value: unknown): Error => + new Error( + `StructuralMap keys must be JSON-like values or Uint8Array; received ${formatUnsupportedStructuralKeyType(value)}.`, + ); + +const serializeStructuralKey = ( + value: StructuralKey, + keyIdByObject: WeakMap, + path: Set, +): string => { + switch (typeof value) { + case "string": + return `s:${JSON.stringify(value)}`; + case "number": + if (Number.isNaN(value)) return "n:NaN"; + if (value === Number.POSITIVE_INFINITY) return "n:Infinity"; + if (value === Number.NEGATIVE_INFINITY) return "n:-Infinity"; + return Object.is(value, -0) ? "n:0" : `n:${value}`; + case "boolean": + return value ? "b:true" : "b:false"; + case "object": { + if (value === null) return "l:null"; + + const cachedId = keyIdByObject.get(value); + if (cachedId) return cachedId; + + let keyId: string; + + if (Array.isArray(value)) { + assert(!path.has(value), "Structural keys must not contain cycles."); + path.add(value); + keyId = serializeStructuralArray(value, keyIdByObject, path); + path.delete(value); + } else if (isPlainObject(value)) { + assert(!path.has(value), "Structural keys must not contain cycles."); + path.add(value); + keyId = serializeStructuralObject(value, keyIdByObject, path); + path.delete(value); + } else if (isUint8Array(value)) { + keyId = `u:${uint8ArrayToBase64Url(value)}`; + } else { + throw createUnsupportedStructuralKeyError(value); + } + + keyIdByObject.set(value, keyId); + return keyId; + } + default: + throw createUnsupportedStructuralKeyError(value); + } +}; + +const serializeStructuralArray = ( + value: StructuralArrayInput, + keyIdByObject: WeakMap, + path: Set, +): string => + `a:[${value + .map((item) => serializeStructuralKey(item, keyIdByObject, path)) + .join(",")}]`; + +const serializeStructuralObject = ( + value: Readonly>, + keyIdByObject: WeakMap, + path: Set, +): string => { + const entries = Object.keys(value) + .sort() + .map((key) => { + const item = value[key]; + assert(item !== undefined, "Structural keys must not contain undefined."); + return `${JSON.stringify(key)}:${serializeStructuralKey(item, keyIdByObject, path)}`; + }); + + return `o:{${entries.join(",")}}`; +}; diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index d6014d262..252f7189e 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -11,6 +11,7 @@ import { mapArray, type NonEmptyReadonlyArray, } from "./Array.js"; +import type { assertNotAborted } from "./Assert.js"; import { assert } from "./Assert.js"; import { type Console, type ConsoleDep, createConsole } from "./Console.js"; import type { RandomBytes, RandomBytesDep } from "./Crypto.js"; @@ -32,6 +33,7 @@ import type { Done, NextResult, Ok, Result } from "./Result.js"; import { err, getOrThrow, ok, tryAsync } from "./Result.js"; import type { Schedule, ScheduleStep } from "./Schedule.js"; import { addToSet, deleteFromSet, emptySet } from "./Set.js"; +import { createStructuralMap, type StructuralKey } from "./StructuralMap.js"; import type { testCreateRun } from "./Test.js"; import type { Duration, Time, TimeDep } from "./Time.js"; import { createTime, durationToMillis, Millis } from "./Time.js"; @@ -56,7 +58,7 @@ import { import type { Awaitable, Callback, - CallbackWithCleanup, + CallbackWithTeardown, Int1To100, isPromiseLike, Mutable, @@ -73,7 +75,7 @@ import type { * forget" bugs. * * - **Automatic cancellation** — abort propagates to all descendants - * - **Guaranteed cleanup** — resources always released + * - **Guaranteed cleanup** — resources always cleaned up * - **Observable state** — inspect what’s running and why * * Evolu implements structured concurrency with these types: @@ -83,8 +85,6 @@ import type { * - **{@link Run}** — a callable object that runs Tasks, manages their lifecycle, * provides dependencies, and creates Fibers * - **{@link Fiber}** — awaitable, abortable/disposable handle to a running Task - * - **{@link AsyncDisposableStack}** — Task-aware resource management that - * completes even when aborted * * Evolu's structured concurrency core is minimal — one function with a several * flags and helper methods using native APIs. @@ -180,9 +180,9 @@ import type { * concurrency, racing, retries, timeouts, and collection processing. It * intentionally does not provide generic chain, flatMap, or pipe-style helpers * for ordinary sequential Task composition, because that would duplicate plain - * control flow and create API ambiguity. While this can look verbose, it is - * explicit, transparent, debuggable, and avoids pipes and nested helper - * chains. + * control flow and create API ambiguity. While it may seem verbose, it is + * explicit, transparent, and avoids pipes and nested helpers, which are harder + * to debug. * * ### Building a better fetch * @@ -319,20 +319,25 @@ import type { * Evolu uses standard JavaScript * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management | resource management}. * - * For Task-based disposal, Evolu provides {@link AsyncDisposableStack} — a - * wrapper around the native + * Use the * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack | AsyncDisposableStack} - * where methods accept {@link Task} for acquisition. All operations run - * {@link unabortable}, ensuring resources are acquired and released even when - * abort is requested. + * for async resource ownership. * * ### Example * * ```ts - * await using stack = run.stack(); - * stack.defer(task); - * const conn = await stack.use(openConnection); - * const session = await stack.adopt(login, logout); + * await using stack = new AsyncDisposableStack(); + * + * const fooResult = await run(createFoo()); + * if (!fooResult.ok) return fooResult; + * const foo = stack.use(fooResult.value); + * + * stack.defer(async () => { + * await run.asUnabortableDaemon(closeConnection); + * }); + * stack.adopt(session, async (session) => { + * await run.asUnabortableDaemon(logout(session)); + * }); * ``` * * ## Awaitable @@ -358,8 +363,8 @@ import type { * {@link isPromiseLike} detection and two-phase disposal (sync first, async if * needed, and a flag for callers) — Evolu prefers plain functions for sync code * because most operations involve I/O, which is inherently async, and when we - * need sync, it's for simplicity (no dependencies) and performance (zero - * abstraction). + * need sync, it's for simplicity (ideally no dependencies) and performance + * (zero abstraction overhead). * * Sync functions should be fast, so there's no need to monitor them. They * should take values, not dependencies — following the @@ -375,8 +380,42 @@ import type { * code inside the worker needs no monitoring; the async call to the worker * provides it. * + * ## Glossary + * + * - **Cleanup** — generic umbrella term when the exact lifecycle operation is not + * important. + * - **Dispose / disposal** — owner-driven cleanup via JavaScript resource + * management (`Symbol.dispose`, `Symbol.asyncDispose`, `using`, + * `AsyncDisposableStack`). + * - **Create** — construct a new value or a resource. + * - **Acquire** — obtain a usable resource. Acquisition may create a new + * resource, borrow one, open one, or take a lease/lock. + * - **Release** — relinquish a previously acquired resource or lease. Release + * pairs with acquire and need not mean disposal; examples include unlock, + * logout, or returning a pooled resource. + * * ## FAQ * + * ### Why can `Task` still return `AbortError`? + * + * The `E` type parameter represents domain errors, not abort control flow. + * + * `AbortError` comes from the {@link Run} runtime. A Task can still return it: + * + * - Before execution, when the parent or root {@link Run} is already stopped + * - During execution, when an abortable Task is aborted + * - At settlement, when abort was requested before the Task result was observed + * + * So `Task` means "no domain errors", not "cannot fail at all". + * + * This also applies to {@link unabortable}. `unabortable(task)` only prevents + * abort from interrupting the Task after it has started running. It cannot + * force execution to start on a parent or root {@link Run} that is already + * disposing or settled, so it can still return {@link AbortError} before + * execution begins. If that abort would indicate a lifecycle bug in your code, + * use {@link assertNotAborted} to crash immediately instead of threading the + * impossible case through domain logic. + * * ### Where is fork and join? * * For those familiar with other structured concurrency implementations: @@ -452,13 +491,20 @@ export type InferTaskDone = * Error returned when a {@link Task} is aborted via * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal}. * + * This is structured-concurrency control flow, not a domain error. It plays a + * role similar to an interrupt: in most code, propagate it unchanged or ignore + * it when cleanup is already handled by the runtime. + * * The `reason` field is `unknown` by design — use typed errors for business * logic. If you need to inspect the reason, use type guards like * `RaceLostError.is(reason)`. * - * In most code, treat `AbortError` as control flow rather than business logic. - * Propagate it unchanged and handle domain errors separately. Inspect - * `AbortError.reason` only when you need reason-specific behavior. + * When a piece of logic must continue once started, wrap it with + * {@link unabortable}. That prevents ordinary mid-flight interruption, but it + * does not erase {@link AbortError} from the type because the Task can still be + * rejected before it starts, for example on an already-stopped {@link Run}. In + * those cases, abort usually indicates a lifecycle bug, so use + * `assertNotAborted` if you want to fail fast. * * @group Core Types */ @@ -507,7 +553,28 @@ export interface Run extends AsyncDisposable { /** Runs a {@link Task} and returns a {@link Fiber} handle. */ (task: Task): Fiber; - /** Runs a {@link Task} and throws if the returned {@link Result} is an error. */ + /** + * Runs a {@link Task} and throws if the returned {@link Result} is an error. + * + * Use this where failure should crash the current flow instead of being + * handled locally. + * + * This is the async equivalent of {@link getOrThrow}. It runs the Task, awaits + * its {@link Result}, and returns the value on success. + * + * **When to use:** + * + * - Application startup or composition-root setup where errors must stop the + * program immediately + * - Module-level constants + * - Test setup with values that are expected to be valid + * + * Prefer `await run(task)` with an explicit `if (!result.ok)` check in + * ordinary application logic where the caller can recover, retry, or choose a + * different flow. + * + * Throws: `Error` with the original Task error attached as `cause`. + */ readonly orThrow: (task: Task) => Promise; /** Unique {@link Id} for this Run. */ @@ -529,8 +596,10 @@ export interface Run extends AsyncDisposable { * If already aborted, the callback is invoked immediately. For * {@link unabortable} Tasks, the callback is never invoked. * - * Intentionally synchronous — abort is signal propagation, not cleanup. Use - * {@link Run.defer} for async cleanup that must run regardless of abort. + * Intentionally synchronous. Use for immediate abort-time teardown such as + * removing listeners or clearing timers. Use {@link Run.asUnabortableDaemon} + * when bridging async cleanup into `AsyncDisposableStack.defer` or + * `AsyncDisposableStack.adopt`. */ readonly onAbort: (callback: Callback) => void; @@ -573,12 +642,18 @@ export interface Run extends AsyncDisposable { onEvent: ((event: RunEvent) => void) | undefined; /** - * The root daemon {@link Run}. + * The root {@link Run} of this Task tree. * - * The daemon is the root Run of the Task tree. Tasks started with - * `run.daemon(task)` are attached to that root Run instead of the current - * Run, so they can outlive the current Task and keep running until the root - * Run is disposed. + * It is called `daemon` because that is how it should be used: for + * long-running work that must not be disposed when the current Task settles. + * Normal child Runs are disposed by their parent when they settle. The root + * Run has no parent, so work started with `run.daemon(task)` is attached to + * that root Run instead of the current Run and keeps running until the root + * Run is disposed manually. + * + * In application code, that usually means disposing the root Run on process + * shutdown in Node.js or when another platform-specific lifecycle hook is + * available. Browsers do not provide a fully reliable app termination hook. * * ### Example * @@ -588,74 +663,68 @@ export interface Run extends AsyncDisposable { * run(helperTask); * * // Outlives myTask, aborted when the root Run is disposed - * run.daemon(backgroundSync); + * const backgroundFiber = run.daemon(backgroundSync); + * + * // Can still be aborted manually if needed + * backgroundFiber.abort(); * * return ok(); * }; * ``` + * + * For a long-lived reusable {@link Run}, use {@link Run.create}. */ readonly daemon: Run; /** - * Creates a {@link Run} from this Run. - * - * Like {@link createRun}, the returned Run is daemon: it stays running until - * disposed. Unlike {@link createRun}, it shares the same Deps as this Run. + * Wraps a {@link Task} with {@link unabortable} and runs it on + * {@link Run.daemon}. * - * Useful for running Tasks with one reusable Run that can be disposed - * manually. Disposing it aborts all running child Tasks and causes later - * calls through it to be aborted as well. + * It's useful for `AsyncDisposableStack.defer` and + * `AsyncDisposableStack.adopt` callbacks. Those callbacks are outside the + * {@link Task} tree, so this bridges their async cleanup back into a + * {@link Task} that runs on the root daemon. * - * To run a single Task as daemon, use {@link Run.daemon}. - */ - readonly create: () => Run; - - /** - * Creates an {@link AsyncDisposable} that runs a cleanup callback or - * {@link Task} when disposed. + * More generally, this is equivalent to `run.daemon(unabortable(task))`. It + * can be used for cleanup or any other daemonized unabortable logic. * - * Use for a one-off Task; for multiple, use {@link Run.stack} instead. + * This does not bypass root disposal. If the root {@link Run} has already + * started disposing, this cannot start new work. * * ### Example * * ```ts - * // One-off Task with defer - * await using _ = run.defer(task); - * - * // For more Tasks, a stack is more practical - * await using stack = run.stack(); - * stack.defer(taskA); - * stack.defer(taskB); - * - * // Spread to make any object disposable with Task - * const connection = { - * send: (data: Data) => { - * // - * }, - * ...run.defer(async (run) => { - * await run(notifyPeers); - * return ok(); - * }), - * }; - * // connection[Symbol.asyncDispose] is now defined + * await using stack = new AsyncDisposableStack(); + * + * stack.defer(async () => { + * await run.asUnabortableDaemon(closeConnection); + * }); + * + * stack.adopt(session, async (session) => { + * await run.asUnabortableDaemon(logout(session)); + * }); * ``` */ - readonly defer: ( - onDisposeAsync: Task | (() => Awaitable), - ) => AsyncDisposable; + readonly asUnabortableDaemon: (task: Task) => Fiber; /** - * Creates an {@link AsyncDisposableStack}. + * Creates a {@link Run} from this Run. * - * ### Example + * Like {@link createRun}, the returned Run is daemon: it stays running until + * disposed. Unlike {@link createRun}, it shares the same Deps as this Run. * - * ```ts - * await using stack = run.stack(); - * stack.defer(task); - * const conn = await stack.use(openConnection); - * ``` + * Use this for long-lived disposable resources that need to own async work. + * The resource creates one internal Run with `run.create()` and uses that Run + * for all of its work. Disposing the resource then disposes that internal + * Run, which aborts in-flight child Tasks, waits for them to settle, and + * rejects later calls through it. + * + * Typical examples are database clients, connection pools, workers, or other + * reusable resources with async methods and an async dispose operation. + * + * To run a single Task as daemon, use {@link Run.daemon}. */ - readonly stack: () => AsyncDisposableStack; + readonly create: () => Run; /** Returns the dependencies passed to {@link createRun}. */ readonly deps: RunDeps & D; @@ -688,9 +757,9 @@ export interface Run extends AsyncDisposable { * (config: Config): Task => * async (run) => { * const { createDb } = run.deps; - * await using stack = run.stack(); + * await using stack = new AsyncDisposableStack(); * - * const db = await stack.use(createDb(config.connectionString)); + * const db = stack.use(await run.orThrow(startApp())); * if (!db.ok) return db; * * const runWithDb = run.addDeps({ db: db.value }); @@ -728,6 +797,11 @@ export interface Run extends AsyncDisposable { readonly addDeps: >(extraDeps: E) => Run; } +/** + * @deprecated Use {@link Run}. Kept for fork compatibility. + */ +export type Runner = Run; + /** * `Fiber` is a handle to a running {@link Task} that can be awaited, aborted, or * disposed. @@ -877,8 +951,8 @@ export type InferFiberDeps> = * - {@link unabortableMask} provides `restore` to restore the previous mask * - Tasks inherit their parent's mask by default * - * This enables nested acquire/use/release patterns where each level can have - * its own abortable section while outer acquisitions remain protected. + * This enables nested resource lifecycle patterns where each level can have its + * own abortable section while outer acquisitions remain protected. * * UI/debugging tools can use this to visually distinguish protected Tasks * (e.g., different icon or color) and explain why abort requests are ignored. @@ -1005,211 +1079,6 @@ export const RunEvent = /*#__PURE__*/ object({ }); export interface RunEvent extends InferType {} -/** - * Task-aware wrapper around native - * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack | AsyncDisposableStack}. - * - * All Tasks run via this stack are {@link unabortable} and run with - * {@link Run.daemon}, ensuring acquisition and cleanup complete even if abort is - * requested. - * - * ### Example - * - * ```ts - * const task: Task = async (run) => { - * await using stack = run.stack(); - * - * const a = await stack.use(acquireA); - * if (!a.ok) return a; - * - * const b = await stack.use(acquireB); - * if (!b.ok) return b; // a released - * - * stack.defer(sendAnalytics); - * - * // work with a.value, b.value... - * return ok(); - * }; // b released, then a released, then analytics sent - * ``` - * - * @group Resource management - */ -export class AsyncDisposableStack implements AsyncDisposable { - readonly #stack = new globalThis.AsyncDisposableStack(); - readonly #daemon: Run["daemon"]; - - constructor(run: Run) { - this.#daemon = run.daemon; - } - - #run(task: Task): Fiber { - return this.#daemon(unabortable(task)); - } - - #runVoid( - task: Task | (() => Awaitable), - ): PromiseLike { - return this.#run(task as Task).then(lazyVoid); - } - - /** - * Registers a cleanup callback or {@link Task} to run when the stack is - * disposed. - * - * Deferred Tasks run in LIFO order and are unabortable. - * - * ### Example - * - * ```ts - * const task: Task = async (run) => { - * await using stack = run.stack(); - * - * stack.defer(() => { - * console.log("cleanup"); - * return ok(); - * }); - * - * // ... do work - * return ok(); - * }; - * // "cleanup" logs when stack is disposed - * ``` - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack/defer - */ - defer(onDisposeAsync: Task | (() => Awaitable)): void { - this.#stack.defer(() => this.#runVoid(onDisposeAsync)); - } - - /** - * Registers a disposable resource and returns it. - * - * Accepts either a direct value (sync) or a {@link Task} (async acquisition). - * Resources are disposed in LIFO order. Acquisition is unabortable. - * - * ### Example - * - * ```ts - * const task: Task = async (run) => { - * await using stack = run.stack(); - * - * const db = await stack.use(createDatabase()); - * if (!db.ok) return db; - * - * const conn = await stack.use(createConnection(db.value)); - * if (!conn.ok) return conn; - * - * // Use conn.value... - * return ok(); - * }; - * // conn and db disposed automatically in LIFO order - * ``` - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack/use - */ - use(value: T): T; - use( - acquire: Task, - ): PromiseLike>; - use( - valueOrAcquire: T | Task, - ): T | PromiseLike> { - if ( - valueOrAcquire == null || - Symbol.dispose in valueOrAcquire || - Symbol.asyncDispose in valueOrAcquire - ) { - return this.#stack.use(valueOrAcquire as T); - } - return this.#run(valueOrAcquire).then((result) => { - if (result.ok) this.#stack.use(result.value); - return result; - }); - } - - /** - * Acquires a resource and registers a custom release {@link Task}. - * - * Runs `acquire` to get a resource and registers `release` to run when the - * stack is disposed. Use for resources that need cleanup but don't implement - * {@link Disposable} or {@link AsyncDisposable}. If the resource is disposable, - * use {@link use} instead. - * - * ### Example - * - * ```ts - * await using stack = run.stack(); - * - * const session = await stack.adopt(login(credentials), (session) => - * logout(session), - * ); - * if (!session.ok) return session; - * - * // Use session.value... - * // logout(session.value) runs automatically when stack is disposed - * ``` - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack/adopt - */ - async adopt( - acquire: Task, - release: (resource: T) => Task, - ): Promise> { - const result = await this.#run(acquire); - if (result.ok) { - this.#stack.adopt(result.value, (v) => this.#runVoid(release(v))); - } - return result; - } - - /** - * Transfers disposal responsibility to a new stack, marking this one - * disposed. - * - * Enables transferring ownership out of the current scope — if an error - * occurs, resources are disposed; if successful, the caller takes ownership. - * - * ### Example - * - * ```ts - * const createBundle: Task = async (run) => { - * await using stack = run.stack(); - * - * const a = await stack.use(createResource("a")); - * if (!a.ok) return a; - * - * const b = await stack.use(createResource("b")); - * if (!b.ok) return b; - * - * const moved = stack.move(); - * return ok({ - * a: a.value, - * b: b.value, - * [Symbol.asyncDispose]: () => moved.disposeAsync(), - * }); - * }; - * ``` - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack/move - */ - move(): globalThis.AsyncDisposableStack { - return this.#stack.move(); - } - - /** Whether this stack has been disposed. */ - get disposed(): boolean { - return this.#stack.disposed; - } - - disposeAsync(): Promise { - return this.#stack.disposeAsync(); - } - - [Symbol.asyncDispose](): Promise { - return this.#stack.disposeAsync(); - } -} - /** * Configuration for {@link Run} behavior. * @@ -1254,6 +1123,11 @@ export interface CreateRun { (deps: D): Run; } +/** + * @deprecated Use {@link CreateRun}. Kept for fork compatibility. + */ +export type CreateRunner = CreateRun; + /** * Creates root {@link Run}. * @@ -1334,20 +1208,9 @@ export const createRun: CreateRun = ( }; /** - * Backward-compatible alias for older API naming. - * - * Prefer {@link Run} and {@link createRun} in new code. - */ -export type Runner = Run; -export type RunnerDeps = RunDeps; -export type CreateRunner = CreateRun; - -/** - * Backward-compatible alias for older API naming. - * - * Prefer {@link createRun} in new code. + * @deprecated Use {@link createRun}. Kept for fork compatibility. */ -export const createRunner = createRun; +export const createRunner: typeof createRun = createRun; /** Internal Run properties, hidden from public API via TypeScript types. */ interface RunInternal extends Run { @@ -1499,12 +1362,9 @@ const createRunInternal = }; run.daemon = daemon ?? self; + run.asUnabortableDaemon = (task) => self.daemon(unabortable(task)); + run.create = () => run.daemon(createDeferred().task).run; - run.defer = (task) => ({ - [Symbol.asyncDispose]: () => - self.daemon(unabortable(task as Task)).then(lazyVoid), - }); - run.stack = () => new AsyncDisposableStack(self); Object.defineProperty(run, "deps", { get: depsRef.get }); @@ -1615,7 +1475,11 @@ const abortBehavior = * * If the parent {@link Run} is already disposing or settled, `run(task)` * short-circuits before task execution and returns `err(AbortError)` with - * {@link runStoppedError} as reason. + * {@link runStoppedError} as reason. So `unabortable` means “do not interrupt + * this Task once it has started”, not “remove AbortError from its type”. + * + * When that pre-start abort would be a programmer error, assert it explicitly + * with `assertNotAborted` after awaiting the result. * * ### Example * @@ -1739,12 +1603,13 @@ export function concurrently( taskOrFallback?: Task, ): Task { const isTask = isFunction(concurrencyOrTask); - const task = (() => { - if (isTask) return concurrencyOrTask; - assert(taskOrFallback, "Task is required when concurrency is provided."); - return taskOrFallback; - })(); - + let task: Task; + if (isTask) { + task = concurrencyOrTask; + } else { + assert(taskOrFallback != null, "task is required"); + task = taskOrFallback; + } return Object.assign((run: Run) => run(task), { [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask, }); @@ -1813,27 +1678,28 @@ export const yieldNow: Task = () => (reason): AbortError => createAbortError(reason), ); -const scheduler = ( - globalThis as unknown as { - readonly scheduler?: { readonly yield?: unknown }; +const scheduler = globalThis as unknown as { + readonly scheduler?: { readonly yield?: unknown }; + readonly setImmediate?: (callback: () => void) => unknown; +}; + +const yieldImpl = (): Promise => { + const schedulerApi = scheduler.scheduler; + if (typeof schedulerApi?.yield === "function") { + return Reflect.apply( + schedulerApi.yield as () => Promise, + schedulerApi, + [], + ); + } + + const setImmediateFn = scheduler.setImmediate; + if (typeof setImmediateFn === "function") { + return new Promise((resolve) => void setImmediateFn(resolve)); } -).scheduler; - -const yieldImpl: () => Promise = - typeof scheduler?.yield === "function" - ? () => (scheduler.yield as () => Promise)() - : "setImmediate" in globalThis - ? () => - new Promise((resolve) => { - const setImmediateFn = ( - globalThis as unknown as { - readonly setImmediate?: (callback: () => void) => unknown; - } - ).setImmediate; - if (typeof setImmediateFn === "function") setImmediateFn(resolve); - else setTimeout(resolve, 0); - }) - : () => new Promise((r) => setTimeout(r, 0)); // Safari + + return new Promise((resolve) => setTimeout(resolve, 0)); // Safari +}; /** * Creates a {@link Task} from a callback-based API. @@ -1841,7 +1707,7 @@ const yieldImpl: () => Promise = * Use this to wrap callback-style APIs (event listeners, Node.js callbacks, * etc.) into Tasks with proper abort handling. * - * Optionally return a cleanup function that runs on abort. + * Optionally return a teardown function that runs on abort. * * ### Example * @@ -1873,16 +1739,16 @@ const yieldImpl: () => Promise = */ export const callback = ( - callback: CallbackWithCleanup<{ - ok: Callback; - err: Callback; - signal: AbortSignal; - deps: RunDeps; + callback: CallbackWithTeardown<{ + readonly ok: Callback; + readonly err: Callback; + readonly signal: AbortSignal; + readonly deps: RunDeps; }>, ): Task => (run) => new Promise((resolve) => { - const cleanup = callback({ + const teardown = callback({ ok: (value) => resolve(ok(value)), err: (error) => resolve(err(error)), signal: run.signal, @@ -1890,7 +1756,7 @@ export const callback = }); run.onAbort((reason) => { - if (cleanup) cleanup(); + if (teardown) teardown(); resolve(err(createAbortError(reason))); }); }); @@ -2726,7 +2592,8 @@ const semaphoreDisposedAbortError: AbortError = createAbortError( * * @group Concurrency primitives */ -export interface SemaphoreByKey extends Disposable { +export interface SemaphoreByKey + extends Disposable { /** * Executes a {@link Task} while holding one permit for a specific key. * @@ -2755,24 +2622,20 @@ export interface SemaphoreByKey extends Disposable { * * @group Concurrency primitives */ -export const createSemaphoreByKey = ( +export const createSemaphoreByKey = ( permits: Concurrency, ): SemaphoreByKey => { type KeyedSemaphore = Semaphore & { - __disposing: boolean; + __disposing?: boolean; }; - const semaphoresByKey = new Map(); + const semaphoresByKey = createStructuralMap(); let disposed = false; + const getActiveSemaphore = (key: K): KeyedSemaphore | undefined => { const semaphore = semaphoresByKey.get(key); if (!semaphore) return undefined; if (!semaphore.__disposing) return semaphore; - - if (semaphoresByKey.get(key) === semaphore) { - semaphoresByKey.delete(key); - } - return undefined; }; @@ -2784,25 +2647,28 @@ export const createSemaphoreByKey = ( let semaphore = getActiveSemaphore(key); if (!semaphore) { - semaphore = Object.assign(createSemaphore(permits), { - __disposing: false, - }); + semaphore = createSemaphore(permits) as KeyedSemaphore; semaphoresByKey.set(key, semaphore); } using _ = { [Symbol.dispose]: () => { if (semaphoresByKey.get(key) !== semaphore) return; - const snapshot = semaphore.snapshot(); if (snapshot.taken !== 0 || snapshot.waiting !== 0) return; semaphore.__disposing = true; - if (semaphoresByKey.get(key) !== semaphore) return; + if (semaphoresByKey.get(key) !== semaphore) { + semaphore.__disposing = false; + return; + } - const drainedSnapshot = semaphore.snapshot(); - if (drainedSnapshot.taken !== 0 || drainedSnapshot.waiting !== 0) { + const snapshotAfterMark = semaphore.snapshot(); + if ( + snapshotAfterMark.taken !== 0 || + snapshotAfterMark.waiting !== 0 + ) { semaphore.__disposing = false; return; } @@ -2906,7 +2772,8 @@ export const createMutex = (): Mutex => { * * @group Concurrency primitives */ -export interface MutexByKey extends Disposable { +export interface MutexByKey + extends Disposable { /** * Executes a {@link Task} while holding the mutex lock for a specific key. * @@ -2924,7 +2791,7 @@ export interface MutexByKey extends Disposable { * @group Concurrency primitives */ export const createMutexByKey = < - K extends string = string, + K extends StructuralKey = StructuralKey, >(): MutexByKey => { const semaphoreByKey = createSemaphoreByKey(minPositiveInt); @@ -2939,113 +2806,104 @@ export const createMutexByKey = < /** * {@link Ref} protected by a {@link Mutex}. * - * `MutexRef` stores mutable state and serializes all operations through an - * internal {@link Mutex}. Reads, writes, and updates observe one consistent - * state transition at a time. If the update fails or is aborted, the previous - * state is preserved. + * `MutexRef` serializes all operations through an internal {@link Mutex}. Reads, + * writes, and updates observe one consistent value transition at a time. If the + * update fails or is aborted, the previous value is preserved. * - * Typical use cases are small stateful coordinators such as caches, session - * state, in-memory registries, and counters whose transitions need to run - * {@link Task}s atomically. + * Typical use cases are small coordinators such as caches, session values, + * in-memory registries, and counters whose transitions need to run {@link Task}s + * atomically. * * @group Concurrency primitives */ export interface MutexRef extends Disposable { - /** Returns the current state. */ + /** Returns the current value. */ readonly get: Task; - /** Sets the state. */ - readonly set: (state: T) => Task; + /** Sets the current value. */ + readonly set: (value: T) => Task; - /** Sets the state and returns the previous state. */ - readonly getAndSet: (state: T) => Task; + /** Sets the current value and returns the previous value. */ + readonly getAndSet: (value: T) => Task; - /** Sets the state and returns the current state after the update. */ - readonly setAndGet: (state: T) => Task; + /** Sets the current value and returns it. */ + readonly setAndGet: (value: T) => Task; - /** Updates the state. */ + /** Updates the current value. */ readonly update: ( updater: (current: T) => Task, ) => Task; - /** Updates the state and returns the previous state. */ + /** Updates the current value and returns the previous value. */ readonly getAndUpdate: ( updater: (current: T) => Task, ) => Task; - /** Updates the state and returns the current state after the update. */ + /** Updates the current value and returns it. */ readonly updateAndGet: ( updater: (current: T) => Task, ) => Task; - /** Modifies the state and returns a computed result from the transition. */ + /** Modifies the current value and returns a computed result. */ readonly modify: ( - updater: (current: T) => Task, + modifier: (current: T) => Task, ) => Task; } /** - * Creates a {@link MutexRef} with the given initial state. + * Creates a {@link MutexRef} with the given initial immutable value. * * @group Concurrency primitives */ -export const createMutexRef = (initialState: T): MutexRef => { - let currentState = initialState; +export const createMutexRef = (initialValue: T): MutexRef => { + const ref = createRef(initialValue); const mutex = createMutex(); return { - get: mutex.withLock(() => ok(currentState)), + get: mutex.withLock(() => ok(ref.get())), - set: (state) => + set: (value) => mutex.withLock(() => { - currentState = state; + ref.set(value); return ok(); }), - getAndSet: (state) => - mutex.withLock(() => { - const previousState = currentState; - currentState = state; - return ok(previousState); - }), + getAndSet: (value) => mutex.withLock(() => ok(ref.getAndSet(value))), - setAndGet: (state) => - mutex.withLock(() => { - currentState = state; - return ok(currentState); - }), + setAndGet: (value) => mutex.withLock(() => ok(ref.setAndGet(value))), update: (updater) => mutex.withLock(async (run) => { - const nextState = await run(updater(currentState)); - if (!nextState.ok) return nextState; - currentState = nextState.value; + const nextValue = await run(updater(ref.get())); + if (!nextValue.ok) return nextValue; + ref.set(nextValue.value); return ok(); }), getAndUpdate: (updater) => mutex.withLock(async (run) => { - const previousState = currentState; - const nextState = await run(updater(currentState)); - if (!nextState.ok) return nextState; - currentState = nextState.value; - return ok(previousState); + const previousValue = ref.get(); + const nextValue = await run(updater(previousValue)); + if (!nextValue.ok) return nextValue; + ref.set(nextValue.value); + return ok(previousValue); }), updateAndGet: (updater) => mutex.withLock(async (run) => { - const nextState = await run(updater(currentState)); - if (!nextState.ok) return nextState; - currentState = nextState.value; - return ok(currentState); + const currentValue = ref.get(); + const nextValue = await run(updater(currentValue)); + if (!nextValue.ok) return nextValue; + ref.set(nextValue.value); + return ok(nextValue.value); }), - modify: (updater) => + modify: (modifier) => mutex.withLock(async (run) => { - const nextState = await run(updater(currentState)); - if (!nextState.ok) return nextState; - const [result, updatedState] = nextState.value; - currentState = updatedState; + const nextValue = await run(modifier(ref.get())); + if (!nextValue.ok) return nextValue; + const [result, updatedValue] = nextValue.value; + ref.set(updatedValue); return ok(result); }), @@ -3056,14 +2914,15 @@ export const createMutexRef = (initialState: T): MutexRef => { /** * Cross-platform leader lock abstraction. * - * `acquire` blocks until leadership is acquired. + * `lock` returns a {@link Task} that waits until leadership is acquired and + * yields a lease. * - * Returns {@link Disposable} lease. Dispose it to release leadership. + * Returns {@link AsyncDisposable} lease. Dispose it to release leadership. * * @group Concurrency primitives */ export interface LeaderLock { - readonly acquire: (name: Name) => Task; + readonly lock: (name: Name) => Task; } /** @group Concurrency primitives */ @@ -3084,26 +2943,41 @@ export const createInMemoryLeaderLock = (): LeaderLock => { const mutexByName = createMutexByKey(); return { - acquire: (name) => async (run) => { - // Two gates are needed: one to wait until lock acquisition and one to - // keep the lock held until lease disposal. - const onAcquired = Promise.withResolvers(); - const onRelease = Promise.withResolvers(); - - void run.daemon( - mutexByName.withLock(name, async () => { - onAcquired.resolve(); - await onRelease.promise; + lock: (name) => async (run) => { + const leaseRun = run.create(); + const released = createDeferred(); + const acquired = createDeferred(); + + void leaseRun( + mutexByName.withLock(name, async (run) => { + acquired.resolve(ok()); + await run(released.task); return ok(); }), + ).then( + (result) => { + if (result.ok) return; + acquired.resolve(err(result.error)); + void leaseRun[Symbol.asyncDispose](); + }, + (error: unknown) => { + acquired.resolve(err(createAbortError(error))); + void leaseRun[Symbol.asyncDispose](); + }, ); - await onAcquired.promise; + const acquiredResult = await run(acquired.task); + if (!acquiredResult.ok) { + assert( + AbortError.is(acquiredResult.error), + "Leader lock acquisition deferred must not be disposed.", + ); + void leaseRun[Symbol.asyncDispose](); + return err(acquiredResult.error); + } return ok({ - [Symbol.dispose]: () => { - onRelease.resolve(); - }, + [Symbol.asyncDispose]: leaseRun[Symbol.asyncDispose], }); }, }; @@ -3130,6 +3004,8 @@ export interface CollectOptions { readonly abortReason?: unknown; } +type NoValue = undefined; + /** * Fails fast on first error across multiple {@link Task}s. * @@ -3219,7 +3095,7 @@ export function all( export function all( tasks: Iterable> | Readonly>>, options: CollectOptions, -): Task; +): Task; export function all( input: CollectInput, @@ -3354,7 +3230,7 @@ export function allSettled( export function allSettled( tasks: Iterable> | Readonly>>, options: CollectOptions, -): Task; +): Task; export function allSettled( input: Iterable | Readonly>, @@ -3381,8 +3257,6 @@ export const allSettledAbortError: AllSettledAbortError = { type: "AllSettledAbortError", }; -type NoCollect = ReturnType<() => void>; - /** * Maps values to {@link Task}s, failing fast on first error. * @@ -3448,13 +3322,13 @@ export function map( items: Iterable | Readonly>, task: (a: A) => Task, options: CollectOptions, -): Task; +): Task; export function map( items: MapInput, fn: (a: A) => Task, { abortReason = mapAbortError, ...options }: CollectOptions = {}, -): Task | Record | NoCollect, E, D> { +): Task | Record | NoValue, E, D> { const mapped = mapInput(items, fn); return all( mapped as Iterable>, @@ -3557,7 +3431,7 @@ export function mapSettled( items: Iterable | Readonly>, task: (a: A) => Task, options: CollectOptions, -): Task; +): Task; export function mapSettled( items: MapInput, @@ -3566,7 +3440,7 @@ export function mapSettled( ): Task< | ReadonlyArray> | Record> - | NoCollect, + | NoValue, never, D > { @@ -3680,7 +3554,8 @@ const collect = ( if (isIterable(input)) { const array = arrayFrom(input as Iterable); - if (!isNonEmptyArray(array)) return () => ok(emptyArray); + if (!isNonEmptyArray(array)) + return () => ok(collect ? emptyArray : undefined); return pool(array as ReadonlyArray>, { stopOn, @@ -3695,12 +3570,12 @@ const collect = ( keys.push(key); taskArray.push((input as Record)[key]); } - if (keys.length === 0) return () => ok(emptyRecord); + if (keys.length === 0) return () => ok(collect ? emptyRecord : undefined); return async (run) => { const result = await run(pool(taskArray, { stopOn, collect, abortReason })); if (!result.ok) return result; - if (!collect) return ok(); + if (!collect) return ok(undefined); const record = createRecord(); for (let i = 0; i < keys.length; i++) { record[keys[i]] = (result.value as Array)[i]; @@ -3779,7 +3654,7 @@ function pool( collect: false; abortReason: unknown; }, -): Task; +): Task; /** Internal overload for {@link collect} with dynamic stopOn/collect. */ function pool( @@ -3804,7 +3679,7 @@ function pool( abortReason: unknown; allFailed?: AnyAllFailed; }, -): Task | T | NoCollect, E> { +): Task | T | NoValue, E> { const tasks = arrayFrom(tasksIterable); const { length } = tasks; if (length === 0) return () => ok(emptyArray); @@ -3867,9 +3742,9 @@ function pool( const workerCount = Math.min(run.concurrency, length); const workers = arrayFrom(workerCount, () => run.daemon(worker)); - await using _ = run.defer(() => { + using _ = new DisposableStack(); + _.defer(() => { abortWorkers(abortReason); - return ok(); }); run.onAbort((reason) => { @@ -3885,21 +3760,18 @@ function pool( return err(run.signal.reason as AbortError); } - if (!stopOn) return results ? ok(results) : ok(); + if (!stopOn) return results ? ok(results) : ok(undefined); if (stopped) return stopped; if (results) return ok(results); // For all/allSettled/map/mapSettled with collect: false (no allFailed handler) - if (!allFailed) return ok(); + if (!allFailed) return ok(undefined); if (allFailed === "completion") { - assert( - lastResult, - "Expected completion result for allFailed=completion.", - ); + assert(lastResult != null, "expected at least one result"); return lastResult; } - assert(lastIndexResult, "Expected last index result for allFailed=index."); + assert(lastIndexResult != null, "expected last index result"); return lastIndexResult; }; } diff --git a/packages/common/src/Test.ts b/packages/common/src/Test.ts index b7e3752c9..07d65c927 100644 --- a/packages/common/src/Test.ts +++ b/packages/common/src/Test.ts @@ -5,28 +5,23 @@ */ import { type TestConsoleDep, testCreateConsole } from "./Console.js"; -import { - Entropy32, - type RandomBytesDep, - testCreateRandomBytes, -} from "./Crypto.js"; +import { Entropy32, testCreateRandomBytes } from "./Crypto.js"; import { createAppOwner, OwnerSecret } from "./local-first/Owner.js"; import { - type RandomDep, type RandomLibDep, testCreateRandom, testCreateRandomLib, } from "./Random.js"; -import { createRunner, type Runner } from "./Task.js"; -import { minMillis, setTimeout, type TimeDep, testCreateTime } from "./Time.js"; +import { createRun, type Run, type RunDeps, type Runner } from "./Task.js"; +import { + type Duration, + minMillis, + setTimeout, + testCreateTime, +} from "./Time.js"; import { SimpleName } from "./Type.js"; -/** Test deps created by {@link testCreateDeps}. */ -export type TestDeps = TestConsoleDep & - RandomBytesDep & - RandomDep & - RandomLibDep & - TimeDep; +export type TestDeps = RunDeps & TestConsoleDep & RandomLibDep; /** * Creates test dependencies for proper isolation. @@ -38,7 +33,7 @@ export type TestDeps = TestConsoleDep & * ```ts * test("my test", async () => { * const deps = testCreateDeps(); - * await using run = testCreateRunner(deps); + * await using run = testCreateRun(deps); * * const fiber = run(sleep("1s")); * deps.time.advance("1s"); @@ -59,7 +54,7 @@ export const testCreateDeps = (options?: { }; /** - * Creates a test {@link Runner} with deterministic deps. + * Creates a test {@link Run} with deterministic deps. * * Uses {@link TestDeps} which provides seeded random values, ensuring * deterministic fiber IDs, timestamps, and other generated values. This makes @@ -71,35 +66,42 @@ export const testCreateDeps = (options?: { * * ```ts * // Basic usage with TestDeps - * await using run = testCreateRunner(); + * await using run = testCreateRun(); * * // Override specific deps - * await using run = testCreateRunner({ time: customTime }); + * await using run = testCreateRun({ time: customTime }); * * // Add custom deps * interface HttpDep { * readonly http: Http; * } - * await using run = testCreateRunner({ http }); - * // run is Runner + * await using run = testCreateRun({ http }); + * // run is Run * ``` */ -export function testCreateRunner(): Runner; +export function testCreateRun(): Run; /** With custom dependencies merged into {@link TestDeps}. */ -export function testCreateRunner(deps: D): Runner; +export function testCreateRun(deps: D): Run; -export function testCreateRunner(deps?: D): Runner { +export function testCreateRun(deps?: D): Run { const defaults = testCreateDeps(); - return createRunner({ ...defaults, ...deps } as TestDeps & D); + return createRun({ ...defaults, ...deps } as TestDeps & D); } /** - * Backward-compatible alias for upstream naming. + * Backward-compatible alias for fork naming. * - * Prefer {@link testCreateRunner} in SQLoot code. + * Prefer {@link testCreateRun} in new code. */ -export const testCreateRun: typeof testCreateRunner = testCreateRunner; +export function testCreateRunner(): Runner; + +/** With custom dependencies merged into {@link TestDeps}. */ +export function testCreateRunner(deps: D): Runner; + +export function testCreateRunner(deps?: D): Runner { + return testCreateRun(deps); +} // Deterministic test values for reproducible fixtures. Keep eager test values // here to avoid affecting tree-shaking baselines. @@ -117,5 +119,8 @@ export const testOwnerSecret = /*#__PURE__*/ OwnerSecret.orThrow(testEntropy32); export const testAppOwner = /*#__PURE__*/ createAppOwner(testOwnerSecret); export const testName = /*#__PURE__*/ SimpleName.orThrow("Name"); -/** Returns a Promise that resolves on the next macrotask. */ -export const testWaitForMacrotask = (): Promise => setTimeout(minMillis); + +/** Returns a Promise that resolves after a macrotask delay. */ +export const testWaitForMacrotask = ( + duration: Duration = minMillis, +): Promise => setTimeout(duration); diff --git a/packages/common/src/Type.ts b/packages/common/src/Type.ts index dac1bec1d..980a01c89 100644 --- a/packages/common/src/Type.ts +++ b/packages/common/src/Type.ts @@ -287,6 +287,9 @@ export interface Type< /** * Creates `T` from an `Input` value, throwing an error if validation fails. * + * Use this where failure should crash the current flow instead of being + * handled locally. + * * Throws an Error with the Type validation error in its `cause` property, * making it debuggable while avoiding the need for custom error messages. * @@ -294,14 +297,18 @@ export interface Type< * * **When to use:** * - * - Configuration values that are guaranteed to be valid (e.g., hardcoded - * constants) - * - Application startup where failure should crash the program + * - Application startup or composition-root setup where errors must stop the + * program immediately + * - Module-level constants + * - Test setup with values that are expected to be valid * - As an alternative to assertions when the Type error in the thrown Error's * `cause` provides sufficient debugging information - * - Test code with known valid inputs (when error message clarity is not - * critical; for better test error messages, use Vitest `schemaMatching` + - * `assert` with `.is()`) + * + * Prefer `from` in ordinary application logic where the caller can recover, + * show validation errors, or choose a different flow. + * + * For clearer test failure messages on invalid input, use Vitest + * `schemaMatching` + `assert` with `.is()`. * * ### Example * @@ -4096,6 +4103,16 @@ export const formatInt64StringError = (error) => `The value ${error.value} is not a valid Int64 string.`, ); +/** + * Validated JSON-compatible value. + * + * This is the output side of JSON data in Evolu. It uses {@link FiniteNumber} + * instead of `number` because JSON numbers are expected to be finite once the + * value has been parsed or validated. + * + * Compare with {@link JsonValueInput}, which represents caller-provided input + * before validation. + */ export type JsonValue = | string | FiniteNumber @@ -4104,6 +4121,19 @@ export type JsonValue = | JsonArray | JsonObject; +/** + * JSON-compatible input value before validation. + * + * This is broader than {@link JsonValue} because inputs arrive as ordinary + * JavaScript values, so numbers are typed as `number` before validation can + * narrow them to {@link FiniteNumber}. + * + * That means `JsonValueInput` can temporarily contain numbers that are lossy in + * JSON serialization. For example, `JSON.stringify(NaN)` and + * `JSON.stringify(Infinity)` produce `null`, and `JSON.stringify(-0)` produces + * `0`. Use {@link JsonValue} when the value must already satisfy JSON numeric + * constraints. + */ export type JsonValueInput = | string | number diff --git a/packages/common/src/Types.ts b/packages/common/src/Types.ts index 7c1a7299c..3e3577447 100644 --- a/packages/common/src/Types.ts +++ b/packages/common/src/Types.ts @@ -22,21 +22,21 @@ import type { TypeName } from "./Type.js"; export type Callback = (value: T) => void; /** - * A function that receives a value and optionally returns a cleanup function. + * A function that receives a value and optionally returns a teardown function. * - * Use for subscriptions or resources that may need cleanup when done. + * Use for subscriptions or callbacks that need abort-time teardown. * * ### Example * * ```ts - * const subscribe: CallbackWithCleanup = (source) => { + * const subscribe: CallbackWithTeardown = (source) => { * source.start(); * return () => source.stop(); * }; * ``` */ // biome-ignore lint/suspicious/noConfusingVoidType: void is intended here -export type CallbackWithCleanup = (value: T) => void | (() => void); +export type CallbackWithTeardown = (value: T) => void | (() => void); /** * Checks a condition on a value and returns a boolean. diff --git a/packages/common/src/WebSocket.ts b/packages/common/src/WebSocket.ts index 50a7a5550..aa51b663a 100644 --- a/packages/common/src/WebSocket.ts +++ b/packages/common/src/WebSocket.ts @@ -58,11 +58,11 @@ import { String, type Typed } from "./Type.js"; * ); * if (ws.ok) { * ws.value.send("Hello"); - * // Later: ws.value[Symbol.dispose](); + * // Later: await ws.value[Symbol.asyncDispose](); * } * ``` */ -export interface WebSocket extends Disposable { +export interface WebSocket extends AsyncDisposable { /** * Send data through the WebSocket connection. Returns {@link Result} with an * error if the data couldn't be sent. @@ -182,6 +182,27 @@ export interface WebSocketConnectionCloseError readonly event: CloseEvent; } +type WebSocketConstructor = typeof globalThis.WebSocket; + +const fallbackNativeToStringState = { + 0: "connecting", + 1: "open", + 2: "closing", + 3: "closed", +} satisfies Record; + +const getNativeToStringState = ( + wsCtor?: WebSocketConstructor, +): Record => + wsCtor + ? { + [wsCtor.CONNECTING]: "connecting", + [wsCtor.OPEN]: "open", + [wsCtor.CLOSING]: "closing", + [wsCtor.CLOSED]: "closed", + } + : fallbackNativeToStringState; + /** Create a new {@link WebSocket}. */ export const createWebSocket: CreateWebSocket = ( @@ -195,11 +216,13 @@ export const createWebSocket: CreateWebSocket = onMessage, onError, schedule = jitter(1)(maxDelay("30s")(exponential("100ms"))), - WebSocketConstructor = globalThis.WebSocket, + WebSocketConstructor, } = {}, ) => async (run) => { - await using stack = run.stack(); + const stack = new AsyncDisposableStack(); + const NativeWebSocket = WebSocketConstructor ?? globalThis.WebSocket; + const nativeToStringState = getNativeToStringState(NativeWebSocket); let socket: globalThis.WebSocket | null = null; let disposed = false; @@ -229,7 +252,7 @@ export const createWebSocket: CreateWebSocket = const connect: Task = callback(({ err, ok }) => { closeSocket(); - socket = new WebSocketConstructor( + socket = new NativeWebSocket( url, String.is(protocols) ? protocols : protocols && [...protocols], ); @@ -295,43 +318,52 @@ export const createWebSocket: CreateWebSocket = }, isOpen: () => - !disposed && socket?.readyState === globalThis.WebSocket.OPEN, + !disposed && socket?.readyState === (NativeWebSocket?.OPEN ?? 1), - [Symbol.dispose]() { + [Symbol.asyncDispose]: async () => { disposed = true; - void moved.disposeAsync(); + await moved.disposeAsync(); }, }); }; -const nativeToStringState: Record = { - [globalThis.WebSocket.CONNECTING]: "connecting", - [globalThis.WebSocket.OPEN]: "open", - [globalThis.WebSocket.CLOSING]: "closing", - [globalThis.WebSocket.CLOSED]: "closed", -}; - -export interface TestCreateWebSocketOptions { - readonly throwOnCreate?: boolean; -} - -/** - * Test helper that creates a deterministic {@link CreateWebSocket} adapter. - */ +/** Creates a deterministic in-memory {@link CreateWebSocket} for testing. */ export const testCreateWebSocket = - ({ - throwOnCreate = false, - }: TestCreateWebSocketOptions = {}): CreateWebSocket => + ( + options: { + /** Throw immediately when a socket is created. */ + readonly throwOnCreate?: boolean; + + /** Initial open state of created sockets. Defaults to true. */ + readonly isOpen?: boolean; + } = {}, + ): CreateWebSocket => () => async () => { - if (throwOnCreate) { - throw new Error("testCreateWebSocket: throwOnCreate"); + if (options.throwOnCreate) { + throw new Error("testCreateWebSocket is configured to throw on create"); } + let isDisposed = false; + let isOpen = options.isOpen ?? true; + return ok({ - send: () => ok(), - getReadyState: () => "open", - isOpen: () => true, - [Symbol.dispose]: () => {}, + send: () => { + if (isDisposed || !isOpen) return err({ type: "WebSocketSendError" }); + return ok(); + }, + + getReadyState: () => { + if (isDisposed) return "closed"; + return isOpen ? "open" : "connecting"; + }, + + isOpen: () => !isDisposed && isOpen, + + [Symbol.asyncDispose]: () => { + isDisposed = true; + isOpen = false; + return Promise.resolve(); + }, }); }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 336e92827..08a5fae43 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -17,7 +17,6 @@ export * from "./Eq.js"; export * from "./Error.js"; export * from "./Function.js"; export * from "./Identicon.js"; -export * from "./Listeners.js"; // Local-first essentials. export type { EvoluError } from "./local-first/Error.js"; export type { @@ -30,19 +29,17 @@ export type { export { AppName, createEvolu } from "./local-first/Evolu.js"; export * from "./local-first/LocalAuth.js"; export * from "./local-first/Owner.js"; -export type { - InferRow, - KyselyNotNull, - Query, - QueryRows, - Row, -} from "./local-first/Query.js"; export { evoluJsonArrayFrom, evoluJsonBuildObject, evoluJsonObjectFrom, getJsonObjectArgs, + type InferRow, + type KyselyNotNull, kyselySql, + type Query, + type QueryRows, + type Row, } from "./local-first/Query.js"; export type { AnyStandardSchemaV1, @@ -61,15 +58,8 @@ export type { } from "./local-first/Schema.js"; export { createQueryBuilder } from "./local-first/Schema.js"; export type { - NetworkError, - PaymentRequiredError, - ServerError, SyncOwner, SyncState, - SyncStateInitial, - SyncStateIsNotSynced, - SyncStateIsSynced, - SyncStateIsSyncing, } from "./local-first/Sync.js"; export type { TimestampBytes, @@ -95,12 +85,13 @@ export * from "./Redacted.js"; export * from "./Ref.js"; export * from "./RefCount.js"; export * from "./Relation.js"; -export * from "./Resources.js"; +export * from "./Resource.js"; export * from "./Result.js"; export * from "./Set.js"; export * from "./Sqlite.js"; export * from "./Store.js"; export * from "./String.js"; +export * from "./StructuralMap.js"; export * from "./Task.js"; export * from "./Test.js"; export * from "./Time.js"; diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 6c2e2666b..835fc630b 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -13,11 +13,13 @@ import { import { assertNonEmptyReadonlyArray } from "../Assert.js"; import type { ConsoleLevel } from "../Console.js"; import type { + DecryptWithXChaCha20Poly1305Error, EncryptionKey, EncryptionKeyDep, RandomBytesDep, } from "../Crypto.js"; -import { getProperty, objectToEntries } from "../Object.js"; +import { lazyFalse, lazyVoid } from "../Function.js"; +import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; import { ok, type Result } from "../Result.js"; import type { @@ -29,11 +31,13 @@ import type { import { booleanToSqliteBoolean, createSqlite, + SqliteBoolean, type SqliteValue, sql, + sqliteBooleanToBoolean, } from "../Sqlite.js"; -import type { AsyncDisposableStack, LeaderLockDep, Task } from "../Task.js"; -import { type Millis, millisToDateIso, type TimeDep } from "../Time.js"; +import type { LeaderLockDep, Task } from "../Task.js"; +import { Millis, millisToDateIso, type TimeDep } from "../Time.js"; import type { Name } from "../Type.js"; import { type Id, @@ -52,7 +56,13 @@ import type { } from "../Worker.js"; import type { OwnerId, OwnerIdBytes } from "./Owner.js"; import { ownerIdBytesToOwnerId, ownerIdToOwnerIdBytes } from "./Owner.js"; -import { encodeAndEncryptDbChange, protocolVersion } from "./Protocol.js"; +import { + decryptAndDecodeDbChange, + encodeAndEncryptDbChange, + type ProtocolInvalidDataError, + type ProtocolTimestampMismatchError, + protocolVersion, +} from "./Protocol.js"; import { deserializeQuery, type Query } from "./Query.js"; import type { MutationChange, SqliteSchemaDep } from "./Schema.js"; import { @@ -67,14 +77,16 @@ import type { QueuedResponse, } from "./Shared.js"; import { + type BaseSqliteStorage, type BaseSqliteStorageDep, type CrdtMessage, createBaseSqliteStorage, createBaseSqliteStorageTables, - type DbChange, + DbChange, getNextStoredBytes, getOwnerUsage, getTimestampInsertStrategy, + type Storage, updateOwnerUsage, } from "./Storage.js"; import type { @@ -86,6 +98,7 @@ import type { import { createInitialTimestamp, defaultTimestampMaxDrift, + receiveTimestamp, sendTimestamp, type TimestampBytes, type TimestampConfigDep, @@ -106,6 +119,8 @@ export interface DbWorkerInit { export type CreateDbWorker = () => DbWorker; +const processedRequestIdsLimit = 10_000; + export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } @@ -116,15 +131,13 @@ export interface PortDep { readonly port: MessagePort; } -const processedRequestIdsLimit = 10_000; - export const initDbWorker = ( self: WorkerSelf, ): Task => (run) => { const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; - const stack = run.stack(); + const stack = new AsyncDisposableStack(); let initialized = false; @@ -143,15 +156,12 @@ export const initDbWorker = createMessagePort(nativeLeaderPort), ); - const unsubscribeConsoleStoreOutputEntry = + stack.defer( consoleStoreOutputEntry.subscribe(() => { const entry = consoleStoreOutputEntry.get(); if (entry) port.postMessage({ type: "OnConsoleEntry", entry }); - }); - stack.defer(() => { - unsubscribeConsoleStoreOutputEntry(); - return ok(); - }); + }), + ); // One DbWorker serves multiple tabs, so console level is global // here. The most recently initialized tab's level wins. @@ -159,11 +169,15 @@ export const initDbWorker = console.info("initDbWorker"); void run.daemon(async (run) => { - await stack.use(leaderLock.acquire(name)); + const lockResult = await run(leaderLock.lock(name)); + if (!lockResult.ok) { + console.info("leaderLock not acquired"); + return lockResult; + } + stack.use(lockResult.value); console.info("leaderLock acquired"); port.postMessage({ type: "LeaderAcquired", name }); - - return await run.addDeps({ + return run.addDeps({ port, timestampConfig: { maxDrift: defaultTimestampMaxDrift }, })(startDbWorker(name, sqliteSchema, encryptionKey)); @@ -184,17 +198,17 @@ const startDbWorker = DbWorkerDeps & PortDep & TimestampConfigDep > => async (run) => { - await using stack = run.stack(); + await using stack = new AsyncDisposableStack(); const console = run.deps.console.child(name).child("DbWorker"); console.info("startDbWorker"); - const sqliteResult = await stack.use( + const sqliteResult = await run( createSqlite(name, { mode: "encrypted", encryptionKey }), ); if (!sqliteResult.ok) return sqliteResult; - const sqlite = sqliteResult.value; - console.info("SQLite created"); + const sqlite = stack.use(sqliteResult.value); + console.debug("SQLite created"); const baseSqliteStorage = createBaseSqliteStorage({ sqlite, ...run.deps }); @@ -228,14 +242,13 @@ const startDbWorker = readonly id: Id; readonly processedAt: Millis; }> = []; - const processedRequestIdTtl = 5 * 60 * 1000; + const processedRequestIdTtl = Millis.orThrow(5 * 60 * 1000); const { port } = run.deps; port.onMessage = ({ callbackId, request, evoluPortId }) => { const now = run.deps.time.now(); - // Evict expired callback IDs based on time-to-live. while (processedRequestIdsOrder.length > 0) { const oldest = processedRequestIdsOrder[0]; if (now - oldest.processedAt <= processedRequestIdTtl) break; @@ -245,7 +258,10 @@ const startDbWorker = if (processedRequestIds.has(callbackId)) return; processedRequestIds.add(callbackId); - processedRequestIdsOrder.push({ id: callbackId, processedAt: now }); + processedRequestIdsOrder.push({ + id: callbackId, + processedAt: now, + }); if (processedRequestIdsOrder.length > processedRequestIdsLimit) { const oldest = processedRequestIdsOrder.shift(); if (oldest) processedRequestIds.delete(oldest.id); @@ -271,14 +287,10 @@ const startDbWorker = }); break; case "Export": - { - const exported = deps.sqlite.export(); - const file = new Uint8Array(exported); - result = ok({ - type: "Export", - file, - }); - } + result = ok({ + type: "Export", + file: deps.sqlite.export(), + }); break; } @@ -300,6 +312,12 @@ const startDbWorker = }; return ok(stack.move()); + + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to + // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu + // name and if silent for 10 seconds, it waits for another DB worker + // to announce itself alive and then routes requests to that worker. }; /** @@ -498,11 +516,9 @@ const validateColumnValue = ); }; -const systemColumnsWithoutOwnerId: ReadonlySet = (() => { - const columns = new Set(systemColumns); - columns.delete("ownerId"); - return columns; -})(); +const systemColumnsWithoutOwnerId = systemColumns.difference( + new Set(["ownerId"]), +); const applyColumnChange = (deps: SqliteDep) => @@ -554,14 +570,165 @@ const applyColumnChange = `); }; -const handleMutation = +interface ClientStorage extends Storage, BaseSqliteStorage {} + +const _createClientStorage = ( deps: BaseSqliteStorageDep & ClockDep & SqliteSchemaDep & EncryptionKeyDep & + RandomBytesDep & RandomDep & + SqliteDep & + TimeDep & + TimestampConfigDep, + ) => + ({ + onError, + }: { + onError: ( + error: + | ProtocolInvalidDataError + | ProtocolTimestampMismatchError + | DecryptWithXChaCha20Poly1305Error + | TimestampCounterOverflowError + | TimestampDriftError + | TimestampTimeOutOfRangeError, + ) => void; + }): ClientStorage => { + const storage: ClientStorage = { + ...deps.baseSqliteStorage, + + // Not implemented yet. + validateWriteKey: lazyFalse, + setWriteKey: lazyVoid, + + writeMessages: (ownerIdBytes, encryptedMessages) => () => { + // TODO: Add quota checking for collaborative scenarios. + // When receiving messages from other owners via relay broadcast, + // check if this owner is within quota before accepting the data. + // This prevents an owner from exceeding storage limits when receiving + // data shared by other collaborators. + if (encryptedMessages.length === 0) return ok(); + + const messages: Array = []; + let incomingBytesSum = 0; + + for (const message of encryptedMessages) { + const change = decryptAndDecodeDbChange(message, deps.encryptionKey); + if (!change.ok) { + onError(change.error); + continue; + } + incomingBytesSum += message.change.length; + messages.push({ timestamp: message.timestamp, change: change.value }); + } + if (messages.length === 0) return ok(); + + const incomingBytes = PositiveInt.orThrow(incomingBytesSum); + + const clockTimestamp = deps.clock.get(); + let validatedClockTimestamp = clockTimestamp; + const timestampErrors = []; + + for (const message of messages) { + const nextTimestamp = receiveTimestamp(deps)( + validatedClockTimestamp, + message.timestamp, + ); + if (!nextTimestamp.ok) { + timestampErrors.push(nextTimestamp.error); + continue; + } + validatedClockTimestamp = nextTimestamp.value; + } + if (timestampErrors.length > 0) { + timestampErrors.forEach(onError); + return ok(); + } + + assertNonEmptyReadonlyArray(messages); + + return deps.sqlite.transaction(() => { + applyMessages(deps)( + ownerIdBytesToOwnerId(ownerIdBytes), + messages, + incomingBytes, + ); + deps.clock.save(validatedClockTimestamp); + return ok(); + }); + }, + + readDbChange: (ownerId, timestamp) => { + const result = deps.sqlite.exec<{ + readonly table: string; + readonly id: IdBytes; + readonly column: string; + readonly value: SqliteValue; + }>(sql` + select "table", "id", "column", "value" + from evolu_history + where "ownerId" = ${ownerId} and "timestamp" = ${timestamp} + union all + select "table", "id", "column", "value" + from evolu_message_quarantine + where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; + `); + + const { rows } = result; + assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows"); + const firstRow = firstInArray(rows); + + const values = createRecord(); + let isInsert: DbChange["isInsert"] = false; + let isDelete: DbChange["isDelete"] = null; + + for (const r of rows) { + switch (r.column) { + case "createdAt": + isInsert = true; + break; + case "updatedAt": + isInsert = false; + break; + case "isDeleted": + if (SqliteBoolean.is(r.value)) { + isDelete = sqliteBooleanToBoolean(r.value); + } + break; + default: + values[r.column] = r.value; + } + } + + const message: CrdtMessage = { + timestamp: timestampBytesToTimestamp(timestamp), + change: DbChange.orThrow({ + table: firstRow.table, + id: idBytesToId(firstRow.id), + values, + isInsert, + isDelete, + }), + }; + + return encodeAndEncryptDbChange(deps)(message, deps.encryptionKey); + }, + }; + + return storage; + }; + +const handleMutation = + ( + deps: BaseSqliteStorageDep & + ClockDep & + EncryptionKeyDep & RandomBytesDep & + SqliteSchemaDep & + RandomDep & SqliteDep & TimeDep & TimestampConfigDep, @@ -603,16 +770,7 @@ const handleMutation = } for (const [ownerId, messages] of messagesByOwnerId) { - const incomingBytes = PositiveInt.orThrow( - messages.reduce( - (sum, message) => - sum + - encodeAndEncryptDbChange(deps)(message, deps.encryptionKey) - .length, - 0, - ), - ); - applyMessages(deps)(ownerId, messages, incomingBytes); + applyMessages(deps)(ownerId, messages); } if (clockChanged) deps.clock.save(clockTimestamp); @@ -654,6 +812,8 @@ const applyMessages = ( deps: BaseSqliteStorageDep & ClockDep & + EncryptionKeyDep & + RandomBytesDep & SqliteSchemaDep & RandomDep & SqliteDep, @@ -661,7 +821,7 @@ const applyMessages = ( ownerId: OwnerId, messages: NonEmptyReadonlyArray, - incomingBytes: PositiveInt, + incomingBytes?: PositiveInt, ): void => { const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); @@ -722,9 +882,19 @@ const applyMessages = ); } + const resolvedIncomingBytes = + incomingBytes ?? + PositiveInt.orThrow( + messages.reduce( + (sum, message) => + sum + + encodeAndEncryptDbChange(deps)(message, deps.encryptionKey).length, + 0, + ), + ); const nextStoredBytes = getNextStoredBytes( usage.value.storedBytes, - incomingBytes, + resolvedIncomingBytes, ); updateOwnerUsage(deps)( ownerIdBytes, @@ -766,14 +936,18 @@ const loadQueries = }; export const testStartDbWorker = startDbWorker; + export const testCreateClock = createClock; + export const testInitializeDb = initializeDb; -export const testTryApplyQuarantinedMessages = tryApplyQuarantinedMessages; + export const testHandleMutation = handleMutation; +export const testTryApplyQuarantinedMessages = tryApplyQuarantinedMessages; + // reset: (deps) => (message) => { // const result = deps.sqlite.transaction(() => { -// const sqliteSchema = getEvoluSqliteSchema(deps)(); +// const sqliteSchema = getSqliteSchema(deps)(); // if (!sqliteSchema.ok) return sqliteSchema; // for (const tableName in sqliteSchema.value.tables) { diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 49017c1d6..bc8b2d8b7 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -11,18 +11,13 @@ import type { ConsoleDep } from "../Console.js"; import { createConsole } from "../Console.js"; import { createUnknownError } from "../Error.js"; import { exhaustiveCheck, todo } from "../Function.js"; -import type { Listener, Unsubscribe } from "../Listeners.js"; import { createMicrotaskBatch } from "../Microtask.js"; import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; import { createRefCount } from "../RefCount.js"; import { err, ok } from "../Result.js"; import { isNonEmptySet } from "../Set.js"; -import { - SqliteBoolean, - type SqliteExportFile, - sqliteBooleanToBoolean, -} from "../Sqlite.js"; -import type { ReadonlyStore } from "../Store.js"; +import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.js"; +import type { Listener, ReadonlyStore, Unsubscribe } from "../Store.js"; import { createStore } from "../Store.js"; import type { createRun, Task } from "../Task.js"; import type { Id, TypeError } from "../Type.js"; @@ -92,17 +87,25 @@ export interface EvoluConfig { readonly appName: AppName; /** - * External AppOwner to use when creating Evolu instance. Use this when you - * want to manage AppOwner creation and persistence externally (e.g., with - * your own authentication system). If omitted, Evolu will automatically - * create and persist an AppOwner locally. + * {@link AppOwner} used to create this {@link Evolu} instance. * - * For device-specific settings and account management state, we can use a - * separate local-only Evolu instance via `transports: []`. + * Exposed as {@link Evolu.appOwner}. If `appOwner` is not passed, Evolu + * creates one. * - * ### Example + * AppOwner controls access to the encrypted local SQLite database. If its + * secret material (owner secret / mnemonic) is not stored safely, data + * written by that instance is permanently inaccessible. + * + * Best onboarding UX is accountless first use: let users try a ready-to-use + * app, then prompt backup of `evolu.appOwner`. * - * Use `appOwner` when restoring or switching owners managed by your app. + * Recommended usage: + * + * - Omit `appOwner` for first run, then persist `evolu.appOwner` after user + * activity and guide the user to back it up. + * - Pass `appOwner` restored from secure storage (for example, Expo + * SecureStore, WebAuthn-backed storage, or app-managed account recovery + * flow). */ readonly appOwner?: AppOwner; @@ -241,10 +244,11 @@ export interface Evolu readonly appOwner: AppOwner; /** - * Shared {@link EvoluError} store for this instance. + * Shared error store for this Evolu instance. * - * When available, this is the same store as - * {@link EvoluErrorDep.evoluError} from {@link createEvoluDeps}. + * When {@link createEvolu} runs with deps created by {@link createEvoluDeps}, + * this references the shared platform-level store. Otherwise a local store is + * exposed to preserve the framework hook API. */ readonly evoluError: ReadonlyStore; @@ -403,7 +407,9 @@ export interface Evolu * The pending promise rejects if this {@link Evolu} instance is disposed * before export completion. */ - readonly exportDatabase: () => Promise; + readonly exportDatabase: () => Promise>; + + // TODO: Add exportHistory. /** * Use a {@link SyncOwner}. Returns a {@link UnuseOwner}. @@ -552,20 +558,20 @@ export const createEvolu = ( schema: ValidateSchema extends never ? S : ValidateSchema, config: EvoluConfig, - ): Task, never, EvoluPlatformDeps> => + ): Task, never, EvoluPlatformDeps & Partial> => async (run) => { - const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = - config; - + const { + appName, + appOwner = createAppOwner(createOwnerSecret(run.deps)), + transports, + } = config; const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`); const console = run.deps.console.child(name).child("Evolu"); console.info("createEvolu"); - const evoluError = - (run.deps as Partial).evoluError ?? - createStore(null); const rowsByQueryMapStore = createStore(new Map()); + // TODO: Sync resource abstraction? const subscribedQueriesRefCount = createRefCount(); interface LoadingPromise { @@ -588,12 +594,16 @@ export const createEvolu = const loadingPromisesByQuery = new Map(); - await using stack = run.stack(); + await using stack = new AsyncDisposableStack(); const onMutateCompleteCallbacks = stack.use(createCallbacks(run.deps)); + const evoluError = + (run.deps as Partial).evoluError ?? + stack.use(createStore(null)); - let exportDatabasePending = - null as PromiseWithResolvers | null; + let exportDatabasePending = null as PromiseWithResolvers< + Uint8Array + > | null; /** * Mutations and refreshes invalidate query snapshots. Keep loading promises @@ -664,7 +674,7 @@ export const createEvolu = { type: "CreateEvolu", name, - appOwner, + appOwner: { ...appOwner, transports: transports ?? emptyArray }, evoluPort: evoluChannel.port2.native, dbWorkerPort: dbWorkerChannel.port2.native, }, @@ -881,7 +891,8 @@ export const createEvolu = exportDatabase: () => { if (!exportDatabasePending) { - exportDatabasePending = Promise.withResolvers(); + exportDatabasePending = + Promise.withResolvers>(); postMessage({ type: "Export" }); } return exportDatabasePending.promise; @@ -893,33 +904,12 @@ export const createEvolu = console.info("disposeEvolu"); exportDatabasePending?.reject({ type: "EvoluDisposedError" }); exportDatabasePending = null; - // Cancel queued microtask batches before sending dispose. - mutateBatch[Symbol.dispose](); - queryBatch[Symbol.dispose](); postMessage({ type: "Dispose" }); return moved.disposeAsync(); }, } as Evolu); }; -// const loadingPromises = createLoadingPromises(subscribedQueries); -// const loadQueryMicrotaskQueue: Array = []; -// case "refreshQueries": { -// const loadingPromisesQueries = loadingPromises.getQueries(); -// loadingPromises.releaseUnsubscribedOnMutation(); - -// const queries = dedupeArray([ -// ...loadingPromisesQueries, -// ...subscribedQueries.get(), -// ]); - -// if (isNonEmptyArray(queries)) { -// dbWorker.postMessage({ type: "query", tabId: getTabId(), queries }); -// } - -// break; -// } - // case "onReset": { // if (message.reload) { // deps.reloadApp(reloadUrl); @@ -929,106 +919,6 @@ export const createEvolu = // break; // } -// case "onExport": { -// exportCallbacks.execute( -// message.onCompleteId, -// message.file as Uint8Array, -// ); -// break; -// } - -// default: -// exhaustiveCheck(message); -// } -// }); - -// const sqliteSchema = evoluSchemaToSqliteSchema(schema, indexes); - -// const processMutationQueue = () => { -// const changes: Array = []; -// const onCompletes = []; - -// for (const [change, onComplete] of mutateMicrotaskQueue) { -// if (change !== null) changes.push(change); -// if (onComplete) onCompletes.push(onComplete); -// } - -// const queueLength = mutateMicrotaskQueue.length; -// mutateMicrotaskQueue.length = 0; - -// // Don't process any mutations if there was a validation error. -// // All mutations within a queue run as a single transaction. -// if (changes.length !== queueLength) { -// return; -// } - -// const _onCompleteIds = onCompletes.map(onCompleteCallbacks.register); -// loadingPromises.releaseUnsubscribedOnMutation(); - -// if (!isNonEmptyArray(changes)) return; - -// TODO: -// dbWorker.postMessage({ -// type: "mutate", -// tabId: getTabId(), -// changes, -// onCompleteIds, -// subscribedQueries: subscribedQueries.get(), -// }); -// }; - -// const evolu: Evolu = { -// name, - -// subscribeError: errorStore.subscribe, -// getError: errorStore.get, - -// loadQuery: (query: Query): Promise> => { -// const { promise, isNew } = loadingPromises.get(query); - -// if (isNew) { -// loadQueryMicrotaskQueue.push(query); -// if (loadQueryMicrotaskQueue.length === 1) { -// queueMicrotask(() => { -// const queries = dedupeArray(loadQueryMicrotaskQueue); -// loadQueryMicrotaskQueue.length = 0; -// assertNonEmptyReadonlyArray(queries); -// deps.console.log("[evolu]", "loadQuery", { queries }); -// // dbWorker.postMessage({ -// // type: "query", -// // tabId: getTabId(), -// // queries, -// // }); -// }); -// } -// } - -// return promise; -// }, - -// loadQueries: >( -// queries: [...Q], -// ): [...QueriesToQueryRowsPromises] => -// queries.map(evolu.loadQuery) as [...QueriesToQueryRowsPromises], - -// subscribeQuery: (query) => (listener) => { -// // Call the listener only if the result has been changed. -// let previousRows: unknown = null; -// const unsubscribe = subscribedQueries.subscribe(query)(() => { -// const rows = evolu.getQueryRows(query); -// if (previousRows === rows) return; -// previousRows = rows; -// listener(); -// }); -// return () => { -// previousRows = null; -// unsubscribe(); -// }; -// }, - -// getQueryRows: (query: Query): QueryRows => -// (rowsStore.get().get(query) ?? emptyRows) as QueryRows, - // resetAppOwner: (_options) => { // const { promise, resolve } = Promise.withResolvers(); // const _onCompleteId = onCompleteCallbacks.register(resolve); diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index b63b6dd7b..05e7a44bf 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -12,7 +12,6 @@ import { } from "../Array.js"; import { assert } from "../Assert.js"; import type { TimingSafeEqualDep } from "../Crypto.js"; -import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; import type { SqliteDep } from "../Sqlite.js"; import { sql } from "../Sqlite.js"; @@ -159,108 +158,106 @@ export const createRelaySqliteStorage = writeMessages: (ownerIdBytes, messages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); - const messagesWithTimestampBytes = filterArray( - mapArray(messages, (m) => ({ - timestamp: timestampToTimestampBytes(m.timestamp), - change: m.change, - })), - (message) => message.change.length > 0, - ); - - if (!isNonEmptyArray(messagesWithTimestampBytes)) { - return ok(); - } + const messagesWithTimestampBytes = mapArray(messages, (m) => ({ + timestamp: timestampToTimestampBytes(m.timestamp), + change: m.change, + })); const result = await run( - mutexByOwnerId.withLock( - ownerId, - async (): Promise> => { - const existingTimestampsResult = - sqliteStorageBase.getExistingTimestamps( - ownerIdBytes, - mapArray(messagesWithTimestampBytes, (m) => m.timestamp), - ); - - const existingTimestampsSet = new Set( - existingTimestampsResult.map((t) => t.toString()), - ); - const newMessages = filterArray( - messagesWithTimestampBytes, - (m) => !existingTimestampsSet.has(m.timestamp.toString()), - ); - - // Nothing to write - if (!isNonEmptyArray(newMessages)) { - return ok(); - } - - const usage = getOwnerUsage(deps)( + mutexByOwnerId.withLock(ownerId, async () => { + const existingTimestampsResult = + sqliteStorageBase.getExistingTimestamps( ownerIdBytes, - firstInArray(newMessages).timestamp, + mapArray(messagesWithTimestampBytes, (m) => m.timestamp), ); - if (!usage.ok) return usage; - const { storedBytes } = usage.value; - - const incomingBytes = newMessages.reduce( - (sum, m) => sum + m.change.length, - 0, - ); - const newStoredBytes = PositiveInt.orThrow( - (storedBytes ?? 0) + incomingBytes, - ); - - const quotaResult = config.isOwnerWithinQuota( + const existingTimestampsSet = new Set( + existingTimestampsResult.map((t) => t.toString()), + ); + const newMessages = filterArray( + messagesWithTimestampBytes, + (m) => + m.change.length > 0 && + !existingTimestampsSet.has(m.timestamp.toString()), + ); + + // Nothing to write + if (!isNonEmptyArray(newMessages)) { + return ok(); + } + + const usageResult = getOwnerUsage(deps)( + ownerIdBytes, + firstInArray(newMessages).timestamp, + ); + if (!usageResult.ok) { + return usageResult; + } + const usage = usageResult.value; + + const incomingBytes = newMessages.reduce( + (sum, m) => sum + m.change.length, + 0, + ); + const newStoredBytes = PositiveInt.orThrow( + (usage.storedBytes ?? 0) + incomingBytes, + ); + + const quotaResult = config.isOwnerWithinQuota( + ownerId, + newStoredBytes, + ); + const isWithinQuota = isPromiseLike(quotaResult) + ? await quotaResult + : quotaResult; + if (!isWithinQuota) { + return err({ + type: "StorageQuotaError", ownerId, - newStoredBytes, - ); - const isWithinQuota = isPromiseLike(quotaResult) - ? await quotaResult - : quotaResult; - if (!isWithinQuota) { - return err({ type: "StorageQuotaError", ownerId }); - } + }); + } + + let { firstTimestamp, lastTimestamp } = usage; - let { firstTimestamp, lastTimestamp } = usage.value; - - return deps.sqlite.transaction(() => { - for (const { timestamp, change } of newMessages) { - let strategy; - [strategy, firstTimestamp, lastTimestamp] = - getTimestampInsertStrategy( - timestamp, - firstTimestamp, - lastTimestamp, - ); - sqliteStorageBase.insertTimestamp( - ownerIdBytes, + return deps.sqlite.transaction(() => { + for (const { timestamp, change } of newMessages) { + let strategy; + [strategy, firstTimestamp, lastTimestamp] = + getTimestampInsertStrategy( timestamp, - strategy, + firstTimestamp, + lastTimestamp, ); - deps.sqlite.exec(sql` - insert into evolu_message - ("ownerId", "timestamp", "change") - values (${ownerIdBytes}, ${timestamp}, ${change}) - on conflict do nothing; - `); - } - - updateOwnerUsage(deps)( + + sqliteStorageBase.insertTimestamp( ownerIdBytes, - newStoredBytes, - firstTimestamp, - lastTimestamp, + timestamp, + strategy, ); - return ok(); - }); - }, - ), + deps.sqlite.exec(sql` + insert into evolu_message + ("ownerId", "timestamp", "change") + values (${ownerIdBytes}, ${timestamp}, ${change}) + on conflict do nothing; + `); + } + + updateOwnerUsage(deps)( + ownerIdBytes, + newStoredBytes, + firstTimestamp, + lastTimestamp, + ); + + return ok(); + }); + }), ); if (!result.ok) { if (result.error.type === "AbortError") return ok(); - return err({ type: "StorageQuotaError", ownerId }); + return result; } return ok(); @@ -305,7 +302,7 @@ export const createRelaySqliteStorage = export const createRelayStorageTables = (deps: SqliteDep): void => { for (const query of [ sql` - create table if not exists evolu_writeKey ( + create table evolu_writeKey ( "ownerId" blob not null, "writeKey" blob not null, primary key ("ownerId") @@ -314,7 +311,7 @@ export const createRelayStorageTables = (deps: SqliteDep): void => { `, sql` - create table if not exists evolu_message ( + create table evolu_message ( "ownerId" blob not null, "timestamp" blob not null, "change" blob not null, diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index cebe8fe15..af3d81e6d 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -5,7 +5,6 @@ */ import * as Kysely from "kysely"; -import { readonly } from "../Function.js"; import { getProperty, mapObject, type ReadonlyRecord } from "../Object.js"; import { eqSqliteIndex, @@ -362,14 +361,14 @@ export type OptionalColumnKeys = { [K in keyof T]: null extends StandardSchemaV1.InferOutput ? K : never; }[keyof T]; -export const systemColumns = /*#__PURE__*/ readonly( - /*#__PURE__*/ new Set(/*#__PURE__*/ Object.keys(SystemColumns.props)), +export const systemColumns: ReadonlySet = /*#__PURE__*/ new Set( + /*#__PURE__*/ Object.keys(SystemColumns.props), ); -export const systemColumnsWithId = /*#__PURE__*/ readonly([ +export const systemColumnsWithId: ReadonlyArray = [ ...systemColumns, "id", -]); +]; export const evoluSchemaToSqliteSchema = ( schema: ValidateSchema extends never ? S : ValidateSchema, diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 8605ccee6..39a616bfe 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -15,7 +15,6 @@ import { assert } from "../Assert.js"; import { createCallbacks } from "../Callbacks.js"; import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; -import { createResources, type Resources } from "../Resources.js"; import { ok } from "../Result.js"; import { spaced } from "../Schedule.js"; import type { NonEmptyReadonlySet } from "../Set.js"; @@ -23,7 +22,7 @@ import { createMutexByKey, type Fiber, repeat, type Task } from "../Task.js"; import type { TimeDep, TimeoutId } from "../Time.js"; import { createId, type Id, type Name } from "../Type.js"; import type { Callback, ExtractType } from "../Types.js"; -import type { CreateWebSocketDep, WebSocket } from "../WebSocket.js"; +import type { CreateWebSocketDep } from "../WebSocket.js"; import type { SharedWorker as CommonSharedWorker, MessagePort, @@ -32,7 +31,7 @@ import type { WorkerDeps, } from "../Worker.js"; import type { EvoluError } from "./Error.js"; -import type { OwnerId, OwnerTransport } from "./Owner.js"; +import type { OwnerId } from "./Owner.js"; import { makePatches, type Patch, @@ -49,10 +48,6 @@ export interface SharedWorkerDep { readonly sharedWorker: SharedWorker; } -interface TransportsDep { - readonly transports: SharedTransportResources; -} - export type SharedWorkerDeps = WorkerDeps & CreateWebSocketDep & TimeDep; export type SharedWorkerInput = @@ -116,8 +111,7 @@ export const initSharedWorker = self: SharedWorkerSelf, ): Task => async (run) => { - const { createMessagePort, consoleStoreOutputEntry, createWebSocket } = - run.deps; + const { createMessagePort, consoleStoreOutputEntry } = run.deps; const console = run.deps.console.child("SharedWorker"); // TODO: Use heartbeat to detect and prune dead ports. @@ -129,34 +123,8 @@ export const initSharedWorker = else for (const port of tabPorts) port.postMessage(output); }; - const createTransportId = (transport: OwnerTransport): string => - `${transport.type}:${transport.url}`; - - await using stack = run.stack(); - - const transports = stack.use( - createResources({ - createResource: async (transport) => { - const transportId = createTransportId(transport); - console.info("createTransportResource", { transportId }); - return await run.daemon.orThrow( - createWebSocket(transport.url, { - binaryType: "arraybuffer", - onOpen: () => { - console.debug("transportOpen", { transportId }); - }, - onClose: () => { - console.debug("transportClose", { transportId }); - }, - }), - ); - }, - getResourceId: createTransportId, - getConsumerId: (owner) => owner.id, - }), - ); + await using stack = new AsyncDisposableStack(); - const runWithSharedEvoluDeps = run.addDeps({ transports }); const sharedEvolusByName = new Map(); const sharedEvolusMutexByName = stack.use(createMutexByKey()); @@ -180,20 +148,17 @@ export const initSharedWorker = } case "CreateEvolu": { - void runWithSharedEvoluDeps.daemon( + void run.daemon( sharedEvolusMutexByName.withLock(message.name, async () => { let sharedEvolu = sharedEvolusByName.get(message.name); if (sharedEvolu == null) { - const result = await runWithSharedEvoluDeps.daemon( + const result = await run.daemon( createSharedEvolu({ name: message.name, - ...(message.appOwner - ? { appOwner: message.appOwner } - : {}), postTabOutput, onDispose: () => { - void runWithSharedEvoluDeps.daemon( + void run.daemon( sharedEvolusMutexByName.withLock( message.name, async () => { @@ -304,29 +269,19 @@ export interface QueuedResult { readonly response: QueuedResponse; } -type SharedTransportResources = Resources< - WebSocket, - string, - OwnerTransport, - SyncOwner, - OwnerId ->; - const createSharedEvolu = ({ name, - appOwner, postTabOutput, onDispose, }: { name: Name; - appOwner?: SyncOwner; postTabOutput: Callback; onDispose: () => void; - }): Task => - async (run) => { + }): Task => + (run) => { const console = run.deps.console.child(name).child("SharedWorker"); - const { createMessagePort, transports } = run.deps; + const { createMessagePort } = run.deps; const evoluPorts = new Map>(); const dbWorkerPorts = new Set>(); @@ -343,12 +298,6 @@ const createSharedEvolu = let activeQueueCallbackId: Id | null = null; let activeLeaderTimeout: TimeoutId | null = null; - const ownerTransports = appOwner?.transports ?? emptyArray; - - if (appOwner) { - await run(transports.addConsumer(appOwner, ownerTransports)); - } - const clearActiveLeaderTimeout = (): void => { if (!activeLeaderTimeout) return; run.deps.time.clearTimeout(activeLeaderTimeout); @@ -590,11 +539,8 @@ const createSharedEvolu = return ok({ addPorts, + // eslint-disable-next-line @typescript-eslint/require-await [Symbol.asyncDispose]: async () => { - if (appOwner) { - await run(transports.removeConsumer(appOwner, ownerTransports)); - } - clearActiveLeaderTimeout(); queueProcessingFiber?.abort(); queueProcessingFiber = null; diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index f74e43e15..14842ff1c 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -209,6 +209,30 @@ export const createSync = getSyncOwner, })(config); + const disposeSocket = async (webSocket: unknown): Promise => { + if (!webSocket) return; + if (typeof webSocket !== "object") return; + if (Symbol.asyncDispose in webSocket) { + const asyncDispose = ( + webSocket as { + [Symbol.asyncDispose]?: () => Promise; + } + )[Symbol.asyncDispose]; + if (typeof asyncDispose === "function") { + await asyncDispose.call(webSocket); + return; + } + } + if (Symbol.dispose in webSocket) { + const dispose = ( + webSocket as { + [Symbol.dispose]?: () => void; + } + )[Symbol.dispose]; + if (typeof dispose === "function") dispose.call(webSocket); + } + }; + const createResource = (transport: OwnerTransport): WebSocket => { const transportKey = createTransportKey(transport); @@ -256,13 +280,21 @@ export const createSync = isOpen: () => !resourceDisposed && (socket?.isOpen() ?? false), - [Symbol.dispose]: () => { + [Symbol.asyncDispose]: async () => { if (resourceDisposed) return; resourceDisposed = true; pendingSends.length = 0; webSocketsByTransportKey.delete(transportKey); - socket?.[Symbol.dispose](); - void run[Symbol.asyncDispose](); + try { + await disposeSocket(socket); + } catch (error) { + deps.console.warn("[sync]", "disposeSocketFailed", { + transportKey, + error, + }); + } finally { + await run[Symbol.asyncDispose](); + } }, }; @@ -336,7 +368,12 @@ export const createSync = /* v8 ignore start */ // Defensive cleanup for a resolved socket after disposal. if (resourceDisposed || isDisposed) { - socket[Symbol.dispose](); + void disposeSocket(socket).catch((error) => { + deps.console.warn("[sync]", "disposeSocketFailed", { + transportKey, + error, + }); + }); } /* v8 ignore stop */ }, diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index fd62c3fbc..75b84658c 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -158,9 +158,9 @@ export const initEvoluWorker = if (entry) postTabOutput({ type: "ConsoleEntry", entry }); }); - run.defer(() => { + const lifetime = run.create(); + lifetime.onAbort(() => { unsubscribe(); - return ok(); }); return ok(); diff --git a/packages/common/test/Assert.test.ts b/packages/common/test/Assert.test.ts index 0480345b4..9068ff6ae 100644 --- a/packages/common/test/Assert.test.ts +++ b/packages/common/test/Assert.test.ts @@ -1,11 +1,18 @@ -import { expect, test } from "vitest"; +import { describe, expect, expectTypeOf, test } from "vitest"; import { assert, assertNonEmptyArray, assertNonEmptyReadonlyArray, + assertNotAborted, + assertNotDisposed, assertType, } from "../src/Assert.js"; -import { AbortError } from "../src/Task.js"; +import type { Ok, Result } from "../src/Result.js"; +import { err, ok } from "../src/Result.js"; +import { AbortError, runStoppedError } from "../src/Task.js"; +import type { Typed } from "../src/Type.js"; + +interface MyError extends Typed<"MyError"> {} test("assert", () => { // Should not throw when the condition is true @@ -14,7 +21,7 @@ test("assert", () => { // Should throw when the condition is false expect(() => { assert(false, "Condition failed"); - }).toThrowError("Condition failed"); + }).toThrow("Condition failed"); }); test("assertNonEmptyArray", () => { @@ -26,12 +33,12 @@ test("assertNonEmptyArray", () => { // Empty array should throw expect(() => { assertNonEmptyArray([]); - }).toThrowError("Expected a non-empty array."); + }).toThrow("Expected a non-empty array."); // Custom error message expect(() => { assertNonEmptyArray([], "Custom error"); - }).toThrowError("Custom error"); + }).toThrow("Custom error"); }); test("assertNonEmptyReadonlyArray", () => { @@ -43,12 +50,12 @@ test("assertNonEmptyReadonlyArray", () => { // Empty readonly array should throw expect(() => { assertNonEmptyReadonlyArray([]); - }).toThrowError("Expected a non-empty readonly array."); + }).toThrow("Expected a non-empty readonly array."); // Custom error message expect(() => { assertNonEmptyReadonlyArray([], "Custom error"); - }).toThrowError("Custom error"); + }).toThrow("Custom error"); }); test("assertType", () => { @@ -56,9 +63,75 @@ test("assertType", () => { expect(() => { assertType(AbortError, { type: "Other" }); - }).toThrowError("Expected Object."); + }).toThrow("Expected Object."); expect(() => { assertType(AbortError, { type: "Other" }, "Custom error"); - }).toThrowError("Custom error"); + }).toThrow("Custom error"); +}); + +describe("assertNotAborted", () => { + test("allows ok and domain errors", () => { + const okResult = ok(1) as Result; + assertNotAborted(okResult); + expect(okResult).toEqual(ok(1)); + + const domainError = err({ type: "MyError" }) as Result< + number, + MyError | AbortError + >; + assertNotAborted(domainError); + expect(domainError).toEqual(err({ type: "MyError" })); + }); + + test("throws for AbortError", () => { + expect(() => { + assertNotAborted(err({ type: "AbortError", reason: "timeout" })); + }).toThrow("Expected result to not be aborted."); + + expect(() => { + assertNotAborted( + err({ type: "AbortError", reason: runStoppedError }), + "Custom error", + ); + }).toThrow("Custom error"); + }); + + test("narrows away AbortError", () => { + const result = err({ type: "MyError" }) as Result< + number, + MyError | AbortError + >; + + assertNotAborted(result); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf(); + } else { + expectTypeOf(result.error).toEqualTypeOf(); + } + }); + + test("narrows abort-only results to ok", () => { + const result = ok(1) as Result; + + assertNotAborted(result); + + expectTypeOf(result).toEqualTypeOf>(); + expect(result.value).toBe(1); + }); +}); + +test("assertNotDisposed", async () => { + const stack = new globalThis.AsyncDisposableStack(); + + expect(() => { + assertNotDisposed(stack); + }).not.toThrow(); + + await stack.disposeAsync(); + + expect(() => { + assertNotDisposed(stack); + }).toThrow("Expected value to not be disposed."); }); diff --git a/packages/common/test/Buffer.test.ts b/packages/common/test/Buffer.test.ts index d4da59b19..ad46f7e6d 100644 --- a/packages/common/test/Buffer.test.ts +++ b/packages/common/test/Buffer.test.ts @@ -45,14 +45,14 @@ test("Buffer", () => { expect(buffer2.unwrap()).toMatchInlineSnapshot(`uint8:[3]`); expect(() => buffer2.shiftN(2 as NonNegativeInt)).toThrow(BufferError); - expect(() => buffer2.shiftN(2 as NonNegativeInt)).toThrowError( + expect(() => buffer2.shiftN(2 as NonNegativeInt)).toThrow( "Buffer parse ended prematurely", ); buffer2.shift(); expect(() => buffer2.shift()).toThrow(BufferError); - expect(() => buffer2.shift()).toThrowError("Buffer parse ended prematurely"); + expect(() => buffer2.shift()).toThrow("Buffer parse ended prematurely"); expect(buffer2.shiftN(0 as NonNegativeInt)).toStrictEqual(new Uint8Array(0)); }); @@ -88,7 +88,7 @@ test("Buffer truncate", () => { }).toThrow(BufferError); expect(() => { buffer.truncate(1 as NonNegativeInt); - }).toThrowError("Cannot truncate to a length greater than current"); + }).toThrow("Cannot truncate to a length greater than current"); buffer.extend([6, 7, 8]); expect(buffer.getLength()).toBe(3); diff --git a/packages/common/test/Function.test.ts b/packages/common/test/Function.test.ts deleted file mode 100644 index 9d5b75eae..000000000 --- a/packages/common/test/Function.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, expectTypeOf, test } from "vitest"; -import type { NonEmptyArray, NonEmptyReadonlyArray } from "../src/Array.js"; -import { - exhaustiveCheck, - identity, - lazyFalse, - lazyNull, - lazyTrue, - type lazyUndefined, - type lazyVoid, - readonly, - todo, -} from "../src/Function.js"; -import type { ReadonlyRecord } from "../src/Object.js"; - -describe("exhaustiveCheck", () => { - test("throws error for unhandled case", () => { - expect(() => exhaustiveCheck("unexpected" as never)).toThrow( - 'exhaustiveCheck unhandled case: "unexpected"', - ); - }); -}); - -describe("identity", () => { - test("returns the same value", () => { - expect(identity(42)).toBe(42); - expect(identity("hello")).toBe("hello"); - expect(identity(null)).toBe(null); - }); - - test("preserves object reference", () => { - const obj = { a: 1 }; - expect(identity(obj)).toBe(obj); - }); - - test("preserves type", () => { - const num = identity(42); - expectTypeOf(num).toEqualTypeOf(); - - const str = identity("hello"); - expectTypeOf(str).toEqualTypeOf(); - }); -}); - -describe("readonly", () => { - describe("documentation example", () => { - test("matches the JSDoc example", () => { - // Array literals become NonEmptyReadonlyArray - const items = readonly([1, 2, 3]); - expectTypeOf(items).toEqualTypeOf>(); - - // NonEmptyArray is preserved as NonEmptyReadonlyArray - const nonEmpty: NonEmptyArray = [1, 2, 3]; - const readonlyNonEmpty = readonly(nonEmpty); - expectTypeOf(readonlyNonEmpty).toEqualTypeOf< - NonEmptyReadonlyArray - >(); - - // Regular arrays become ReadonlyArray - const arr: Array = [1, 2, 3]; - const readonlyArr = readonly(arr); - expectTypeOf(readonlyArr).toEqualTypeOf>(); - - // Sets, Records, and Maps - const ids = readonly(new Set(["a", "b"])); - expectTypeOf(ids).toEqualTypeOf>(); - - type UserId = string & { readonly __brand: "UserId" }; - const users: Record = {} as Record; - const readonlyUsers = readonly(users); - expectTypeOf(readonlyUsers).toEqualTypeOf< - ReadonlyRecord - >(); - - const lookup = readonly(new Map([["key", "value"]])); - expectTypeOf(lookup).toEqualTypeOf>(); - }); - }); - - describe("Array", () => { - test("returns the same array", () => { - const arr = [1, 2, 3]; - expect(readonly(arr)).toBe(arr); - }); - - test("types array literal as NonEmptyReadonlyArray", () => { - const arr = readonly([1, 2, 3]); - expectTypeOf(arr).toEqualTypeOf>(); - }); - - test("types empty array literal as ReadonlyArray", () => { - const arr = readonly([]); - expectTypeOf(arr).toEqualTypeOf>(); - }); - - test("types Array as ReadonlyArray", () => { - const arr: Array = [1, 2, 3]; - const result = readonly(arr); - expectTypeOf(result).toEqualTypeOf>(); - }); - - test("types empty array as ReadonlyArray", () => { - const arr: Array = []; - const result = readonly(arr); - expect(result).toBe(arr); - expectTypeOf(result).toEqualTypeOf>(); - }); - - test("types NonEmptyArray as NonEmptyReadonlyArray", () => { - const arr: NonEmptyArray = [1, 2, 3]; - const result = readonly(arr); - expect(result).toBe(arr); - expectTypeOf(result).toEqualTypeOf>(); - }); - }); - - describe("Set", () => { - test("returns the same set", () => { - const set = new Set([1, 2, 3]); - expect(readonly(set)).toBe(set); - }); - - test("types as ReadonlySet", () => { - const set = readonly(new Set([1, 2, 3])); - expectTypeOf(set).toEqualTypeOf>(); - }); - }); - - describe("Record", () => { - test("returns the same record", () => { - const record: Record = { a: 1, b: 2 }; - expect(readonly(record)).toBe(record); - }); - - test("types as ReadonlyRecord", () => { - const record: Record = { a: 1, b: 2 }; - const result = readonly(record); - expectTypeOf(result).toEqualTypeOf>(); - }); - - test("preserves branded key types", () => { - type UserId = string & { readonly __brand: "UserId" }; - const users: Record = {} as Record; - const result = readonly(users); - expectTypeOf(result).toEqualTypeOf>(); - }); - }); - - describe("Map", () => { - test("returns the same map", () => { - const map = new Map([["a", 1]]); - expect(readonly(map)).toBe(map); - }); - - test("types as ReadonlyMap", () => { - const map = readonly( - new Map([ - ["a", 1], - ["b", 2], - ]), - ); - expectTypeOf(map).toEqualTypeOf>(); - }); - }); - - // TODO: Re-enable when TypeScript fully supports ES2025 Iterator Helpers - // describe("with ES2025 iterator .toArray()", () => { - // test("converts iterator chain to ReadonlyArray", () => { - // const result = readonly( - // [1, 2, 3] - // .values() - // .map((x) => x * 2) - // .toArray() as Array, - // ); - // expect(result).toEqual([2, 4, 6]); - // expectTypeOf(result).toEqualTypeOf>(); - // }); - // }); -}); - -describe("lazy", () => { - test("lazyVoid returns void", () => { - expectTypeOf>().toEqualTypeOf(); - }); - - test("lazyUndefined returns undefined", () => { - expectTypeOf>().toEqualTypeOf(); - }); - - test("lazyNull returns null", () => { - expect(lazyNull()).toBe(null); - }); - - test("lazyTrue returns true", () => { - expect(lazyTrue()).toBe(true); - }); - - test("lazyFalse returns false", () => { - expect(lazyFalse()).toBe(false); - }); -}); - -describe("todo", () => { - test("throws", () => { - expect(() => todo()).toThrow("not yet implemented"); - }); - - test("infers type from return type annotation", () => { - const fn = (): number => todo(); - expectTypeOf(fn).returns.toEqualTypeOf(); - }); - - test("accepts explicit generic when no return type", () => { - const fn = () => todo(); - expectTypeOf(fn).returns.toEqualTypeOf(); - }); -}); diff --git a/packages/common/test/Listeners.test.ts b/packages/common/test/Listeners.test.ts deleted file mode 100644 index 8599e979c..000000000 --- a/packages/common/test/Listeners.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { createListeners } from "../src/Listeners.js"; - -describe("createListeners", () => { - test("subscribe and notify", () => { - const listeners = createListeners(); - const listener = vi.fn(); - - listeners.subscribe(listener); - listeners.notify(); - - expect(listener).toHaveBeenCalledOnce(); - }); - - test("unsubscribe removes listener", () => { - const listeners = createListeners(); - const listener = vi.fn(); - - const unsubscribe = listeners.subscribe(listener); - unsubscribe(); - listeners.notify(); - - expect(listener).not.toHaveBeenCalled(); - }); - - test("unsubscribe is idempotent", () => { - const listeners = createListeners(); - const listener = vi.fn(); - - const unsubscribe = listeners.subscribe(listener); - unsubscribe(); - unsubscribe(); // second call should not throw - - listeners.notify(); - expect(listener).not.toHaveBeenCalled(); - }); - - test("notifies listeners in subscription order", () => { - const listeners = createListeners(); - const order: Array = []; - - listeners.subscribe(() => order.push(1)); - listeners.subscribe(() => order.push(2)); - listeners.subscribe(() => order.push(3)); - listeners.notify(); - - expect(order).toEqual([1, 2, 3]); - }); - - test("passes value to listeners", () => { - const listeners = createListeners<{ id: string }>(); - const listener = vi.fn(); - - listeners.subscribe(listener); - listeners.notify({ id: "123" }); - - expect(listener).toHaveBeenCalledWith({ id: "123" }); - }); - - test("same listener added twice is called once", () => { - const listeners = createListeners(); - const listener = vi.fn(); - - listeners.subscribe(listener); - listeners.subscribe(listener); - listeners.notify(); - - expect(listener).toHaveBeenCalledOnce(); - }); - - test("dispose clears all listeners", () => { - const listeners = createListeners(); - const listener = vi.fn(); - - listeners.subscribe(listener); - listeners[Symbol.dispose](); - listeners.notify(); - - expect(listener).not.toHaveBeenCalled(); - }); - - test("notify with no listeners does not throw", () => { - const listeners = createListeners(); - expect(() => { - listeners.notify(); - }).not.toThrow(); - }); - - test("listener can unsubscribe itself during notify", () => { - const listeners = createListeners(); - const selfUnsubscribing = vi.fn(); - const listener = vi.fn(); - - const unsubscribe = listeners.subscribe(() => { - selfUnsubscribing(); - unsubscribe(); - }); - listeners.subscribe(listener); - - listeners.notify(); - expect(selfUnsubscribing).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledOnce(); - - listeners.notify(); - expect(selfUnsubscribing).toHaveBeenCalledOnce(); // still once, was removed - expect(listener).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/common/test/Ref.test.ts b/packages/common/test/Ref.test.ts index 76a476762..cdf4158f9 100644 --- a/packages/common/test/Ref.test.ts +++ b/packages/common/test/Ref.test.ts @@ -2,20 +2,20 @@ import { describe, expect, test } from "vitest"; import { createRef } from "../src/Ref.js"; describe("get", () => { - test("get returns initial state", () => { + test("get returns initial value", () => { const ref = createRef(42); expect(ref.get()).toBe(42); }); }); describe("set", () => { - test("updates state", () => { + test("updates value", () => { const ref = createRef(0); ref.set(1); expect(ref.get()).toBe(1); }); - test("always assigns the provided state", () => { + test("always assigns the provided value", () => { const ref = createRef(1); ref.set(1); expect(ref.get()).toBe(1); @@ -23,14 +23,14 @@ describe("set", () => { }); describe("getAndSet", () => { - test("returns previous state and updates state", () => { + test("returns previous value and updates value", () => { const ref = createRef(1); expect(ref.getAndSet(2)).toBe(1); expect(ref.get()).toBe(2); }); - test("returns current state without updating when next state is equal", () => { + test("returns current value without updating when next value is equal", () => { const ref = createRef(1); expect(ref.getAndSet(1)).toBe(1); @@ -39,21 +39,21 @@ describe("getAndSet", () => { }); describe("setAndGet", () => { - test("returns updated state", () => { + test("returns updated value", () => { const ref = createRef(1); expect(ref.setAndGet(2)).toBe(2); expect(ref.get()).toBe(2); }); - test("returns current state when next state is equal", () => { + test("returns current value when next value is equal", () => { const ref = createRef(1); expect(ref.setAndGet(1)).toBe(1); expect(ref.get()).toBe(1); }); - test("assigns the provided state", () => { + test("assigns the provided value", () => { const ref = createRef(5); expect(ref.setAndGet(5)).toBe(5); @@ -65,7 +65,7 @@ describe("setAndGet", () => { }); describe("update", () => { - test("updates state", () => { + test("updates value", () => { const ref = createRef(1); ref.update((n) => n + 1); @@ -73,7 +73,7 @@ describe("update", () => { expect(ref.get()).toBe(2); }); - test("can keep the same state", () => { + test("can keep the same value", () => { const ref = createRef(1); ref.update((n) => n); @@ -83,14 +83,14 @@ describe("update", () => { }); describe("getAndUpdate", () => { - test("returns previous state and updates state", () => { + test("returns previous value and updates value", () => { const ref = createRef(1); expect(ref.getAndUpdate((n) => n + 1)).toBe(1); expect(ref.get()).toBe(2); }); - test("returns current state without updating when next state is equal", () => { + test("returns current value without updating when next value is equal", () => { const ref = createRef(1); expect(ref.getAndUpdate((n) => n)).toBe(1); @@ -99,14 +99,14 @@ describe("getAndUpdate", () => { }); describe("updateAndGet", () => { - test("returns updated state", () => { + test("returns updated value", () => { const ref = createRef(1); expect(ref.updateAndGet((n) => n + 1)).toBe(2); expect(ref.get()).toBe(2); }); - test("returns current state when next state is equal", () => { + test("returns current value when next value is equal", () => { const ref = createRef(1); expect(ref.updateAndGet((n) => n)).toBe(1); @@ -115,7 +115,7 @@ describe("updateAndGet", () => { }); describe("modify", () => { - test("returns a computed result and updates state", () => { + test("returns a computed result and updates value", () => { const ref = createRef(0); const result = ref.modify((current) => [current, current + 1]); @@ -123,7 +123,7 @@ describe("modify", () => { expect(ref.get()).toBe(1); }); - test("can keep the same state while returning a result", () => { + test("can keep the same value while returning a result", () => { const ref = createRef(1); const result = ref.modify((current) => [`current:${current}`, current]); diff --git a/packages/common/test/Resource.test.ts b/packages/common/test/Resource.test.ts new file mode 100644 index 000000000..0c1dfc7b9 --- /dev/null +++ b/packages/common/test/Resource.test.ts @@ -0,0 +1,1730 @@ +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + createResourceRef, + createSharedResource, + createSharedResourceByKey, + type Resource, + type ResourceRef, + type SharedResource, + type SharedResourceByKey, +} from "../src/Resource.js"; +import { type AnyResult, err, ok, type Result } from "../src/Result.js"; +import type { StructuralKey } from "../src/StructuralMap.js"; +import { type AbortError, runStoppedError, type Task } from "../src/Task.js"; +import { testCreateRun, testWaitForMacrotask } from "../src/Test.js"; +import { type Duration, testCreateTime } from "../src/Time.js"; +import type { NonNegativeInt } from "../src/Type.js"; + +type TestResource = { + readonly id: string; + readonly isDisposed: () => boolean; +} & Resource; + +type DisposeKind = "sync" | "async"; + +const testCreateResource = + (disposeKind: DisposeKind) => + (id: string): Task => { + let disposed = false; + + return () => + ok({ + id, + isDisposed: () => disposed, + ...(disposeKind === "sync" + ? { + [Symbol.dispose]: () => { + disposed = true; + }, + } + : { + [Symbol.asyncDispose]: async () => { + await testWaitForMacrotask(); + disposed = true; + }, + }), + }); + }; + +const expectRunStopped = async (result: PromiseLike) => { + expect(await result).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); +}; + +const createThrowingResource = ( + disposeKind: DisposeKind, + disposeError: Error, +): Task => { + let disposed = false; + + return () => + ok({ + id: "resource-1", + isDisposed: () => disposed, + ...(disposeKind === "sync" + ? { + [Symbol.dispose]: () => { + disposed = true; + throw disposeError; + }, + } + : { + [Symbol.asyncDispose]: async () => { + await testWaitForMacrotask(); + disposed = true; + throw disposeError; + }, + }), + }); +}; + +describe("createResourceRef", () => { + const createInitializedResourceRef = + ( + createResource: (id: string) => Task, + id = "resource-1", + ): Task< + { + readonly resource: TestResource; + readonly resourceRef: ResourceRef; + }, + never, + D + > => + async (run) => { + const resource = await run.orThrow(createResource(id)); + const resourceRef = await run.orThrow( + createResourceRef(() => ok(resource)), + ); + return ok({ resource, resourceRef }); + }; + + test("types require non-failing create Tasks", () => { + expectTypeOf().toEqualTypeOf< + ( + create: Task, + ) => Task, never, D> + >(); + + expectTypeOf["set"]>().toEqualTypeOf< + (create: Task) => Task + >(); + }); + + test("create returns AbortError on Run disposal", async () => { + const run = testCreateRun(); + + const createStarted = Promise.withResolvers(); + const createFiber = run( + createResourceRef( + (run) => + new Promise>((resolve) => { + createStarted.resolve(); + run.onAbort((reason) => { + resolve(err({ type: "AbortError", reason })); + }); + }), + ), + ); + + await createStarted.promise; + await run[Symbol.asyncDispose](); + + await expectRunStopped(createFiber); + expect(run.getChildren().size).toBe(0); + }); + + for (const { label, disposeKind, createResource } of [ + { + label: "with sync dispose", + disposeKind: "sync", + createResource: testCreateResource("sync"), + }, + { + label: "with async dispose", + disposeKind: "async", + createResource: testCreateResource("async"), + }, + ] as const) { + describe(label, () => { + test("get returns the initial borrowed resource", async () => { + await using run = testCreateRun(); + + await using resourceRef = await run.orThrow( + createResourceRef(createResource("resource-1")), + ); + + const current = await run.orThrow(resourceRef.get); + + expectTypeOf(current).toEqualTypeOf< + Omit + >(); + + expect(current.id).toBe("resource-1"); + expect(current.isDisposed()).toBe(false); + }); + + test("create completes despite caller abort", async () => { + await using run = testCreateRun(); + + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + const resource = await run.orThrow(createResource("resource-1")); + const createFiber = run( + createResourceRef(async () => { + createStarted.resolve(); + await createCanFinish.promise; + return ok(resource); + }), + ); + + await createStarted.promise; + createFiber.abort("stop"); + createCanFinish.resolve(); + + const resourceRefResult = await createFiber; + + expect(resourceRefResult.ok).toBe(true); + if (!resourceRefResult.ok) return; + + await using resourceRef = resourceRefResult.value; + expect(await run.orThrow(resourceRef.get)).toBe(resource); + expect(resource.isDisposed()).toBe(false); + }); + + test("create aborts on a stopped root Run", async () => { + const run = testCreateRun(); + await run[Symbol.asyncDispose](); + + await expectRunStopped( + run(createResourceRef(createResource("resource-1"))), + ); + }); + + test("set sets the next resource", async () => { + await using run = testCreateRun(); + + const initialResource = await run.orThrow(createResource("resource-0")); + await using resourceRef = await run.orThrow( + createResourceRef(() => ok(initialResource)), + ); + + const resource = await run.orThrow(createResource("resource-1")); + + await run.orThrow(resourceRef.set(() => ok(resource))); + const current = await run.orThrow(resourceRef.get); + + expect(initialResource.isDisposed()).toBe(true); + expect(current.id).toBe(resource.id); + expect(resource.isDisposed()).toBe(false); + }); + + test("set completes replacement despite abort", async () => { + await using run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + await using _resourceRef = resourceRef; + + const acquisitionStarted = Promise.withResolvers(); + const next = await run.orThrow(createResource("resource-2")); + const acquisitionCanFinish = Promise.withResolvers(); + const setFiber = run( + resourceRef.set(async () => { + acquisitionStarted.resolve(); + await acquisitionCanFinish.promise; + return ok(next); + }), + ); + + await acquisitionStarted.promise; + expect(resource.isDisposed()).toBe(true); + + setFiber.abort("stop"); + acquisitionCanFinish.resolve(); + + expect(await setFiber).toEqual(ok()); + expect(await run.orThrow(resourceRef.get)).toBe(next); + expect(resource.isDisposed()).toBe(true); + expect(next.isDisposed()).toBe(false); + }); + + test("get waits for an in-flight set and observes the next resource", async () => { + await using run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + await using _resourceRef = resourceRef; + + const next = await run.orThrow(createResource("resource-2")); + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + const setFiber = run( + resourceRef.set(async () => { + createStarted.resolve(); + await createCanFinish.promise; + return ok(next); + }), + ); + + await createStarted.promise; + expect(resource.isDisposed()).toBe(true); + + let getResolved = false; + const getFiber = run(resourceRef.get).then((result) => { + getResolved = true; + return result; + }); + + await testWaitForMacrotask(); + expect(getResolved).toBe(false); + + createCanFinish.resolve(); + + expect(await setFiber).toEqual(ok()); + expect(await getFiber).toEqual(ok(next)); + }); + + test("set aborts before installing the next resource when the ref is disposed", async () => { + await using run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + await using next = await run.orThrow(createResource("resource-2")); + const setFiber = run( + resourceRef.set(async () => { + createStarted.resolve(); + await createCanFinish.promise; + return ok(next); + }), + ); + + await createStarted.promise; + const disposeRefPromise = resourceRef[Symbol.asyncDispose](); + createCanFinish.resolve(); + + await expectRunStopped(setFiber); + await disposeRefPromise; + + expect(resource.isDisposed()).toBe(true); + expect(next.isDisposed()).toBe(false); + }); + + test("dispose disposes the current resource", async () => { + await using run = testCreateRun(); + + const current = await run.orThrow(createResource("resource-1")); + const resourceRef = await run.orThrow( + createResourceRef(() => ok(current)), + ); + + await resourceRef[Symbol.asyncDispose](); + await resourceRef[Symbol.asyncDispose](); + + expect(current.isDisposed()).toBe(true); + }); + + test("dispose aborts later operations", async () => { + await using run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + + await resourceRef[Symbol.asyncDispose](); + + expect(resource.isDisposed()).toBe(true); + await expectRunStopped(run(resourceRef.get)); + + await using next = await run.orThrow(createResource("resource-2")); + + await expectRunStopped(run(resourceRef.set(() => ok(next)))); + expect(next.isDisposed()).toBe(false); + }); + + test("root Run disposal aborts later operations until the ref is disposed", async () => { + const run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + + await run[Symbol.asyncDispose](); + + expect(resource.isDisposed()).toBe(false); + + await using checkRun = testCreateRun(); + await expectRunStopped(checkRun(resourceRef.get)); + + await using next = await checkRun.orThrow(createResource("resource-2")); + await expectRunStopped(checkRun(resourceRef.set(() => ok(next)))); + expect(next.isDisposed()).toBe(false); + + await resourceRef[Symbol.asyncDispose](); + expect(resource.isDisposed()).toBe(true); + }); + + test("root Run disposal aborts set after current is disposed", async () => { + const run = testCreateRun(); + + const { resource, resourceRef } = await run.orThrow( + createInitializedResourceRef(createResource), + ); + + const createStarted = Promise.withResolvers(); + const setFiber = run( + resourceRef.set( + (run) => + new Promise>((resolve) => { + createStarted.resolve(); + run.onAbort((reason) => { + resolve(err({ type: "AbortError", reason })); + }); + }), + ), + ); + + await createStarted.promise; + expect(resource.isDisposed()).toBe(true); + + await run[Symbol.asyncDispose](); + + await expectRunStopped(setFiber); + + await using checkRun = testCreateRun(); + await expectRunStopped(checkRun(resourceRef.get)); + }); + + test("dispose still aborts later operations when current disposal throws", async () => { + await using run = testCreateRun(); + + const disposeError = new Error("dispose failed"); + const resourceRef = await run.orThrow( + createResourceRef(createThrowingResource(disposeKind, disposeError)), + ); + + await expect(resourceRef[Symbol.asyncDispose]()).rejects.toBe( + disposeError, + ); + + await expectRunStopped(run(resourceRef.get)); + }); + }); + } +}); + +describe("createSharedResource", () => { + const createInitializedSharedResource = + ( + createResource: (id: string) => Task, + id = "resource-1", + ): Task< + { + readonly resource: TestResource; + readonly sharedResource: SharedResource; + }, + never, + D + > => + async (run) => { + const resource = await run.orThrow(createResource(id)); + const sharedResource = await run.orThrow( + createSharedResource(() => ok(resource)), + ); + return ok({ resource, sharedResource }); + }; + + test("types require non-failing create Tasks", () => { + const _createSharedResource: ( + create: Task, + options?: { + idleDisposeAfter?: Duration; + onDisposed?: () => void; + }, + ) => Task, never, D> = createSharedResource; + + expectTypeOf(_createSharedResource).toBeFunction(); + + expectTypeOf["acquire"]>().toEqualTypeOf< + Task< + Omit + > + >(); + + expectTypeOf["release"]>().toEqualTypeOf< + Task + >(); + + expectTypeOf["getCount"]>().toEqualTypeOf< + Task + >(); + }); + + test("create aborts on a stopped root Run", async () => { + const run = testCreateRun(); + await run[Symbol.asyncDispose](); + + await expectRunStopped( + run(createSharedResource(testCreateResource("sync")("r1"))), + ); + }); + + test("acquire aborts before publishing a resource when the shared resource is disposed", async () => { + await using run = testCreateRun(); + + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + await using resource = await run.orThrow(testCreateResource("sync")("r1")); + const sharedResource = await run.orThrow( + createSharedResource( + (run) => + new Promise>((resolve) => { + createStarted.resolve(); + run.onAbort((reason) => { + resolve(err({ type: "AbortError", reason })); + }); + + void createCanFinish.promise.then(() => { + resolve(ok(resource)); + }); + }), + ), + ); + + const acquireFiber = run(sharedResource.acquire); + + await createStarted.promise; + const disposePromise = sharedResource[Symbol.asyncDispose](); + createCanFinish.resolve(); + + await expectRunStopped(acquireFiber); + await disposePromise; + + expect(resource.isDisposed()).toBe(false); + }); + + test("onDisposed is called after current disposal completes", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const events: Array = []; + const resource = await run.orThrow(testCreateResource("sync")("r1")); + await using sharedResource = await run.orThrow( + createSharedResource(() => ok(resource), { + idleDisposeAfter: "10ms", + onDisposed: () => { + events.push(resource.isDisposed() ? "disposed" : "not-disposed"); + }, + }), + ); + + await run.orThrow(sharedResource.acquire); + await run.orThrow(sharedResource.release); + + expect(events).toEqual([]); + + time.advance("10ms"); + await testWaitForMacrotask(); + + expect(events).toEqual(["disposed"]); + }); + + for (const { label, disposeKind, createResource } of [ + { + label: "with sync dispose", + disposeKind: "sync", + createResource: testCreateResource("sync"), + }, + { + label: "with async dispose", + disposeKind: "async", + createResource: testCreateResource("async"), + }, + ] as const) { + describe(label, () => { + test("acquire lazily creates the resource and increments count", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + await using sharedResource = await run.orThrow( + createSharedResource(async (run) => { + createCallCount += 1; + return run(createResource("resource-1")); + }), + ); + + expect(await run.orThrow(sharedResource.getCount)).toBe(0); + + const resource = await run.orThrow(sharedResource.acquire); + + expect(resource.id).toBe("resource-1"); + expect(resource.isDisposed()).toBe(false); + expect(createCallCount).toBe(1); + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + }); + + test("acquire reuses the current resource across callers", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + await using sharedResource = await run.orThrow( + createSharedResource(async (run) => { + createCallCount += 1; + return run(createResource("resource-1")); + }), + ); + + const first = await run.orThrow(sharedResource.acquire); + const second = await run.orThrow(sharedResource.acquire); + + expect(first).toBe(second); + expect(createCallCount).toBe(1); + expect(await run.orThrow(sharedResource.getCount)).toBe(2); + }); + + test("concurrent first acquires share one resource creation", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + await using sharedResource = await run.orThrow( + createSharedResource(async (run) => { + createCallCount += 1; + createStarted.resolve(); + await createCanFinish.promise; + return run(createResource("resource-1")); + }), + ); + + let firstResolved = false; + const firstAcquire = run(sharedResource.acquire).then((result) => { + firstResolved = true; + return result; + }); + + await createStarted.promise; + + let secondResolved = false; + const secondAcquire = run(sharedResource.acquire).then((result) => { + secondResolved = true; + return result; + }); + + await testWaitForMacrotask(); + expect(firstResolved).toBe(false); + expect(secondResolved).toBe(false); + expect(createCallCount).toBe(1); + + createCanFinish.resolve(); + + const first = await run.orThrow(() => firstAcquire); + const second = await run.orThrow(() => secondAcquire); + + expect(first).toBe(second); + expect(createCallCount).toBe(1); + expect(await run.orThrow(sharedResource.getCount)).toBe(2); + }); + + test("release decrements count and disposes on the last release", async () => { + await using run = testCreateRun(); + + const { resource, sharedResource } = await run.orThrow( + createInitializedSharedResource(createResource), + ); + await using _sharedResource = sharedResource; + + const first = await run.orThrow(sharedResource.acquire); + const second = await run.orThrow(sharedResource.acquire); + + expect(first).toBe(second); + expect(resource.isDisposed()).toBe(false); + + await run.orThrow(sharedResource.release); + + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + expect(resource.isDisposed()).toBe(false); + + await run.orThrow(sharedResource.release); + + expect(await run.orThrow(sharedResource.getCount)).toBe(0); + expect(resource.isDisposed()).toBe(true); + }); + + test("acquire creates a fresh resource after the last release", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + await using sharedResource = await run.orThrow( + createSharedResource(async (run) => { + createCallCount += 1; + return run(createResource(`resource-${createCallCount}`)); + }), + ); + + const first = await run.orThrow(sharedResource.acquire); + expect(first.id).toBe("resource-1"); + + await run.orThrow(sharedResource.release); + + expect(first.isDisposed()).toBe(true); + expect(await run.orThrow(sharedResource.getCount)).toBe(0); + + const second = await run.orThrow(sharedResource.acquire); + + expect(second).not.toBe(first); + expect(second.id).toBe("resource-2"); + expect(second.isDisposed()).toBe(false); + expect(createCallCount).toBe(2); + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + }); + + test("release disposes after idleDisposeAfter elapses", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const resource = await run.orThrow(createResource("resource-1")); + await using sharedResource = await run.orThrow( + createSharedResource(() => ok(resource), { + idleDisposeAfter: "10ms", + }), + ); + + await run.orThrow(sharedResource.acquire); + await run.orThrow(sharedResource.release); + + expect(await run.orThrow(sharedResource.getCount)).toBe(0); + expect(resource.isDisposed()).toBe(false); + + time.advance("9ms"); + await testWaitForMacrotask(); + expect(resource.isDisposed()).toBe(false); + + time.advance("1ms"); + await testWaitForMacrotask(); + await testWaitForMacrotask(); + expect(resource.isDisposed()).toBe(true); + }); + + test("acquire cancels pending idle disposal and reuses the current resource", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + let createCallCount = 0; + await using sharedResource = await run.orThrow( + createSharedResource( + async (run) => { + createCallCount += 1; + return run(createResource(`resource-${createCallCount}`)); + }, + { + idleDisposeAfter: "10ms", + }, + ), + ); + + const first = await run.orThrow(sharedResource.acquire); + await run.orThrow(sharedResource.release); + + time.advance("9ms"); + const second = await run.orThrow(sharedResource.acquire); + + expect(second).toBe(first); + expect(first.isDisposed()).toBe(false); + expect(createCallCount).toBe(1); + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + + time.advance("10ms"); + await testWaitForMacrotask(); + expect(first.isDisposed()).toBe(false); + + await run.orThrow(sharedResource.release); + time.advance("10ms"); + await testWaitForMacrotask(); + await testWaitForMacrotask(); + expect(first.isDisposed()).toBe(true); + }); + + test("acquire after timeout fires cancels the stale idle disposal", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + let createCallCount = 0; + await using sharedResource = await run.orThrow( + createSharedResource( + async (run) => { + createCallCount += 1; + return run(createResource(`resource-${createCallCount}`)); + }, + { + idleDisposeAfter: "10ms", + }, + ), + ); + + const first = await run.orThrow(sharedResource.acquire); + await run.orThrow(sharedResource.release); + + time.advance("10ms"); + const second = await run.orThrow(sharedResource.acquire); + + expect(second).toBe(first); + expect(createCallCount).toBe(1); + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + + await testWaitForMacrotask(); + expect(first.isDisposed()).toBe(false); + }); + + test("dispose cancels pending idle disposal and disposes immediately", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const resource = await run.orThrow(createResource("resource-1")); + const sharedResource = await run.orThrow( + createSharedResource(() => ok(resource), { + idleDisposeAfter: "10ms", + }), + ); + + await run.orThrow(sharedResource.acquire); + await run.orThrow(sharedResource.release); + + expect(resource.isDisposed()).toBe(false); + + await sharedResource[Symbol.asyncDispose](); + expect(resource.isDisposed()).toBe(true); + + time.advance("10ms"); + await testWaitForMacrotask(); + expect(resource.isDisposed()).toBe(true); + }); + + test("acquire completes resource creation despite caller abort", async () => { + await using run = testCreateRun(); + + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + const resource = await run.orThrow(createResource("resource-1")); + await using sharedResource = await run.orThrow( + createSharedResource(async () => { + createStarted.resolve(); + await createCanFinish.promise; + return ok(resource); + }), + ); + + const acquireFiber = run(sharedResource.acquire); + + await createStarted.promise; + acquireFiber.abort("stop"); + createCanFinish.resolve(); + + expect(await acquireFiber).toEqual(ok(resource)); + expect(await run.orThrow(sharedResource.getCount)).toBe(1); + expect(resource.isDisposed()).toBe(false); + }); + + test("dispose disposes the current resource and aborts later operations", async () => { + await using run = testCreateRun(); + + const { resource, sharedResource } = await run.orThrow( + createInitializedSharedResource(createResource), + ); + + await run.orThrow(sharedResource.acquire); + await sharedResource[Symbol.asyncDispose](); + await sharedResource[Symbol.asyncDispose](); + + expect(resource.isDisposed()).toBe(true); + await expectRunStopped(run(sharedResource.acquire)); + await expectRunStopped(run(sharedResource.release)); + await expectRunStopped(run(sharedResource.getCount)); + }); + + test("dispose still aborts later operations when current disposal throws", async () => { + await using run = testCreateRun(); + + const disposeError = new Error("dispose failed"); + const sharedResource = await run.orThrow( + createSharedResource( + createThrowingResource(disposeKind, disposeError), + ), + ); + + await run.orThrow(sharedResource.acquire); + + await expect(sharedResource[Symbol.asyncDispose]()).rejects.toBe( + disposeError, + ); + + await expectRunStopped(run(sharedResource.acquire)); + await expectRunStopped(run(sharedResource.release)); + await expectRunStopped(run(sharedResource.getCount)); + }); + + test("root Run disposal aborts later operations until the shared resource is disposed", async () => { + const run = testCreateRun(); + + const { resource, sharedResource } = await run.orThrow( + createInitializedSharedResource(createResource), + ); + + await run.orThrow(sharedResource.acquire); + await run[Symbol.asyncDispose](); + + expect(resource.isDisposed()).toBe(false); + + await using checkRun = testCreateRun(); + await expectRunStopped(checkRun(sharedResource.acquire)); + await expectRunStopped(checkRun(sharedResource.release)); + await expectRunStopped(checkRun(sharedResource.getCount)); + + await sharedResource[Symbol.asyncDispose](); + expect(resource.isDisposed()).toBe(true); + }); + + test("release throws on over-release", async () => { + await using run = testCreateRun(); + + await using sharedResource = await run.orThrow( + createSharedResource(createResource("resource-1")), + ); + + await expect(run(sharedResource.release)).rejects.toThrow( + "Release must not be called more times than acquire.", + ); + }); + }); + } +}); + +describe("createSharedResourceByKey", () => { + test("types require non-failing keyed create Tasks", () => { + expectTypeOf().toEqualTypeOf< + ( + create: (key: K) => Task, + options?: { + readonly idleDisposeAfter?: Duration; + readonly onDisposed?: (key: K) => void; + }, + ) => Task, never, D> + >(); + + expectTypeOf< + SharedResourceByKey<"a", TestResource>["acquire"] + >().toEqualTypeOf< + ( + key: "a", + ) => Task< + Omit + > + >(); + + expectTypeOf< + SharedResourceByKey<"a", TestResource>["release"] + >().toEqualTypeOf<(key: "a") => Task>(); + + expectTypeOf< + SharedResourceByKey<"a", TestResource>["getCount"] + >().toEqualTypeOf<(key: "a") => Task>(); + }); + + test("create aborts on a stopped root Run", async () => { + const run = testCreateRun(); + await run[Symbol.asyncDispose](); + + await expectRunStopped( + run( + createSharedResourceByKey((key: string) => + testCreateResource("sync")(key), + ), + ), + ); + }); + + test("onDisposed is called with the key after keyed disposal completes", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const events: Array = []; + const resource = await run.orThrow(testCreateResource("sync")("a")); + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey( + (key: string) => { + expect(key).toBe("a"); + return () => ok(resource); + }, + { + idleDisposeAfter: "10ms", + onDisposed: (key) => { + events.push(resource.isDisposed() ? key : `not-disposed:${key}`); + }, + }, + ), + ); + + await run.orThrow(sharedResourceByKey.acquire("a")); + await run.orThrow(sharedResourceByKey.release("a")); + + expect(events).toEqual([]); + + time.advance("10ms"); + await testWaitForMacrotask(); + + expect(events).toEqual(["a"]); + }); + + test("structurally equal object keys share one resource", async () => { + await using run = testCreateRun(); + + const createCalls: Array = []; + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey( + (key: { readonly ownerId: string; readonly transport: string }) => { + createCalls.push(`${key.ownerId}:${key.transport}`); + return testCreateResource("sync")(`${key.ownerId}:${key.transport}`); + }, + ), + ); + + const first = await run.orThrow( + sharedResourceByKey.acquire({ ownerId: "a", transport: "ws" }), + ); + const second = await run.orThrow( + sharedResourceByKey.acquire({ transport: "ws", ownerId: "a" }), + ); + + expect(first).toBe(second); + expect(createCalls).toEqual(["a:ws"]); + expect( + await run.orThrow( + sharedResourceByKey.getCount({ ownerId: "a", transport: "ws" }), + ), + ).toBe(2); + }); + + test("structurally equal object keys release and dispose symmetrically", async () => { + await using run = testCreateRun(); + + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey( + (key: { readonly ownerId: string; readonly transport: string }) => + testCreateResource("sync")(`${key.ownerId}:${key.transport}`), + ), + ); + + const resource = await run.orThrow( + sharedResourceByKey.acquire({ ownerId: "a", transport: "ws" }), + ); + + await run.orThrow( + sharedResourceByKey.release({ transport: "ws", ownerId: "a" }), + ); + + expect(resource.isDisposed()).toBe(true); + expect( + await run.orThrow( + sharedResourceByKey.getCount({ ownerId: "a", transport: "ws" }), + ), + ).toBe(0); + }); + + for (const { label, disposeKind, createResource } of [ + { + label: "with sync dispose", + disposeKind: "sync", + createResource: testCreateResource("sync"), + }, + { + label: "with async dispose", + disposeKind: "async", + createResource: testCreateResource("async"), + }, + ] as const) { + describe(label, () => { + test("acquire creates once per key and reuses the current resource", async () => { + await using run = testCreateRun(); + + const createCalls: Array = []; + const createKeyedResource = + (key: string): Task => + async (run) => { + createCalls.push(key); + return run(createResource(key)); + }; + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource), + ); + + const first = await run.orThrow(sharedResourceByKey.acquire("a")); + const second = await run.orThrow(sharedResourceByKey.acquire("a")); + + expect(first).toBe(second); + expect(first.id).toBe("a"); + expect(createCalls).toEqual(["a"]); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(2); + expect(await run.orThrow(sharedResourceByKey.getCount("b"))).toBe(0); + }); + + test("different keys keep independent resources and counts", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + const createKeyedResource = + (key: string): Task => + async (run) => { + createCallCount += 1; + return run(createResource(`resource-${key}`)); + }; + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource), + ); + + const first = await run.orThrow(sharedResourceByKey.acquire("a")); + const second = await run.orThrow(sharedResourceByKey.acquire("b")); + + expect(first).not.toBe(second); + expect(first.id).toBe("resource-a"); + expect(second.id).toBe("resource-b"); + expect(createCallCount).toBe(2); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(1); + expect(await run.orThrow(sharedResourceByKey.getCount("b"))).toBe(1); + }); + + test("concurrent first acquires for the same key share one creation", async () => { + await using run = testCreateRun(); + + let createCallCount = 0; + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + const createKeyedResource = + (key: string): Task => + async (run) => { + createCallCount += 1; + createStarted.resolve(); + await createCanFinish.promise; + return run(createResource(key)); + }; + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource), + ); + + let firstResolved = false; + const firstAcquire = run(sharedResourceByKey.acquire("a")).then( + (result) => { + firstResolved = true; + return result; + }, + ); + + await createStarted.promise; + + let secondResolved = false; + const secondAcquire = run(sharedResourceByKey.acquire("a")).then( + (result) => { + secondResolved = true; + return result; + }, + ); + + await testWaitForMacrotask(); + expect(firstResolved).toBe(false); + expect(secondResolved).toBe(false); + expect(createCallCount).toBe(1); + + createCanFinish.resolve(); + + const first = await run.orThrow(() => firstAcquire); + const second = await run.orThrow(() => secondAcquire); + + expect(first).toBe(second); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(2); + }); + + test("release decrements count and disposes on the last release", async () => { + await using run = testCreateRun(); + + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey((key: string) => createResource(key)), + ); + + const resource = await run.orThrow(sharedResourceByKey.acquire("a")); + await run.orThrow(sharedResourceByKey.acquire("a")); + + await run.orThrow(sharedResourceByKey.release("a")); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(1); + expect(resource.isDisposed()).toBe(false); + + await run.orThrow(sharedResourceByKey.release("a")); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(0); + expect(resource.isDisposed()).toBe(true); + + const next = await run.orThrow(sharedResourceByKey.acquire("a")); + expect(next).not.toBe(resource); + }); + + test("release disposes after idleDisposeAfter elapses and reacquire cancels it", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + let createCallCount = 0; + const createKeyedResource = + (key: string): Task => + async (run) => { + createCallCount += 1; + return run(createResource(`${key}-${createCallCount}`)); + }; + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource, { + idleDisposeAfter: "10ms", + }), + ); + + const first = await run.orThrow(sharedResourceByKey.acquire("a")); + await run.orThrow(sharedResourceByKey.release("a")); + + time.advance("9ms"); + const second = await run.orThrow(sharedResourceByKey.acquire("a")); + + expect(second).toBe(first); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(1); + + await run.orThrow(sharedResourceByKey.release("a")); + time.advance("10ms"); + await testWaitForMacrotask(); + await testWaitForMacrotask(); + + expect(first.isDisposed()).toBe(true); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(0); + + const third = await run.orThrow(sharedResourceByKey.acquire("a")); + expect(third).not.toBe(first); + expect(createCallCount).toBe(2); + }); + + test("reacquire during async disposal keeps the keyed entry tracked", async () => { + if (disposeKind !== "async") return; + + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const finishDispose = Promise.withResolvers(); + let createCallCount = 0; + + const createKeyedResource = + (key: string): Task => + () => + ok({ + id: `${key}-${++createCallCount}`, + isDisposed: () => false, + [Symbol.asyncDispose]: async () => { + await finishDispose.promise; + }, + }); + + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource, { + idleDisposeAfter: "1ms", + }), + ); + + const first = await run.orThrow(sharedResourceByKey.acquire("a")); + await run.orThrow(sharedResourceByKey.release("a")); + time.advance("1ms"); + await testWaitForMacrotask(); + + const reacquire = run(sharedResourceByKey.acquire("a")); + await testWaitForMacrotask(); + finishDispose.resolve(); + + const secondResult = await reacquire; + expect(secondResult.ok).toBe(true); + if (!secondResult.ok) return; + + expect(secondResult.value.id).toBe("a-2"); + expect(secondResult.value).not.toBe(first); + expect(await run.orThrow(sharedResourceByKey.getCount("a"))).toBe(1); + await run.orThrow(sharedResourceByKey.release("a")); + }); + + test("dispose aborts later operations", async () => { + await using run = testCreateRun(); + + const createStarted = Promise.withResolvers(); + const createCanFinish = Promise.withResolvers(); + await using resource = await run.orThrow(createResource("a")); + const createKeyedResource = (): Task => (run) => + new Promise>((resolve) => { + createStarted.resolve(); + run.onAbort((reason) => { + resolve(err({ type: "AbortError", reason })); + }); + + void createCanFinish.promise.then(() => { + resolve(ok(resource)); + }); + }); + const sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(createKeyedResource), + ); + + const acquireFiber = run(sharedResourceByKey.acquire("a")); + + await createStarted.promise; + const disposePromise = sharedResourceByKey[Symbol.asyncDispose](); + createCanFinish.resolve(); + + await expectRunStopped(acquireFiber); + await disposePromise; + + await expectRunStopped(run(sharedResourceByKey.acquire("a"))); + await expectRunStopped(run(sharedResourceByKey.release("a"))); + await expectRunStopped(run(sharedResourceByKey.getCount("a"))); + }); + + test("root Run disposal aborts later operations until the keyed resource is disposed", async () => { + const run = testCreateRun(); + + const sharedResourceByKey = await run.orThrow( + createSharedResourceByKey((key: string) => createResource(key)), + ); + const resource = await run.orThrow(sharedResourceByKey.acquire("a")); + + await run[Symbol.asyncDispose](); + + expect(resource.isDisposed()).toBe(false); + + await using checkRun = testCreateRun(); + await expectRunStopped(checkRun(sharedResourceByKey.acquire("a"))); + await expectRunStopped(checkRun(sharedResourceByKey.release("a"))); + await expectRunStopped(checkRun(sharedResourceByKey.getCount("a"))); + + await sharedResourceByKey[Symbol.asyncDispose](); + expect(resource.isDisposed()).toBe(true); + }); + + test("release throws on over-release", async () => { + await using run = testCreateRun(); + + await using sharedResourceByKey = await run.orThrow( + createSharedResourceByKey((key: string) => createResource(key)), + ); + + await expect(run(sharedResourceByKey.release("a"))).rejects.toThrow( + "Release must not be called more times than acquire.", + ); + }); + + test("dispose still aborts later operations when current disposal throws", async () => { + await using run = testCreateRun(); + + const disposeError = new Error("dispose failed"); + const sharedResourceByKey = await run.orThrow( + createSharedResourceByKey(() => + createThrowingResource(disposeKind, disposeError), + ), + ); + + await run.orThrow(sharedResourceByKey.acquire("a")); + + await expect(sharedResourceByKey[Symbol.asyncDispose]()).rejects.toBe( + disposeError, + ); + + await expectRunStopped(run(sharedResourceByKey.acquire("a"))); + }); + + test("dispose still attempts later keyed disposals when one throws", async () => { + await using run = testCreateRun(); + + const disposeError = new Error("dispose failed"); + let secondDisposed = false; + const sharedResourceByKey = await run.orThrow( + createSharedResourceByKey((key: string): Task => { + if (key === "a") { + return createThrowingResource(disposeKind, disposeError); + } + + return () => + ok({ + id: key, + isDisposed: () => secondDisposed, + ...(disposeKind === "sync" + ? { + [Symbol.dispose]: () => { + secondDisposed = true; + }, + } + : { + [Symbol.asyncDispose]: async () => { + await testWaitForMacrotask(); + secondDisposed = true; + }, + }), + }); + }), + ); + + await run.orThrow(sharedResourceByKey.acquire("a")); + await run.orThrow(sharedResourceByKey.acquire("b")); + + await expect(sharedResourceByKey[Symbol.asyncDispose]()).rejects.toBe( + disposeError, + ); + expect(secondDisposed).toBe(true); + }); + }); + } +}); + +// type ConsumerId = string & Brand<"ConsumerId">; + +// interface _ResourceConfig { +// readonly key: ResourceKey; +// } + +// interface _Consumer { +// readonly id: ConsumerId; +// } + +// test("addConsumer creates resource and indexes consumer-resource relation", async () => { +// await using run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer: Consumer = { id: "consumer-1" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" }; + +// await run(resources.addConsumer(consumer, [resourceConfig])); + +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set([consumer.id]), +// ); + +// const resourcesForConsumer = resources.getResourcesForConsumerId(consumer.id); +// expect(resourcesForConsumer.size).toBe(1); + +// const [resource] = Array.from(resourcesForConsumer); +// expect(resource.id).toBe(resourceConfig.key); + +// await run(resources.removeConsumer(consumer, [resourceConfig])); +// }); + +// test("addConsumer reuses existing resource for the same key", async () => { +// await using run = testCreateRun(); + +// let createResourceCallCount = 0; +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => { +// createResourceCallCount += 1; +// return testCreateResource(resourceConfig.key); +// }, +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer1: Consumer = { id: "consumer-1" as ConsumerId }; +// const consumer2: Consumer = { id: "consumer-2" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// await run(resources.addConsumer(consumer1, [resourceConfig])); +// await run(resources.addConsumer(consumer2, [resourceConfig])); + +// expect(createResourceCallCount).toBe(1); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set([consumer1.id, consumer2.id]), +// ); +// }); + +// test("lookups return empty sets for unknown keys", async () => { +// await using _run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// expect( +// resources.getConsumerIdsForResource("resource-missing" as ResourceKey), +// ).toEqual(new Set()); +// expect( +// resources.getResourcesForConsumerId("consumer-missing" as ConsumerId), +// ).toEqual(new Set()); +// }); + +// test("removeConsumer disposes resource when last consumer is removed", async () => { +// await using run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer: Consumer = { id: "consumer-1" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// await run(resources.addConsumer(consumer, [resourceConfig])); +// const [resource] = Array.from( +// resources.getResourcesForConsumerId(consumer.id), +// ); + +// await run(resources.removeConsumer(consumer, [resourceConfig])); + +// expect(resource.isDisposed()).toBe(true); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set(), +// ); +// expect(resources.getResourcesForConsumerId(consumer.id)).toEqual(new Set()); +// }); + +// test("removeConsumer decrements reference count for repeated addConsumer", async () => { +// await using run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer: Consumer = { id: "consumer-1" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// await run(resources.addConsumer(consumer, [resourceConfig])); +// await run(resources.addConsumer(consumer, [resourceConfig])); + +// const [resource] = Array.from( +// resources.getResourcesForConsumerId(consumer.id), +// ); + +// await run(resources.removeConsumer(consumer, [resourceConfig])); +// expect(resource.isDisposed()).toBe(false); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set([consumer.id]), +// ); + +// await run(resources.removeConsumer(consumer, [resourceConfig])); +// expect(resource.isDisposed()).toBe(true); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set(), +// ); +// expect(resources.getResourcesForConsumerId(consumer.id)).toEqual(new Set()); +// }); + +// test("removeConsumer is no-op for unknown resource and unknown consumer", async () => { +// await using run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer1: Consumer = { id: "consumer-1" as ConsumerId }; +// const consumer2: Consumer = { id: "consumer-2" as ConsumerId }; +// const existingResourceConfig: ResourceConfig = { +// key: "resource-1" as ResourceKey, +// }; +// const missingResourceConfig: ResourceConfig = { +// key: "resource-missing" as ResourceKey, +// }; + +// await run(resources.addConsumer(consumer1, [existingResourceConfig])); + +// await run(resources.removeConsumer(consumer1, [missingResourceConfig])); +// await run(resources.removeConsumer(consumer2, [existingResourceConfig])); + +// expect( +// resources.getConsumerIdsForResource(existingResourceConfig.key), +// ).toEqual(new Set([consumer1.id])); +// expect(resources.getResourcesForConsumerId(consumer1.id).size).toBe(1); +// }); + +// test("removeConsumer preserves symmetry when ref counts are already cleared", async () => { +// await using run = testCreateRun(); + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => testCreateResource(resourceConfig.key), +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer: Consumer = { id: "consumer-1" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// await run(resources.addConsumer(consumer, [resourceConfig])); +// await run(resources.removeConsumer(consumer, [resourceConfig])); + +// // First removal disposes and clears ref counts; mutex instance remains cached. +// await run(resources.removeConsumer(consumer, [resourceConfig])); + +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set(), +// ); +// expect(resources.getResourcesForConsumerId(consumer.id)).toEqual(new Set()); +// }); + +// test("concurrent add/remove on same resource is serialized", async () => { +// await using run = testCreateRun(); + +// const onCreateRelease = Promise.withResolvers(); +// let createResourceCallCount = 0; +// let disposeCallCount = 0; + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: async (resourceConfig) => { +// createResourceCallCount += 1; +// await onCreateRelease.promise; + +// let disposed = false; +// return { +// id: resourceConfig.key, +// isDisposed: () => disposed, +// [Symbol.dispose]: () => { +// disposed = true; +// disposeCallCount += 1; +// }, +// }; +// }, +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer1: Consumer = { id: "consumer-1" as ConsumerId }; +// const consumer2: Consumer = { id: "consumer-2" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// const add1 = run(resources.addConsumer(consumer1, [resourceConfig])); +// const add2 = run(resources.addConsumer(consumer2, [resourceConfig])); + +// onCreateRelease.resolve(); + +// await Promise.all([add1, add2]); + +// expect(createResourceCallCount).toBe(1); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set([consumer1.id, consumer2.id]), +// ); + +// const remove1 = run(resources.removeConsumer(consumer1, [resourceConfig])); +// const remove2 = run(resources.removeConsumer(consumer2, [resourceConfig])); +// await Promise.all([remove1, remove2]); + +// expect(disposeCallCount).toBe(1); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set(), +// ); +// }); + +// test("queued addConsumer during last removeConsumer does not fail", async () => { +// await using run = testCreateRun(); + +// let createResourceCallCount = 0; +// let disposeCallCount = 0; + +// await using resources = createResources< +// TestResource, +// ResourceKey, +// ResourceConfig, +// Consumer, +// ConsumerId +// >({ +// createResource: (resourceConfig) => { +// createResourceCallCount += 1; +// let disposed = false; + +// return Promise.resolve({ +// id: resourceConfig.key, +// isDisposed: () => disposed, +// [Symbol.dispose]: () => { +// disposeCallCount += 1; +// disposed = true; +// }, +// }); +// }, +// getResourceId: (resourceConfig) => resourceConfig.key, +// getConsumerId: (consumer) => consumer.id, +// }); + +// const consumer1: Consumer = { id: "consumer-1" as ConsumerId }; +// const consumer2: Consumer = { id: "consumer-2" as ConsumerId }; +// const resourceConfig: ResourceConfig = { key: "resource-1" as ResourceKey }; + +// await run(resources.addConsumer(consumer1, [resourceConfig])); + +// const remove = run(resources.removeConsumer(consumer1, [resourceConfig])); +// const queuedAdd = run(resources.addConsumer(consumer2, [resourceConfig])); + +// await remove; +// await queuedAdd; + +// expect(createResourceCallCount).toBe(2); +// expect(disposeCallCount).toBe(1); +// expect(resources.getConsumerIdsForResource(resourceConfig.key)).toEqual( +// new Set([consumer2.id]), +// ); + +// await run(resources.removeConsumer(consumer2, [resourceConfig])); +// expect(disposeCallCount).toBe(2); +// }); diff --git a/packages/common/test/Resources.test.ts b/packages/common/test/Resources.test.ts deleted file mode 100644 index 7863752b0..000000000 --- a/packages/common/test/Resources.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { expect, test } from "vitest"; -import type { Brand } from "../src/Brand.js"; -import { createResources } from "../src/Resources.js"; -import { err } from "../src/Result.js"; -import { type Duration, testCreateTime } from "../src/Time.js"; - -interface Resource extends Disposable { - readonly id: ResourceKey; - readonly disposed: boolean; -} - -type ResourceKey = string & Brand<"ResourceKey">; - -interface ResourceConfig { - readonly key: ResourceKey; -} - -interface Consumer { - readonly id: ConsumerId; - readonly name: string; -} - -type ConsumerId = string & Brand<"ConsumerId">; - -const createResource = (config: ResourceConfig): Resource => { - let disposed = false; - return { - id: config.key, - get disposed() { - return disposed; - }, - [Symbol.dispose]() { - disposed = true; - }, - }; -}; - -const createTestResources = (disposalDelay: Duration = "10ms") => { - const time = testCreateTime(); - const resources = createResources< - Resource, - ResourceKey, - ResourceConfig, - Consumer, - ConsumerId - >({ time })({ - createResource, - getResourceKey: (config) => config.key, - getConsumerId: (consumer) => consumer.id, - disposalDelay, - }); - - return { resources, time }; -}; - -const consumer1: Consumer = { id: "consumer1" as ConsumerId, name: "Alice" }; -const consumer2: Consumer = { id: "consumer2" as ConsumerId, name: "Bob" }; - -const resourceConfig1: ResourceConfig = { key: "resource1" as ResourceKey }; -const resourceConfig2: ResourceConfig = { key: "resource2" as ResourceKey }; -const resourceConfig3: ResourceConfig = { key: "resource3" as ResourceKey }; - -test("creates resources on demand when adding consumers", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1, resourceConfig2]); - - expect(resources.getResource(resourceConfig1.key)?.id).toBe( - resourceConfig1.key, - ); - expect(resources.getResource(resourceConfig2.key)?.id).toBe( - resourceConfig2.key, - ); -}); - -test("ignores addConsumer calls with empty resource list", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, []); - - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - expect(resources.getConsumer(consumer1.id)).toBeNull(); -}); - -test("rolls back addConsumer mutations when createResource throws", () => { - const time = testCreateTime(); - const lifecycleEvents: Array = []; - const createdResources = new Map(); - - const resources = createResources< - Resource, - ResourceKey, - ResourceConfig, - Consumer, - ConsumerId - >({ time })({ - createResource: (config) => { - if (config.key === resourceConfig2.key) { - throw new Error("boom"); - } - - const resource = createResource(config); - createdResources.set(config.key, resource); - return resource; - }, - getResourceKey: (config) => config.key, - getConsumerId: (consumer) => consumer.id, - onConsumerAdded: (consumer, _resource, key) => { - lifecycleEvents.push(`add:${consumer.id}:${key}`); - }, - onConsumerRemoved: (consumer, _resource, key) => { - lifecycleEvents.push(`remove:${consumer.id}:${key}`); - }, - }); - - expect(() => - resources.addConsumer(consumer1, [resourceConfig1, resourceConfig2]), - ).toThrow("boom"); - - expect(resources.getConsumer(consumer1.id)).toBeNull(); - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([]); - expect(resources.getResource(resourceConfig1.key)).toBeNull(); - expect(createdResources.get(resourceConfig1.key)?.disposed).toBe(true); - expect(lifecycleEvents).toEqual([ - `add:${consumer1.id}:${resourceConfig1.key}`, - `remove:${consumer1.id}:${resourceConfig1.key}`, - ]); -}); - -test("tracks consumers for each resource", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - resources.addConsumer(consumer2, [resourceConfig1, resourceConfig2]); - - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([ - "consumer1", - "consumer2", - ]); - - expect(resources.getConsumersForResource(resourceConfig2.key)).toEqual([ - "consumer2", - ]); -}); - -test("deduplicates resources for multiple consumers", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - resources.addConsumer(consumer2, [resourceConfig1]); - - const resource1 = resources.getResource(resourceConfig1.key); - const resource2 = resources.getResource(resourceConfig1.key); - - expect(resource1).toBe(resource2); - expect(resource1).not.toBeNull(); - - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([ - "consumer1", - "consumer2", - ]); -}); - -test("increments reference counts for the same consumer", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - resources.addConsumer(consumer1, [resourceConfig1]); - resources.addConsumer(consumer1, [resourceConfig1]); - - const consumers = resources.getConsumersForResource(resourceConfig1.key); - expect(consumers).toEqual(["consumer1"]); - - const result1 = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result1.ok).toBe(true); - const result2 = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result2.ok).toBe(true); - - const resource = resources.getResource(resourceConfig1.key); - expect(resource?.disposed).toBe(false); - - const result3 = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result3.ok).toBe(true); - expect(resource?.disposed).toBe(false); -}); - -test("removes consumers and decrements reference counts", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - resources.addConsumer(consumer2, [resourceConfig1]); - - const removeResult = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(removeResult.ok).toBe(true); - - const consumers = resources.getConsumersForResource(resourceConfig1.key); - expect(consumers).toEqual(["consumer2"]); - - const resource = resources.getResource(resourceConfig1.key); - expect(resource).toBeTruthy(); - expect(resource?.disposed).toBe(false); -}); - -test("schedules resource disposal when no consumers remain", () => { - const { resources, time } = createTestResources("50ms"); - - resources.addConsumer(consumer1, [resourceConfig1]); - const resource = resources.getResource(resourceConfig1.key); - expect(resource).toBeTruthy(); - - const removeResult = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(removeResult.ok).toBe(true); - - expect(resources.getResource(resourceConfig1.key)).toBeTruthy(); - expect(resource?.disposed).toBe(false); - - time.advance("100ms"); - - expect(resources.getResource(resourceConfig1.key)).toBeNull(); - expect(resource?.disposed).toBe(true); -}); - -test("cancels pending disposal when consumer is re-added", () => { - const { resources, time } = createTestResources("50ms"); - - resources.addConsumer(consumer1, [resourceConfig1]); - const resource = resources.getResource(resourceConfig1.key); - - const removeResult = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(removeResult.ok).toBe(true); - - time.advance("25ms"); - - resources.addConsumer(consumer1, [resourceConfig1]); - - time.advance("50ms"); - - expect(resources.getResource(resourceConfig1.key)).toBeTruthy(); - expect(resource?.disposed).toBe(false); -}); - -test("hasConsumerAnyResource returns correct status", () => { - const { resources } = createTestResources(); - - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - expect(resources.hasConsumerAnyResource(consumer2)).toBe(false); - - resources.addConsumer(consumer1, [resourceConfig1, resourceConfig2]); - expect(resources.hasConsumerAnyResource(consumer1)).toBe(true); - expect(resources.hasConsumerAnyResource(consumer2)).toBe(false); - - resources.addConsumer(consumer2, [resourceConfig3]); - expect(resources.hasConsumerAnyResource(consumer1)).toBe(true); - expect(resources.hasConsumerAnyResource(consumer2)).toBe(true); - - const removeResult = resources.removeConsumer(consumer1, [ - resourceConfig1, - resourceConfig2, - ]); - expect(removeResult.ok).toBe(true); - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - expect(resources.hasConsumerAnyResource(consumer2)).toBe(true); -}); - -test("removeConsumer keeps consumer when still using another resource", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1, resourceConfig2]); - - const result = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result.ok).toBe(true); - - expect(resources.hasConsumerAnyResource(consumer1)).toBe(true); - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1); - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([]); - expect(resources.getConsumersForResource(resourceConfig2.key)).toEqual([ - consumer1.id, - ]); -}); - -test("returns error when removing consumer from non-existent resource", () => { - const { resources } = createTestResources(); - - const nonexistentConfig: ResourceConfig = { - key: "nonexistent" as ResourceKey, - }; - const result = resources.removeConsumer(consumer1, [nonexistentConfig]); - - expect(result).toEqual( - err({ - type: "ResourceNotFoundError", - resourceKey: "nonexistent", - }), - ); -}); - -test("returns error when removing consumer not added to resource", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - - const result = resources.removeConsumer(consumer2, [resourceConfig1]); - - expect(result).toEqual( - err({ - type: "ConsumerNotFoundError", - consumerId: "consumer2", - resourceKey: "resource1", - }), - ); -}); - -test("disposes all resources when disposed", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [ - resourceConfig1, - resourceConfig2, - resourceConfig3, - ]); - - const resource1 = resources.getResource(resourceConfig1.key); - const resource2 = resources.getResource(resourceConfig2.key); - const resource3 = resources.getResource(resourceConfig3.key); - - expect(resource1?.disposed).toBe(false); - expect(resource2?.disposed).toBe(false); - expect(resource3?.disposed).toBe(false); - - resources[Symbol.dispose](); - - expect(resource1?.disposed).toBe(true); - expect(resource2?.disposed).toBe(true); - expect(resource3?.disposed).toBe(true); -}); - -test("returns empty array for non-existent resource consumers", () => { - const { resources } = createTestResources(); - - const consumers = resources.getConsumersForResource( - "nonexistent" as ResourceKey, - ); - expect(consumers).toEqual([]); -}); - -test("returns null for non-existent resource", () => { - const { resources } = createTestResources(); - - const resource = resources.getResource("nonexistent" as ResourceKey); - expect(resource).toBeNull(); -}); - -test("getConsumer returns consumer data when consumer is using resources", () => { - const { resources } = createTestResources(); - const consumer1 = { id: "consumer1" as ConsumerId, name: "Consumer 1" }; - const consumer2 = { id: "consumer2" as ConsumerId, name: "Consumer 2" }; - const resourceConfig1 = { key: "resource1" as ResourceKey }; - - resources.addConsumer(consumer1, [resourceConfig1]); - - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1); - expect(resources.getConsumer(consumer2.id)).toBeNull(); -}); - -test("getConsumer returns null when consumer is not using any resources", () => { - const { resources } = createTestResources(); - const consumer1 = { id: "consumer1" as ConsumerId, name: "Consumer 1" }; - const resourceConfig1 = { key: "resource1" as ResourceKey }; - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1); - - const removeResult = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(removeResult.ok).toBe(true); - expect(resources.getConsumer(consumer1.id)).toBeNull(); -}); - -test("getConsumer returns null when consumer has no associated resources", () => { - const resources = createResources< - Resource, - ResourceKey, - ResourceConfig, - Consumer, - ConsumerId - >({ time: testCreateTime() })({ - createResource: () => { - throw new Error("createResource failed"); - }, - getResourceKey: (config) => config.key, - getConsumerId: (consumer) => consumer.id, - disposalDelay: "10ms", - }); - - expect(() => resources.addConsumer(consumer1, [resourceConfig1])).toThrow( - "createResource failed", - ); - - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - expect(resources.getConsumer(consumer1.id)).toBeNull(); -}); - -test("removeConsumer does not partially mutate state when validation fails", () => { - const { resources } = createTestResources(); - const consumer = { id: "consumer-stale" as ConsumerId, name: "Stale" }; - const existingResource = { key: "existing" as ResourceKey }; - const missingResource = { key: "missing" as ResourceKey }; - - resources.addConsumer(consumer, [existingResource]); - - const result = resources.removeConsumer(consumer, [ - existingResource, - missingResource, - ]); - - expect(result).toEqual( - err({ - type: "ResourceNotFoundError", - resourceKey: "missing", - }), - ); - - expect(resources.hasConsumerAnyResource(consumer)).toBe(true); - expect(resources.getConsumer(consumer.id)).toEqual(consumer); - expect(resources.getConsumersForResource(existingResource.key)).toEqual([ - consumer.id, - ]); -}); - -test("removeConsumer fails when remove count exceeds tracked references", () => { - const { resources } = createTestResources(); - - resources.addConsumer(consumer1, [resourceConfig1]); - const result = resources.removeConsumer(consumer1, [ - resourceConfig1, - resourceConfig1, - ]); - - expect(result).toEqual( - err({ - type: "ConsumerNotFoundError", - consumerId: consumer1.id, - resourceKey: resourceConfig1.key, - }), - ); - - expect(resources.hasConsumerAnyResource(consumer1)).toBe(true); - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1); - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([ - consumer1.id, - ]); -}); - -test("getConsumer returns updated consumer data when re-added", () => { - const { resources } = createTestResources(); - const consumer1 = { id: "consumer1" as ConsumerId, name: "Consumer 1" }; - const consumer1Updated = { - id: "consumer1" as ConsumerId, - name: "Consumer 1 Updated", - extra: "data", - }; - const resourceConfig1 = { key: "resource1" as ResourceKey }; - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1); - - resources.addConsumer(consumer1Updated, [resourceConfig1]); - expect(resources.getConsumer(consumer1.id)).toEqual(consumer1Updated); -}); - -test("operations after disposal return safe defaults", () => { - const { resources } = createTestResources(); - const consumer1 = { id: "consumer1" as ConsumerId, name: "Consumer 1" }; - const resourceConfig1 = { key: "resource1" as ResourceKey }; - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(resources.getResource(resourceConfig1.key)).not.toBeNull(); - - resources[Symbol.dispose](); - - expect(resources.getResource(resourceConfig1.key)).toBeNull(); - expect(resources.getConsumer(consumer1.id)).toBeNull(); - expect(resources.getConsumersForResource(resourceConfig1.key)).toEqual([]); - expect(resources.hasConsumerAnyResource(consumer1)).toBe(false); - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(resources.getResource(resourceConfig1.key)).toBeNull(); - - const result = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result.ok).toBe(true); -}); - -test("calls onConsumerAdded and onConsumerRemoved callbacks", () => { - const addedCalls: Array<{ - consumer: Consumer; - resourceKey: ResourceKey; - resource: Resource; - }> = []; - const removedCalls: Array<{ - consumer: Consumer; - resourceKey: ResourceKey; - resource: Resource; - }> = []; - - const resources = createResources< - Resource, - ResourceKey, - ResourceConfig, - Consumer, - ConsumerId - >({ time: testCreateTime() })({ - createResource, - getResourceKey: (config) => config.key, - getConsumerId: (consumer) => consumer.id, - disposalDelay: "10ms", - onConsumerAdded: (consumer, resource, resourceKey) => { - addedCalls.push({ consumer, resource, resourceKey }); - }, - onConsumerRemoved: (consumer, resource, resourceKey) => { - removedCalls.push({ consumer, resource, resourceKey }); - }, - }); - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(addedCalls).toHaveLength(1); - expect(addedCalls[0].consumer).toBe(consumer1); - expect(addedCalls[0].resourceKey).toBe(resourceConfig1.key); - expect(removedCalls).toHaveLength(0); - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(addedCalls).toHaveLength(1); - expect(removedCalls).toHaveLength(0); - - resources.removeConsumer(consumer1, [resourceConfig1]); - expect(addedCalls).toHaveLength(1); - expect(removedCalls).toHaveLength(0); - - resources.removeConsumer(consumer1, [resourceConfig1]); - expect(addedCalls).toHaveLength(1); - expect(removedCalls).toHaveLength(1); - expect(removedCalls[0].consumer).toBe(consumer1); - expect(removedCalls[0].resourceKey).toBe(resourceConfig1.key); -}); - -test("multiple dispose calls are safe", () => { - const { resources } = createTestResources(); - const consumer1 = { id: "consumer1" as ConsumerId, name: "Consumer 1" }; - const resourceConfig1 = { key: "resource1" as ResourceKey }; - - resources.addConsumer(consumer1, [resourceConfig1]); - const resource = resources.getResource(resourceConfig1.key); - - resources[Symbol.dispose](); - expect(resource?.disposed).toBe(true); - - expect(() => { - resources[Symbol.dispose](); - }).not.toThrow(); -}); - -test("dispose clears scheduled disposal timeouts", () => { - const { resources, time } = createTestResources("50ms"); - - resources.addConsumer(consumer1, [resourceConfig1]); - const resource = resources.getResource(resourceConfig1.key); - expect(resource).not.toBeNull(); - - const result = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result.ok).toBe(true); - - resources[Symbol.dispose](); - - time.advance("100ms"); - - expect(resource?.disposed).toBe(true); - expect(resources.getResource(resourceConfig1.key)).toBeNull(); -}); - -test("handles falsy resource values and uses default disposal delay", () => { - const addedCalls: Array = []; - const removedCalls: Array = []; - const time = testCreateTime(); - - const resources = createResources< - Resource, - ResourceKey, - ResourceConfig, - Consumer, - ConsumerId - >({ time })({ - createResource: () => null as unknown as Resource, - getResourceKey: (config) => config.key, - getConsumerId: (consumer) => consumer.id, - onConsumerAdded: (...args) => { - addedCalls.push(args); - }, - onConsumerRemoved: (...args) => { - removedCalls.push(args); - }, - }); - - resources.addConsumer(consumer1, [resourceConfig1]); - expect(addedCalls).toHaveLength(0); - - const result = resources.removeConsumer(consumer1, [resourceConfig1]); - expect(result.ok).toBe(true); - expect(removedCalls).toHaveLength(0); - - time.advance("120ms"); - expect(resources.getResource(resourceConfig1.key)).toBeNull(); -}); diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index 4447fe65b..8b6d4a86d 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -380,7 +380,7 @@ describe("logExplainQueryPlan", () => { }); }); -test("dispose is idempotent", async () => { +test("async dispose is idempotent", async () => { let driverDisposeCount = 0; await using run = testCreateRun({ createSqliteDriver: () => () => { @@ -398,23 +398,27 @@ test("dispose is idempotent", async () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - // Should not throw on second dispose - sqlite[Symbol.dispose](); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); + await sqlite[Symbol.asyncDispose](); expect(driverDisposeCount).toBe(1); }); -test("run disposal disposes sqlite automatically", async () => { - let driverDisposeCount = 0; +test("sync methods throw after sqlite is disposed", async () => { + let execCalls = 0; + let exportCalls = 0; - const run = testCreateRun({ + await using run = testCreateRun({ createSqliteDriver: () => () => { const driver: SqliteDriver = { - exec: () => ({ rows: [], changes: 0 }), - export: () => new Uint8Array(), - [Symbol.dispose]: () => { - driverDisposeCount++; + exec: () => { + execCalls++; + return { rows: [], changes: 0 }; + }, + export: () => { + exportCalls++; + return new Uint8Array(); }, + [Symbol.dispose]: lazyVoid, }; return ok(driver); }, @@ -424,14 +428,20 @@ test("run disposal disposes sqlite automatically", async () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - expect(driverDisposeCount).toBe(0); - - await run[Symbol.asyncDispose](); - - expect(driverDisposeCount).toBe(1); + await sqlite[Symbol.asyncDispose](); - sqlite[Symbol.dispose](); - expect(driverDisposeCount).toBe(1); + expect(() => sqlite.exec(sql`select 1;`)).toThrow( + "Expected value to not be disposed.", + ); + expect(() => + sqlite.transaction(() => { + throw new Error("should not run"); + }), + ).toThrow("Expected value to not be disposed."); + expect(() => sqlite.export()).toThrow("Expected value to not be disposed."); + + expect(execCalls).toBe(0); + expect(exportCalls).toBe(0); }); test("manual sqlite dispose removes daemon abort listener", async () => { @@ -461,7 +471,7 @@ test("manual sqlite dispose removes daemon abort listener", async () => { expect(abortAddCall).toBeDefined(); const abortListener = abortAddCall?.[1]; - sqliteResult.value[Symbol.dispose](); + await sqliteResult.value[Symbol.asyncDispose](); expect( removeSpy.mock.calls.some( @@ -503,7 +513,7 @@ test("createSqlite disposes immediately when daemon is already aborted", async ( test("createSqlite returns error when driver creation is aborted", async () => { const createSlowDriver: CreateSqliteDriver = () => async (run) => { - await run(sleep("10s")); + await run(sleep("10ms")); return ok({ exec: () => ({ rows: [], changes: 0 }), export: () => new Uint8Array(), @@ -519,7 +529,7 @@ test("createSqlite returns error when driver creation is aborted", async () => { fiber.abort("test"); const result = await fiber; - expect(result.ok).toBe(false); + expect(result).toEqual(err({ type: "AbortError", reason: "test" })); }); describe("createPreparedStatementsCache", () => { @@ -863,6 +873,21 @@ describe("getSqliteSchema", () => { `); }); + test("excludeIndexNamePrefix escapes LIKE wildcards safely", async () => { + await using run = await testCreateRunWithSqlite(testCreateSqliteDeps()); + const { sqlite } = run.deps; + + sqlite.exec(sql`create table t_like (id text primary key, value text);`); + sqlite.exec(sql`create index "abc%_idx" on t_like (value);`); + sqlite.exec(sql`create index abcXYZidx on t_like (value);`); + + const schema = getSqliteSchema(run.deps)({ + excludeIndexNamePrefix: "abc%_", + }); + + expect(schema.indexes.map((index) => index.name)).toEqual(["abcXYZidx"]); + }); + test("excludeSqliteInternalIndexes false includes internal index rows but null SQL is skipped", async () => { await using run = await testCreateRunWithSqlite(testCreateSqliteDeps()); const { sqlite } = run.deps; diff --git a/packages/common/test/Store.test.ts b/packages/common/test/Store.test.ts index 685f61b25..70e40a744 100644 --- a/packages/common/test/Store.test.ts +++ b/packages/common/test/Store.test.ts @@ -227,6 +227,31 @@ describe("subscribe", () => { expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); }); + + test("uses a listener snapshot during notifications", () => { + const store = createStore(0); + const calls: Array = []; + + let unsubscribeSecond = () => {}; + + store.subscribe(() => { + calls.push("first"); + unsubscribeSecond(); + store.subscribe(() => { + calls.push("third"); + }); + }); + + unsubscribeSecond = store.subscribe(() => { + calls.push("second"); + }); + + store.set(1); + expect(calls).toEqual(["first", "second"]); + + store.set(2); + expect(calls).toEqual(["first", "second", "first", "third"]); + }); }); describe("dispose", () => { diff --git a/packages/common/test/StructuralMap.test.ts b/packages/common/test/StructuralMap.test.ts new file mode 100644 index 000000000..160c15e15 --- /dev/null +++ b/packages/common/test/StructuralMap.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "vitest"; +import { createStructuralMap } from "../src/StructuralMap.js"; + +describe("createStructuralMap", () => { + test("stores and retrieves primitive keys", () => { + const map = createStructuralMap(); + + map.set("x", "string"); + map.set(1, "number"); + map.set(true, "boolean"); + map.set(false, "boolean-false"); + map.set(null, "null"); + + expect(map.size).toBe(5); + expect(map.get("x")).toBe("string"); + expect(map.get(1)).toBe("number"); + expect(map.get(true)).toBe("boolean"); + expect(map.get(false)).toBe("boolean-false"); + expect(map.get(null)).toBe("null"); + expect(map.has("missing")).toBe(false); + expect(map.delete("missing")).toBe(false); + }); + + test("treats equal numbers with JSON-like semantics", () => { + const map = createStructuralMap(); + + map.set(-0, "zero"); + map.set(NaN, "nan"); + map.set(Number.POSITIVE_INFINITY, "infinity"); + map.set(Number.NEGATIVE_INFINITY, "negative-infinity"); + + expect(map.get(0)).toBe("zero"); + expect(map.get(-0)).toBe("zero"); + expect(map.get(NaN)).toBe("nan"); + expect(map.get(Number.POSITIVE_INFINITY)).toBe("infinity"); + expect(map.get(Number.NEGATIVE_INFINITY)).toBe("negative-infinity"); + }); + + test("shares entries for structurally equal object keys", () => { + const map = createStructuralMap< + { readonly id: string; readonly nested: { readonly enabled: boolean } }, + string + >(); + + map.set({ id: "a", nested: { enabled: true } }, "value"); + + expect(map.get({ nested: { enabled: true }, id: "a" })).toBe("value"); + expect(map.size).toBe(1); + }); + + test("shares entries for structurally equal array keys", () => { + const map = createStructuralMap< + readonly [string, { readonly count: number }], + string + >(); + + map.set(["a", { count: 1 }], "value"); + + expect(map.get(["a", { count: 1 }])).toBe("value"); + expect(map.has(["a", { count: 2 }])).toBe(false); + }); + + test("shares entries for equal Uint8Array keys", () => { + const map = createStructuralMap(); + + map.set(new Uint8Array([1, 2, 3]), "value"); + + expect(map.get(new Uint8Array([1, 2, 3]))).toBe("value"); + expect(map.has(new Uint8Array([1, 2, 4]))).toBe(false); + expect(map.size).toBe(1); + }); + + test("shares entries for structurally equal keys containing Uint8Array", () => { + const map = createStructuralMap< + { + readonly id: string; + readonly bytes: Uint8Array; + }, + string + >(); + + map.set({ id: "a", bytes: new Uint8Array([1, 2, 3]) }, "value"); + + expect(map.get({ bytes: new Uint8Array([1, 2, 3]), id: "a" })).toBe( + "value", + ); + expect( + map.get({ bytes: new Uint8Array([1, 2, 4]), id: "a" }), + ).toBeUndefined(); + }); + + test("supports iteration and forEach", () => { + const map = createStructuralMap(); + + map.set("a", 1); + map.set({ id: "b" }, 2); + + expect(Array.from(map.keys())).toEqual(["a", { id: "b" }]); + expect(Array.from(map.values())).toEqual([1, 2]); + expect(Array.from(map.entries())).toEqual([ + ["a", 1], + [{ id: "b" }, 2], + ]); + expect(Array.from(map)).toEqual([ + ["a", 1], + [{ id: "b" }, 2], + ]); + + const entries: Array = + []; + map.forEach((value, key) => { + entries.push([key, value]); + }); + + expect(entries).toEqual([ + ["a", 1], + [{ id: "b" }, 2], + ]); + }); + + test("deletes and clears entries using structural equality", () => { + const map = createStructuralMap<{ readonly id: string }, string>(); + + map.set({ id: "a" }, "value"); + + expect(map.delete({ id: "a" })).toBe(true); + expect(map.size).toBe(0); + + map.set({ id: "b" }, "next"); + map.clear(); + + expect(map.size).toBe(0); + expect(map.get({ id: "b" })).toBeUndefined(); + }); + + test("reuses cached structural ids for repeated object lookups", () => { + const map = createStructuralMap<{ readonly id: string }, string>(); + const key = { id: "a" } as const; + + map.set(key, "value"); + + expect(map.get(key)).toBe("value"); + expect(map.has(key)).toBe(true); + }); + + test("rejects keys containing undefined", () => { + const map = createStructuralMap(); + + expect(() => + map.set({ ok: true, bad: undefined } as never, "value"), + ).toThrow("Structural keys must not contain undefined."); + }); + + test("rejects cyclic keys", () => { + const map = createStructuralMap(); + const key: Record = { id: "a" }; + key.self = key; + + expect(() => map.set(key as never, "value")).toThrow( + "Structural keys must not contain cycles.", + ); + }); + + test("supports null-prototype object keys", () => { + const map = createStructuralMap<{ readonly id: string }, string>(); + const key = Object.assign(Object.create(null), { id: "a" }) as { + readonly id: string; + }; + + map.set(key, "value"); + + expect(map.get({ id: "a" })).toBe("value"); + }); + + test("rejects keys outside JSON-like values and Uint8Array", () => { + const map = createStructuralMap(); + + expect(() => map.set((() => undefined) as never, "value")).toThrow( + "StructuralMap keys must be JSON-like values or Uint8Array; received function.", + ); + }); + + test("rejects non-Uint8Array object keys with a clear type name", () => { + const map = createStructuralMap(); + + expect(() => map.set(new Date() as never, "value")).toThrow( + "StructuralMap keys must be JSON-like values or Uint8Array; received Date.", + ); + }); +}); diff --git a/packages/common/test/Task.test.ts b/packages/common/test/Task.test.ts index 986757aa7..c720bd153 100644 --- a/packages/common/test/Task.test.ts +++ b/packages/common/test/Task.test.ts @@ -38,7 +38,6 @@ import { AllAbortError, AllSettledAbortError, AnyAbortError, - type AsyncDisposableStack, all, allSettled, any, @@ -87,11 +86,6 @@ const eventsEnabled: RunConfigDep = { runConfig: { eventsEnabled: createRef(true) }, }; -const must = (value: T | null | undefined): T => { - assert(value != null); - return value; -}; - interface MyError extends Typed<"MyError"> {} describe("Task", () => { @@ -668,85 +662,6 @@ describe("Run", () => { }); }); - describe("defer", () => { - test("runs task when disposed", async () => { - await using run = createRun(); - - const events: Array = []; - - const cleanup = () => { - events.push("cleanup"); - return ok(); - }; - - const task: Task = async (run) => { - await using _ = run.defer(cleanup); - - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - - test("accepts cleanup callback returning void", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using _ = run.defer(() => { - events.push("cleanup"); - }); - - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - - test("is unabortable", async () => { - await using run = createRun(); - - const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); - - const cleanup = () => { - events.push("cleanup"); - return ok(); - }; - - const task: Task = async (run) => { - await using _ = run.defer(cleanup); - events.push("work started"); - taskStarted.resolve(); - await canComplete.promise; - if (run.signal.aborted) { - return err(run.signal.reason); - } - return ok(); - }; - - const fiber = run(task); - await taskStarted.promise; - fiber.abort("stop"); - canComplete.resolve(); - - const result = await fiber; - - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(events).toEqual(["work started", "cleanup"]); - }); - }); - describe("dispose", () => { test("aborts all running fibers", async () => { const results: Array = []; @@ -800,11 +715,13 @@ describe("Run", () => { const task: Task = async (run) => { run.signal.addEventListener("abort", () => { - stateInAbortHandler = must(run.parent).getState(); + assert(run.parent != null); + stateInAbortHandler = run.parent.getState(); }); taskStarted.resolve(); await taskCanFinish.promise; - stateAfterAwait = must(run.parent).getState(); + assert(run.parent != null); + stateAfterAwait = run.parent.getState(); return ok(); }; @@ -817,8 +734,10 @@ describe("Run", () => { taskCanFinish.resolve(); await disposePromise; - expect(must(stateInAbortHandler).type).toBe("Disposing"); - expect(must(stateAfterAwait).type).toBe("Disposing"); + assert(stateInAbortHandler != null); + assert(stateAfterAwait != null); + expect(stateInAbortHandler.type).toBe("Disposing"); + expect(stateAfterAwait.type).toBe("Disposing"); expect(run.getState().type).toBe("Settled"); }); @@ -1045,8 +964,8 @@ describe("Run", () => { }); // Cleanup signal should exist and be aborted after disposal - expect(cleanupSignal).not.toBeNull(); - expect(must(cleanupSignal).aborted).toBe(true); + assert(cleanupSignal != null); + expect(cleanupSignal.aborted).toBe(true); } finally { AbortSignal.prototype.addEventListener = originalAddEventListener; } @@ -1100,8 +1019,8 @@ describe("Run", () => { }); // Cleanup signal should exist and be aborted after child disposal - expect(cleanupSignal).not.toBeNull(); - expect(must(cleanupSignal).aborted).toBe(true); + assert(cleanupSignal != null); + expect(cleanupSignal.aborted).toBe(true); } finally { AbortSignal.prototype.addEventListener = originalAddEventListener; } @@ -1109,6 +1028,112 @@ describe("Run", () => { }); describe("create", () => { + interface SyncNativeFooResource extends Disposable { + readonly foo: (arg: string) => Task; + } + + interface SyncFooResource extends AsyncDisposable { + readonly foo: (arg: string) => Task; + } + + const testCreateSyncFooResource = () => { + const events: Array = []; + let nativeDisposed = false; + + const createNativeResource: Task = () => + ok({ + foo: (arg) => async (run) => { + events.push(`sync foo started ${arg}`); + const slept = await run(sleep("10s")); + if (!slept.ok) return slept; + events.push(`sync foo completed ${arg}`); + return ok(`sync:${arg}`); + }, + [Symbol.dispose]: () => { + events.push("sync native disposed"); + nativeDisposed = true; + }, + }); + + const createResource: Task = async (run) => { + const resourceRun = run.create(); + await using stack = new AsyncDisposableStack(); + + const nativeResourceResult = await run(createNativeResource); + if (!nativeResourceResult.ok) return nativeResourceResult; + const nativeResource = stack.use(nativeResourceResult.value); + + stack.use(resourceRun); + const moved = stack.move(); + + return ok({ + foo: (arg) => () => resourceRun(nativeResource.foo(arg)), + [Symbol.asyncDispose]: () => moved.disposeAsync(), + }); + }; + + return { + createResource, + events, + isNativeDisposed: () => nativeDisposed, + }; + }; + + interface AsyncNativeFooResource extends AsyncDisposable { + readonly foo: (arg: string) => Task; + } + + interface AsyncFooResource extends AsyncDisposable { + readonly foo: (arg: string) => Task; + } + + const testCreateAsyncFooResource = () => { + const events: Array = []; + let nativeDisposed = false; + const nativeDisposeCanFinish = Promise.withResolvers(); + + const createNativeResource: Task = () => + ok({ + foo: (arg) => async (run) => { + events.push(`async foo started ${arg}`); + const slept = await run(sleep("10s")); + if (!slept.ok) return slept; + events.push(`async foo completed ${arg}`); + return ok(`async:${arg}`); + }, + [Symbol.asyncDispose]: async () => { + events.push("async native dispose started"); + await nativeDisposeCanFinish.promise; + events.push("async native dispose completed"); + nativeDisposed = true; + }, + }); + + const createResource: Task = async (run) => { + const resourceRun = run.create(); + await using stack = new AsyncDisposableStack(); + + const nativeResourceResult = await run(createNativeResource); + if (!nativeResourceResult.ok) return nativeResourceResult; + const nativeResource = stack.use(nativeResourceResult.value); + + stack.use(resourceRun); + const moved = stack.move(); + + return ok({ + foo: (arg) => () => resourceRun(nativeResource.foo(arg)), + [Symbol.asyncDispose]: () => moved.disposeAsync(), + }); + }; + + return { + createResource, + events, + finishNativeDispose: () => nativeDisposeCanFinish.resolve(), + isNativeDisposed: () => nativeDisposed, + }; + }; + test("created run outlives parent task", async () => { const time = testCreateTime(); await using run = testCreateRun({ time }); @@ -1147,11 +1172,12 @@ describe("Run", () => { "child started", "parent completed", ]); - expect(must(childFiber).run.getState().type).toBe("Running"); + assert(childFiber != null); + expect(childFiber.run.getState().type).toBe("Running"); time.advance("2s"); - expect(await must(childFiber)).toEqual(ok()); + expect(await childFiber).toEqual(ok()); expect(events).toEqual([ "parent started", "child started", @@ -1199,7 +1225,8 @@ describe("Run", () => { }), ).toEqual(ok()); - const childFiber = must(createdRun)(async (run) => { + assert(createdRun != null); + const childFiber = createdRun(async (run) => { events.push("child started"); const result = await run(sleep("10s")); if (!result.ok) { @@ -1213,14 +1240,14 @@ describe("Run", () => { expect(events).toEqual(["child started"]); - await must(createdRun)[Symbol.asyncDispose](); + await createdRun[Symbol.asyncDispose](); expect(await childFiber).toEqual( err({ type: "AbortError", reason: runStoppedError }), ); expect(events).toEqual(["child started", "child aborted"]); - const lateResult = await must(createdRun)(() => ok("late")); + const lateResult = await createdRun(() => ok("late")); expect(lateResult).toEqual( err({ type: "AbortError", reason: runStoppedError }), ); @@ -1240,7 +1267,8 @@ describe("Run", () => { }), ).toEqual(ok()); - const childFiber = must(createdRun)(async (run) => { + assert(createdRun != null); + const childFiber = createdRun(async (run) => { events.push("child started"); const result = await run(sleep("10s")); if (!result.ok) { @@ -1261,6 +1289,115 @@ describe("Run", () => { ); expect(events).toEqual(["child started", "child aborted"]); }); + + describe("with sync-disposable wrapped resource", () => { + test("tasks called after disposal are aborted", async () => { + await using run = testCreateRun(); + const resource = testCreateSyncFooResource(); + + const fooResource = await run.orThrow(resource.createResource); + await fooResource[Symbol.asyncDispose](); + + const result = await run(fooResource.foo("late")); + + expect(result).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(resource.isNativeDisposed()).toBe(true); + expect(resource.events).toEqual(["sync native disposed"]); + }); + + test("disposal aborts in-flight tasks", async () => { + await using run = testCreateRun(); + const resource = testCreateSyncFooResource(); + + const fooResource = await run.orThrow(resource.createResource); + const fiber = run(fooResource.foo("slow")); + + expect(resource.events).toEqual(["sync foo started slow"]); + + await fooResource[Symbol.asyncDispose](); + + expect(await fiber).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(resource.isNativeDisposed()).toBe(true); + expect(resource.events).toEqual([ + "sync foo started slow", + "sync native disposed", + ]); + }); + }); + + describe("with async-disposable wrapped resource", () => { + test("tasks called after disposal are aborted", async () => { + await using run = testCreateRun(); + const resource = testCreateAsyncFooResource(); + + const fooResource = await run.orThrow(resource.createResource); + let disposed = false; + const disposePromise = fooResource[Symbol.asyncDispose](); + void disposePromise.then(() => { + disposed = true; + }); + + await testWaitForMacrotask(); + + expect(disposed).toBe(false); + + resource.finishNativeDispose(); + await disposePromise; + + expect(disposed).toBe(true); + + const result = await run(fooResource.foo("late")); + + expect(result).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(resource.isNativeDisposed()).toBe(true); + expect(resource.events).toEqual([ + "async native dispose started", + "async native dispose completed", + ]); + }); + + test("disposal aborts in-flight tasks", async () => { + await using run = testCreateRun(); + const resource = testCreateAsyncFooResource(); + + const fooResource = await run.orThrow(resource.createResource); + const fiber = run(fooResource.foo("slow")); + + expect(resource.events).toEqual(["async foo started slow"]); + + let disposed = false; + const disposePromise = fooResource[Symbol.asyncDispose](); + void disposePromise.then(() => { + disposed = true; + }); + + await testWaitForMacrotask(); + + expect(disposed).toBe(false); + + resource.finishNativeDispose(); + + expect(await fiber).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + await disposePromise; + + expect(disposed).toBe(true); + + expect(resource.isNativeDisposed()).toBe(true); + expect(resource.events).toEqual([ + "async foo started slow", + "async native dispose started", + "async native dispose completed", + ]); + }); + }); }); }); @@ -1466,7 +1603,8 @@ describe("Fiber", () => { await parentFiber; expect(parentFiberId).toBe(parentFiber.run.id); - expect(childFiberId).toBe(must(childFiber).run.id); + assert(childFiber != null); + expect(childFiberId).toBe(childFiber.run.id); expect(parentFiberId).not.toBe(childFiberId); }); @@ -1543,7 +1681,8 @@ describe("Fiber", () => { // Let daemon complete and wait for it daemonCanComplete.resolve(); - await must(daemonFiber); + assert(daemonFiber != null); + await daemonFiber; expect(events).toEqual([ "parent started", @@ -1634,7 +1773,8 @@ describe("Fiber", () => { ]); daemonCanComplete.resolve(); - await must(daemonFiber); + assert(daemonFiber != null); + await daemonFiber; expect(events).toEqual([ "parent started", @@ -1673,6 +1813,122 @@ describe("Fiber", () => { }); }); + describe("asUnabortableDaemon", () => { + test("supports AsyncDisposableStack.defer", async () => { + const events: Array = []; + const cleanupStarted = Promise.withResolvers(); + const cleanupCanFinish = Promise.withResolvers(); + + const run = createRun(); + + const fiber = run(async (run) => { + await using stack = new AsyncDisposableStack(); + + const closeConnection: Task = async ({ signal }) => { + events.push("cleanup started"); + cleanupStarted.resolve(); + await cleanupCanFinish.promise; + events.push(signal.aborted ? "cleanup aborted" : "cleanup completed"); + return ok(); + }; + + stack.defer(async () => { + events.push("cleanup callback"); + await run.asUnabortableDaemon(closeConnection); + events.push("cleanup callback done"); + }); + + events.push("task body done"); + return ok(); + }); + + await cleanupStarted.promise; + // Dispose the root Run to prove the native cleanup callback can still + // finish after root shutdown has started. + const disposePromise = run[Symbol.asyncDispose](); + cleanupCanFinish.resolve(); + await disposePromise; + + expect(await fiber).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(events).toEqual([ + "task body done", + "cleanup callback", + "cleanup started", + "cleanup completed", + "cleanup callback done", + ]); + }); + + test("supports AsyncDisposableStack.adopt", async () => { + const events: Array = []; + const cleanupStarted = Promise.withResolvers(); + const cleanupCanFinish = Promise.withResolvers(); + + const run = createRun(); + + const fiber = run(async (run) => { + await using stack = new AsyncDisposableStack(); + const session = "session-1"; + + const logout = + (value: string): Task => + async ({ signal }) => { + events.push(`logout started:${value}`); + cleanupStarted.resolve(); + await cleanupCanFinish.promise; + events.push( + signal.aborted + ? `logout aborted:${value}` + : `logout completed:${value}`, + ); + return ok(); + }; + + stack.adopt(session, async (session) => { + events.push(`logout callback:${session}`); + await run.asUnabortableDaemon(logout(session)); + events.push(`logout callback done:${session}`); + }); + + events.push("task body done"); + return ok(); + }); + + await cleanupStarted.promise; + // Dispose the root Run to prove the native cleanup callback can still + // finish after root shutdown has started. + const disposePromise = run[Symbol.asyncDispose](); + cleanupCanFinish.resolve(); + await disposePromise; + + expect(await fiber).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(events).toEqual([ + "task body done", + "logout callback:session-1", + "logout started:session-1", + "logout completed:session-1", + "logout callback done:session-1", + ]); + }); + + test("returns task values", async () => { + const run = createRun(); + + const result = await run(async (run) => { + const daemonFiber = run.asUnabortableDaemon(() => ok("done")); + expectTypeOf(daemonFiber).toExtend>(); + const daemonResult = await daemonFiber; + return ok(daemonResult); + }); + + expect(result).toEqual(ok(ok("done"))); + }); + }); + test("InferFiberOk and InferFiberErr extract type parameters", () => { type MyFiber = Fiber; expectTypeOf>().toEqualTypeOf(); @@ -1926,1000 +2182,41 @@ describe("unabortableMask", () => { expect(events).toEqual([ "outer acquire", - "inner acquire", - // abortable1 skipped (mask=0, abort visible) - "restore2 task (aborted=false)", - "inner release", - ]); - expect(result).toEqual(ok()); - }); - - test("restore throws when used outside its unabortableMask", async () => { - await using run = createRun(); - - let restoreFromInner: ((task: Task) => Task) | undefined; - - const task = unabortableMask( - (_restore1) => async (run) => - await run( - unabortableMask((restore2) => () => { - // restore2 restores to mask=1 - restoreFromInner = restore2; - - return ok(); - }), - ), - ); - - const result = await run(task); - expect(result).toEqual(ok()); - expect(restoreFromInner).toBeDefined(); - - // Using restore2 outside its intended scope would increase abort mask - // (root mask=0, override=1). This must crash. - expect(() => run(must(restoreFromInner)(() => ok()))).toThrow( - "restore used outside its unabortableMask", - ); - }); -}); - -describe("AsyncDisposableStack", () => { - interface Resource extends globalThis.AsyncDisposable { - readonly id: string; - } - - const createResource = - (id: string, events: Array): Task => - () => { - events.push(`${id} acquired`); - return ok({ - id, - // eslint-disable-next-line @typescript-eslint/require-await - [Symbol.asyncDispose]: async () => { - events.push(`${id} released`); - }, - }); - }; - - describe("stack via Run", () => { - test("run.stack() creates AsyncDisposableStack", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - - expectTypeOf(stack).toEqualTypeOf(); - - stack.defer(() => { - events.push("cleanup"); - return ok(); - }); - - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - }); - - describe("defer", () => { - test("runs task on dispose", async () => { - await using run = createRun(); - - const events: Array = []; - - const cleanup = () => { - events.push("cleanup"); - return ok(); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - stack.defer(cleanup); - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - - test("accepts async cleanup callback returning Promise", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - stack.defer(async () => { - await Promise.resolve(); - events.push("cleanup"); - }); - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - - test("requires cleanup task without domain errors", async () => { - await using run = createRun(); - - const task: Task = async (run) => { - await using stack = run.stack(); - - const cleanupOk: Task = () => ok(); - stack.defer(cleanupOk); - - const cleanupWithError: Task = () => - err({ type: "MyError" }); - // @ts-expect-error cleanup task must not return domain errors - stack.defer(cleanupWithError); - - return ok(); - }; - - expect(await run(task)).toEqual(ok()); - }); - - test("runs multiple deferred tasks in LIFO order", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - stack.defer(() => { - events.push("cleanup A"); - return ok(); - }); - stack.defer(() => { - events.push("cleanup B"); - return ok(); - }); - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup B", "cleanup A"]); - }); - - test("is unabortable", async () => { - await using run = createRun(); - - const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); - - const task: Task = async (run) => { - await using stack = run.stack(); - stack.defer(() => { - events.push("cleanup"); - return ok(); - }); - events.push("work started"); - taskStarted.resolve(); - await canComplete.promise; - if (run.signal.aborted) { - return err(run.signal.reason); - } - return ok(); - }; - - const fiber = run(task); - await taskStarted.promise; - fiber.abort("stop"); - canComplete.resolve(); - - const result = await fiber; - - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(events).toEqual(["work started", "cleanup"]); - }); - }); - - describe("disposeAsync", () => { - test("disposes the stack", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - const stack = run.stack(); - - stack.defer(() => { - events.push("cleanup"); - return ok(); - }); - - events.push("work"); - await stack.disposeAsync(); - - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "cleanup"]); - }); - }); - - describe("disposed", () => { - test("returns false before dispose, true after", async () => { - await using run = createRun(); - - const task: Task = async (run) => { - const stack = run.stack(); - expect(stack.disposed).toBe(false); - - await stack.disposeAsync(); - expect(stack.disposed).toBe(true); - - return ok(); - }; - - expect(await run(task)).toEqual(ok()); - }); - }); - - describe("use", () => { - test("acquires and disposes resource", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - const a = await stack.use(createResource("a", events)); - - expectTypeOf(a).toEqualTypeOf>(); - if (!a.ok) return a; - events.push(`using ${a.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["a acquired", "using a", "a released"]); - }); - - test("acquires multiple resources in LIFO disposal order", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const a = await stack.use(createResource("a", events)); - if (!a.ok) return a; - - const b = await stack.use(createResource("b", events)); - if (!b.ok) return b; - - const c = await stack.use(createResource("c", events)); - if (!c.ok) return c; - - events.push(`using ${a.value.id}, ${b.value.id}, ${c.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual([ - "a acquired", - "b acquired", - "c acquired", - "using a, b, c", - "c released", - "b released", - "a released", - ]); - }); - - test("propagates acquire error and releases acquired resources", async () => { - await using run = createRun(); - - interface AcquireError extends Typed<"AcquireError"> {} - - const events: Array = []; - - const failingResource: Task = () => { - events.push("b failed"); - return err({ type: "AcquireError" }); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const a = await stack.use(createResource("a", events)); - if (!a.ok) return a; - - const b = await stack.use(failingResource); - if (!b.ok) return b; - - const c = await stack.use(createResource("c", events)); - if (!c.ok) return c; - - events.push(`using ${a.value.id}, ${b.value.id}, ${c.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(err({ type: "AcquireError" })); - expect(events).toEqual(["a acquired", "b failed", "a released"]); - }); - - test("releases acquired resources when acquire throws", async () => { - await using run = createRun(); - - const events: Array = []; - - const throwingAcquire: Task = () => { - events.push("b throwing"); - throw new Error("acquire threw"); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const a = await stack.use(createResource("a", events)); - if (!a.ok) return a; - - const b = await stack.use(throwingAcquire); - if (!b.ok) return b; - - const c = await stack.use(createResource("c", events)); - if (!c.ok) return c; - - events.push(`using ${a.value.id}, ${b.value.id}, ${c.value.id}`); - return ok(); - }; - - await expect(run(task)).rejects.toThrow("acquire threw"); - expect(events).toEqual(["a acquired", "b throwing", "a released"]); - }); - - test("acquisition is unabortable", async () => { - await using run = createRun(); - - const events: Array = []; - const canComplete = Promise.withResolvers(); - - const slowAcquire: Task = async ({ signal }) => { - events.push(`acquire started, aborted: ${signal.aborted}`); - await canComplete.promise; - events.push(`acquire completed, aborted: ${signal.aborted}`); - return ok({ - id: "slow", - // eslint-disable-next-line @typescript-eslint/require-await - [Symbol.asyncDispose]: async () => { - events.push("slow released"); - }, - }); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - const a = await stack.use(slowAcquire); - if (!a.ok) return a; - if (run.signal.aborted) { - return err(run.signal.reason); - } - events.push(`using ${a.value.id}`); - return ok(); - }; - - const fiber = run(task); - fiber.abort("stop"); - canComplete.resolve(); - - const result = await fiber; - - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(events).toEqual([ - "acquire started, aborted: false", - "acquire completed, aborted: false", - "slow released", - ]); - }); - - test("accepts sync Disposable", async () => { - await using run = createRun(); - - const events: Array = []; - - interface SyncResource extends Disposable { - readonly id: string; - } - - const createSyncResource = - (id: string): Task => - () => { - events.push(`${id} acquired`); - return ok({ - id, - [Symbol.dispose]: () => { - events.push(`${id} released`); - }, - }); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - const a = await stack.use(createSyncResource("a")); - if (!a.ok) return a; - events.push(`using ${a.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["a acquired", "using a", "a released"]); - }); - - test("accepts null without registering disposal", async () => { - await using run = createRun(); - - const task: Task = async (run) => { - await using stack = run.stack(); - const result = await stack.use(() => ok(null)); - return result; - }; - - expect(await run(task)).toEqual(ok(null)); - }); - - test("accepts undefined without registering disposal", async () => { - await using run = createRun(); - - const task: Task = async (run) => { - await using stack = run.stack(); - const result = await stack.use(() => ok(undefined)); - return result; - }; - - expect(await run(task)).toEqual(ok(undefined)); - }); - - test("accepts direct value (sync)", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const resource: AsyncDisposable = { - // eslint-disable-next-line @typescript-eslint/require-await - [Symbol.asyncDispose]: async () => { - events.push("released"); - }, - }; - - const value = stack.use(resource); - expectTypeOf(value).toEqualTypeOf(); - expect(value).toBe(resource); - - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "released"]); - }); - - test("accepts disposable callable (not mistaken for Task)", async () => { - await using run = createRun(); - - let childRun: Run | null = null; - let stateWhileWorking: RunState | null = null; - - const task: Task = async (run) => { - await using stack = run.stack(); - - // Run is a callable with Symbol.asyncDispose - // use must detect the symbol, not use typeof === "function" - childRun = createRun(); - stack.use(childRun); - - stateWhileWorking = childRun.getState(); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(must(stateWhileWorking).type).toBe("Running"); - expect(must(childRun).getState().type).toBe("Settled"); - }); - - test("accepts moved native stack", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using outerStack = run.stack(); - - // Create inner stack with resources - const innerStack = run.stack(); - innerStack.defer(() => { - events.push("inner cleanup"); - return ok(); - }); - - // Move and add to outer stack - const moved = innerStack.move(); - outerStack.use(moved); - - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["work", "inner cleanup"]); - }); - }); - - describe("adopt", () => { - test("acquires value via task and registers task-based disposal", async () => { - await using run = createRun(); - - const events: Array = []; - - interface Handle { - readonly id: string; - } - - const acquireHandle = - (id: string): Task => - () => { - events.push(`${id} acquired`); - return ok({ id }); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const handle = await stack.adopt(acquireHandle("h1"), (h) => () => { - events.push(`${h.id} released`); - return ok(); - }); - if (!handle.ok) return handle; - - events.push(`using ${handle.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["h1 acquired", "using h1", "h1 released"]); - }); - - test("requires release task without domain errors", async () => { - await using run = createRun(); - - const task: Task = async (run) => { - await using stack = run.stack(); - - const acquire: Task<{ readonly id: string }> = () => ok({ id: "h1" }); - - const releaseOk = - (_: { readonly id: string }): Task => - () => - ok(); - - const result = await stack.adopt(acquire, releaseOk); - if (!result.ok) return result; - - const releaseWithError = - (_: { readonly id: string }): Task => - () => - err({ type: "MyError" }); - // @ts-expect-error release task must not return domain errors - await stack.adopt(acquire, releaseWithError); - - return ok(); - }; - - expect(await run(task)).toEqual(ok()); - }); - - test("disposal is unabortable", async () => { - await using run = createRun(); - - const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); - - const task: Task = async (run) => { - await using stack = run.stack(); - - const handle = await stack.adopt( - () => ok({ id: "h1" }), - (h) => () => { - events.push(`${h.id} released`); - return ok(); - }, - ); - if (!handle.ok) return handle; - - events.push("work started"); - taskStarted.resolve(); - await canComplete.promise; - if (run.signal.aborted) { - return err(run.signal.reason); - } - return ok(); - }; - - const fiber = run(task); - await taskStarted.promise; - fiber.abort("stop"); - canComplete.resolve(); - - const result = await fiber; - - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(events).toEqual(["work started", "h1 released"]); - }); - - test("does not register disposal if acquire fails", async () => { - await using run = createRun(); - - const events: Array = []; - - interface AcquireError extends Typed<"AcquireError"> {} - - const task: Task = async (run) => { - await using stack = run.stack(); - - const handle = await stack.adopt<{ id: string }, AcquireError>( - () => { - events.push("acquire failed"); - return err({ type: "AcquireError" }); - }, - (h) => () => { - events.push(`${h.id} released`); - return ok(); - }, - ); - if (!handle.ok) return handle; - - events.push(`using ${handle.value.id}`); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(err({ type: "AcquireError" })); - // Release should not be called since acquire failed - expect(events).toEqual(["acquire failed"]); - }); - }); - - describe("move", () => { - test("transfers ownership to returned AsyncDisposableStack", async () => { - await using run = createRun(); - - const events: Array = []; - - const createBundle: Task< - { a: Resource; b: Resource } & AsyncDisposable - > = async (run) => { - await using stack = run.stack(); - - const a = await stack.use(createResource("a", events)); - if (!a.ok) return a; - - const b = await stack.use(createResource("b", events)); - if (!b.ok) return b; - - const moved = stack.move(); - return ok({ - a: a.value, - b: b.value, - [Symbol.asyncDispose]: () => moved.disposeAsync(), - }); - }; - - const bundle = await run(createBundle); - expect(bundle.ok).toBe(true); - if (!bundle.ok) throw new Error("unreachable"); - - events.push(`using ${bundle.value.a.id}, ${bundle.value.b.id}`); - - // Resources not released yet - expect(events).toEqual(["a acquired", "b acquired", "using a, b"]); - - // Dispose the bundle - await bundle.value[Symbol.asyncDispose](); - - expect(events).toEqual([ - "a acquired", - "b acquired", - "using a, b", - "b released", - "a released", - ]); - }); - - test("cleans up on early return after move is possible", async () => { - await using run = createRun(); - - const events: Array = []; - const canContinue = Promise.withResolvers(); - - // Abortable factory - if aborted, acquired resources are cleaned up - const createBundle: Task< - { a: Resource; b: Resource } & AsyncDisposable - > = async (run) => { - await using stack = run.stack(); - - const a = await stack.use(createResource("a", events)); - if (!a.ok) return a; - - // Simulate slow acquisition - await canContinue.promise; - - // Check abort after await - if (run.signal.aborted) { - return err(run.signal.reason); - } - - const b = await stack.use(createResource("b", events)); - if (!b.ok) return b; - - const moved = stack.move(); - return ok({ - a: a.value, - b: b.value, - [Symbol.asyncDispose]: () => moved.disposeAsync(), - }); - }; - - const fiber = run(createBundle); - - // Abort while 'a' is acquired but waiting for 'b' - fiber.abort("cancelled"); - canContinue.resolve(); - - const result = await fiber; - - expect(result).toEqual(err({ type: "AbortError", reason: "cancelled" })); - // 'a' was acquired then cleaned up when scope exited - expect(events).toEqual(["a acquired", "a released"]); - }); - }); - - describe("cleanup runs on root scope", () => { - test("defer cleanup survives factory task scope", async () => { - await using run = createRun(); - - const events: Array = []; - - // Factory task creates a resource with Task-based cleanup via defer - const createBundle: Task = async (run) => { - await using stack = run.stack(); - - events.push("factory: acquired"); - stack.defer(() => { - events.push("factory: cleanup via defer"); - return ok(); - }); - - const moved = stack.move(); - return ok({ - [Symbol.asyncDispose]: () => moved.disposeAsync(), - }); - }; - - const bundle = await run(createBundle); - expect(bundle.ok).toBe(true); - if (!bundle.ok) throw new Error("unreachable"); - - events.push("using bundle after factory ended"); - - await bundle.value[Symbol.asyncDispose](); - - expect(events).toEqual([ - "factory: acquired", - "using bundle after factory ended", - "factory: cleanup via defer", - ]); - }); - - test("adopt disposal survives factory task scope", async () => { - await using run = createRun(); - - const events: Array = []; - - interface Handle { - readonly id: string; - } - - // Factory task creates a resource with Task-based disposal via adopt - const createBundle: Task<{ handle: Handle } & AsyncDisposable> = async ( - run, - ) => { - await using stack = run.stack(); - - const handle = await stack.adopt( - () => { - events.push("factory: h1 acquired"); - return ok({ id: "h1" }); - }, - (h) => () => { - events.push(`factory: ${h.id} disposal via adopt`); - return ok(); - }, - ); - if (!handle.ok) return handle; - - const moved = stack.move(); - return ok({ - handle: handle.value, - [Symbol.asyncDispose]: () => moved.disposeAsync(), - }); - }; - - const bundle = await run(createBundle); - expect(bundle.ok).toBe(true); - if (!bundle.ok) throw new Error("unreachable"); - - events.push(`using ${bundle.value.handle.id} after factory ended`); - - await bundle.value[Symbol.asyncDispose](); - - expect(events).toEqual([ - "factory: h1 acquired", - "using h1 after factory ended", - "factory: h1 disposal via adopt", - ]); - }); - }); - - describe("AsyncDisposable with Task-based disposal via run.defer", () => { - interface Resource extends AsyncDisposable { - readonly id: string; - } - - const createResourceFactory = ( - events: Array, - disposalTask?: Task, - ) => { - const createResource: Task = (run) => { - events.push("acquired"); - return ok({ - id: "r1", - ...run.defer( - disposalTask ?? - (() => { - events.push("disposed"); - return ok(); - }), - ), - }); - }; - return createResource; - }; - - test("disposal runs when stack disposes", async () => { - await using run = createRun(); - - const events: Array = []; - - const task: Task = async (run) => { - await using stack = run.stack(); - const r = await stack.use(createResourceFactory(events)); - if (!r.ok) return r; - events.push("work"); - return ok(); - }; - - const result = await run(task); - - expect(result).toEqual(ok()); - expect(events).toEqual(["acquired", "work", "disposed"]); - }); - - test("disposal completes even when parent task is aborted", async () => { - await using run = createRun(); - - const events: Array = []; - const workStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); - - const cleanupHelper = () => { - events.push("cleanup helper ran"); - return ok(); - }; - - const task: Task = async (run) => { - await using stack = run.stack(); - - const r = await stack.use( - createResourceFactory(events, async (run) => { - events.push("disposal started"); - await canComplete.promise; - // Verify run works inside disposal task - await run(cleanupHelper); - events.push("disposal completed"); - return ok(); - }), - ); - if (!r.ok) return r; - - events.push("work started"); - workStarted.resolve(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - if (run.signal.aborted) return err(run.signal.reason); - - events.push("work completed"); - return ok(); - }; - - const fiber = run(task); - await workStarted.promise; - fiber.abort("stop"); - canComplete.resolve(); - - const result = await fiber; + "inner acquire", + // abortable1 skipped (mask=0, abort visible) + "restore2 task (aborted=false)", + "inner release", + ]); + expect(result).toEqual(ok()); + }); - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(events).toEqual([ - "acquired", - "work started", - "disposal started", - "cleanup helper ran", - "disposal completed", - ]); - }); + test("restore throws when used outside its unabortableMask", async () => { + await using run = createRun(); - test("disposal survives factory task scope ending", async () => { - await using run = createRun(); + let restoreFromInner: ((task: Task) => Task) | undefined; - const events: Array = []; + const task = unabortableMask( + (_restore1) => async (run) => + await run( + unabortableMask((restore2) => () => { + // restore2 restores to mask=1 + restoreFromInner = restore2; - const r = await run(createResourceFactory(events)); - expect(r.ok).toBe(true); - if (!r.ok) throw new Error("unreachable"); + return ok(); + }), + ), + ); - events.push("using after factory ended"); - await r.value[Symbol.asyncDispose](); + const result = await run(task); + expect(result).toEqual(ok()); + expect(restoreFromInner).toBeDefined(); + assert(restoreFromInner != null); - expect(events).toEqual([ - "acquired", - "using after factory ended", - "disposed", - ]); - }); + // Using restore2 outside its intended scope would increase abort mask + // (root mask=0, override=1). This must crash. + expect(() => run(restoreFromInner(() => ok()))).toThrow( + "restore used outside its unabortableMask", + ); }); }); @@ -4222,7 +3519,9 @@ describe("DI", () => { () => { attempts++; if (attempts < 3) return err({ type: "NetworkError" }); - return ok(must(url.split("/").pop())); + const lastSegment = url.split("/").pop(); + assert(lastSegment != null); + return ok(lastSegment); }, }; @@ -5128,9 +4427,10 @@ describe("concurrency", () => { expect(await run(setup)).toEqual(ok()); expect(restoreFromInner).toBeDefined(); + assert(restoreFromInner != null); await expect( - run(semaphore.withPermit(must(restoreFromInner)(() => ok()))), + run(semaphore.withPermit(restoreFromInner(() => ok()))), ).rejects.toThrow("restore used outside its unabortableMask"); expect(await run(semaphore.withPermit(() => ok("after-throw")))).toEqual( @@ -5751,6 +5051,45 @@ describe("concurrency", () => { expect(semaphoreByKey.snapshot("a")).toBeNull(); }); + test("shares semaphore for structurally equal object keys", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<{ + readonly ownerId: string; + }>(1); + const events: Array = []; + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const fiber1 = run( + semaphoreByKey.withPermit({ ownerId: "a" }, async () => { + events.push("start 1"); + firstStarted.resolve(); + await firstCanFinish.promise; + events.push("end 1"); + return ok(); + }), + ); + + await firstStarted.promise; + + const fiber2 = run( + semaphoreByKey.withPermit({ ownerId: "a" }, () => { + events.push("task 2"); + return ok(); + }), + ); + + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + firstCanFinish.resolve(); + await Promise.all([fiber1, fiber2]); + + expect(events).toEqual(["start 1", "end 1", "task 2"]); + expect(semaphoreByKey.snapshot({ ownerId: "a" })).toBeNull(); + }); + test("dispose aborts keyed semaphores and future acquire fails", async () => { await using run = createRun(); @@ -6028,6 +5367,78 @@ describe("concurrency", () => { expect(afterAbort).toEqual(ok("after")); }); + test("shares mutex for structurally equal object keys", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<{ readonly id: string }>(); + const events: Array = []; + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const fiber1 = run( + mutexByKey.withLock({ id: "a" }, async () => { + events.push("start 1"); + firstStarted.resolve(); + await firstCanFinish.promise; + events.push("end 1"); + return ok(); + }), + ); + + await firstStarted.promise; + + const fiber2 = run( + mutexByKey.withLock({ id: "a" }, () => { + events.push("task 2"); + return ok(); + }), + ); + + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + firstCanFinish.resolve(); + await Promise.all([fiber1, fiber2]); + + expect(events).toEqual(["start 1", "end 1", "task 2"]); + }); + + test("shares mutex for equal Uint8Array keys", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey(); + const events: Array = []; + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const fiber1 = run( + mutexByKey.withLock(new Uint8Array([1, 2, 3]), async () => { + events.push("start 1"); + firstStarted.resolve(); + await firstCanFinish.promise; + events.push("end 1"); + return ok(); + }), + ); + + await firstStarted.promise; + + const fiber2 = run( + mutexByKey.withLock(new Uint8Array([1, 2, 3]), () => { + events.push("task 2"); + return ok(); + }), + ); + + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + firstCanFinish.resolve(); + await Promise.all([fiber1, fiber2]); + + expect(events).toEqual(["start 1", "end 1", "task 2"]); + }); + test("dispose aborts keyed locks and future acquire fails", async () => { await using run = createRun(); @@ -6079,7 +5490,7 @@ describe("concurrency", () => { } describe("get", () => { - test("returns initial state", async () => { + test("returns initial value", async () => { await using run = testCreateRun(); using ref = createMutexRef(42); @@ -6088,7 +5499,7 @@ describe("concurrency", () => { }); describe("set", () => { - test("updates state", async () => { + test("updates value", async () => { await using run = testCreateRun(); using ref = createMutexRef(0); @@ -6098,7 +5509,7 @@ describe("concurrency", () => { }); describe("getAndSet", () => { - test("returns previous state and updates state", async () => { + test("returns previous value and updates value", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6108,7 +5519,7 @@ describe("concurrency", () => { }); describe("setAndGet", () => { - test("returns updated state", async () => { + test("returns updated value", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6118,7 +5529,7 @@ describe("concurrency", () => { }); describe("update", () => { - test("updates state with a taskful updater", async () => { + test("updates value with a taskful updater", async () => { await using run = testCreateRun({ prefix: "dep" }); using ref = createMutexRef("value"); @@ -6135,7 +5546,7 @@ describe("concurrency", () => { expect(await run(ref.get)).toEqual(ok("dep-value")); }); - test("keeps state unchanged when updater fails", async () => { + test("keeps value unchanged when updater fails", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6184,7 +5595,7 @@ describe("concurrency", () => { }); describe("getAndUpdate", () => { - test("returns previous state and updates state", async () => { + test("returns previous value and updates value", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6194,7 +5605,7 @@ describe("concurrency", () => { expect(await run(ref.get)).toEqual(ok(2)); }); - test("returns error and keeps state unchanged", async () => { + test("returns error and keeps value unchanged", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6207,7 +5618,7 @@ describe("concurrency", () => { }); describe("updateAndGet", () => { - test("returns updated state", async () => { + test("returns updated value", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6217,7 +5628,7 @@ describe("concurrency", () => { expect(await run(ref.get)).toEqual(ok(2)); }); - test("returns error and keeps state unchanged", async () => { + test("returns error and keeps value unchanged", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6230,7 +5641,7 @@ describe("concurrency", () => { }); describe("modify", () => { - test("returns a computed result and updates state", async () => { + test("returns a computed result and updates value", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6245,7 +5656,7 @@ describe("concurrency", () => { expect(await run(ref.get)).toEqual(ok(2)); }); - test("returns error and keeps state unchanged", async () => { + test("returns error and keeps value unchanged", async () => { await using run = testCreateRun(); using ref = createMutexRef(1); @@ -6285,20 +5696,80 @@ describe("concurrency", () => { err({ type: "AbortError", reason: semaphoreDisposedError }), ); }); + + test("aborts set after disposal", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + ref[Symbol.dispose](); + + expect(await run(ref.set(2))).toEqual( + err({ type: "AbortError", reason: semaphoreDisposedError }), + ); + expect(await run(ref.get)).toEqual( + err({ type: "AbortError", reason: semaphoreDisposedError }), + ); + }); + + test("dispose prevents an in-flight abortable update", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + const updateStarted = Promise.withResolvers(); + const updateCanFinish = Promise.withResolvers(); + const updateFiber = run( + ref.update((current) => async () => { + updateStarted.resolve(); + await updateCanFinish.promise; + return ok(current + 1); + }), + ); + + await updateStarted.promise; + + ref[Symbol.dispose](); + updateCanFinish.resolve(); + + expect(await updateFiber).toEqual( + err({ type: "AbortError", reason: semaphoreDisposedError }), + ); + }); + + test("dispose does not prevent an in-flight unabortable update", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + const updateStarted = Promise.withResolvers(); + const updateCanFinish = Promise.withResolvers(); + const updateFiber = run( + unabortable( + ref.update((current) => async () => { + updateStarted.resolve(); + await updateCanFinish.promise; + return ok(current + 1); + }), + ), + ); + + await updateStarted.promise; + + ref[Symbol.dispose](); + updateCanFinish.resolve(); + + expect(await updateFiber).toEqual(ok()); + }); }); }); describe("InMemoryLeaderLock", () => { - test("acquire waits until previous lease is disposed", async () => { + test("lock waits until previous lease is disposed", async () => { await using run = createRun(); const leaderLock = createInMemoryLeaderLock(); - const first = await run(leaderLock.acquire(testName)); - expect(first.ok).toBe(true); - if (!first.ok) return; + const first = await run.orThrow(leaderLock.lock(testName)); let secondSettled = false; - const second = run(leaderLock.acquire(testName)); + const second = run(leaderLock.lock(testName)); void second.then(() => { secondSettled = true; }); @@ -6306,16 +5777,13 @@ describe("concurrency", () => { await run(yieldNow); expect(secondSettled).toBe(false); - first.value[Symbol.dispose](); - - const secondResult = await second; - expect(secondResult.ok).toBe(true); - if (!secondResult.ok) return; + await first[Symbol.asyncDispose](); - secondResult.value[Symbol.dispose](); + const secondLease = await run.orThrow(() => second); + await secondLease[Symbol.asyncDispose](); }); - test("different names acquire independently", async () => { + test("different names lock independently", async () => { await using run = createRun(); const leaderLock = createInMemoryLeaderLock(); @@ -6323,15 +5791,90 @@ describe("concurrency", () => { const bName = Name.orThrow("LeaderLockB"); const [a, b] = await Promise.all([ - run(leaderLock.acquire(aName)), - run(leaderLock.acquire(bName)), + run.orThrow(leaderLock.lock(aName)), + run.orThrow(leaderLock.lock(bName)), ]); - expect(a.ok).toBe(true); - expect(b.ok).toBe(true); + await a[Symbol.asyncDispose](); + await b[Symbol.asyncDispose](); + }); + + test("root Run disposal releases lease-owned lock wait", async () => { + const run = createRun(); + const leaderLock = createInMemoryLeaderLock(); + + await run.orThrow(leaderLock.lock(testName)); + + const disposePromise = run[Symbol.asyncDispose](); + await testWaitForMacrotask(); + expect(run.getState().type).toBe("Settled"); + await disposePromise; + }); + + test("waiting lock caller aborts when root Run disposes", async () => { + const run = createRun(); + const leaderLock = createInMemoryLeaderLock(); + + const first = await run.orThrow(leaderLock.lock(testName)); + + const second = run(leaderLock.lock(testName)); + await run(yieldNow); + + const disposePromise = run[Symbol.asyncDispose](); + await expect(second).resolves.toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + await disposePromise; + + await first[Symbol.asyncDispose](); + }); + + test("aborting waiting lock caller releases leadership", async () => { + await using run = createRun(); + const leaderLock = createInMemoryLeaderLock(); + + const first = await run.orThrow(leaderLock.lock(testName)); + + const second = run(leaderLock.lock(testName)); + await run(yieldNow); + + second.abort("stop"); + await expect(second).resolves.toEqual( + err({ type: "AbortError", reason: "stop" }), + ); + + await first[Symbol.asyncDispose](); + + const third = await run.orThrow(leaderLock.lock(testName)); + await third[Symbol.asyncDispose](); + }); + + test("acquisition does not hang when background lease run rejects", async () => { + const leaderLock = createInMemoryLeaderLock(); + const disposeLeaseRun = vi.fn(async () => {}); + + const rejectingLeaseRun = Object.assign( + (() => Promise.reject("lease-failed")) as unknown as Run, + { + [Symbol.asyncDispose]: disposeLeaseRun, + }, + ); - if (a.ok) a.value[Symbol.dispose](); - if (b.ok) b.value[Symbol.dispose](); + let fakeRun!: Run; + fakeRun = Object.assign( + ((task: Task) => + Promise.resolve(task(fakeRun))) as unknown as Run, + { + create: () => rejectingLeaseRun, + onAbort: () => undefined, + [Symbol.asyncDispose]: async () => {}, + }, + ); + + await expect(leaderLock.lock(testName)(fakeRun)).resolves.toEqual( + err({ type: "AbortError", reason: "lease-failed" }), + ); + expect(disposeLeaseRun).toHaveBeenCalled(); }); }); }); @@ -6740,6 +6283,26 @@ describe("all", () => { if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); expect(events).toEqual(["task a", "task b"]); }); + + test("collect: false returns void for empty array", async () => { + await using run = createRun(); + + const emptyTasks: Array> = []; + const result = await run(all(emptyTasks, { collect: false })); + + expect(result).toStrictEqual(ok(undefined)); + if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); + }); + + test("collect: false returns void for empty record", async () => { + await using run = createRun(); + + const emptyTasks: Record> = {}; + const result = await run(all(emptyTasks, { collect: false })); + + expect(result).toStrictEqual(ok(undefined)); + if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); + }); }); describe("allSettled", () => { @@ -7054,6 +6617,26 @@ describe("allSettled", () => { if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); expect(events).toEqual(["task a", "task b"]); }); + + test("collect: false returns void for empty array", async () => { + await using run = createRun(); + + const emptyTasks: Array> = []; + const result = await run(allSettled(emptyTasks, { collect: false })); + + expect(result).toStrictEqual(ok(undefined)); + if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); + }); + + test("collect: false returns void for empty record", async () => { + await using run = createRun(); + + const emptyTasks: Record> = {}; + const result = await run(allSettled(emptyTasks, { collect: false })); + + expect(result).toStrictEqual(ok(undefined)); + if (result.ok) expectTypeOf(result.value).toEqualTypeOf(); + }); }); describe("map", () => { @@ -7662,7 +7245,7 @@ describe("fetch", () => { }); describe("examples TODO", () => { - describe("composition types from JSDoc", () => { + describe.skip("composition types from JSDoc", () => { // These tests verify the types shown in Task.ts JSDoc examples are accurate. // No runtime behavior - just type-level assertions. @@ -7674,14 +7257,12 @@ describe("examples TODO", () => { readonly fetch: typeof globalThis.fetch; } - // Simulated fetch task for type checks and controlled runtime behavior. - const fetch = - (_url: string): Task => - () => - err({ - type: "FetchError", - error: new Error("Not implemented - type test only"), - }); + // Simulated fetch task - typed but never executed + const fetch = ( + _url: string, + ): Task => { + throw new Error("Not implemented - type test only"); + }; test("timeout adds TimeoutError to error union", () => { const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); @@ -7712,7 +7293,6 @@ describe("examples TODO", () => { const deps: RunDeps & NativeFetchDep = { ...testCreateDeps(), fetch: globalThis.fetch, - time: createTime(), }; await using run = createRun(deps); diff --git a/packages/common/test/TreeShaking.test.ts b/packages/common/test/TreeShaking.test.ts index 2c48b2bb5..557ab69c3 100644 --- a/packages/common/test/TreeShaking.test.ts +++ b/packages/common/test/TreeShaking.test.ts @@ -184,8 +184,8 @@ const normalizeBundleSize = ( } if (fixture === "task-example") { - if (gzip >= 5350 && gzip <= 5450) gzip = 5395; - if (raw >= 14250 && raw <= 14500) raw = 14356; + if (gzip >= 5100 && gzip <= 5225) gzip = 5150; + if (raw >= 13400 && raw <= 13700) raw = 13516; } if (fixture === "type-object") { @@ -215,8 +215,8 @@ describe("tree-shaking", () => { "raw": 1602, }, "task-example": { - "gzip": 5395, - "raw": 14356, + "gzip": 5150, + "raw": 13516, }, "type-object": { "gzip": 1937, diff --git a/packages/common/test/Type.test.ts b/packages/common/test/Type.test.ts index 0bc0fb36c..7490ae9df 100644 --- a/packages/common/test/Type.test.ts +++ b/packages/common/test/Type.test.ts @@ -936,8 +936,8 @@ test("id", () => { expectTypeOf(UserId.Parent).toEqualTypeOf(); expectTypeOf(UserId.ParentError).toEqualTypeOf(); - const OrderId = id("Order"); - type OrderId = typeof OrderId.Type; + const _OrderId = id("Order"); + type OrderId = typeof _OrderId.Type; expectTypeOf().not.toEqualTypeOf(); }); diff --git a/packages/common/test/WebSocket.test.ts b/packages/common/test/WebSocket.test.ts index d6afb79a9..9c58610c1 100644 --- a/packages/common/test/WebSocket.test.ts +++ b/packages/common/test/WebSocket.test.ts @@ -3,7 +3,11 @@ import { utf8ToBytes } from "../src/Buffer.js"; import { isServer } from "../src/Platform.js"; import { spaced, take } from "../src/Schedule.js"; import { createRunner } from "../src/Task.js"; -import { createWebSocket, type WebSocketError } from "../src/WebSocket.js"; +import { + createWebSocket, + testCreateWebSocket, + type WebSocketError, +} from "../src/WebSocket.js"; declare module "vitest/browser" { interface BrowserCommands { @@ -51,6 +55,17 @@ const envValue = const browserWsEnabled = envValue === "1"; const wsTest = isServer || browserWsEnabled ? test : test.skip; +test("testCreateWebSocket mirrors production ready states", async () => { + const result = await testCreateWebSocket({ isOpen: false })()(); + assert(result.ok); + + const ws = result.value; + expect(ws.getReadyState()).toBe("connecting"); + + await ws[Symbol.asyncDispose](); + expect(ws.getReadyState()).toBe("closed"); +}); + wsTest("connects, receives message, sends message, and disposes", async () => { await using run = createRunner(); @@ -78,7 +93,7 @@ wsTest("connects, receives message, sends message, and disposes", async () => { await vi.waitFor(() => expect(messages).toHaveLength(2)); expect(messages).toEqual([utf8ToBytes("welcome"), utf8ToBytes("hello")]); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); expect(ws.getReadyState()).toBe("closed"); }); @@ -102,7 +117,7 @@ wsTest("calls onOpen callback", async () => { expect(ws.isOpen()).toBe(true); expect(ws.getReadyState()).toBe("open"); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); expect(ws.isOpen()).toBe(false); }); @@ -128,7 +143,7 @@ wsTest("does not call onClose when disposed", async () => { await vi.waitFor(() => expect(openCalled).toBe(true)); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); expect(closeCalled).toBe(false); }); @@ -141,10 +156,8 @@ wsTest("send returns error when socket is not ready", async () => { assert(result.ok); const ws = result.value; - // Dispose immediately to close socket - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); - // Now send should fail const sendResult = ws.send("test"); expect(sendResult.ok).toBe(false); if (!sendResult.ok) { @@ -170,7 +183,7 @@ wsTest("supports protocols as array", async () => { const ws = result.value; await vi.waitFor(() => expect(openCalled).toBe(true)); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("supports protocols as string", async () => { @@ -191,13 +204,12 @@ wsTest("supports protocols as string", async () => { const ws = result.value; await vi.waitFor(() => expect(openCalled).toBe(true)); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("getReadyState returns connecting when socket is null", async () => { await using run = createRunner(); - // Create with invalid URL and no retries to test null socket state const result = await run( createWebSocket("ws://localhost:1", { schedule: take(0)(spaced("1ms")), @@ -207,10 +219,9 @@ wsTest("getReadyState returns connecting when socket is null", async () => { assert(result.ok); const ws = result.value; - // After failed connection with no retries, socket is null await vi.waitFor(() => expect(ws.getReadyState()).toBe("connecting")); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("calls onError on connection failure", async () => { @@ -218,10 +229,9 @@ wsTest("calls onError on connection failure", async () => { const errors: Array = []; - // Use invalid port to trigger connection error const result = await run( createWebSocket("ws://localhost:1", { - schedule: take(0)(spaced("1ms")), // No retry - fail immediately + schedule: take(0)(spaced("1ms")), onError: (error) => { errors.push(error); }, @@ -234,7 +244,7 @@ wsTest("calls onError on connection failure", async () => { await vi.waitFor(() => expect(errors.length).toBeGreaterThan(0)); expect(errors[0]?.type).toBe("WebSocketConnectError"); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("calls onClose when server closes connection", async () => { @@ -244,7 +254,7 @@ wsTest("calls onClose when server closes connection", async () => { const result = await run( createWebSocket(getServerUrl("close"), { - schedule: take(0)(spaced("1ms")), // No retry + schedule: take(0)(spaced("1ms")), onClose: () => { closeCalled = true; }, @@ -255,8 +265,7 @@ wsTest("calls onClose when server closes connection", async () => { const ws = result.value; await vi.waitFor(() => expect(closeCalled).toBe(true)); - - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("does not retry when shouldRetryOnClose returns false", async () => { @@ -287,7 +296,7 @@ wsTest("does not retry when shouldRetryOnClose returns false", async () => { expect(closeCount).toBe(1); expect(errors).toHaveLength(0); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("reconnects after server closes connection", async () => { @@ -299,7 +308,7 @@ wsTest("reconnects after server closes connection", async () => { const result = await run( createWebSocket(getServerUrl("close-after-message"), { binaryType: "arraybuffer", - schedule: spaced("1ms"), // Fast retry + schedule: spaced("1ms"), onMessage: (data) => { assert(data instanceof ArrayBuffer); messages.push(new Uint8Array(data)); @@ -313,15 +322,13 @@ wsTest("reconnects after server closes connection", async () => { assert(result.ok); const ws = result.value; - // Trigger close by sending a message (server closes after first message) await vi.waitFor(() => expect(messages).toHaveLength(1)); ws.send("trigger-close"); - // Wait for reconnection (should receive "hello" from both connections) await vi.waitFor(() => expect(messages.length).toBeGreaterThanOrEqual(2)); expect(closeCount).toBeGreaterThan(0); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("reports RetryError when schedule is exhausted", async () => { @@ -329,11 +336,9 @@ wsTest("reports RetryError when schedule is exhausted", async () => { const errors: Array = []; - // Use close endpoint so each connection attempt succeeds then closes, - // triggering retry until schedule is exhausted const result = await run( createWebSocket(getServerUrl("close"), { - schedule: take(2)(spaced("1ms")), // Allow 2 retries then exhaust + schedule: take(2)(spaced("1ms")), onError: (error) => { errors.push(error); }, @@ -350,7 +355,7 @@ wsTest("reports RetryError when schedule is exhausted", async () => { ] `); - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); wsTest("WebSocketConnectionError behavior on abrupt termination", async () => { @@ -361,7 +366,7 @@ wsTest("WebSocketConnectionError behavior on abrupt termination", async () => { const result = await run( createWebSocket(getServerUrl("terminate"), { - schedule: take(0)(spaced("1ms")), // No retry + schedule: take(0)(spaced("1ms")), onError: (error) => { errors.push(error); }, @@ -376,29 +381,40 @@ wsTest("WebSocketConnectionError behavior on abrupt termination", async () => { await vi.waitFor(() => expect(closeCalled).toBe(true), { timeout: 2000 }); - // Map errors to snapshot-friendly shape (Event internals differ across platforms) const mapped = errors.map((e) => e.type === "RetryError" ? { type: e.type, attempts: e.attempts, causeType: e.cause.type } : { type: e.type }, ); - // Assert invariants instead of exact snapshots (platform differences exist) - // All platforms should receive a RetryError with WebSocketConnectionCloseError cause - const retryError = mapped.find((e) => e.type === "RetryError"); - expect(retryError).toBeDefined(); - expect(retryError).toMatchObject({ - type: "RetryError", - attempts: 1, - causeType: "WebSocketConnectionCloseError", - }); - - // Some platforms (Server/WebKit) also fire WebSocketConnectionError, but it's optional - const hasConnectionError = mapped.some( - (e) => e.type === "WebSocketConnectionError", - ); - // Just verify it's present or not, don't assert a specific expectation - expect(typeof hasConnectionError).toBe("boolean"); + const isWebKit = + !isServer && + (await import("vitest/browser").then((m) => m.server.browser === "webkit")); + + if (isWebKit) { + expect(mapped).toMatchInlineSnapshot(` + [ + { + "type": "WebSocketConnectionError", + }, + { + "attempts": 1, + "causeType": "WebSocketConnectionCloseError", + "type": "RetryError", + }, + ] + `); + } else { + expect(mapped).toMatchInlineSnapshot(` + [ + { + "attempts": 1, + "causeType": "WebSocketConnectionCloseError", + "type": "RetryError", + }, + ] + `); + } - ws[Symbol.dispose](); + await ws[Symbol.asyncDispose](); }); diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index ecdb98033..253742008 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -34,7 +34,7 @@ const require = createRequire(import.meta.url); export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; -/** In-memory better-sqlite3 driver for tests. */ +/** In-memory sqlite driver for tests with Bun and Node fallbacks. */ export const testCreateSqliteDriver: CreateSqliteDriver = (name) => createBetterSqliteDriver(name, { mode: "memory" }); @@ -55,9 +55,7 @@ export const testCreateSqliteDeps: TestCreateSqliteDeps = Object.assign( export const testCreateRunWithSqlite = async (): Promise< Run -> => { - return createTestRunWithSqlite(testCreateSqliteDeps); -}; +> => createTestRunWithSqlite(testCreateSqliteDeps); interface StatementLike { readonly reader?: boolean; diff --git a/packages/common/test/local-first/Db.internal.test.ts b/packages/common/test/local-first/Db.internal.test.ts index 6d437a812..810cbef8e 100644 --- a/packages/common/test/local-first/Db.internal.test.ts +++ b/packages/common/test/local-first/Db.internal.test.ts @@ -7,6 +7,7 @@ import { testTryApplyQuarantinedMessages, } from "../../src/local-first/Db.js"; import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; +import { encodeAndEncryptDbChange } from "../../src/local-first/Protocol.js"; import { serializeQuery } from "../../src/local-first/Query.js"; import type { DbWorkerInput, @@ -149,6 +150,22 @@ test("testHandleMutation writes local and shared changes", async () => { expect(result.value.type).toBe("Mutate"); expect(result.value.messagesByOwnerId.size).toBe(1); + const ownerMessages = result.value.messagesByOwnerId.get(testAppOwner.id); + expect(ownerMessages).toBeDefined(); + if (!ownerMessages) return; + + const expectedStoredBytes = ownerMessages.reduce( + (sum, message) => + sum + encodeAndEncryptDbChange(deps)(message, deps.encryptionKey).length, + 0, + ); + const usageRows = run.deps.sqlite.exec<{ storedBytes: number }>(sql` + select storedBytes + from evolu_usage + where ownerId = ${ownerIdToOwnerIdBytes(testAppOwner.id)}; + `).rows; + expect(usageRows).toEqual([{ storedBytes: expectedStoredBytes }]); + const todoRows = run.deps.sqlite.exec<{ title: string }>(sql` select title from todo order by title; `).rows; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 112e2eb04..140d66f62 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -1504,7 +1504,7 @@ describe("integration tests", () => { "name": "evolu_config", "rows": [ { - "clock": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], + "clock": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], }, ], }, @@ -1513,18 +1513,18 @@ describe("integration tests", () => { "rows": [ { "column": "title", - "id": uint8:[160,113,55,152,72,115,160,45,137,237,156,223,234,49,112,82], + "id": uint8:[217,98,93,222,71,108,31,191,50,211,245,208,146,84,136,116], "ownerId": uint8:[213,187,31,214,138,191,248,80,138,181,64,156,48,57,155,184], "table": "todo", - "timestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], + "timestamp": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], "value": "Integration todo", }, { "column": "createdAt", - "id": uint8:[160,113,55,152,72,115,160,45,137,237,156,223,234,49,112,82], + "id": uint8:[217,98,93,222,71,108,31,191,50,211,245,208,146,84,136,116], "ownerId": uint8:[213,187,31,214,138,191,248,80,138,181,64,156,48,57,155,184], "table": "todo", - "timestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], + "timestamp": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], "value": "1970-01-01T00:00:00.000Z", }, ], @@ -1538,11 +1538,11 @@ describe("integration tests", () => { "rows": [ { "c": 1, - "h1": 104312911511672, - "h2": 160957934804849, + "h1": 160625592932811, + "h2": 63811512510140, "l": 1, "ownerId": uint8:[213,187,31,214,138,191,248,80,138,181,64,156,48,57,155,184], - "t": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], + "t": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], }, ], }, @@ -1550,8 +1550,8 @@ describe("integration tests", () => { "name": "evolu_usage", "rows": [ { - "firstTimestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], - "lastTimestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], + "firstTimestamp": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], + "lastTimestamp": uint8:[0,0,0,0,0,0,0,1,76,30,181,71,191,84,133,34], "ownerId": uint8:[213,187,31,214,138,191,248,80,138,181,64,156,48,57,155,184], "storedBytes": 105, }, @@ -1562,7 +1562,7 @@ describe("integration tests", () => { "rows": [ { "createdAt": "1970-01-01T00:00:00.000Z", - "id": "oHE3mEhzoC2J7Zzf6jFwUg", + "id": "2WJd3kdsH78y0_XQklSIdA", "isCompleted": null, "isDeleted": null, "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 0349e3e32..6078682af 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -28,6 +28,7 @@ import { } from "../../src/Worker.js"; describe("initSharedWorker", () => { + // TODO: Replace with a Run with deps. const setupWorker = async ( consoleStoreOutputEntry: ReadonlyStore = createStore( null, diff --git a/packages/common/test/local-first/Sync.test.ts b/packages/common/test/local-first/Sync.test.ts index e5b10b944..57173018e 100644 --- a/packages/common/test/local-first/Sync.test.ts +++ b/packages/common/test/local-first/Sync.test.ts @@ -1086,6 +1086,52 @@ test("createSync invokes websocket lifecycle handlers and disposes deferred sock expect(warns.some((entry) => entry[1] === "onError")).toBe(true); }); +test("createSync logs socket dispose failures and still tears down", async () => { + await using run = await testCreateRunWithSqlite(); + prepareSyncTables(run.deps); + + const warns: Array> = []; + let socketDisposed = 0; + + const sync = createSync({ + ...run.deps, + console: { + ...run.deps.console, + warn: (...args) => { + warns.push(args); + }, + }, + clock: createInMemoryClock(run.deps), + sqliteSchema: testSqliteSchema, + createWebSocket: () => async () => + ok({ + send: () => ok(), + getReadyState: () => "open", + isOpen: () => true, + [Symbol.asyncDispose]: async () => { + socketDisposed += 1; + throw new Error("socket-dispose-failed"); + }, + }), + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })({ + appOwner: testAppOwner, + transports: [{ type: "WebSocket", url: "ws://localhost:4011" }], + disposalDelayMs: 0, + onError: () => {}, + onReceive: () => {}, + }); + + sync.useOwner(true, testAppOwner); + await new Promise((resolve) => setTimeout(resolve, 0)); + + sync[Symbol.dispose](); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(socketDisposed).toBe(1); + expect(warns.some((entry) => entry[1] === "disposeSocketFailed")).toBe(true); +}); + test("createSync logs warning when removing consumer with mismatched transports", async () => { await using run = await testCreateRunWithSqlite(); prepareSyncTables(run.deps); diff --git a/packages/common/vitest.browser.config.ts b/packages/common/vitest.browser.config.ts index eab8a080e..acb7120a7 100644 --- a/packages/common/vitest.browser.config.ts +++ b/packages/common/vitest.browser.config.ts @@ -5,8 +5,11 @@ import { defineProject } from "vitest/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Coverage with v8 only works with a single browser instance -const isCoverage = process.argv.includes("--coverage"); +// Coverage with v8 only works with a single browser instance. The VS Code +// Vitest extension enables coverage internally instead of passing --coverage, +// so extension runs need the same browser setup. +const isSingleBrowserRun = + process.argv.includes("--coverage") || process.env.VITEST_VSCODE === "true"; export default defineProject({ // Transpile `using`/`await using` for WebKit which doesn't support it yet @@ -22,7 +25,7 @@ export default defineProject({ "test/Identicon.test.ts", // needs canvas "test/Redacted.test.ts", // uses node:util ], - name: "browser", + name: "@evolu/common", setupFiles: ["./test/_browserSetup.ts"], browser: { enabled: true, @@ -40,7 +43,7 @@ export default defineProject({ await closeServer(port); }, }, - instances: isCoverage + instances: isSingleBrowserRun ? [{ browser: "chromium" }] : [ { browser: "chromium" }, diff --git a/packages/common/vitest.unit.config.ts b/packages/common/vitest.unit.config.ts index 7bb8028b2..596cf6a18 100644 --- a/packages/common/vitest.unit.config.ts +++ b/packages/common/vitest.unit.config.ts @@ -11,7 +11,7 @@ export default defineProject({ ], setupFiles: ["./test/_nodeSetup.ts"], include: ["test/**/*.test.ts"], - name: "unit", + name: "@evolu/common (nodejs)", environment: "node", }, }); diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index e6eebd3b1..be5b6acb8 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -28,14 +28,14 @@ "clean": "bun ../../scripts/rm.mts .turbo node_modules dist coverage" }, "dependencies": { - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.8.0", "ws": "^8.19.0" }, "devDependencies": { "@evolu/common": "workspace:*", "@evolu/tsconfig": "workspace:*", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^25.3.3", + "@types/node": "^25.5.0", "@types/ws": "^8.18.1", "typescript": "^5.9.3" }, diff --git a/packages/nodejs/src/Task.ts b/packages/nodejs/src/Task.ts index c27d63ac4..90a315abc 100644 --- a/packages/nodejs/src/Task.ts +++ b/packages/nodejs/src/Task.ts @@ -5,11 +5,11 @@ */ import { - type CreateRunner, - createRunner as createCommonRunner, + type CreateRun, + createRun as createCommonRun, createUnknownError, - type Runner, - type RunnerDeps, + type Run, + type RunDeps, } from "@evolu/common"; /** @@ -19,7 +19,7 @@ import { * `SIGHUP` (console close/terminal disconnect), or `SIGBREAK` (Windows * Ctrl-Break). * - * @group Node.js Runner + * @group Node.js Run */ export type Shutdown = Promise; @@ -28,42 +28,36 @@ export interface ShutdownDep { } /** - * Creates {@link Runner} for Node.js with global error handling and graceful + * Creates {@link Run} for Node.js with global error handling and graceful * shutdown. * * Registers `uncaughtException` and `unhandledRejection` handlers that log * errors and initiate graceful shutdown. Adds a `shutdown` promise to deps that * resolves on termination signals (`SIGINT`, `SIGTERM`, `SIGHUP`). Handlers are - * removed when the Runner is disposed. + * removed when the Run is disposed. * * ### Example * * ```ts - * const console = createConsole({ - * formatter: createConsoleFormatter()({ - * timestampFormat: "relative", - * }), - * }); - * * const deps = { ...createRelayDeps(), console }; * - * await using run = createRunner(deps); - * await using stack = run.stack(); + * await using run = createRun(deps); + * await using stack = new AsyncDisposableStack(); * - * await stack.use(startRelay({ port: 4000 })); + * stack.use(await run.orThrow(startRelay({ port: 4000 }))); * * await run.deps.shutdown; * ``` * - * @group Node.js Runner + * @group Node.js Run */ -export const createRunner: CreateRunner = ( +export const createRun: CreateRun = ( deps?: D, -): Runner => { +): Run => { const { promise: shutdown, resolve: resolveShutdown } = Promise.withResolvers(); - const run = createCommonRunner({ ...deps, shutdown } as D & ShutdownDep); + const run = createCommonRun({ ...deps, shutdown } as D & ShutdownDep); const console = run.deps.console.child("global"); @@ -99,7 +93,6 @@ export const createRunner: CreateRunner = ( }; /** - * @deprecated Use {@link createRunner}. Kept for `upstream/common-v8` - * compatibility. + * @deprecated Use {@link createRun}. Kept for fork compatibility. */ -export const createRun: typeof createRunner = createRunner; +export const createRunner: typeof createRun = createRun; diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index a349198f9..e04832778 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -2,15 +2,14 @@ import { existsSync } from "node:fs"; import { createServer } from "node:http"; import { type CreateSqliteDriverDep, - callback, createRandom, createRelation, createSqlite, isPromiseLike, + Name, type OwnerId, ok, type RandomDep, - SimpleName, type Task, type TimingSafeEqualDep, Uint8Array, @@ -53,12 +52,37 @@ export const createRelayDeps = (): RelayDeps => ({ * ### Example * * ```ts + * // Ensure the database is created in a predictable location for Docker. + * mkdirSync("data", { recursive: true }); + * process.chdir("data"); + * + * const console = createConsole({ + * // level: "debug", + * formatter: createConsoleFormatter()({ + * timestampFormat: "relative", + * }), + * }); + * * const deps = { ...createRelayDeps(), console }; * * await using run = createRun(deps); - * await using stack = run.stack(); + * await using stack = new AsyncDisposableStack(); + * + * stack.use( + * await run.orThrow( + * startRelay({ + * port: 4000, + * + * // Note: Relay requires URL in format ws://host:port/ + * // isOwnerAllowed: (_ownerId) => true, * - * await stack.use(startRelay({ port: 4000 })); + * isOwnerWithinQuota: (_ownerId, requiredBytes) => { + * const maxBytes = 1024 * 1024; // 1MB + * return requiredBytes <= maxBytes; + * }, + * }), + * ), + * ); * * await run.deps.shutdown; * ``` @@ -66,20 +90,21 @@ export const createRelayDeps = (): RelayDeps => ({ export const startRelay = ({ port = 443, - name = SimpleName.orThrow("evolu-relay"), + name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig): Task => async (run) => { - await using stack = run.stack(); + await using stack = new AsyncDisposableStack(); const console = run.deps.console.child("relay"); const dbFileExists = existsSync(`${name}.db`); - const sqliteResult = await stack.use(createSqlite(name)); + const sqliteResult = await run(createSqlite(name)); if (!sqliteResult.ok) return sqliteResult; + const sqlite = stack.use(sqliteResult.value); - const deps = { ...run.deps, sqlite: sqliteResult.value }; + const deps = { ...run.deps, sqlite }; if (!dbFileExists) { createBaseSqliteStorageTables(deps); @@ -90,9 +115,7 @@ export const startRelay = isOwnerWithinQuota, }); - // Use root daemon runner for WS callbacks; task-scoped runner closes - // after startRelay returns and would reject message handling with - // RunnerClosingError. + // WebSocket callbacks outlive the startRelay task scope. const daemonRun = run.daemon.addDeps({ storage }); const server = createServer(); @@ -149,6 +172,7 @@ export const startRelay = rejectUpgrade(401, "Unauthorized"); return; } + completeUpgrade(); } catch (error) { console.error("owner authorization failed", error); @@ -183,15 +207,13 @@ export const startRelay = const sockets = ownerSocketRelation.getB(ownerId); if (!sockets) return; - let broadcastCount = 0; for (const socket of sockets) { if (socket !== ws && socket.readyState === WebSocket.OPEN) { socket.send(message, { binary: true }); - broadcastCount++; } } - console.debug("broadcast", ownerId, broadcastCount, sockets.size); + console.debug("broadcast", ownerId, sockets.size); }, }; @@ -219,22 +241,21 @@ export const startRelay = // Cleanup runs in LIFO order: clients → WebSocketServer → HTTP server stack.defer(() => { console.info("Shutdown complete"); - return ok(); }); - stack.defer( - callback(({ ok }) => { - const serverWithCloseAll = server as typeof server & { - closeAllConnections?: () => void; - }; + stack.defer(async () => { + const serverWithCloseAll = server as typeof server & { + closeAllConnections?: () => void; + }; + await new Promise((resolve) => { let settled = false; const timeout = setTimeout(() => { if (settled) return; settled = true; serverWithCloseAll.closeAllConnections?.(); console.warn("HTTP server close timed out"); - ok(); + resolve(); }, 1_000); timeout.unref?.(); @@ -243,15 +264,14 @@ export const startRelay = settled = true; clearTimeout(timeout); console.info("HTTP server closed"); - ok(); + resolve(); }); - }), - ); + }); + }); - stack.defer( - callback(({ ok }) => { - // wss.close() emits 'close' when all clients have disconnected. - // Guard with timeout to avoid hanging shutdown on stale sockets. + stack.defer(async () => { + // wss.close() emits 'close' when all clients have disconnected. + await new Promise((resolve) => { let settled = false; const timeout = setTimeout(() => { if (settled) return; @@ -260,7 +280,7 @@ export const startRelay = client.terminate(); } console.warn("WebSocketServer close timed out"); - ok(); + resolve(); }, 1_000); timeout.unref?.(); @@ -269,26 +289,23 @@ export const startRelay = settled = true; clearTimeout(timeout); console.info("WebSocketServer closed"); - ok(); + resolve(); }); - }), - ); - - stack.defer( - callback(({ ok }) => { - console.info("Shutting down..."); - for (const client of wss.clients) { - if ( - client.readyState === WebSocket.OPEN || - client.readyState === WebSocket.CONNECTING || - client.readyState === WebSocket.CLOSING - ) { - client.close(1000, "Evolu Relay shutting down"); - } + }); + }); + + stack.defer(() => { + console.info("Shutting down..."); + for (const client of wss.clients) { + if ( + client.readyState === WebSocket.OPEN || + client.readyState === WebSocket.CONNECTING || + client.readyState === WebSocket.CLOSING + ) { + client.close(1000, "Evolu Relay shutting down"); } - ok(); - }), - ); + } + }); await new Promise((resolve, reject) => { const onError = (error: Error) => { diff --git a/packages/nodejs/test/Sqlite.test.ts b/packages/nodejs/test/Sqlite.test.ts index 34d7d295b..ae552d205 100644 --- a/packages/nodejs/test/Sqlite.test.ts +++ b/packages/nodejs/test/Sqlite.test.ts @@ -62,7 +62,7 @@ describe("createBetterSqliteDriver", () => { const rows = sqlite.exec(sql`select * from t;`); expect(rows.rows).toEqual([{ data: "hello" }]); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("exec returns rows for reader queries", async () => { @@ -81,7 +81,7 @@ describe("createBetterSqliteDriver", () => { expect(rows.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); expect(rows.changes).toBe(0); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("exec returns changes for writer queries", async () => { @@ -100,7 +100,7 @@ describe("createBetterSqliteDriver", () => { expect(deleteResult.rows).toEqual([]); expect(deleteResult.changes).toBe(2); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("export returns serialized database bytes", async () => { @@ -118,7 +118,7 @@ describe("createBetterSqliteDriver", () => { expect(exported).toBeInstanceOf(Uint8Array); expect(exported.length).toBeGreaterThan(0); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("dispose is idempotent", async () => { @@ -129,8 +129,8 @@ describe("createBetterSqliteDriver", () => { assert(result.ok); const sqlite = result.value; - sqlite[Symbol.dispose](); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); + await sqlite[Symbol.asyncDispose](); }); test("prepared statements are cached and reused", async () => { @@ -152,7 +152,7 @@ describe("createBetterSqliteDriver", () => { const rows = sqlite.exec(sql`select name from t order by id;`); expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("driver dispose is idempotent", async () => { diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 97ccf5a4f..707e7e143 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -90,9 +90,9 @@ "@evolu/common": "workspace:*", "@evolu/react": "workspace:*", "@evolu/tsconfig": "workspace:*", - "@op-engineering/op-sqlite": "^15.2.2", + "@op-engineering/op-sqlite": "^15.2.7", "@types/react": "~19.2.14", - "expo": "^55.0.5", + "expo": "^55.0.6", "expo-secure-store": "~55.0.8", "expo-sqlite": "~55.0.10", "react": "19.2.4", diff --git a/packages/react-native/src/Task.ts b/packages/react-native/src/Task.ts index 4b4a5663d..bd91aacbc 100644 --- a/packages/react-native/src/Task.ts +++ b/packages/react-native/src/Task.ts @@ -5,18 +5,18 @@ */ import { - type CreateRunner, - createRunner as createCommonRunner, + type CreateRun, + createRun as createCommonRun, createUnknownError, - type Runner, - type RunnerDeps, + type Run, + type RunDeps, } from "@evolu/common"; /** - * Creates {@link Runner} for React Native with global error handling. + * Creates {@link Run} for React Native with global error handling. * * Registers `ErrorUtils.setGlobalHandler` for uncaught JavaScript errors. The - * handler is restored to the previous one when the runner is disposed. + * handler is restored to the previous one when the Run is disposed. * * ### Example * @@ -27,18 +27,18 @@ import { * }), * }); * - * await using run = createRunner({ console }); - * await using stack = run.stack(); + * await using run = createRun({ console }); + * await using stack = new AsyncDisposableStack(); * - * await stack.use(startApp()); + * stack.use(await run.orThrow(startApp())); * ``` * - * @group React Native Runner + * @group React Native Run */ -export const createRunner: CreateRunner = ( +export const createRun: CreateRun = ( deps?: D, -): Runner => { - const run = createCommonRunner(deps); +): Run => { + const run = createCommonRun(deps); const console = run.deps.console.child("global"); @@ -64,3 +64,8 @@ export const createRunner: CreateRunner = ( return run; }; + +/** + * @deprecated Use {@link createRun}. Kept for fork compatibility. + */ +export const createRunner: typeof createRun = createRun; diff --git a/packages/react-native/src/sqlite-drivers/createExpoSqliteDriver.ts b/packages/react-native/src/sqlite-drivers/createExpoSqliteDriver.ts index e77a54b2a..1c9dc832e 100644 --- a/packages/react-native/src/sqlite-drivers/createExpoSqliteDriver.ts +++ b/packages/react-native/src/sqlite-drivers/createExpoSqliteDriver.ts @@ -9,19 +9,26 @@ import { openDatabaseSync, type SQLiteStatement } from "expo-sqlite"; export const createExpoSqliteDriver: CreateSqliteDriver = (name, options) => () => { - const db = openDatabaseSync( - options?.mode === "memory" ? ":memory:" : `evolu1-${name}.db`, + const stack = new globalThis.DisposableStack(); + const db = stack.adopt( + openDatabaseSync( + options?.mode === "memory" ? ":memory:" : `evolu1-${name}.db`, + ), + (db) => { + db.closeSync(); + }, ); if (options?.mode === "encrypted") { db.execSync(`PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'"`); } - let isDisposed = false; - const cache = createPreparedStatementsCache( - (sql) => db.prepareSync(sql), - (statement) => { - statement.finalizeSync(); - }, + const cache = stack.use( + createPreparedStatementsCache( + (sql) => db.prepareSync(sql), + (statement) => { + statement.finalizeSync(); + }, + ), ); return ok({ @@ -61,10 +68,7 @@ export const createExpoSqliteDriver: CreateSqliteDriver = }, [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - cache[Symbol.dispose](); - db.closeSync(); + stack.dispose(); }, }); }; diff --git a/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts b/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts index 26382d36a..a45ecf2b3 100644 --- a/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts +++ b/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts @@ -11,29 +11,29 @@ import { open, type PreparedStatement } from "@op-engineering/op-sqlite"; export const createOpSqliteDriver: CreateSqliteDriver = (name, options) => () => { // https://op-engineering.github.io/op-sqlite/docs/configuration#in-memory - const db = open( - options?.mode === "memory" - ? { name: `inMemoryDb`, location: ":memory:" } - : { - name: `evolu1-${name}.db`, - ...(options?.mode === "encrypted" && { - encryptionKey: `x'${bytesToHex(options.encryptionKey)}'`, - }), - }, + const stack = new globalThis.DisposableStack(); + const db = stack.adopt( + open( + options?.mode === "memory" + ? { name: `inMemoryDb`, location: ":memory:" } + : { + name: `evolu1-${name}.db`, + ...(options?.mode === "encrypted" && { + encryptionKey: `x'${bytesToHex(options.encryptionKey)}'`, + }), + }, + ), + (db) => { + db.close(); + }, ); - let isDisposed = false; - const getDbPath = (): string | null => { - try { - return db.getDbPath(); - } catch { - return null; - } - }; - const cache = createPreparedStatementsCache( - (sql) => db.prepareStatement(sql), - // op-sqlite doesn't have API for that - lazyVoid, + const cache = stack.use( + createPreparedStatementsCache( + (sql) => db.prepareStatement(sql), + // op-sqlite doesn't have API for that + lazyVoid, + ), ); return ok({ @@ -51,21 +51,26 @@ export const createOpSqliteDriver: CreateSqliteDriver = return { rows: rows as Array, changes: rowsAffected }; }, + // FIXME: op-sqlite does not expose binary, but a path to the database file + // another react native dependency would be needed to implement this export: () => { - const dbPath = getDbPath(); - const pathSuffix = dbPath ? ` Database path: ${dbPath}.` : ""; - throw new Error( - "Evolu export() is not supported with @op-engineering/op-sqlite because the driver does not expose database bytes." + - pathSuffix + - " Use @evolu/react-native/expo-sqlite when export is required.", - ); + let message = + "Database export is not supported with @op-engineering/op-sqlite."; + + try { + const dbPath = db.getDbPath?.(); + if (dbPath) { + message += ` Database path: ${dbPath}.`; + } + } catch { + // Best-effort path lookup only. + } + + throw new Error(message); }, [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - cache[Symbol.dispose](); - db.close(); + stack.dispose(); }, }); }; diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5477e1c2e..485163a37 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -43,14 +43,14 @@ "@evolu/web": "workspace:*", "@sveltejs/package": "^2.5.7", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.10", + "svelte": "^5.53.12", "svelte-check": "^4.4.3", "typescript": "^5.9.3" }, "peerDependencies": { "@evolu/common": "^7.4.1", "@evolu/web": "^2.4.0", - "svelte": ">=5.53.10" + "svelte": ">=5.53.12" }, "publishConfig": { "access": "public" diff --git a/packages/web/src/Sqlite.ts b/packages/web/src/Sqlite.ts index f3808889e..276a3c852 100644 --- a/packages/web/src/Sqlite.ts +++ b/packages/web/src/Sqlite.ts @@ -63,77 +63,91 @@ export const createWasmSqliteDriver: CreateSqliteDriver = // @ts-expect-error Missing types (update @evolu/sqlite-wasm types) sqlite3.capi.sqlite3mc_vfs_create("opfs", 1); - let db: Database; - switch (options?.mode) { - case "memory": - db = new sqlite3.oo1.DB(":memory:"); - break; - case "encrypted": { - const pool = await sqlite3.installOpfsSAHPoolVfs({ - directory: `.${name}`, - }); - db = new pool.OpfsSAHPoolDb( - "file:evolu1.db?vfs=multipleciphers-opfs-sahpool", - ); + const stack = new globalThis.DisposableStack(); + try { + let db: Database; + switch (options?.mode) { + case "memory": + db = new sqlite3.oo1.DB(":memory:"); + break; + case "encrypted": { + const pool = await sqlite3.installOpfsSAHPoolVfs({ + directory: `.${name}`, + }); + db = new pool.OpfsSAHPoolDb( + "file:evolu1.db?vfs=multipleciphers-opfs-sahpool", + ); + break; + } + default: { + const pool = await sqlite3.installOpfsSAHPoolVfs({ name }); + db = new pool.OpfsSAHPoolDb("file:evolu1.db"); + } + } + + db = stack.adopt(db, (db) => { + db.close(); + }); + + if (options?.mode === "encrypted") { db.exec(` PRAGMA cipher = 'sqlcipher'; PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'"; `); - break; - } - default: { - const pool = await sqlite3.installOpfsSAHPoolVfs({ name }); - db = new pool.OpfsSAHPoolDb("file:evolu1.db"); } - } - - let isDisposed = false; - const cache = createPreparedStatementsCache( - (sql) => db.prepare(sql), - (statement) => { - statement.finalize(); - }, - ); - - return ok({ - exec: (query) => { - const prepared = cache.get(query); - - if (prepared) { - if (query.parameters.length > 0) prepared.bind(query.parameters); - - const rows = []; - while (prepared.step()) { - rows.push(prepared.get({})); + const cache = stack.use( + createPreparedStatementsCache( + (sql) => db.prepare(sql), + (statement) => { + statement.finalize(); + }, + ), + ); + + return ok({ + exec: (query) => { + const prepared = cache.get(query); + + if (prepared) { + prepared.clearBindings(); + if (query.parameters.length > 0) prepared.bind(query.parameters); + + const rows = []; + try { + while (prepared.step()) { + rows.push(prepared.get({})); + } + } finally { + prepared.reset(); + } + + return { + rows: rows as ReadonlyArray, + changes: db.changes(), + }; } - prepared.reset(); - - return { - rows: rows as ReadonlyArray, - changes: db.changes(), - }; - } - const rows = db.exec(query.sql, { - returnValue: "resultRows", - rowMode: "object", - bind: query.parameters, - }) as ReadonlyArray; + const rows = db.exec(query.sql, { + returnValue: "resultRows", + rowMode: "object", + bind: query.parameters, + }) as ReadonlyArray; - const changes = db.changes(); + const changes = db.changes(); - return { rows, changes }; - }, + return { rows, changes }; + }, - export: () => sqlite3.capi.sqlite3_js_db_export(db), + export: () => sqlite3.capi.sqlite3_js_db_export(db), - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - // poolUtil.unlink? - cache[Symbol.dispose](); - db.close(); - }, - }); + [Symbol.dispose]: () => { + // poolUtil.unlink? + stack.dispose(); + }, + }); + } catch (error) { + stack.dispose(); + throw error; + } }; diff --git a/packages/web/src/Task.ts b/packages/web/src/Task.ts index ec4abac90..b27ac1b48 100644 --- a/packages/web/src/Task.ts +++ b/packages/web/src/Task.ts @@ -5,7 +5,7 @@ */ import { - type CreateRunner, + type CreateRun, createRun as createCommonRun, createInMemoryLeaderLock, createUnknownError, @@ -13,6 +13,7 @@ import { ok, type Run, type RunDeps, + unabortable, } from "@evolu/common"; const inMemoryLeaderLock = createInMemoryLeaderLock(); @@ -30,30 +31,35 @@ const createLeaseRelease = () => { }; }; -/** Creates a {@link LeaderLock} backed by the Web Locks API. */ +/** + * Creates a {@link LeaderLock} backed by the Web Locks API. + * + * Waiting for the web platform lock is intentionally unabortable. If a caller starts + * waiting and its {@link Run} or fiber is later aborted, the underlying Web + * Locks request keeps waiting until the browser grants the lock. Only the + * returned lease releases it. + */ export const createLeaderLock = (): LeaderLock => ({ - acquire: (name) => async (run) => { + lock: (name) => async (run) => { const locks = globalThis.navigator?.locks; - if (!locks) return run(inMemoryLeaderLock.acquire(name)); + if (!locks) return run(unabortable(inMemoryLeaderLock.lock(name))); const acquired = Promise.withResolvers(); const requestFailed = Promise.withResolvers(); const release = createLeaseRelease(); - run.onAbort(release.resolve); - let request: Promise; try { request = locks.request( `evolu-leaderlock-${name}`, - { mode: "exclusive", signal: run.signal }, + { mode: "exclusive" }, async () => { acquired.resolve(); await release.promise; }, ); } catch { - return run(inMemoryLeaderLock.acquire(name)); + return run(unabortable(inMemoryLeaderLock.lock(name))); } void request.catch(() => { @@ -67,11 +73,14 @@ export const createLeaderLock = (): LeaderLock => ({ if (state === "failed") { release.resolve(); - return run(inMemoryLeaderLock.acquire(name)); + return run(unabortable(inMemoryLeaderLock.lock(name))); } return ok({ - [Symbol.dispose]: release.resolve, + [Symbol.asyncDispose]: async () => { + release.resolve(); + return Promise.resolve(); + }, }); }, }); @@ -80,7 +89,7 @@ export const createLeaderLock = (): LeaderLock => ({ * Creates {@link Run} for the web platform with global error handling. * * Registers `error` and `unhandledrejection` handlers that log errors to the - * console. Handlers are removed when the run is disposed. + * console. Handlers are removed when the Run is disposed. * * ### Example * @@ -92,14 +101,12 @@ export const createLeaderLock = (): LeaderLock => ({ * }); * * await using run = createRun({ console }); - * await using stack = run.stack(); + * await using stack = new AsyncDisposableStack(); * - * await stack.use(startApp()); + * stack.use(await run.orThrow(startApp())); * ``` - * - * @group Web Platform Runner */ -export const createRun: CreateRunner = ( +export const createRun: CreateRun = ( deps?: D, ): Run => { const run = createCommonRun(deps); @@ -125,6 +132,6 @@ export const createRun: CreateRunner = ( }; /** - * @deprecated Use {@link createRun}. + * @deprecated Use {@link createRun}. Kept for fork compatibility. */ -export const createRunner = createRun; +export const createRunner: typeof createRun = createRun; diff --git a/packages/web/test/Platform.test.ts b/packages/web/test/Platform.test.ts index e3c6f9058..56e5e45f5 100644 --- a/packages/web/test/Platform.test.ts +++ b/packages/web/test/Platform.test.ts @@ -1,63 +1,32 @@ import { Name, testName } from "@evolu/common"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { createLeaderLock, createRun } from "../src/Task.js"; -afterEach(() => { - vi.restoreAllMocks(); -}); - -const withNavigator = async ( - navigator: typeof globalThis.navigator | undefined, - runTest: () => Promise, -): Promise => { - const originalNavigator = globalThis.navigator; +describe("leaderLock", () => { + test("acquire waits until previous lease is disposed", async () => { + await using run = createRun(); + const leaderLock = createLeaderLock(); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - writable: true, - value: navigator, - }); + const first = await run(leaderLock.lock(testName)); + expect(first.ok).toBe(true); + if (!first.ok) return; - try { - await runTest(); - } finally { - Object.defineProperty(globalThis, "navigator", { - configurable: true, - writable: true, - value: originalNavigator, + let secondSettled = false; + const second = run(leaderLock.lock(testName)); + void second.then(() => { + secondSettled = true; }); - } -}; - -const expectSequentialAcquireForSameName = async (): Promise => { - await using run = createRun(); - const leaderLock = createLeaderLock(); - - const first = await run(leaderLock.acquire(testName)); - expect(first.ok).toBe(true); - if (!first.ok) return; - - let secondSettled = false; - const second = run(leaderLock.acquire(testName)); - void second.then(() => { - secondSettled = true; - }); - - await Promise.resolve(); - expect(secondSettled).toBe(false); - first.value[Symbol.dispose](); + await Promise.resolve(); + expect(secondSettled).toBe(false); - const secondResult = await second; - expect(secondResult.ok).toBe(true); - if (!secondResult.ok) return; + await first.value[Symbol.asyncDispose](); - secondResult.value[Symbol.dispose](); -}; + const secondResult = await second; + expect(secondResult.ok).toBe(true); + if (!secondResult.ok) return; -describe("leaderLock", () => { - test("acquire waits until previous lease is disposed", async () => { - await expectSequentialAcquireForSameName(); + await secondResult.value[Symbol.asyncDispose](); }); test("different names acquire independently", async () => { @@ -68,85 +37,14 @@ describe("leaderLock", () => { const bName = Name.orThrow("LeaderLockB"); const [a, b] = await Promise.all([ - run(leaderLock.acquire(aName)), - run(leaderLock.acquire(bName)), + run(leaderLock.lock(aName)), + run(leaderLock.lock(bName)), ]); expect(a.ok).toBe(true); expect(b.ok).toBe(true); - if (a.ok) a.value[Symbol.dispose](); - if (b.ok) b.value[Symbol.dispose](); - }); - - test("falls back to in-memory lock when navigator.locks is missing", async () => { - await withNavigator( - {} as typeof globalThis.navigator, - expectSequentialAcquireForSameName, - ); - }); - - test("falls back to in-memory lock when locks.request throws", async () => { - await withNavigator( - { - locks: { - request: () => { - throw new Error("request unavailable"); - }, - }, - } as unknown as typeof globalThis.navigator, - expectSequentialAcquireForSameName, - ); - }); - - test("falls back to in-memory lock when locks.request rejects", async () => { - await withNavigator( - { - locks: { - request: async () => { - throw new Error("request rejected"); - }, - }, - } as unknown as typeof globalThis.navigator, - expectSequentialAcquireForSameName, - ); - }); -}); - -describe("reloadApp", () => { - test("returns early in worker context where document is undefined", async () => { - const moduleUrl = new URL("../src/Platform.ts", import.meta.url).href; - const workerSource = ` - import { reloadApp } from ${JSON.stringify(moduleUrl)}; - self.onmessage = () => { - reloadApp("/ignored"); - self.postMessage("done"); - }; - `; - const workerUrl = URL.createObjectURL( - new Blob([workerSource], { type: "text/javascript" }), - ); - let worker: Worker | undefined; - - try { - worker = new Worker(workerUrl, { type: "module" }); - await new Promise((resolve, reject) => { - const timeout = globalThis.setTimeout(() => { - reject(new Error("Worker did not finish reloadApp call")); - }, 1_000); - worker.onmessage = () => { - clearTimeout(timeout); - resolve(); - }; - worker.onerror = (event) => { - clearTimeout(timeout); - reject(event.error ?? new Error(event.message)); - }; - worker.postMessage("run"); - }); - } finally { - worker?.terminate(); - URL.revokeObjectURL(workerUrl); - } + if (a.ok) await a.value[Symbol.asyncDispose](); + if (b.ok) await b.value[Symbol.asyncDispose](); }); }); diff --git a/packages/web/test/Sqlite.test.ts b/packages/web/test/Sqlite.test.ts index 5fed8cf90..acb4016bb 100644 --- a/packages/web/test/Sqlite.test.ts +++ b/packages/web/test/Sqlite.test.ts @@ -1,20 +1,21 @@ import { type CreateSqliteDriverDep, createSqlite, - SimpleName, + Name, sql, testCreateRun, } from "@evolu/common"; +import { installPolyfills } from "@evolu/common/polyfills"; import { assert, describe, expect, test } from "vitest"; import { createWasmSqliteDriver } from "../src/Sqlite.js"; -const testName = SimpleName.orThrow("Test"); +installPolyfills(); + +const testName = Name.orThrow("Test"); const isWebKit = navigator.userAgent.includes("WebKit") && !navigator.userAgent.includes("Chrome"); -const webKitOpfsSkipReason = - "WebKit OPFS can fail with an unknown transient reason in CI/runtime."; // Helper to communicate with the sqlite-worker for OPFS tests. const createWorkerDriver = () => { @@ -69,7 +70,7 @@ describe("createWasmSqliteDriver", () => { const rows = sqlite.exec(sql`select * from t;`); expect(rows.rows).toEqual([{ data: "hello" }]); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("exec returns changes for writer queries", async () => { @@ -88,7 +89,7 @@ describe("createWasmSqliteDriver", () => { expect(deleteResult.rows).toEqual([]); expect(deleteResult.changes).toBe(2); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("prepared statements are cached and reused", async () => { @@ -107,7 +108,7 @@ describe("createWasmSqliteDriver", () => { const rows = sqlite.exec(sql.prepared`select name from t order by id;`); expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("export returns database bytes", async () => { @@ -125,7 +126,7 @@ describe("createWasmSqliteDriver", () => { expect(exported).toBeInstanceOf(Uint8Array); expect(exported.length).toBeGreaterThan(0); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); }); test("dispose is idempotent", async () => { @@ -136,17 +137,13 @@ describe("createWasmSqliteDriver", () => { assert(result.ok); const sqlite = result.value; - sqlite[Symbol.dispose](); - sqlite[Symbol.dispose](); + await sqlite[Symbol.asyncDispose](); + await sqlite[Symbol.asyncDispose](); }); }); - test.skipIf(!isWebKit)("documents WebKit OPFS skip reason", () => { - expect(navigator.userAgent).toContain("WebKit"); - expect(webKitOpfsSkipReason).toContain("unknown transient reason"); - }); - - describe.skipIf(isWebKit)(`opfs (${webKitOpfsSkipReason})`, () => { + // TODO: Investigate WebKit OPFS failure ("unknown transient reason"). + describe.skipIf(isWebKit)("opfs", () => { const timeout = 30_000; test(