diff --git a/apps/relay/src/startBunRelay.ts b/apps/relay/src/startBunRelay.ts index d0f245bb1..1b4e1ce8f 100644 --- a/apps/relay/src/startBunRelay.ts +++ b/apps/relay/src/startBunRelay.ts @@ -2,7 +2,7 @@ import { type CreateSqliteDriverDep, callback, createSqlite, - getOk, + getOrThrow, isPromiseLike, type OwnerId, ok, @@ -99,7 +99,7 @@ export const startBunRelay = const console = _run.deps.console.child("relay"); const relayName = name ?? SimpleName.orThrow("evolu-relay"); - const sqlite = getOk(await stack.use(createSqlite(relayName))); + const sqlite = getOrThrow(await stack.use(createSqlite(relayName))); const deps = { ..._run.deps, sqlite }; createBaseSqliteStorageTables(deps); diff --git a/biome.json b/biome.json index 61cb06a40..88b0acb36 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 7f9f78070..a271cd95a 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", - "turbo": "^2.8.13", + "turbo": "^2.8.16", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.10.0", "typescript": "^5.9.3", @@ -35,15 +35,15 @@ "name": "@example/angular-vite-pwa", "version": "0.0.0", "dependencies": { - "@angular/core": "^21.2.1", - "@angular/platform-browser": "^21.2.1", + "@angular/core": "^21.2.2", + "@angular/platform-browser": "^21.2.2", "@evolu/common": "workspace:*", "@evolu/web": "workspace:*", }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.3.1", "@angular/build": "^21.2.1", - "@angular/compiler-cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.2", "@tailwindcss/vite": "^4.2.1", "@vite-pwa/assets-generator": "^1.0.2", "tailwindcss": "^4.2.1", @@ -115,7 +115,7 @@ "react-native": "0.84.1", "react-native-nitro-modules": "0.34.1", "react-native-quick-crypto": "^1.0.16", - "react-native-safe-area-context": "^5.6.2", + "react-native-safe-area-context": "^5.7.0", "react-native-screens": "^4.24.0", "react-native-svg": "15.15.3", "react-native-web": "^0.21.0", @@ -197,7 +197,7 @@ "@evolu/web": "workspace:*", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.7", + "svelte": "^5.53.10", "svelte-check": "^4.4.3", "tslib": "^2.8.1", "typescript": "^5.9.3", @@ -247,7 +247,7 @@ "@evolu/common": "workspace:*", "@evolu/vue": "workspace:*", "@evolu/web": "workspace:*", - "vue": "^3.5.29", + "vue": "^3.5.30", "workbox-window": "^7.4.0", }, "devDependencies": { @@ -293,7 +293,7 @@ "@noble/hashes": "^2.0.1", "@scure/bip39": "^2.0.1", "kysely": "^0.28.11", - "msgpackr": "^1.11.8", + "msgpackr": "^1.11.9", "zod": "^4.3.6", }, "devDependencies": { @@ -301,7 +301,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.18.1", "better-sqlite3": "^12.6.2", - "fast-check": "^4.5.3", + "fast-check": "^4.6.0", "playwright": "^1.58.2", "typescript": "^5.9.3", "ws": "^8.19.0", @@ -423,14 +423,14 @@ "@evolu/web": "workspace:*", "@sveltejs/package": "^2.5.7", "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.53.7", + "svelte": "^5.53.10", "svelte-check": "^4.4.3", "typescript": "^5.9.3", }, "peerDependencies": { "@evolu/common": "^7.4.1", "@evolu/web": "^2.4.0", - "svelte": ">=5.53.7", + "svelte": ">=5.53.10", }, }, "packages/tanstack-start": { @@ -471,7 +471,7 @@ }, "peerDependencies": { "@evolu/common": "^7.4.1", - "vue": ">=3.5.29", + "vue": ">=3.5.30", }, }, "packages/web": { @@ -527,23 +527,23 @@ "@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/common": ["@angular/common@21.2.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "21.2.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-6zJMPi0i/XDniEgv3/t2BjuDHiOG44lgIR5PYyxqGpgJ0kqB5hku/0TuentNEi1VnBYgthnfhjek7c+lakXmhw=="], + "@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.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-0RPkma8UVNpse/VJcXT9w6SKzTMz4J/uMGj0l9enM1frg9xrx1fwi/lLmaVV9Nr9LfqPjQdxNFFlvaBB7g/2zg=="], + "@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.1", "", { "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.1", "typescript": ">=5.9 <6.1" }, "optionalPeers": ["typescript"], "bin": { "ngc": "bundles/src/bin/ngc.js", "ng-xi18n": "bundles/src/bin/ng_xi18n.js" } }, "sha512-qYCWLGtEju4cDtYLi4ZzbwKoF0lcGs+Lc31kuESvAzYvWNgk2EUOtwWo8kbgpAzAwSYodtxW6Q90iWEwfU6elw=="], + "@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/core": ["@angular/core@21.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw=="], + "@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/platform-browser": ["@angular/platform-browser@21.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "21.2.1", "@angular/common": "21.2.1", "@angular/core": "21.2.1" }, "optionalPeers": ["@angular/animations"] }, "sha512-k4SJLxIaLT26vLjLuFL+ho0BiG5PrdxEsjsXFC7w5iUhomeouzkHVTZ4t7gaLNKrdRD7QNtU4Faw0nL0yx0ZPQ=="], + "@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=="], "@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/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="], + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], - "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.10", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.5", "@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.19.0", "smol-toml": "^1.5.2", "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-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A=="], + "@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/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -567,7 +567,7 @@ "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="], - "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.6", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA=="], + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], @@ -803,23 +803,23 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="], + "@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/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], "@blazejkustra/react-native-alert": ["@blazejkustra/react-native-alert@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bgvKlnhfS39vz38BSBdHk1smVME0Nf5tJEzgoQOIPpci8KuTBcOORa93B2PV3/S5a0QkR1d8nW2yw76HDj6zqQ=="], @@ -991,7 +991,7 @@ "@example/vue-vite-pwa": ["@example/vue-vite-pwa@workspace:examples/vue-vite-pwa"], - "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.24", "", {}, "sha512-1bJ63Yv2Bn8SN2MjrlbwLwUhnC8COOeejd15H88WjCtw5iNErqEPaBnpvmYyqciVYwudGo5drUIdY9C/5yPGbg=="], + "@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=="], @@ -1505,9 +1505,9 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - "@tabler/icons": ["@tabler/icons@3.38.0", "", {}, "sha512-FdETQSpQ3lN7BEjEUzjKhsfTDCamrvMDops4HEMphTm3DmkIFpThoODn8XXZ8Q9MhjshIvphIYVHHB7zpq167w=="], + "@tabler/icons": ["@tabler/icons@3.40.0", "", {}, "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ=="], - "@tabler/icons-react": ["@tabler/icons-react@3.38.0", "", { "dependencies": { "@tabler/icons": "3.38.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-kR5wv+m4+GgmnSszg3rQd6SrTFAQ/XnQC/yTwIfuRJSfqB12KoIC7fPbIijFgOHTFlBN5DARnN0IVrR7KYG6/A=="], + "@tabler/icons-react": ["@tabler/icons-react@3.40.0", "", { "dependencies": { "@tabler/icons": "3.40.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg=="], "@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="], @@ -1545,13 +1545,13 @@ "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], - "@tanstack/react-router": ["@tanstack/react-router@1.166.2", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.2", "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-pKhUtrvVLlhjWhsHkJSuIzh1J4LcP+8ErbIqRLORX9Js8dUFMKoT0+8oFpi+P8QRpuhm/7rzjYiWfcyTsqQZtA=="], + "@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-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "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-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + "@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.2", "", { "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-zn3NhENOAX9ToQiX077UV2OH3aJKOvV2ZMNZZxZ3gDG3i3WqL8NfWfEgetEAfMN37/Mnt90PpotYgf7IyuoKqQ=="], + "@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/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], @@ -1627,7 +1627,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1697,25 +1697,25 @@ "@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=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="], + "@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.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="], + "@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=="], - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="], "@vue/language-core": ["@vue/language-core@3.2.5", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g=="], - "@vue/reactivity": ["@vue/reactivity@3.5.29", "", { "dependencies": { "@vue/shared": "3.5.29" } }, "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA=="], + "@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="], - "@vue/runtime-core": ["@vue/runtime-core@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg=="], + "@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="], - "@vue/runtime-dom": ["@vue/runtime-dom@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg=="], + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.29", "", { "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "vue": "3.5.29" } }, "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="], - "@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="], + "@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="], "@vue/tsconfig": ["@vue/tsconfig@0.9.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ=="], @@ -1785,7 +1785,7 @@ "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - "astro": ["astro@5.18.0", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", "@astrojs/markdown-remark": "6.3.10", "@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-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ=="], + "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=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -1811,11 +1811,11 @@ "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-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.15", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw=="], + "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=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], - "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.6", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.6" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A=="], + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.7", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.7" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], @@ -1903,7 +1903,7 @@ "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -2247,7 +2247,7 @@ "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], - "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], + "fast-check": ["fast-check@4.6.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -2271,7 +2271,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fetch-nodeshim": ["fetch-nodeshim@0.4.8", "", {}, "sha512-YW5vG33rabBq6JpYosLNoXoaMN69/WH26MeeX2hkDVjN6UlvRGq3Wkazl9H0kisH95aMu/HtHL64JUvv/+Nv/g=="], + "fetch-nodeshim": ["fetch-nodeshim@0.4.9", "", {}, "sha512-XIQWlB2A4RZ7NebXWGxS0uDMdvRHkiUDTghBVJKFg9yEOd45w/PP8cZANuPf2H08W6Cor3+2n7Q6TTZgAS3Fkw=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -2295,7 +2295,7 @@ "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], - "fontkitten": ["fontkitten@1.0.2", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q=="], + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -2367,7 +2367,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], + "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -2555,7 +2555,7 @@ "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], + "isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -2877,7 +2877,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + "msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], @@ -3093,7 +3093,7 @@ "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], - "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "pure-rand": ["pure-rand@8.0.0", "", {}, "sha512-7rgWlxG2gAvFPIQfUreo1XYlNvrQ9VnQPFWdncPkdl3icucLK0InOxsaafbvxGTnI6Bk/Rxmslg0lQlRCuzOXw=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -3279,9 +3279,9 @@ "serialize-javascript": ["serialize-javascript@7.0.4", "", {}, "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg=="], - "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], - "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], @@ -3449,11 +3449,11 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svelte": ["svelte@5.53.7", "", { "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-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ=="], + "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-check": ["svelte-check@4.4.4", "", { "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-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ=="], + "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=="], - "svelte2tsx": ["svelte2tsx@0.7.51", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-YbVMQi5LtQkVGOMdATTY8v3SMtkNjzYtrVDGaN3Bv+0LQ47tGXu/Oc8ryTkcYuEJWTZFJ8G2+2I8ORcQVGt9Ag=="], + "svelte2tsx": ["svelte2tsx@0.7.52", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0" } }, "sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg=="], "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], @@ -3533,19 +3533,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], + "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-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="], - "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], + "turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="], - "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], + "turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -3683,7 +3683,7 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="], + "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=="], "vue-tsc": ["vue-tsc@3.2.5", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA=="], @@ -3805,7 +3805,7 @@ "@angular-devkit/core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@angular/common/@angular/core": ["@angular/core@21.2.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "21.2.0", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-VnTbmZq3g3Q+s3nCZ8VUDMLjMezOg/bqUxAJ/DrRWCrEcTP5JO3mrNPs3FHj+qlB0T+BQP7uQv6QTzPVKybwoA=="], + "@angular/common/@angular/core": ["@angular/core@21.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/compiler": "21.2.1", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, "optionalPeers": ["@angular/compiler", "zone.js"] }, "sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw=="], "@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=="], @@ -3829,7 +3829,7 @@ "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/preset-env/babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.6", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ=="], + "@babel/preset-env/babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.1", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.7", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw=="], "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3897,8 +3897,14 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@jest/environment/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@jest/fake-timers/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@jest/transform/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "@jest/types/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@malept/flatpak-bundler/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=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -3963,6 +3969,24 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/better-sqlite3/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/cacheable-request/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/fs-extra/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/graceful-fs/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/keyv/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/plist/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/responselike/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/ws/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "@types/yauzl/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@vitejs/plugin-react/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "@vitejs/plugin-vue/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], @@ -3973,6 +3997,10 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@vue/language-core/@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="], + + "@vue/language-core/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="], + "ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -4031,8 +4059,12 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "chrome-launcher/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "chromium-edge-launcher/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], @@ -4067,7 +4099,7 @@ "dmg-license/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "electron/@types/node": ["@types/node@24.11.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw=="], + "electron/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -4115,20 +4147,32 @@ "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "jest-environment-node/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "jest-haste-map/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "jest-mock/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + + "jest-util/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "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=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "listr2/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=="], + "lmdb/msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + "locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -4261,7 +4305,7 @@ "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - "svgo/css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -4289,7 +4333,7 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "unifont/css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4397,6 +4441,12 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@jest/environment/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@jest/fake-timers/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@jest/types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "@malept/flatpak-bundler/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -4457,6 +4507,26 @@ "@rollup/plugin-replace/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/fs-extra/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/graceful-fs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/keyv/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/plist/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/responselike/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -4531,8 +4601,12 @@ "cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "chrome-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "chromium-edge-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -4577,6 +4651,16 @@ "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + "jest-environment-node/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "jest-haste-map/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "jest-mock/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "jest-util/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "jest-worker/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "listr2/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -4715,7 +4799,7 @@ "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "svgo/css-tree/mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "temp-file/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -4725,7 +4809,7 @@ "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "unifont/css-tree/mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + "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=="], @@ -4831,6 +4915,10 @@ "@react-native/community-cli-plugin/metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@vue/language-core/@vue/compiler-dom/@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/language-core/@vue/compiler-dom/@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "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=="], diff --git a/examples/angular-vite-pwa/package.json b/examples/angular-vite-pwa/package.json index 240007478..afd94224d 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.1", - "@angular/platform-browser": "^21.2.1", + "@angular/core": "^21.2.2", + "@angular/platform-browser": "^21.2.2", "@evolu/common": "workspace:*", "@evolu/web": "workspace:*" }, "devDependencies": { "@analogjs/vite-plugin-angular": "^2.3.1", "@angular/build": "^21.2.1", - "@angular/compiler-cli": "^21.2.1", + "@angular/compiler-cli": "^21.2.2", "@tailwindcss/vite": "^4.2.1", "@vite-pwa/assets-generator": "^1.0.2", "tailwindcss": "^4.2.1", diff --git a/examples/react-electron/components/EvoluMinimalExample.tsx b/examples/react-electron/components/EvoluMinimalExample.tsx index 4845ea3a4..53e2ff495 100644 --- a/examples/react-electron/components/EvoluMinimalExample.tsx +++ b/examples/react-electron/components/EvoluMinimalExample.tsx @@ -88,7 +88,7 @@ const todosQuery = createQuery((db) => // (even if defined without nullOr in the schema) to allow schema // evolution without migrations. Filter nulls with where + $narrowType. .where("title", "is not", null) - .$narrowType<{ title: Evolu.kysely.NotNull }>() + .$narrowType<{ title: Evolu.KyselyNotNull }>() // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. .orderBy("createdAt"), ); diff --git a/examples/react-expo/app/index.tsx b/examples/react-expo/app/index.tsx index 18b0df930..4118971bd 100644 --- a/examples/react-expo/app/index.tsx +++ b/examples/react-expo/app/index.tsx @@ -54,7 +54,7 @@ const todosQuery = createQuery((db) => // (even if defined without nullOr in the schema) to allow schema // evolution without migrations. Filter nulls with where + $narrowType. .where("title", "is not", null) - .$narrowType<{ title: Evolu.kysely.NotNull }>() + .$narrowType<{ title: Evolu.KyselyNotNull }>() // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. .orderBy("createdAt"), ); diff --git a/examples/react-expo/package.json b/examples/react-expo/package.json index a94fa1b03..23bf78211 100644 --- a/examples/react-expo/package.json +++ b/examples/react-expo/package.json @@ -39,7 +39,7 @@ "react-native": "0.84.1", "react-native-nitro-modules": "0.34.1", "react-native-quick-crypto": "^1.0.16", - "react-native-safe-area-context": "^5.6.2", + "react-native-safe-area-context": "^5.7.0", "react-native-screens": "^4.24.0", "react-native-svg": "15.15.3", "react-native-web": "^0.21.0", diff --git a/examples/react-nextjs/components/EvoluMinimalExample.tsx b/examples/react-nextjs/components/EvoluMinimalExample.tsx index a64a93564..1898c17d1 100644 --- a/examples/react-nextjs/components/EvoluMinimalExample.tsx +++ b/examples/react-nextjs/components/EvoluMinimalExample.tsx @@ -90,7 +90,7 @@ const todosQuery = createQuery((db) => // (even if defined without nullOr in the schema) to allow schema // evolution without migrations. Filter nulls with where + $narrowType. .where("title", "is not", null) - .$narrowType<{ title: Evolu.kysely.NotNull }>() + .$narrowType<{ title: Evolu.KyselyNotNull }>() // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. .orderBy("createdAt"), ); diff --git a/examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx b/examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx index 64673da28..0d03202a4 100644 --- a/examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx +++ b/examples/react-vite-pwa/src/components/EvoluMinimalExample.tsx @@ -100,7 +100,7 @@ const todosQuery = createQuery((db) => // (even if defined without nullOr in the schema) to allow schema // evolution without migrations. Filter nulls with where + $narrowType. .where("title", "is not", null) - .$narrowType<{ title: Evolu.kysely.NotNull }>() + .$narrowType<{ title: Evolu.KyselyNotNull }>() // Columns createdAt, updatedAt, isDeleted are auto-added to all tables. .orderBy("createdAt"), ); diff --git a/examples/svelte-vite-pwa/package.json b/examples/svelte-vite-pwa/package.json index 150f98d72..a06b0f12b 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.7", + "svelte": "^5.53.10", "svelte-check": "^4.4.3", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/examples/vue-vite-pwa/package.json b/examples/vue-vite-pwa/package.json index 4672380c4..b5ca76219 100644 --- a/examples/vue-vite-pwa/package.json +++ b/examples/vue-vite-pwa/package.json @@ -14,7 +14,7 @@ "@evolu/common": "workspace:*", "@evolu/vue": "workspace:*", "@evolu/web": "workspace:*", - "vue": "^3.5.29", + "vue": "^3.5.30", "workbox-window": "^7.4.0" }, "devDependencies": { diff --git a/package.json b/package.json index 65c6b6167..ccac9d8ce 100755 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", - "turbo": "^2.8.13", + "turbo": "^2.8.16", "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 86c4a0e55..cdfa717f8 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -57,7 +57,7 @@ "@noble/hashes": "^2.0.1", "@scure/bip39": "^2.0.1", "kysely": "^0.28.11", - "msgpackr": "^1.11.8", + "msgpackr": "^1.11.9", "zod": "^4.3.6" }, "publishConfig": { @@ -72,7 +72,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/ws": "^8.18.1", "better-sqlite3": "^12.6.2", - "fast-check": "^4.5.3", + "fast-check": "^4.6.0", "playwright": "^1.58.2", "typescript": "^5.9.3", "ws": "^8.19.0" diff --git a/packages/common/src/Instances.ts b/packages/common/src/Instances.ts deleted file mode 100644 index 004c20740..000000000 --- a/packages/common/src/Instances.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * A multiton for disposable instances. - * - * @module - */ - -/** - * A multiton for disposable instances. - * - * A multiton guarantees exactly one instance per key. - * - * Use cases: - * - * - One Mutex per key to prevent concurrent writes - * - Preserving state during hot module reloading - * - * Note: Do not use this as global shared state. Use it locally or pass it as a - * dependency instead. The only exception is hot reloading, where Evolu uses - * this to keep a single instance across module reloads. Bundler hot-reload APIs - * are not consistent across environments, so this is a portable fallback. - * Having two Evolu instances with the same name would mean two SQLite - * connections to the same file, which could corrupt data. - * - * // TODO: Example. - */ -export interface Instances - extends Disposable { - /** - * Ensures an instance exists for the given key, creating it if necessary. If - * the instance already exists, the optional `onCacheHit` callback is invoked - * to update the existing instance. - */ - readonly ensure: ( - key: K, - create: () => T, - onCacheHit?: (instance: T) => void, - ) => T; - - /** Gets an instance by key, or returns `null` if it doesn't exist. */ - readonly get: (key: K) => T | null; - - /** Checks if an instance exists for the given key. */ - readonly has: (key: K) => boolean; - - /** - * Deletes and disposes an instance by key. Returns `true` if the instance - * existed and was deleted, `false` otherwise. - */ - readonly delete: (key: K) => boolean; -} - -/** Creates an {@link Instances}. */ -export const createInstances = < - K extends string, - T extends Disposable, ->(): Instances => { - const instances = new Map(); - - return { - ensure: (key, create, onCacheHit) => { - let instance = instances.get(key); - - if (instance == null) { - instance = create(); - instances.set(key, instance); - } else if (onCacheHit) { - onCacheHit(instance); - } - - return instance; - }, - - get: (key) => instances.get(key) ?? null, - - has: (key) => instances.has(key), - - delete: (key) => { - const instance = instances.get(key); - if (instance == null) return false; - - instances.delete(key); - instance[Symbol.dispose](); - - return true; - }, - - [Symbol.dispose]: () => { - const errors: Array = []; - for (const instance of instances.values()) { - try { - instance[Symbol.dispose](); - } catch (error) { - errors.push(error); - } - } - - instances.clear(); - - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) { - throw new AggregateError(errors, "Multiple disposal errors occurred"); - } - }, - }; -}; diff --git a/packages/common/src/Number.ts b/packages/common/src/Number.ts index aeb97eeef..4f16d0871 100644 --- a/packages/common/src/Number.ts +++ b/packages/common/src/Number.ts @@ -105,7 +105,7 @@ export const computeBalancedBuckets = ( */ export const FibonacciIndex = /*#__PURE__*/ brand( "FibonacciIndex", - lessThanOrEqualTo(78)(PositiveInt), + /*#__PURE__*/ lessThanOrEqualTo(78)(PositiveInt), ); export type FibonacciIndex = typeof FibonacciIndex.Type; diff --git a/packages/common/src/Ref.ts b/packages/common/src/Ref.ts index 9cb94859e..58a0f31ad 100644 --- a/packages/common/src/Ref.ts +++ b/packages/common/src/Ref.ts @@ -1,30 +1,26 @@ /** - * Mutable reference container for state management. + * Mutable reference. * * @module */ -import type { Eq } from "./Eq.js"; import type { Store } from "./Store.js"; /** - * `Ref` provides a simple API to hold and update a value, similar to a "ref" in - * functional programming or React. It exposes methods to get, set, and modify - * the current state. + * Mutable reference. * - * Use a Ref instead of a variable when you want to pass state around as an - * object. If you need subscriptions, see {@link Store}. + * `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 is a valid dependency in Evolu's [Dependency - * Injection](https://evolu.dev/docs/dependency-injection) pattern—use it when - * functions need shared mutable state. + * For reactive state with subscriptions, see {@link Store}. * * ### Example * * ```ts * const count = createRef(0); * count.set(1); - * count.modify((n) => n + 1); + * count.update((n) => n + 1); * console.log(count.get()); // 2 * ``` * @@ -40,37 +36,71 @@ export interface Ref { /** Returns the current state. */ readonly get: () => T; - /** Sets the state. Returns `true` if the state was updated. */ - readonly set: (state: T) => boolean; + /** Sets the state. */ + readonly set: (state: T) => void; - /** - * Modifies the state using an updater function. Returns `true` if the state - * was updated. - */ - readonly modify: (updater: (current: T) => T) => boolean; + /** Sets the state and returns the previous state. */ + readonly getAndSet: (state: T) => T; + + /** Sets the state and returns the current state after the update. */ + readonly setAndGet: (state: T) => T; + + /** Updates the state. */ + readonly update: (updater: (current: T) => T) => void; + + /** Updates the state and returns the previous state. */ + readonly getAndUpdate: (updater: (current: T) => T) => T; + + /** Updates the state and returns the current state after the update. */ + readonly updateAndGet: (updater: (current: T) => T) => T; + + /** Modifies the state and returns a computed result from the transition. */ + readonly modify: ( + updater: (current: T) => readonly [result: R, nextState: T], + ) => R; } -/** - * Creates a {@link Ref} with the given initial state. - * - * By default, state is always updated. We can provide an optional {@link Eq} - * function as the second argument to skip updates when the new state equals the - * current state. - */ -export const createRef = (initialState: T, eq?: Eq): Ref => { +/** Creates a {@link Ref} with the given initial state. */ +export const createRef = (initialState: T): Ref => { let currentState = initialState; - const updateState = (newState: T): boolean => { - if (eq?.(newState, currentState)) return false; - currentState = newState; - return true; - }; - return { get: () => currentState, - set: (state) => updateState(state), + set: (state) => { + currentState = state; + }, + + getAndSet: (state) => { + const previousState = currentState; + currentState = state; + return previousState; + }, + + setAndGet: (state) => { + currentState = state; + return currentState; + }, + + update: (updater) => { + currentState = updater(currentState); + }, + + getAndUpdate: (updater) => { + const previousState = currentState; + currentState = updater(currentState); + return previousState; + }, + + updateAndGet: (updater) => { + currentState = updater(currentState); + return currentState; + }, - modify: (updater) => updateState(updater(currentState)), + modify: (updater) => { + const [result, nextState] = updater(currentState); + currentState = nextState; + return result; + }, }; }; diff --git a/packages/common/src/Resources.ts b/packages/common/src/Resources.ts index 835fcfae7..a96688e43 100644 --- a/packages/common/src/Resources.ts +++ b/packages/common/src/Resources.ts @@ -1,42 +1,106 @@ /** - * Reference-counted resource management with delayed disposal. + * Reference-counted resource management. * * @module */ +import { assert } from "./Assert.js"; +import { isNone } from "./Option.js"; +import { createRefCount, type RefCount } from "./RefCount.js"; +import { createRelation } from "./Relation.js"; import type { Result } from "./Result.js"; import { err, ok } from "./Result.js"; -import type { Duration, TimeDep, TimeoutId } from "./Time.js"; +import { createMutexByKey, createRun, type Task, unabortable } from "./Task.js"; +import { + createTime, + type Duration, + type TimeDep, + type TimeoutId, +} from "./Time.js"; import { PositiveInt, type Typed } from "./Type.js"; /** - * A generic resource manager that handles reference counting and delayed - * disposal of shared resources. Useful for managing expensive resources like - * WebSocket connections that need to be shared among multiple consumers. + * Async reference-counted resource management. + * + * Tracks which consumers use which shared resources and keeps resources alive + * while at least one consumer is attached. */ export interface Resources< + TResource extends Disposable, + 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< + void, + | ResourceNotFoundError + | ConsumerNotFoundError + >; + + readonly getConsumerIdsForResource: ( + resourceId: TResourceId, + ) => ReadonlySet; + + readonly getResourcesForConsumerId: ( + consumerId: TConsumerId, + ) => ReadonlySet; +} + +/** Configuration for async {@link Resources}. */ +export interface AsyncResourcesConfig< + TResource extends Disposable, + TResourceId extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +> { + /** Creates a resource for the provided configuration. */ + readonly createResource: ( + resourceConfig: TResourceConfig, + ) => Promise; + + /** Maps a resource configuration to its shared resource identifier. */ + readonly getResourceId: (resourceConfig: TResourceConfig) => TResourceId; + + /** Maps a consumer value to its stable consumer identifier. */ + readonly getConsumerId: (consumer: TConsumer) => TConsumerId; + + /** Delay before disposing unused resources. Defaults to `"100ms"`. */ + readonly disposalDelay?: Duration; + + /** Optional clock for timeout scheduling (useful for deterministic tests). */ + readonly time?: TimeDep["time"]; +} + +/** + * Legacy synchronous resource manager with delayed disposal. + * + * Kept for local-first internals and tests that use synchronous resource + * creation and deterministic timeout behavior. + */ +export interface LegacyResources< TResource extends Disposable, TResourceKey extends string, TResourceConfig, TConsumer, TConsumerId extends string, > extends Disposable { - /** - * Adds a consumer to resources, creating them if necessary. Increments - * reference counts for existing consumer-resource pairs. - */ readonly addConsumer: ( consumer: TConsumer, resourceConfigs: ReadonlyArray, ) => void; - /** - * Removes a consumer from resources. Decrements reference counts and - * schedules disposal when no consumers remain. - * - * Returns an error if the resource doesn't exist or if the consumer wasn't - * added to the resource. - */ readonly removeConsumer: ( consumer: TConsumer, resourceConfigs: ReadonlyArray, @@ -46,21 +110,14 @@ export interface Resources< | ConsumerNotFoundError >; - /** Gets the resource for the specified key, or null if it doesn't exist. */ readonly getResource: (key: TResourceKey) => TResource | null; - /** Gets all consumer IDs currently using the specified resource key. */ readonly getConsumersForResource: ( key: TResourceKey, ) => ReadonlyArray; - /** Checks if a consumer is currently using any resources. */ readonly hasConsumerAnyResource: (consumer: TConsumer) => boolean; - /** - * Gets the consumer for the specified consumer ID, or null if not found or - * not using any resources. - */ readonly getConsumer: (consumerId: TConsumerId) => TConsumer | null; } @@ -79,43 +136,28 @@ export interface ConsumerNotFoundError< readonly resourceKey: TResourceKey; } -export interface ResourcesConfig< +/** Configuration for legacy synchronous {@link LegacyResources}. */ +export interface LegacyResourcesConfig< TResource extends Disposable, TResourceKey extends string, TResourceConfig, TConsumer, TConsumerId extends string, > { - /** Creates a new resource for the given config. */ readonly createResource: (config: TResourceConfig) => TResource; - /** Extracts a unique key from a resource config for deduplication. */ readonly getResourceKey: (config: TResourceConfig) => TResourceKey; - /** Extracts a unique identifier from a consumer for reference counting. */ readonly getConsumerId: (consumer: TConsumer) => TConsumerId; - /** - * Delay before disposing unused resources. Helps avoid resource churn during - * rapid add/remove cycles. Defaults to `"100ms"`. - */ readonly disposalDelay?: Duration; - /** - * Called when a consumer is added to a resource for the first time. This - * happens when the consumer's reference count goes from 0 to 1 for this - * resource. - */ readonly onConsumerAdded?: ( consumer: TConsumer, resource: TResource, resourceKey: TResourceKey, ) => void; - /** - * Called when a consumer is completely removed from a resource. This happens - * when the consumer's reference count goes from 1 to 0 for this resource. - */ readonly onConsumerRemoved?: ( consumer: TConsumer, resource: TResource, @@ -123,138 +165,425 @@ export interface ResourcesConfig< ) => void; } -/** - * Creates {@link Resources}. - * - * This tracks which consumers are using which resources and maintains reference - * counts to know when it's safe to dispose resources. Resources are created - * on-demand and disposed with a configurable delay to avoid churn. - * - * ### Example Usage - * - * ```ts - * // WebSocket connections - * interface WebSocketConfig { - * readonly url: WebSocketUrl; - * } - * - * type WebSocketUrl = string & Brand<"WebSocketUrl">; - * type UserId = string & Brand<"UserId">; - * - * const webSockets = createResources< - * WebSocket, - * WebSocketUrl, - * WebSocketConfig, - * User, - * UserId - * >({ time: createTime() })({ - * createResource: (config) => new WebSocket(config.url), - * getResourceKey: (config) => config.url, - * getConsumerId: (user) => user.id, - * disposalDelay: "1s", - * }); - * - * // Add users to WebSocket connections - * webSockets.addConsumer(user1, [ - * { url: "ws://server1.com" as WebSocketUrl }, - * { url: "ws://server2.com" as WebSocketUrl }, - * ]); - * webSockets.addConsumer(user2, [ - * { url: "ws://server1.com" as WebSocketUrl }, - * ]); - * - * // Remove users - server1 stays alive (user2 still using it) - * webSockets.removeConsumer(user1, [ - * { url: "ws://server1.com" as WebSocketUrl }, - * { url: "ws://server2.com" as WebSocketUrl }, - * ]); - * - * // server2 gets disposed after delay, server1 stays alive - * ``` - */ -export const createResources = - < - TResource extends Disposable, - TResourceKey extends string, - TResourceConfig, - TConsumer, - TConsumerId extends string, - >( - deps: TimeDep, - ) => - ( - config: ResourcesConfig< - TResource, - TResourceKey, - TResourceConfig, - TConsumer, - TConsumerId - >, - ): Resources< +const createAsyncResources = < + TResource extends Disposable, + TResourceId extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +>({ + createResource, + getResourceId, + getConsumerId, + disposalDelay = "100ms", + time: maybeTime, +}: AsyncResourcesConfig< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId +>): Resources< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId +> => { + const time = maybeTime ?? createTime(); + const resourcesById = new Map(); + const consumerRefCountsByResourceId = new Map< + TResourceId, + RefCount + >(); + const consumerIdsByResourceId = createRelation(); + const mutexByResourceId = createMutexByKey(); + const disposalTimeoutByResourceId = new Map(); + const resourceIdsWithMutex = new Set(); + let disposing = false; + let disposePromise: Promise | null = null; + + const clearDisposalTimeout = (resourceId: TResourceId): void => { + const timeout = disposalTimeoutByResourceId.get(resourceId); + if (!timeout) return; + time.clearTimeout(timeout); + disposalTimeoutByResourceId.delete(resourceId); + }; + + const scheduleResourceDisposal = (resourceId: TResourceId): void => { + clearDisposalTimeout(resourceId); + + const timeout = time.setTimeout(() => { + void (async () => { + if (disposing) return; + + await using run = createRun(); + const result = await run( + unabortable( + mutexByResourceId.withLock(resourceId, () => { + disposalTimeoutByResourceId.delete(resourceId); + + if (consumerIdsByResourceId.hasA(resourceId)) return ok(); + + consumerRefCountsByResourceId.delete(resourceId); + const resource = resourcesById.get(resourceId); + if (!resource) return ok(); + + resourcesById.delete(resourceId); + resource[Symbol.dispose](); + return ok(); + }), + ), + ); + assert( + result.ok, + "Unabortable scheduled resource disposal must not abort", + ); + })(); + }, disposalDelay); + + disposalTimeoutByResourceId.set(resourceId, timeout); + }; + + return { + addConsumer: (consumer, resourceConfigs) => async (run) => { + if (disposing) return ok(); + + const consumerId = getConsumerId(consumer); + + for (const resourceConfig of resourceConfigs) { + if (disposing) return ok(); + + const resourceId = getResourceId(resourceConfig); + resourceIdsWithMutex.add(resourceId); + + const result = await run( + unabortable( + mutexByResourceId.withLock(resourceId, async () => { + if (disposing) return ok(); + clearDisposalTimeout(resourceId); + + 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(); + }), + ), + ); + if (!result.ok) return result; + } + + return ok(); + }, + + removeConsumer: (consumer, resourceConfigs) => async (run) => { + if (disposing) return ok(); + + const consumerId = getConsumerId(consumer); + type RemoveConsumerError = + | ResourceNotFoundError + | ConsumerNotFoundError; + + for (const resourceConfig of resourceConfigs) { + if (disposing) return ok(); + + const resourceId = getResourceId(resourceConfig); + resourceIdsWithMutex.add(resourceId); + + const result = await run( + unabortable( + mutexByResourceId.withLock( + resourceId, + (): Result => { + if (disposing) return ok(); + + const consumerRefCountsByConsumerId = + consumerRefCountsByResourceId.get(resourceId); + if (!consumerRefCountsByConsumerId) { + return err>({ + type: "ResourceNotFoundError", + resourceKey: resourceId, + }); + } + + const nextCount = + consumerRefCountsByConsumerId.decrement(consumerId); + if (isNone(nextCount)) { + return err>({ + type: "ConsumerNotFoundError", + consumerId, + resourceKey: resourceId, + }); + } + + if (nextCount.value === 0) { + consumerIdsByResourceId.remove(resourceId, consumerId); + } + + if (!consumerIdsByResourceId.hasA(resourceId)) { + consumerRefCountsByResourceId.delete(resourceId); + scheduleResourceDisposal(resourceId); + } + + return ok(); + }, + ), + ), + ); + if (!result.ok) return result; + } + + return ok(); + }, + + getConsumerIdsForResource: (resourceId) => + new Set(consumerIdsByResourceId.getB(resourceId)), + + getResourcesForConsumerId: (consumerId) => { + const resources = new Set(); + const resourceIds = consumerIdsByResourceId.getA(consumerId); + if (!resourceIds) return resources; + + for (const resourceId of resourceIds) { + const resource = resourcesById.get(resourceId); + if (resource) resources.add(resource); + } + + return resources; + }, + + [Symbol.asyncDispose]: () => { + if (disposePromise) return disposePromise; + + disposing = true; + + disposePromise = (async () => { + await using run = createRun(); + for (const timeout of disposalTimeoutByResourceId.values()) { + time.clearTimeout(timeout); + } + disposalTimeoutByResourceId.clear(); + + const drainIds = new Set(resourceIdsWithMutex); + for (const resourceId of resourcesById.keys()) { + drainIds.add(resourceId); + } + for (const resourceId of consumerRefCountsByResourceId.keys()) { + drainIds.add(resourceId); + } + + for (const resourceId of drainIds) { + const result = await run( + unabortable(mutexByResourceId.withLock(resourceId, () => ok())), + ); + assert( + result.ok, + "Unabortable resources dispose drain must not abort", + ); + } + + for (const resource of resourcesById.values()) { + resource[Symbol.dispose](); + } + resourcesById.clear(); + consumerRefCountsByResourceId.clear(); + consumerIdsByResourceId.clear(); + disposalTimeoutByResourceId.clear(); + resourceIdsWithMutex.clear(); + mutexByResourceId[Symbol.dispose](); + })(); + + return disposePromise; + }, + }; +}; + +const createLegacyResources = < + TResource extends Disposable, + TResourceKey extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +>( + deps: TimeDep, + config: LegacyResourcesConfig< TResource, TResourceKey, TResourceConfig, TConsumer, TConsumerId - > => { - let isDisposed = false; - - const resourcesMap = new Map(); - const consumerCounts = new Map< - TResourceKey, - Map - >(); - const consumers = new Map(); - const disposalTimeouts = new Map(); - - const disposalDelay = config.disposalDelay ?? "100ms"; - - const ensureResource = (resourceConfig: TResourceConfig) => { - const key = config.getResourceKey(resourceConfig); - const timeout = disposalTimeouts.get(key); - if (timeout) { - deps.time.clearTimeout(timeout); - disposalTimeouts.delete(key); - } + >, +): LegacyResources< + TResource, + TResourceKey, + TResourceConfig, + TConsumer, + TConsumerId +> => { + let isDisposed = false; + + const resourcesMap = new Map(); + const consumerCounts = new Map>(); + const consumers = new Map(); + const disposalTimeouts = new Map(); + + const disposalDelay = config.disposalDelay ?? "100ms"; + + const ensureResource = ( + resourceConfig: TResourceConfig, + ): { + readonly resourceKey: TResourceKey; + readonly resource: TResource; + readonly created: boolean; + } => { + const key = config.getResourceKey(resourceConfig); + const timeout = disposalTimeouts.get(key); + if (timeout) { + deps.time.clearTimeout(timeout); + disposalTimeouts.delete(key); + } + + if (resourcesMap.has(key)) { + const existingResource = resourcesMap.get(key) as TResource; + return { resourceKey: key, resource: existingResource, created: false }; + } + + const resource = config.createResource(resourceConfig); + resourcesMap.set(key, resource); + + return { resourceKey: key, resource, created: true }; + }; - if (!resourcesMap.has(key)) { - const resource = config.createResource(resourceConfig); - resourcesMap.set(key, resource); + const rollbackAddConsumer = ( + consumer: TConsumer, + consumerId: TConsumerId, + hadConsumerBefore: boolean, + previousConsumer: TConsumer | undefined, + incrementedCountsByResourceKey: ReadonlyMap, + onConsumerAddedResourceKeys: ReadonlySet, + createdResourceKeys: ReadonlySet, + ): void => { + for (const [ + resourceKey, + incrementedCount, + ] of incrementedCountsByResourceKey) { + const counts = consumerCounts.get(resourceKey); + if (!counts) continue; + + const currentCount = counts.get(consumerId); + if (currentCount == null) continue; + + const nextCount = currentCount - incrementedCount; + if (nextCount <= 0) { + counts.delete(consumerId); + } else { + counts.set(consumerId, PositiveInt.orThrow(nextCount)); } - }; - const scheduleDisposal = (key: TResourceKey): void => { - const timeout = deps.time.setTimeout(() => { - const resource = resourcesMap.get(key); - if (resource) { - resource[Symbol.dispose](); - resourcesMap.delete(key); + if (counts.size === 0) { + consumerCounts.delete(resourceKey); + } + } + + if (config.onConsumerRemoved) { + for (const resourceKey of onConsumerAddedResourceKeys) { + const resource = resourcesMap.get(resourceKey); + if (!resource) continue; + try { + config.onConsumerRemoved(consumer, resource, resourceKey); + } catch { + // Keep rollback best-effort and preserve the original addConsumer error. } - disposalTimeouts.delete(key); - }, disposalDelay); + } + } - disposalTimeouts.set(key, timeout); - }; + for (const resourceKey of createdResourceKeys) { + const counts = consumerCounts.get(resourceKey); + if (counts && counts.size > 0) continue; - const resources: Resources< - TResource, - TResourceKey, - TResourceConfig, - TConsumer, - TConsumerId - > = { - addConsumer: (consumer, resourceConfigs) => { - if (isDisposed) return; - if (resourceConfigs.length === 0) return; + const resource = resourcesMap.get(resourceKey); + if (!resource) continue; - const consumerId = config.getConsumerId(consumer); + const timeout = disposalTimeouts.get(resourceKey); + if (timeout) { + deps.time.clearTimeout(timeout); + disposalTimeouts.delete(resourceKey); + } - // Store consumer (last added consumer for this ID) - consumers.set(consumerId, consumer); + try { + resource[Symbol.dispose](); + } catch { + // Keep rollback best-effort and preserve the original addConsumer error. + } + resourcesMap.delete(resourceKey); + } + + if (hadConsumerBefore) { + consumers.set(consumerId, previousConsumer as TConsumer); + } else { + consumers.delete(consumerId); + } + }; + const scheduleDisposal = (key: TResourceKey): void => { + const timeout = deps.time.setTimeout(() => { + const resource = resourcesMap.get(key); + if (resource) { + resource[Symbol.dispose](); + resourcesMap.delete(key); + } + disposalTimeouts.delete(key); + }, disposalDelay); + + disposalTimeouts.set(key, timeout); + }; + + const resources: LegacyResources< + TResource, + TResourceKey, + TResourceConfig, + TConsumer, + TConsumerId + > = { + addConsumer: (consumer, resourceConfigs) => { + if (isDisposed) return; + if (resourceConfigs.length === 0) return; + + const consumerId = config.getConsumerId(consumer); + const hadConsumerBefore = consumers.has(consumerId); + const previousConsumer = consumers.get(consumerId); + consumers.set(consumerId, consumer); + const incrementedCountsByResourceKey = new Map(); + const onConsumerAddedResourceKeys = new Set(); + const createdResourceKeys = new Set(); + + try { for (const resourceConfig of resourceConfigs) { - ensureResource(resourceConfig); - const resourceKey = config.getResourceKey(resourceConfig); + const { resourceKey, resource, created } = + ensureResource(resourceConfig); + if (created) { + createdResourceKeys.add(resourceKey); + } let counts = consumerCounts.get(resourceKey); if (!counts) { @@ -265,136 +594,237 @@ export const createResources = const currentCount = counts.get(consumerId) ?? 0; const newCount = currentCount + 1; counts.set(consumerId, PositiveInt.orThrow(newCount)); - - // Call onConsumerAdded callback only when consumer is added for the first time (0 -> 1) - if (currentCount === 0 && config.onConsumerAdded) { - const resource = resourcesMap.get(resourceKey); - if (resource) { - config.onConsumerAdded(consumer, resource, resourceKey); - } + incrementedCountsByResourceKey.set( + resourceKey, + (incrementedCountsByResourceKey.get(resourceKey) ?? 0) + 1, + ); + + if (currentCount === 0 && config.onConsumerAdded && resource) { + onConsumerAddedResourceKeys.add(resourceKey); + config.onConsumerAdded(consumer, resource, resourceKey); } } - }, + } catch (error) { + rollbackAddConsumer( + consumer, + consumerId, + hadConsumerBefore, + previousConsumer, + incrementedCountsByResourceKey, + onConsumerAddedResourceKeys, + createdResourceKeys, + ); + throw error; + } + }, - removeConsumer: (consumer, resourceConfigs) => { - if (isDisposed) return ok(); + removeConsumer: (consumer, resourceConfigs) => { + if (isDisposed) return ok(); - const consumerId = config.getConsumerId(consumer); - const removeCountsByResourceKey = new Map(); + const consumerId = config.getConsumerId(consumer); + const removeCountsByResourceKey = new Map(); - for (const resourceConfig of resourceConfigs) { - const key = config.getResourceKey(resourceConfig); - const removeCount = (removeCountsByResourceKey.get(key) ?? 0) + 1; - removeCountsByResourceKey.set(key, removeCount); - } + for (const resourceConfig of resourceConfigs) { + const key = config.getResourceKey(resourceConfig); + const removeCount = (removeCountsByResourceKey.get(key) ?? 0) + 1; + removeCountsByResourceKey.set(key, removeCount); + } - const validatedRemovals = new Map< - TResourceKey, - { - readonly counts: Map; - readonly currentCount: PositiveInt; - readonly removeCount: number; - } - >(); + const validatedRemovals = new Map< + TResourceKey, + { + readonly counts: Map; + readonly currentCount: PositiveInt; + readonly removeCount: number; + } + >(); - for (const [key, removeCount] of removeCountsByResourceKey) { - const counts = consumerCounts.get(key); - if (!counts) { - return err({ type: "ResourceNotFoundError", resourceKey: key }); - } + for (const [key, removeCount] of removeCountsByResourceKey) { + const counts = consumerCounts.get(key); + if (!counts) { + return err({ type: "ResourceNotFoundError", resourceKey: key }); + } - const currentCount = counts.get(consumerId); - if (currentCount == null || currentCount < removeCount) { - return err({ - type: "ConsumerNotFoundError", - consumerId: consumerId, - resourceKey: key, - }); - } - validatedRemovals.set(key, { counts, currentCount, removeCount }); + const currentCount = counts.get(consumerId); + if (currentCount == null || currentCount < removeCount) { + return err({ + type: "ConsumerNotFoundError", + consumerId, + resourceKey: key, + }); } - for (const [key, removal] of validatedRemovals) { - const { counts, currentCount, removeCount } = removal; - const nextCount = currentCount - removeCount; + validatedRemovals.set(key, { counts, currentCount, removeCount }); + } - if (nextCount === 0) { - counts.delete(consumerId); + for (const [key, removal] of validatedRemovals) { + const { counts, currentCount, removeCount } = removal; + const nextCount = currentCount - removeCount; - // Call onConsumerRemoved callback only when consumer is completely removed (1 -> 0) - if (config.onConsumerRemoved) { - const resource = resourcesMap.get(key); - if (resource) { - config.onConsumerRemoved(consumer, resource, key); - } - } + if (nextCount === 0) { + counts.delete(consumerId); - if (counts.size === 0) { - consumerCounts.delete(key); - scheduleDisposal(key); + if (config.onConsumerRemoved) { + const resource = resourcesMap.get(key); + if (resource) { + config.onConsumerRemoved(consumer, resource, key); } - } else { - counts.set(consumerId, PositiveInt.orThrow(nextCount)); } - } - if (!resources.hasConsumerAnyResource(consumer)) { - consumers.delete(consumerId); + if (counts.size === 0) { + consumerCounts.delete(key); + scheduleDisposal(key); + } + } else { + counts.set(consumerId, PositiveInt.orThrow(nextCount)); } + } - return ok(); - }, - - getResource: (key) => { - if (isDisposed) return null; - return resourcesMap.get(key) ?? null; - }, - - getConsumersForResource: (key) => { - if (isDisposed) return []; - const counts = consumerCounts.get(key); - return counts ? Array.from(counts.keys()) : []; - }, - - hasConsumerAnyResource: (consumer) => { - if (isDisposed) return false; - const consumerId = config.getConsumerId(consumer); - // If slow, can be optimized with reverse index - return Array.from(consumerCounts.values()).some((counts) => - counts.has(consumerId), - ); - }, - - getConsumer: (consumerId) => { - if (isDisposed) return null; - const consumer = consumers.get(consumerId); - if (!consumer) return null; + if (!resources.hasConsumerAnyResource(consumer)) { + consumers.delete(consumerId); + } - // Only return consumer if it's currently using any resources - if (!resources.hasConsumerAnyResource(consumer)) { - return null; - } + return ok(); + }, + + getResource: (key) => { + if (isDisposed) return null; + return resourcesMap.get(key) ?? null; + }, + + getConsumersForResource: (key) => { + if (isDisposed) return []; + const counts = consumerCounts.get(key); + return counts ? Array.from(counts.keys()) : []; + }, + + hasConsumerAnyResource: (consumer) => { + if (isDisposed) return false; + const consumerId = config.getConsumerId(consumer); + return Array.from(consumerCounts.values()).some((counts) => + counts.has(consumerId), + ); + }, + + getConsumer: (consumerId) => { + if (isDisposed) return null; + const consumer = consumers.get(consumerId); + if (!consumer) return null; + if (!resources.hasConsumerAnyResource(consumer)) return null; + return consumer; + }, + + [Symbol.dispose]: () => { + if (isDisposed) return; + isDisposed = true; + + for (const timeout of disposalTimeouts.values()) { + deps.time.clearTimeout(timeout); + } + disposalTimeouts.clear(); - return consumer; - }, + for (const resource of resourcesMap.values()) { + resource[Symbol.dispose](); + } + resourcesMap.clear(); + consumerCounts.clear(); + consumers.clear(); + }, + }; - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; + return resources; +}; - for (const timeout of disposalTimeouts.values()) { - deps.time.clearTimeout(timeout); - } - disposalTimeouts.clear(); +/** + * Creates {@link Resources}. + * + * Supports two call forms: + * + * - `createResources(config)` for async Task-based resources. + * - `createResources({ time })(config)` for legacy synchronous resources. + */ +export function createResources< + TResource extends Disposable, + TResourceId extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +>( + deps: TimeDep, +): ( + config: LegacyResourcesConfig< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId + >, +) => LegacyResources< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId +>; +export function createResources< + TResource extends Disposable, + TResourceId extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +>( + config: AsyncResourcesConfig< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId + >, +): Resources; +export function createResources< + TResource extends Disposable, + TResourceId extends string, + TResourceConfig, + TConsumer, + TConsumerId extends string, +>( + configOrDeps: + | TimeDep + | AsyncResourcesConfig< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId + >, +): + | Resources + | (( + config: LegacyResourcesConfig< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId + >, + ) => LegacyResources< + TResource, + TResourceId, + TResourceConfig, + TConsumer, + TConsumerId + >) { + if (isTimeDep(configOrDeps)) { + return (config) => createLegacyResources(configOrDeps, config); + } - for (const resource of resourcesMap.values()) { - resource[Symbol.dispose](); - } - resourcesMap.clear(); - consumerCounts.clear(); - consumers.clear(); - }, - }; + return createAsyncResources(configOrDeps); +} - return resources; - }; +const isTimeDep = (value: unknown): value is TimeDep => + typeof value === "object" && + value !== null && + "time" in value && + !("createResource" in value) && + !("getResourceId" in value) && + !("getConsumerId" in value); diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index e454eb9e8..f672d4e5a 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -11,7 +11,6 @@ import { type NonEmptyReadonlyArray, } from "./Array.js"; import { assert } from "./Assert.js"; -import type { UnknownError } from "./Error.js"; import type { Lazy } from "./Function.js"; import { exhaustiveCheck } from "./Function.js"; import { createRecord, emptyRecord, isIterable } from "./Object.js"; @@ -94,6 +93,10 @@ import type { Typed } from "./Type.js"; * The caller doesn't need `try/catch`, just `if (!json.ok)`, and the error is * `ParseJsonError`, not `unknown`. * + * Use this pattern when the caller can recover or choose a different flow. + * Parsing unknown JSON is a good fit because invalid input is expected and + * actionable. + * * To avoid `try/catch` inside `parseJson` too, use {@link trySync}: * * ```ts @@ -104,8 +107,12 @@ import type { Typed } from "./Type.js"; * ); * ``` * - * `trySync` makes synchronous code that can throw safe. For asynchronous code, - * use {@link tryAsync}. + * `trySync` and {@link tryAsync} are for intentionally converting thrown errors + * into typed, recoverable {@link Result} values. + * + * Do not wrap every throwing API in {@link Result}. If an error is unrecoverable + * and the caller has no meaningful fallback, let it throw and handle it at the + * app boundary. * * Since `Result` is a plain object, imperative code works naturally: * @@ -126,6 +133,28 @@ import type { Typed } from "./Type.js"; * } * ``` * + * ## Style + * + * Imperative code is the preferred way to compose sequential {@link Result} + * operations. + * + * ```ts + * const user = getUser(); + * if (!user.ok) return user; + * + * const profile = getProfile(user.value.id); + * if (!profile.ok) return profile; + * + * return ok({ user: user.value, profile: profile.value }); + * ``` + * + * This is an intentional style choice. Evolu does not provide helper + * 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. + * * ## Composition * * Some patterns are common enough that deserve helpers. The previous example @@ -180,56 +209,15 @@ import type { Typed } from "./Type.js"; * * Some errors can't be handled locally — they must propagate to the top level. * These are unrecoverable errors: expected (you know they can happen) but only - * handleable at the app level. Group them in a union type like `AppError`: - * - * ```ts - * type AppError = TimestampError | SyncError | UnknownError; - * - * interface TimestampError extends Typed<"TimestampError"> { - * readonly error: UnknownError; - * } - * ``` + * handleable at the app level. * - * {@link UnknownError} wraps `unknown` so it can be part of a union (`unknown` - * absorbs all other types). + * Do not force these errors into {@link Result} just because the underlying API + * throws. If the local caller cannot recover, let the error propagate to a + * global handler or other app boundary. * - * Handle unrecoverable errors at the top level: - * - * ```ts - * const handleAppError = (error: AppError): void => { - * switch (error.type) { - * case "TimestampError": - * console.error(error.error.stack); // Log preserved stack trace - * showToast( - * "Timestamp error. Your computer clock appears to be incorrect.", - * ); - * break; - * case "SyncError": - * showToast("Sync failed. Retrying..."); - * break; - * case "UnknownError": - * console.error(error.stack); - * showToast("An unexpected error occurred."); - * break; - * default: - * exhaustiveCheck(error); - * } - * }; - * ``` - * - * ## Unexpected errors - * - * Wrapping all unsafe code with {@link trySync} or {@link tryAsync} doesn't - * prevent all errors — bugs can still throw. Catch them with global handlers: - * - * ```ts - * // Worker - * scope.onError = (error) => { - * errorPort.postMessage(error); - * }; - * ``` - * - * TODO: Window and Node.js + * In Evolu apps, that boundary is typically a platform `createRun` adapter such + * as `@evolu/web`, `@evolu/nodejs`, or `@evolu/react-native`, which add + * platform-specific global error handling. * * ## FAQ * @@ -397,6 +385,10 @@ export const getOk = (result: Result): T => { /** * Wraps a synchronous function that may throw, returning a {@link Result}. * + * Use this when the thrown value should become a typed, recoverable error for + * the caller. Do not use it for failures that should terminate the current flow + * and propagate to a global handler. + * * ### Example * * ```ts @@ -421,6 +413,10 @@ export const trySync = ( /** * Wraps an async function that may throw, returning a {@link Result}. * + * Use this when the rejection should become a typed, recoverable error for the + * caller. Do not use it for failures that should terminate the current flow and + * propagate to a global handler. + * * ### Example * * ```ts diff --git a/packages/common/src/Schedule.ts b/packages/common/src/Schedule.ts index 2a73b06d7..7f84224f8 100644 --- a/packages/common/src/Schedule.ts +++ b/packages/common/src/Schedule.ts @@ -1465,5 +1465,7 @@ export const tapScheduleInput = * @see https://github.com/aws/aws-sdk-java-v2/blob/master/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java */ export const retryStrategyAws: Schedule = /*#__PURE__*/ jitter(1)( - maxDelay("20s")(take(2)(exponential("100ms"))), + /*#__PURE__*/ maxDelay("20s")( + /*#__PURE__*/ take(2)(/*#__PURE__*/ exponential("100ms")), + ), ); diff --git a/packages/common/src/Set.ts b/packages/common/src/Set.ts index 0a0074d5a..a9fed7b91 100644 --- a/packages/common/src/Set.ts +++ b/packages/common/src/Set.ts @@ -43,7 +43,7 @@ import type { PredicateWithIndex, RefinementWithIndex } from "./Types.js"; * * @group Constants */ -export const emptySet: ReadonlySet = new Set(); +export const emptySet: ReadonlySet = /*#__PURE__*/ new Set(); /** * Creates a readonly set from an array. diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index e2ccf80c6..082c9da07 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -195,10 +195,10 @@ export const createSqlite = const { createSqliteDriver } = run.deps; const console = run.deps.console.child("sql"); - console.debug("createSqliteDriver"); - const result = await run(createSqliteDriver(name, options)); - if (!result.ok) return result; - const driver = result.value; + const driverResult = await run(createSqliteDriver(name, options)); + if (!driverResult.ok) return driverResult; + const driver = driverResult.value; + console.debug("SQLite driver created"); let isDisposed = false; @@ -490,8 +490,8 @@ export const eqSqliteIndex: Eq = /*#__PURE__*/ createEqObject({ * Includes table-column mappings and user-visible indexes. */ export const SqliteSchema = /*#__PURE__*/ object({ - tables: record(String, set(String)), - indexes: array(SqliteIndex), + tables: /*#__PURE__*/ record(String, /*#__PURE__*/ set(String)), + indexes: /*#__PURE__*/ array(SqliteIndex), }); export interface SqliteSchema extends InferType {} diff --git a/packages/common/src/Store.ts b/packages/common/src/Store.ts index 6561a4655..1b1b75d99 100644 --- a/packages/common/src/Store.ts +++ b/packages/common/src/Store.ts @@ -19,14 +19,14 @@ import { createRef } from "./Ref.js"; * but not modify it directly. */ export interface ReadonlyStore extends Disposable { + /** Returns the current state of the store. */ + readonly get: () => T; + /** * Registers a listener to be called on state changes and returns a function * to unsubscribe. */ readonly subscribe: (listener: Listener) => Unsubscribe; - - /** Returns the current state of the store. */ - readonly get: () => T; } /** @@ -41,33 +41,70 @@ export interface Store extends ReadonlyStore, Ref {} /** * Creates a store with the given initial state. The store encapsulates its - * state, which can be read with `get` and updated with `set` or `modify`. All + * state, which can be read with `get` and updated with `set` or `update`. All * changes are broadcast to subscribers. * - * By default, state changes are detected using `===` (shallow equality). You - * can provide a custom equality function as the second argument. + * By default, state changes are detected using strict equality (`===`). You can + * provide a custom equality function as the second argument. */ -export const createStore = ( - initialState: T, - eq: Eq = eqStrict, -): Store => { +export const createStore = (initialState: T, eq?: Eq): Store => { const listeners = createListeners(); - const ref = createRef(initialState, eq); + const equality = eq ?? eqStrict; + const ref = createRef(initialState); + + const notifyIfChanged = (previousState: T): void => { + if (!equality(previousState, ref.get())) listeners.notify(); + }; return { - subscribe: listeners.subscribe, get: ref.get, + subscribe: listeners.subscribe, set: (state) => { - const updated = ref.set(state); - if (updated) listeners.notify(); - return updated; + const previousState = ref.get(); + ref.set(state); + notifyIfChanged(previousState); + }, + + getAndSet: (state) => { + const previousState = ref.getAndSet(state); + notifyIfChanged(previousState); + return previousState; + }, + + setAndGet: (state) => { + const previousState = ref.get(); + ref.set(state); + notifyIfChanged(previousState); + return ref.get(); + }, + + update: (updater) => { + const previousState = ref.get(); + ref.update(updater); + notifyIfChanged(previousState); + }, + + getAndUpdate: (updater) => { + const previousState = ref.getAndUpdate(updater); + notifyIfChanged(previousState); + return previousState; + }, + + updateAndGet: (updater) => { + const previousState = ref.get(); + ref.updateAndGet(updater); + notifyIfChanged(previousState); + return ref.get(); }, - modify: (updater) => { - const updated = ref.modify(updater); - if (updated) listeners.notify(); - return updated; + modify: ( + updater: (current: T) => readonly [result: R, nextState: T], + ): R => { + const previousState = ref.get(); + const result = ref.modify(updater); + notifyIfChanged(previousState); + return result; }, [Symbol.dispose]: listeners[Symbol.dispose], diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index c1ee1af95..d6014d262 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -17,7 +17,6 @@ import type { RandomBytes, RandomBytesDep } from "./Crypto.js"; import { createRandomBytes } from "./Crypto.js"; import { eqArrayStrict } from "./Eq.js"; import { lazyTrue, lazyVoid } from "./Function.js"; -import { createInstances } from "./Instances.js"; import { decrement, increment } from "./Number.js"; import { createRecord, @@ -30,10 +29,10 @@ import type { Random, RandomDep, RandomNumber } from "./Random.js"; import { createRandom } from "./Random.js"; import { createRef, type Ref } from "./Ref.js"; import type { Done, NextResult, Ok, Result } from "./Result.js"; -import { err, getOrThrow, isErr, ok, tryAsync } 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 type { testCreateRunner } from "./Test.js"; +import type { testCreateRun } from "./Test.js"; import type { Duration, Time, TimeDep } from "./Time.js"; import { createTime, durationToMillis, Millis } from "./Time.js"; import type { TracerConfigDep, TracerDep } from "./Tracer.js"; @@ -65,20 +64,6 @@ import type { Predicate, } from "./Types.js"; -declare global { - interface PromiseConstructor { - try( - executor: (...args: Args) => T | PromiseLike, - ...args: Args - ): Promise; - withResolvers(): { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: any) => void; - }; - } -} - /** * JavaScript-native structured concurrency. * @@ -93,15 +78,15 @@ declare global { * * Evolu implements structured concurrency with these types: * - * - **{@link Task}** — a function that takes {@link Runner} and returns - * {@link Awaitable} (sync or async) {@link Result} - * - **{@link Runner}** — runs tasks, creates {@link Fiber}s, monitors and aborts - * them - * - **{@link Fiber}** — awaitable, abortable/disposable handle to a running task - * - **{@link AsyncDisposableStack}** — task-aware resource management that + * - **{@link Task}** — a function that takes Run and returns {@link Awaitable} + * (sync or async) {@link Result} + * - **{@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 few + * Evolu's structured concurrency core is minimal — one function with a several * flags and helper methods using native APIs. * * ### Example @@ -133,30 +118,34 @@ declare global { * fetch: globalThis.fetch.bind(globalThis), * }; * - * // Create runner with dependencies. - * await using run = createRunner(deps); + * // Create a Run with dependencies. + * await using run = createRun(deps); * - * // Running a task returns a fiber that can be awaited. + * // Running a Task returns a Fiber that can be awaited. * const result = await run(fetch("/users/123")); * expectTypeOf(result).toEqualTypeOf< * Result * >(); * - * // A fiber can also be aborted (or disposed with `using`). + * // A Fiber can also be aborted (or disposed with `using`). * const fiber = run(fetch("/users/456")); * fiber.abort(); * - * // When this block ends, `await using` disposes run — aborting all fibers. + * // When this block ends, `await using` disposes the Run — aborting all Fibers. * ``` * + * In composition roots, prefer Evolu platform `createRun` adapters when one + * exists. `@evolu/web`, `@evolu/nodejs`, and `@evolu/react-native` build on the + * common {@link createRun} and add platform-specific global error handling. + * * ## Composition * * | Category | Helper | Description | * | ---------- | ------------------ | ----------------------------------- | * | Collection | {@link all} | fail-fast on first error | * | | {@link allSettled} | complete all regardless of failures | - * | | {@link map} | values to tasks, fail-fast | - * | | {@link mapSettled} | values to tasks, complete all | + * | | {@link map} | values to Tasks, fail-fast | + * | | {@link mapSettled} | values to Tasks, complete all | * | Timing | {@link sleep} | pause execution | * | | {@link timeout} | time-bounded execution | * | | {@link repeat} | repeat with schedule | @@ -167,9 +156,33 @@ declare global { * | Interop | {@link callback} | wrap callback APIs | * | | {@link fetch} | HTTP requests with abort handling | * - * Collection helpers run sequentially by default. Use {@link parallel} to run - * tasks in parallel. Note helpers like {@link race} always run in parallel; - * sequential execution wouldn't make sense for their semantics. + * Collection helpers run sequentially by default. Use {@link concurrently} to + * run Tasks concurrently. Note helpers like {@link race} always run + * concurrently; sequential execution wouldn't make sense for their semantics. + * + * ## Style + * + * Imperative code is the preferred way to compose sequential {@link Task} + * operations inside another Task. + * + * ```ts + * const user = await run(fetchUser(id)); + * if (!user.ok) return user; + * + * const profile = await run(fetchProfile(user.value.id)); + * if (!profile.ok) return profile; + * + * return ok({ user: user.value, profile: profile.value }); + * ``` + * + * This is an intentional style choice. Evolu keeps helpers for operations with + * distinct semantics that plain control flow does not express well, such as + * 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. * * ### Building a better fetch * @@ -206,10 +219,10 @@ declare global { * >(); * ``` * - * Run composed tasks with {@link parallel} and {@link map}: + * Run composed tasks with {@link concurrently} and {@link map}: * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * const urls = [ * "https://api.example.com/users", @@ -218,7 +231,7 @@ declare global { * ]; * * // At most 2 concurrent requests - * const result = await run(parallel(2, map(urls, fetchWithRetry))); + * const result = await run(concurrently(2, map(urls, fetchWithRetry))); * * expectTypeOf(result).toEqualTypeOf< * Result< @@ -246,22 +259,22 @@ declare global { * }; * ``` * - * Provide dependencies when creating a runner: + * Provide dependencies when creating a Run: * * ```ts * const deps: FetchDep = { * fetch: globalThis.fetch.bind(globalThis), * }; * - * await using run = createRunner(deps); + * await using run = createRun(deps); * await run(fetchUser(123)); * ``` * - * For runtime-created dependencies, use {@link Runner.addDeps}. + * For runtime-created dependencies, use {@link Run#addDeps}. * * ### Built-in dependencies * - * {@link createRunner} provides default {@link RunnerDeps} available to all tasks + * {@link createRun} provides default {@link RunDeps} available to all Tasks * without declaring `D`: * * - {@link Console} — logging with hierarchical context via `child()` @@ -290,7 +303,7 @@ declare global { * }), * }; * - * await using run = createRunner(deps); + * await using run = createRun(deps); * * const console = run.deps.console.child("main"); * @@ -298,15 +311,15 @@ declare global { * // 21:20:25.588 [main] started * ``` * - * For testing, use {@link testCreateRunner} to get deterministic, controllable - * implementations of all RunnerDeps. + * For testing, use {@link testCreateRun} to get deterministic, controllable + * implementations of all RunDeps. * * ## Resource management * * 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 + * For Task-based disposal, Evolu provides {@link AsyncDisposableStack} — a * wrapper around the native * {@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 @@ -329,11 +342,11 @@ declare global { * ``` * * Even though {@link Task} returns {@link Awaitable} (allowing sync or async - * results), the {@link Runner} itself is always async. This is a deliberate - * design choice: + * results), the {@link Run} itself is always async. This is a deliberate design + * choice: * * - **Sync** → {@link Result}, native `using` / `DisposableStack` - * - **Async** → {@link Task}, {@link Runner}, {@link Fiber}, `await using` / + * - **Async** → {@link Task}, {@link Run}, {@link Fiber}, `await using` / * `AsyncDisposableStack` * * Benefits: @@ -374,20 +387,20 @@ declare global { * @group Core Types */ export type Task = ( - run: Runner, + run: Run, ) => Awaitable>; /** * Shorthand for a {@link Task} with `any` type parameters. * - * @group Type Utilities + * @group Type utilities */ export type AnyTask = Task; /** * Extracts the value type from a {@link Task}. * - * @group Type Utilities + * @group Type utilities */ export type InferTaskOk = R extends Task ? T : never; @@ -395,7 +408,7 @@ export type InferTaskOk = /** * Extracts the error type from a {@link Task}. * - * @group Type Utilities + * @group Type utilities */ export type InferTaskErr = R extends Task ? E : never; @@ -403,7 +416,7 @@ export type InferTaskErr = /** * Extracts the deps type from a {@link Task}. * - * @group Type Utilities + * @group Type utilities */ export type InferTaskDeps = R extends Task ? D : never; @@ -411,7 +424,7 @@ export type InferTaskDeps = /** * A {@link Task} that can complete with a value, signal done, or fail. * - * Forms a parallel with {@link NextResult}: + * Forms a pair with {@link NextResult}: * * - `Result` → `NextResult` * - `Task` → `NextTask` @@ -426,7 +439,7 @@ export type NextTask = Task>; /** * Extracts the done value type from a {@link NextTask}. * - * @group Type Utilities + * @group Type utilities */ export type InferTaskDone = InferTaskErr extends infer Errors @@ -436,7 +449,6 @@ export type InferTaskDone = : never; /** - * Error returned when a {@link Task} is aborted via * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal}. * @@ -444,6 +456,10 @@ export type InferTaskDone = * 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. + * * @group Core Types */ export const AbortError = /*#__PURE__*/ typed("AbortError", { @@ -454,41 +470,51 @@ export interface AbortError extends InferType {} /** * Runs a {@link Task} with * {@link https://en.wikipedia.org/wiki/Structured_concurrency | structured concurrency} - * guarantees. + * semantics. * - * - **Lifetime** — child tasks are bound to parent scope - * - **Cancellation** — abort propagates to all descendants - * - **Observable state** — inspect running tasks via snapshots and events + * Each `Run` forms a Task tree: child Tasks are bound to it, abort propagates + * through that tree, and state is observable via snapshots and events. * - * `Runner` is a callable object — callable because it's convenient to run tasks - * as `run(task)`, and an object because it holds state for abortability and - * monitoring. + * `Run` is a callable object — callable because it's convenient to run Tasks as + * `run(task)`, and an object because it holds state. * - * Evolu's structured concurrency leverages native JavaScript APIs: + * Calling `run(task)` creates a child `Run`, passes it to the Task, and returns + * a {@link Fiber}. The child is tracked in `getChildren()`/events while running, + * then disposed and removed when settled. * - * - `PromiseLike` as the async primitive - * - `AbortSignal` for cancellation - * - `await using` for resource management + * Before Task execution, `run(task)` applies two short-circuit checks: * - * This makes Runner idiomatic to JavaScript, tiny with minimal overhead, and - * easy to debug (native stack traces). + * - If this Run is not `Running`, the child is aborted with + * {@link runStoppedError} and the Task is replaced with `err(AbortError)`. + * - If this Run's signal is already aborted and the child is abortable + * (`abortMask === 0`), the child is aborted with the same reason and the Task + * is replaced with `err(AbortError)`. + * + * After execution, the child stores both values: `outcome` (what the Task + * returned) and `result` (what callers observe). If the child signal is aborted + * at settlement time, `result` is forced to `err(AbortError)` even when + * `outcome` is `ok(...)`. + * + * That's the whole mechanism: {@link Task} is a function that takes a `Run` and + * returns an {@link Awaitable} {@link Result}. `run(task)` runs the Task via + * `Promise.try(task, run)` with aforementioned logic. * * @group Core Types - * @see {@link createRunner} + * @see {@link createRun} * @see {@link Task} */ -export interface Runner extends AsyncDisposable { +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. */ readonly orThrow: (task: Task) => Promise; - /** Unique {@link Id} for this runner. */ + /** Unique {@link Id} for this Run. */ readonly id: Id; - /** The parent {@link Runner}, if this runner was created as a child. */ - readonly parent: Runner | null; + /** The parent {@link Run}, if this Run was created as a child. */ + readonly parent: Run | null; /** @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */ readonly signal: AbortSignal; @@ -501,53 +527,58 @@ export interface Runner extends AsyncDisposable { * * The callback receives the abort reason (extracted from {@link AbortError}). * If already aborted, the callback is invoked immediately. For - * {@link unabortable} tasks, the callback is never invoked. + * {@link unabortable} Tasks, the callback is never invoked. * * Intentionally synchronous — abort is signal propagation, not cleanup. Use - * {@link Runner.defer} for async cleanup that must run regardless of abort. + * {@link Run.defer} for async cleanup that must run regardless of abort. */ readonly onAbort: (callback: Callback) => void; - /** Returns the current {@link FiberState}. */ - readonly getState: () => FiberState; + /** Returns the current {@link RunState}. */ + readonly getState: () => RunState; /** Returns the current child {@link Fiber}s. */ readonly getChildren: () => ReadonlySet>; /** - * Creates a memoized {@link FiberSnapshot} of this runner. + * Creates a memoized {@link RunSnapshot} of this Run. * - * Use for monitoring, debugging, or building UI that visualizes task trees. + * Use for monitoring, debugging, or building UI that visualizes Task trees. * * ### Example * * ```ts * // React integration with useSyncExternalStore - * const useFiberSnapshot = (runner: Runner) => + * const useRunSnapshot = (run: Run) => * useSyncExternalStore( * (callback) => { - * runner.onEvent = callback; + * run.onEvent = callback; * return () => { - * runner.onEvent = undefined; + * run.onEvent = undefined; * }; * }, - * () => runner.snapshot(), + * () => run.snapshot(), * ); * ``` */ - readonly snapshot: () => FiberSnapshot; + readonly snapshot: () => RunSnapshot; /** - * Callback for monitoring runner events. + * Callback for monitoring Run events. * - * Called when this runner or any descendant emits a {@link RunnerEvent}. - * Events bubble up through parent runners, enabling centralized monitoring. - * Only emitted when {@link RunnerConfig.eventsEnabled} is `true`. + * Called when this Run or any descendant emits a {@link RunEvent}. Events + * bubble up through parent runs, enabling centralized monitoring. Only + * emitted when {@link RunConfig.eventsEnabled} is `true`. */ - onEvent: ((event: RunnerEvent) => void) | undefined; + onEvent: ((event: RunEvent) => void) | undefined; /** - * Runs a {@link Task} on the root runner instead of the current runner. + * The root daemon {@link Run}. + * + * 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. * * ### Example * @@ -556,7 +587,7 @@ export interface Runner extends AsyncDisposable { * // Aborted when myTask ends * run(helperTask); * - * // Outlives myTask, aborted when the root runner is disposed + * // Outlives myTask, aborted when the root Run is disposed * run.daemon(backgroundSync); * * return ok(); @@ -566,17 +597,32 @@ export interface Runner extends AsyncDisposable { readonly daemon: Run; /** - * Creates an {@link AsyncDisposable} that runs the task when disposed. + * 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. + * + * 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. + * + * 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. * - * Use for one-off task; for multiple, use {@link Runner.stack} instead. + * Use for a one-off Task; for multiple, use {@link Run.stack} instead. * * ### Example * * ```ts - * // One-off task with defer + * // One-off Task with defer * await using _ = run.defer(task); * - * // For more tasks, a stack is more practical + * // For more Tasks, a stack is more practical * await using stack = run.stack(); * stack.defer(taskA); * stack.defer(taskB); @@ -594,10 +640,12 @@ export interface Runner extends AsyncDisposable { * // connection[Symbol.asyncDispose] is now defined * ``` */ - readonly defer: (onDisposeAsync: Task) => AsyncDisposable; + readonly defer: ( + onDisposeAsync: Task | (() => Awaitable), + ) => AsyncDisposable; /** - * Creates an {@link AsyncDisposableStack} bound to the root runner. + * Creates an {@link AsyncDisposableStack}. * * ### Example * @@ -609,17 +657,17 @@ export interface Runner extends AsyncDisposable { */ readonly stack: () => AsyncDisposableStack; - /** Returns the dependencies passed to {@link createRunner}. */ - readonly deps: RunnerDeps & D; + /** Returns the dependencies passed to {@link createRun}. */ + readonly deps: RunDeps & D; /** * @see {@link Concurrency} - * @see {@link parallel} + * @see {@link concurrently} */ readonly concurrency: Concurrency; /** - * Adds additional dependencies to this runner and returns it. + * Adds additional dependencies to this Run and returns it. * * Use for runtime-created dependencies — dependencies that cannot be created * in the composition root (e.g., app start). @@ -638,23 +686,21 @@ export interface Runner extends AsyncDisposable { * * const init = * (config: Config): Task => - * async (_run) => { - * const { createDb } = _run.deps; - * await using stack = _run.stack(); + * async (run) => { + * const { createDb } = run.deps; + * await using stack = run.stack(); * * const db = await stack.use(createDb(config.connectionString)); * if (!db.ok) return db; * - * const run = _run.addDeps({ db: db.value }); + * const runWithDb = run.addDeps({ db: db.value }); * - * await run(getUser(123)); - * await run(insertUser(user)); + * await runWithDb(getUser(123)); + * await runWithDb(insertUser(user)); * return ok(); * }; * ``` * - * The `_run` naming convention reserves `run` for the extended runner. - * * ## FAQ * * ### How does it work? @@ -662,65 +708,26 @@ export interface Runner extends AsyncDisposable { * This is the whole implementation: * * ```ts - * run.addDeps = >(newDeps: E): Runner => { + * run.addDeps = >(newDeps: E): Run => { * depsRef.modify((currentDeps) => { * const duplicate = Object.keys(newDeps).find( * (k) => k in currentDeps, * ); * assert(!duplicate, `Dependency '${duplicate}' already added.`); - * return { ...currentDeps, ...newDeps }; + * return [undefined, { ...currentDeps, ...newDeps }]; * }); - * return self as unknown as Runner; + * return self as unknown as Run; * }; * ``` * * Dependencies are stored in a shared {@link Ref}, so `addDeps` propagates to - * all runners. The runtime assertion ensures dependencies are created once — + * all runs. The runtime assertion ensures dependencies are created once — * automatic deduplication would mask poor design (dependencies should have a * single, clear point of creation). */ - readonly addDeps: >(extraDeps: E) => Runner; + readonly addDeps: >(extraDeps: E) => Run; } -/** Backward-compatible alias for upstream naming. */ -export type Run = Runner; - -/** - * Abort mask depth for a {@link Runner} or {@link Fiber}. - * - * - `0` — abortable (default) - * - `>= 1` — inside {@link unabortable}, abort requests are ignored - * - * The mask tracks nested unabortable regions. When abort is requested, the - * signal only propagates if `mask === 0`. - * - * - {@link unabortable} increments the mask — task becomes protected - * - {@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. - * - * UI/debugging tools can use this to visually distinguish protected tasks - * (e.g., different icon or color) and explain why abort requests are ignored. - * - * @group Abort Masking - */ -export const AbortMask = /*#__PURE__*/ brand("AbortMask", NonNegativeInt); -export type AbortMask = typeof AbortMask.Type; - -/** - * Maximum number of concurrent tasks. - * - * Default is 1 (sequential). Use 1-100 as a literal or {@link PositiveInt} for - * larger values. - * - * @group Concurrency Primitives - * @see {@link parallel} - * @see {@link createSemaphore} - */ -export type Concurrency = Int1To100 | PositiveInt; - /** * `Fiber` is a handle to a running {@link Task} that can be awaited, aborted, or * disposed. @@ -728,7 +735,7 @@ export type Concurrency = Int1To100 | PositiveInt; * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * // Await to get Result * const result = await run(fetchData); @@ -747,19 +754,19 @@ export type Concurrency = Int1To100 | PositiveInt; * // Run child tasks in fiber's scope * fiber.run(childTask); * - * // Monitor via the Runner + * // Monitor via the Run * fiber.run.onEvent = (event) => { * // handle event * }; * ``` * - * Because `Fiber` is a {@link PromiseLike} object, fibers can be composed with + * Because `Fiber` is a {@link PromiseLike} object, Fibers can be composed with * `Promise.all`, `Promise.race`, etc. * - * Microtask timing: Runner wraps the task's promise with `.then` and - * `.finally`, which adds microtasks between task completion and fiber - * settlement. Do not write code that relies on a specific number of microtask - * yields between tasks. Use explicit synchronization primitives instead. + * Microtask timing: Run wraps the Task's promise with `.then` and `.finally`, + * which adds microtasks between Task completion and Fiber settlement. Do not + * write code that relies on a specific number of microtask yields between + * Tasks. Use explicit synchronization primitives instead. * * @group Core Types */ @@ -769,9 +776,9 @@ export class Fiber readonly then: PromiseLike>["then"]; /** - * A {@link Runner} whose lifetime is tied to this fiber. + * A {@link Run} of this Fiber. * - * Tasks run via this runner are aborted when the fiber ends. + * Tasks run via this Run are aborted when the Fiber ends. * * ### Example * @@ -781,21 +788,21 @@ export class Fiber * // helperTask is aborted when longRunningTask ends * fiber.run(helperTask); * - * // Monitor this fiber's runner + * // Monitor this Fiber's Run * fiber.run.onEvent = (event) => { * console.log(event); * }; * ``` */ - readonly run: Runner; + readonly run: Run; - constructor(run: Runner, promise: Promise>) { + constructor(run: Run, promise: Promise>) { this.then = promise.then.bind(promise); this.run = run; } /** - * Requests abort for this fiber (and any child it started). + * Requests abort for this Fiber (and any child it started). * * ### Example * @@ -805,8 +812,8 @@ export class Fiber * const result = await fiber; // err(AbortError) * ``` * - * When abort is requested, the fiber's result becomes {@link AbortError} even - * if the task completed successfully. This keeps behavior predictable — + * When abort is requested, the Fiber's result becomes {@link AbortError} even + * if the Task completed successfully. This keeps behavior predictable — * calling `abort()` always yields `AbortError`. * * The optional reason is stored in `AbortError.reason`. Since any value can @@ -818,14 +825,14 @@ export class Fiber * beyond the first call. */ abort(reason?: unknown): void { - (this.run as RunnerInternal).requestAbort( + (this.run as RunInternal).requestAbort( createAbortError(reason), ); } - /** Returns the current {@link FiberState}. */ - getState(): FiberState { - return this.run.getState() as FiberState; + /** Returns the current {@link RunState} of this Fiber's {@link Run}. */ + getState(): RunState { + return this.run.getState() as RunState; } [Symbol.dispose](): void { @@ -836,7 +843,7 @@ export class Fiber /** * Extracts the value type from a {@link Fiber}. * - * @group Type Utilities + * @group Type utilities */ export type InferFiberOk> = F extends Fiber ? T : never; @@ -844,7 +851,7 @@ export type InferFiberOk> = /** * Extracts the error type from a {@link Fiber}. * - * @group Type Utilities + * @group Type utilities */ export type InferFiberErr> = F extends Fiber ? E : never; @@ -852,62 +859,101 @@ export type InferFiberErr> = /** * Extracts the deps type from a {@link Fiber}. * - * @group Type Utilities + * @group Type utilities */ export type InferFiberDeps> = F extends Fiber ? D : never; /** - * The lifecycle state of a {@link Fiber}. + * Abort mask depth for a {@link Run} or {@link Fiber}. + * + * - `0` — abortable (default) + * - `>= 1` — inside {@link unabortable}, abort requests are ignored + * + * The mask tracks nested unabortable regions. When abort is requested, the + * signal only propagates if `mask === 0`. + * + * - {@link unabortable} increments the mask — Task becomes protected + * - {@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. + * + * UI/debugging tools can use this to visually distinguish protected Tasks + * (e.g., different icon or color) and explain why abort requests are ignored. + * + * @group Abort masking + */ +export const AbortMask = /*#__PURE__*/ brand("AbortMask", NonNegativeInt); +export type AbortMask = typeof AbortMask.Type; + +/** + * Maximum number of concurrent Tasks. * - * - `running` — task running, no result yet - * - `completing` — waiting for children to complete - * - `completed` — completed with result and outcome + * Default is 1 (sequential). Use 1-100 as a literal or {@link PositiveInt} for + * larger values. + * + * @group Concurrency primitives + * @see {@link concurrently} + * @see {@link createSemaphore} + */ +export type Concurrency = Int1To100 | PositiveInt; + +/** + * The lifecycle state of a {@link Run}. + * + * - `Running` — Task running, no result yet + * - `Disposing` — abort requested, waiting for children to settle + * - `Settled` — settled with result and outcome * * @group Core Types */ -export interface FiberStateRunning extends Typed<"Running"> {} +export type RunState = + | RunStateRunning + | RunStateDisposing + | RunStateSettled; + +export interface RunStateRunning extends Typed<"Running"> {} -export interface FiberStateCompleting extends Typed<"Completing"> {} +export interface RunStateDisposing extends Typed<"Disposing"> {} -export interface FiberStateCompleted - extends Typed<"Completed"> { +export interface RunStateSettled + extends Typed<"Settled"> { /** - * The fiber's completion value. + * The Run's completion value. * - * If abort was requested, this is {@link AbortError} even if the task - * completed successfully — see `outcome` for what the task actually + * If abort was requested, this is {@link AbortError} even if the Task + * completed successfully — see `outcome` for what the Task actually * returned. */ readonly result: Result; /** - * What the task actually returned. + * What the Task actually returned. * * Unlike `result`, not overridden by abort. */ readonly outcome: Result; } -export type FiberState = - | FiberStateRunning - | FiberStateCompleting - | FiberStateCompleted; - /** - * {@link FiberState} Type. + * {@link RunSnapshot} state Type. * * @group Monitoring */ -export const FiberSnapshotState = /*#__PURE__*/ union( - typed("Running"), - typed("Completing"), - typed("Completed", { result: UnknownResult, outcome: UnknownResult }), +export const RunSnapshotState = /*#__PURE__*/ union( + /*#__PURE__*/ typed("Running"), + /*#__PURE__*/ typed("Disposing"), + /*#__PURE__*/ typed("Settled", { + result: UnknownResult, + outcome: UnknownResult, + }), ); -export type FiberSnapshotState = typeof FiberSnapshotState.Type; +export type RunSnapshotState = typeof RunSnapshotState.Type; /** - * A recursive snapshot of a {@link Runner} tree. + * A recursive snapshot of a {@link Run} tree. * * Snapshots use structural sharing — unchanged subtrees return the same object * reference. This is useful for UI libraries like React that leverage @@ -916,56 +962,56 @@ export type FiberSnapshotState = typeof FiberSnapshotState.Type; * O(depth) new snapshot objects per mutation. * * @group Core Types - * @see {@link Runner.snapshot} + * @see {@link Run.snapshot} */ -export interface FiberSnapshot { - /** The {@link Runner.id} of the {@link Fiber} this snapshot represents. */ +export interface RunSnapshot { + /** The {@link Run.id} this snapshot represents. */ readonly id: Id; /** The current lifecycle state. */ - readonly state: FiberSnapshotState; + readonly state: RunSnapshotState; /** Child snapshots in run order. */ - readonly children: ReadonlyArray; + readonly children: ReadonlyArray; /** The abort mask depth. `0` means abortable, `>= 1` means unabortable. */ readonly abortMask: AbortMask; } /** - * The event-specific payload of a {@link RunnerEvent}. + * The event-specific payload of a {@link RunEvent}. * * @group Monitoring */ -export const RunnerEventData = /*#__PURE__*/ union( - typed("ChildAdded", { childId: Id }), - typed("ChildRemoved", { childId: Id }), - typed("StateChanged", { state: FiberSnapshotState }), +export const RunEventData = /*#__PURE__*/ union( + /*#__PURE__*/ typed("ChildAdded", { childId: Id }), + /*#__PURE__*/ typed("ChildRemoved", { childId: Id }), + /*#__PURE__*/ typed("StateChanged", { state: RunSnapshotState }), ); -export type RunnerEventData = typeof RunnerEventData.Type; +export type RunEventData = typeof RunEventData.Type; /** - * Events emitted by a {@link Runner} for monitoring and debugging. + * Events emitted by a {@link Run} for monitoring and debugging. * - * Events bubble up through parent runners, enabling centralized monitoring at - * the root. Use with {@link Runner.onEvent} to track task lifecycle. + * Events bubble up through parent runs, enabling centralized monitoring at the + * root. Use with {@link Run.onEvent} to track Run lifecycle. * * @group Monitoring */ -export const RunnerEvent = /*#__PURE__*/ object({ +export const RunEvent = /*#__PURE__*/ object({ id: Id, timestamp: Millis, - data: RunnerEventData, + data: RunEventData, }); -export interface RunnerEvent extends InferType {} +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 Runner.daemon}, ensuring acquisition and cleanup complete even if abort - * is requested. + * 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 * @@ -986,13 +1032,13 @@ export interface RunnerEvent extends InferType {} * }; // b released, then a released, then analytics sent * ``` * - * @group Resource Management + * @group Resource management */ export class AsyncDisposableStack implements AsyncDisposable { readonly #stack = new globalThis.AsyncDisposableStack(); - readonly #daemon: Runner["daemon"]; + readonly #daemon: Run["daemon"]; - constructor(run: Runner) { + constructor(run: Run) { this.#daemon = run.daemon; } @@ -1000,14 +1046,17 @@ export class AsyncDisposableStack implements AsyncDisposable { return this.#daemon(unabortable(task)); } - #runVoid(task: Task): PromiseLike { - return this.#run(task).then(lazyVoid); + #runVoid( + task: Task | (() => Awaitable), + ): PromiseLike { + return this.#run(task as Task).then(lazyVoid); } /** - * Registers a {@link Task} to run when the stack is disposed. + * Registers a cleanup callback or {@link Task} to run when the stack is + * disposed. * - * Deferred tasks run in LIFO order and are unabortable. + * Deferred Tasks run in LIFO order and are unabortable. * * ### Example * @@ -1028,7 +1077,7 @@ export class AsyncDisposableStack implements AsyncDisposable { * * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack/defer */ - defer(onDisposeAsync: Task): void { + defer(onDisposeAsync: Task | (() => Awaitable)): void { this.#stack.defer(() => this.#runVoid(onDisposeAsync)); } @@ -1061,46 +1110,19 @@ export class AsyncDisposableStack implements AsyncDisposable { use(value: T): T; use( acquire: Task, - ): PromiseLike>; + ): PromiseLike>; use( valueOrAcquire: T | Task, ): T | PromiseLike> { - const register = (value: T): T => { - if (value == null || Symbol.asyncDispose in value) { - this.#stack.use(value as T); - return value; - } - - if (Symbol.dispose in value) { - const disposable = value as Disposable; - const dispose = (disposable as { [Symbol.dispose]?: unknown })[ - Symbol.dispose - ]; - if (typeof dispose !== "function") { - throw new TypeError("Resource does not implement Symbol.dispose."); - } - this.#stack.use({ - [Symbol.asyncDispose]: () => - Promise.resolve().then(() => { - dispose.call(disposable); - }), - }); - return value; - } - - this.#stack.use(value as T); - return value; - }; - if ( valueOrAcquire == null || Symbol.dispose in valueOrAcquire || Symbol.asyncDispose in valueOrAcquire ) { - return register(valueOrAcquire as T); + return this.#stack.use(valueOrAcquire as T); } return this.#run(valueOrAcquire).then((result) => { - if (result.ok) register(result.value); + if (result.ok) this.#stack.use(result.value); return result; }); } @@ -1189,42 +1211,33 @@ export class AsyncDisposableStack implements AsyncDisposable { } /** - * Configuration for {@link Runner} behavior. + * Configuration for {@link Run} behavior. * * @group Monitoring */ -export interface RunnerConfig { +export interface RunConfig { /** - * Whether to emit {@link RunnerEvent}s. + * Whether to emit {@link RunEvent}s. * - * Use a {@link Ref} to enable/disable at runtime without recreating the - * runner. Disabled by default for zero overhead in production. + * Use a {@link Ref} to enable/disable at runtime without recreating the Run. + * Disabled by default for zero overhead in production. */ readonly eventsEnabled: Ref; } -export interface RunnerConfigDep { - readonly runnerConfig: RunnerConfig; +export interface RunConfigDep { + readonly runConfig: RunConfig; } -/** Backward-compatible alias. */ -export type RunConfig = RunnerConfig; - -/** Backward-compatible alias. */ -export type RunConfigDep = RunnerConfigDep; - -export type RunnerDeps = ConsoleDep & +export type RunDeps = ConsoleDep & RandomBytesDep & RandomDep & TimeDep & - Partial & + Partial & Partial & // TODO: Partial; // TODO: -/** Backward-compatible alias. */ -export type RunDeps = RunnerDeps; - -const defaultDeps: RunnerDeps = { +const defaultDeps: RunDeps = { console: createConsole(), randomBytes: createRandomBytes(), random: createRandom(), @@ -1232,22 +1245,31 @@ const defaultDeps: RunnerDeps = { }; /** - * Factory type for creating root {@link Runner} instances. + * Factory type for creating root {@link Run} instances. * - * @group Creating Runners + * @group Creating Run */ -export interface CreateRunner { - (): Runner; - (deps: D): Runner; +export interface CreateRun { + (): Run; + (deps: D): Run; } /** - * Creates root {@link Runner}. + * Creates root {@link Run}. + * + * The root Run is also the daemon Run: it stays running until disposed. Child + * Runs created by `run(task)` are disposed by their parent once they settle. * * Call once per entry point (main thread, worker, etc.) and dispose on - * shutdown. All tasks run as descendants of this root runner. + * shutdown. All Tasks run as descendants of this root Run. + * + * This common {@link createRun} is platform-agnostic. At application entry + * points, prefer the platform adapter when one exists. `@evolu/web` adds + * browser `error` and `unhandledrejection` handlers, `@evolu/nodejs` adds + * Node.js `uncaughtException`, `unhandledRejection`, and graceful shutdown + * handling, and `@evolu/react-native` adds React Native global error handling. * - * {@link RunnerDeps} provides default dependencies: + * {@link RunDeps} provides default dependencies: * * - {@link Time} * - {@link Console} @@ -1258,7 +1280,7 @@ export interface CreateRunner { * * ```ts * // App entry point - * await using run = createRunner(); + * await using run = createRun(); * * const result = await run(fetchData); * ``` @@ -1284,51 +1306,64 @@ export interface CreateRunner { * // ... * }; * - * // Composition root: create runner with custom deps - * type AppDeps = RunnerDeps & ConfigDep; + * // Composition root: create a Run with custom deps + * type AppDeps = RunDeps & ConfigDep; * * const appDeps: AppDeps = { * ...testCreateDeps(), // or spread individual deps * config: { apiUrl: "https://api.example.com" }, * }; * - * await using run = createRunner(appDeps); + * await using run = createRun(appDeps); * - * // Runner type is inferred from deps argument + * // Run type is inferred from the deps argument * const result = await run(fetchUser("123")); * * // TypeScript catches missing deps at compile time: - * // await using run2 = createRunner(); // Runner + * // await using run2 = createRun(); // Run * // run2(fetchUser("123")); // Error: Property 'config' is missing * ``` * - * @group Creating Runners + * @group Creating Run */ -export const createRunner: CreateRunner = ( +export const createRun: CreateRun = ( deps?: D, -): Runner => { - const mergedDeps = { ...defaultDeps, ...deps } as RunnerDeps & D; - return createRunnerInternal(createRef(mergedDeps))(); +): Run => { + const mergedDeps = { ...defaultDeps, ...deps } as RunDeps & D; + return createRunInternal(createRef(mergedDeps))(); }; -/** Backward-compatible alias. */ -export const createRun: typeof createRunner = createRunner; +/** + * 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; -/** Internal Runner properties, hidden from public API via TypeScript types. */ -interface RunnerInternal extends Runner { +/** + * Backward-compatible alias for older API naming. + * + * Prefer {@link createRun} in new code. + */ +export const createRunner = createRun; + +/** Internal Run properties, hidden from public API via TypeScript types. */ +interface RunInternal extends Run { readonly requestAbort: (reason: unknown) => void; readonly requestSignal: AbortSignal; readonly complete: (result: UnknownResult, outcome: UnknownResult) => void; } -const createRunnerInternal = - (depsRef: Ref) => +const createRunInternal = + (depsRef: Ref) => ( - parent?: RunnerInternal, - daemon?: RunnerInternal, + parent?: RunInternal, + daemon?: RunInternal, abortBehavior?: AbortBehavior, concurrencyBehavior?: Concurrency, - ): RunnerInternal => { + ): RunInternal => { const parentMask = parent?.abortMask ?? isAbortable; let abortMask: AbortMask; @@ -1350,7 +1385,7 @@ const createRunnerInternal = const requestController = new AbortController(); const signalController = new AbortController(); - let state: FiberState = running; + let state: RunState = running; let result: UnknownResult | undefined; let outcome: UnknownResult | undefined; let children: ReadonlySet> = emptySet; @@ -1369,16 +1404,17 @@ const createRunnerInternal = ); } - const emitEvent = (data: RunnerEventData) => { + const emitEvent = (data: RunEventData) => { const deps = depsRef.get(); - if (!deps.runnerConfig?.eventsEnabled.get()) return; - const e: RunnerEvent = { id: self.id, timestamp: deps.time.now(), data }; - for (let node: Runner | null = self; node; node = node.parent) + if (!deps.runConfig?.eventsEnabled.get()) return; + const e: RunEvent = { id: self.id, timestamp: deps.time.now(), data }; + for (let node: Run | null = self; node; node = node.parent) { node.onEvent?.(e); + } }; const run = (task: Task): Fiber => { - const runner = createRunnerInternal(depsRef)( + const run = createRunInternal(depsRef)( self, daemon ?? self, getAbortBehavior(task), @@ -1386,46 +1422,45 @@ const createRunnerInternal = ); if (state !== running) { - runner.requestAbort(runnerClosingAbortError); - task = () => err(runnerClosingAbortError); + run.requestAbort(runStoppedAbortError); + task = () => err(runStoppedAbortError); } else if ( signalController.signal.aborted && - runner.abortMask === isAbortable + run.abortMask === isAbortable ) { - runner.requestAbort(signalController.signal.reason); + run.requestAbort(signalController.signal.reason); task = () => err(signalController.signal.reason); } - // Evolu polyfills `Promise.try` - const promise = Promise.try(task, runner) + const promise = Promise.try(task, run) .then((taskOutcome) => { - const taskResult = runner.signal.aborted - ? err(runner.signal.reason) + const taskResult = run.signal.aborted + ? err(run.signal.reason) : taskOutcome; - runner.complete(taskResult, taskOutcome); + run.complete(taskResult, taskOutcome); return taskResult; }) - .finally(runner[Symbol.asyncDispose]) + .finally(run[Symbol.asyncDispose]) .finally(() => { children = deleteFromSet(children, fiber); - emitEvent({ type: "ChildRemoved", childId: runner.id }); + emitEvent({ type: "ChildRemoved", childId: run.id }); }); - const fiber = new Fiber(runner, promise); + const fiber = new Fiber(run, promise); children = addToSet(children, fiber); - emitEvent({ type: "ChildAdded", childId: runner.id }); + emitEvent({ type: "ChildAdded", childId: run.id }); return fiber; }; - const self = run as unknown as RunnerInternal; + const self = run as RunInternal; { - const run = self as Mutable>; + const run = self as Mutable>; const id = createId(depsRef.get()); - let snapshot: FiberSnapshot | null = null; + let snapshot: RunSnapshot | null = null; let disposingPromise: Promise | null = null; run.orThrow = async (task) => getOrThrow(await self(task)); @@ -1434,7 +1469,7 @@ const createRunnerInternal = run.signal = signalController.signal; run.abortMask = abortMask; - run.onAbort = (callback: Callback) => { + run.onAbort = (callback) => { if (abortMask !== isAbortable) return; subscribeToAbort( signalController.signal, @@ -1455,7 +1490,7 @@ const createRunnerInternal = ) { snapshot = { id, - state: state as FiberSnapshotState, + state: state as RunSnapshotState, children: childSnapshots, abortMask, }; @@ -1464,9 +1499,10 @@ const createRunnerInternal = }; run.daemon = daemon ?? self; - run.defer = (task: Task) => ({ + run.create = () => run.daemon(createDeferred().task).run; + run.defer = (task) => ({ [Symbol.asyncDispose]: () => - run.daemon(unabortable(task)).then(lazyVoid), + self.daemon(unabortable(task as Task)).then(lazyVoid), }); run.stack = () => new AsyncDisposableStack(self); @@ -1475,7 +1511,7 @@ const createRunnerInternal = run.concurrency = concurrencyBehavior ?? parent?.concurrency ?? defaultConcurrency; - run.addDeps = >(newDeps: E): Runner => { + run.addDeps = >(newDeps: E): Run => { depsRef.modify((currentDeps) => { const duplicate = Object.keys(newDeps).find((k) => k in currentDeps); assert( @@ -1484,24 +1520,23 @@ const createRunnerInternal = `This assert ensures dependencies are created once. ` + `Automatic deduplication would mask bugs.`, ); - return { ...currentDeps, ...newDeps }; + return [undefined, { ...currentDeps, ...newDeps }]; }); - return self as unknown as Runner; + return self as unknown as Run; }; run[Symbol.asyncDispose] = () => { if (disposingPromise) return disposingPromise; - state = { type: "Completing" }; + state = { type: "Disposing" }; emitEvent({ type: "StateChanged", state }); - - requestAbort(runnerClosingAbortError); + requestAbort(runStoppedAbortError); disposingPromise = Promise.allSettled(children) .then(lazyVoid) .finally(() => { [result, outcome] = [result ?? ok(), outcome ?? ok()]; - state = { type: "Completed", result, outcome }; + state = { type: "Settled", result, outcome }; emitEvent({ type: "StateChanged", state }); }); @@ -1511,10 +1546,7 @@ const createRunnerInternal = // Internal run.requestAbort = requestAbort; run.requestSignal = requestController.signal; - run.complete = ( - taskResult: UnknownResult, - taskOutcome: UnknownResult, - ) => { + run.complete = (taskResult, taskOutcome) => { result = taskResult; outcome = taskOutcome; }; @@ -1523,27 +1555,26 @@ const createRunnerInternal = return self; }; -const running: FiberState = { type: "Running" }; +const running: RunState = { type: "Running" }; /** - * Error used as {@link AbortError} reason when a {@link Runner} is disposed. + * Abort reason indicating a {@link Run} can no longer start new Tasks. * - * @group Creating Runners + * Covers both disposing and settled Runs. + * + * @group Creating Run */ -export const RunnerClosingError = /*#__PURE__*/ typed("RunnerClosingError"); -export interface RunnerClosingError - extends InferType {} +export const RunStoppedError = /*#__PURE__*/ typed("RunStoppedError"); +export interface RunStoppedError extends InferType {} /** - * The {@link RunnerClosingError} used when a {@link Runner} is disposed. - * - * Tasks run on a disposing or disposed runner receive this error as - * {@link AbortError} reason. + * Shared {@link RunStoppedError} instance used as the default + * {@link AbortError.reason} when a Task is started on a non-running {@link Run}. * - * @group Creating Runners + * @group Creating Run */ -export const runnerClosingError: RunnerClosingError = { - type: "RunnerClosingError", +export const runStoppedError: RunStoppedError = { + type: "RunStoppedError", }; const createAbortError = (reason: unknown): AbortError => ({ @@ -1560,8 +1591,7 @@ const subscribeToAbort = ( else signal.addEventListener("abort", handler, options); }; -const runnerClosingAbortError: AbortError = - createAbortError(runnerClosingError); +const runStoppedAbortError: AbortError = createAbortError(runStoppedError); const isAbortable = AbortMask.orThrow(0); type AbortBehavior = "unabortable" | AbortMask; @@ -1573,20 +1603,24 @@ const getAbortBehavior = (task: AnyTask): AbortBehavior | undefined => const abortBehavior = (behavior: AbortBehavior) => (task: Task): Task => - Object.assign((run: Runner) => run(task), { + Object.assign((run: Run) => run(task), { [abortBehaviorSymbol]: behavior, }); /** * Makes a {@link Task} unabortable. * - * Once started, an unabortable task always completes — abort requests are + * Once started, an unabortable Task always completes — abort requests are * ignored and `signal.aborted` remains `false`. * + * 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. + * * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * const events: Array = []; * const canComplete = Promise.withResolvers(); @@ -1606,7 +1640,7 @@ const abortBehavior = * const trackImportantEvent = (event: number) => * unabortable(sendToAnalytics(event)); * - * // User clicks, we start tracking (task runs until first await) + * // User clicks, we start tracking (Task runs until first await) * const fiber = run(trackImportantEvent(123)); * * // User navigates away (abort requested while task is running) @@ -1621,8 +1655,9 @@ const abortBehavior = * expect(result).toEqual(ok()); * ``` * - * @group Abort Masking + * @group Abort masking */ +// TODO: Clear AbortError from unabortable task results. export const unabortable = /*#__PURE__*/ abortBehavior("unabortable"); /** @@ -1634,7 +1669,7 @@ export const unabortable = /*#__PURE__*/ abortBehavior("unabortable"); * - Tasks run inside `unabortableMask` are unabortable by default * - Tasks wrapped with `restore()` restore the previous abortability * - * @group Abort Masking + * @group Abort masking */ export const unabortableMask = ( fn: ( @@ -1653,14 +1688,13 @@ const getConcurrencyBehavior = (task: AnyTask): Concurrency | undefined => (task as never)[concurrencyBehaviorSymbol]; /** - * Runs tasks in parallel instead of sequentially. + * Runs tasks concurrently instead of sequentially. * * Sets the {@link Concurrency} level for a {@link Task}, which helpers like * {@link all}, {@link map}, etc. use to control how many tasks run at once. * * By default, tasks run sequentially (one at a time) to encourage thinking - * about concurrency explicitly — unlike `Promise.all` which runs everything in - * parallel. + * about concurrency explicitly. * * For tuple-based calls like `all([taskA, taskB, taskC])` with a known small * number of tasks, omit the limit (runs unlimited). For arrays of unknown @@ -1670,20 +1704,20 @@ const getConcurrencyBehavior = (task: AnyTask): Concurrency | undefined => * Composition helpers should respect inherited concurrency — they should not * override it with a fixed number unless semantically required (like * {@link race}). Helpers with a recommended concurrency should export it for use - * with `parallel`. + * with `concurrently`. * * ### Example * * ```ts * // Unlimited (omit the limit) - * run(parallel(all([fetchA, fetchB, fetchC]))); + * run(concurrently(all([fetchA, fetchB, fetchC]))); * * // Limited — at most 5 tasks run at a time - * run(parallel(5, all(tasks))); - * run(parallel(5, map(userIds, fetchUser))); + * run(concurrently(5, all(tasks))); + * run(concurrently(5, map(userIds, fetchUser))); * * // Inherited — inner all() uses parent's limit - * const pipeline = parallel(5, async (run) => { + * const pipeline = concurrently(5, async (run) => { * const users = await run(map(userIds, fetchUser)); // uses 5 * if (!users.ok) return users; * return run(map(users.value, enrichUser)); // also uses 5 @@ -1692,20 +1726,26 @@ const getConcurrencyBehavior = (task: AnyTask): Concurrency | undefined => * * @group Composition */ -export function parallel( +export function concurrently( concurrency: Concurrency, task: Task, ): Task; /** Unlimited. */ -export function parallel(task: Task): Task; -export function parallel( +export function concurrently( + task: Task, +): Task; +export function concurrently( concurrencyOrTask: Concurrency | Task, taskOrFallback?: Task, ): Task { const isTask = isFunction(concurrencyOrTask); - // biome-ignore lint/style/noNonNullAssertion: Context - const task = isTask ? concurrencyOrTask : taskOrFallback!; - return Object.assign((run: Runner) => run(task), { + const task = (() => { + if (isTask) return concurrencyOrTask; + assert(taskOrFallback, "Task is required when concurrency is provided."); + return taskOrFallback; + })(); + + return Object.assign((run: Run) => run(task), { [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask, }); } @@ -1773,26 +1813,27 @@ export const yieldNow: Task = () => (reason): AbortError => createAbortError(reason), ); -type SchedulerLike = { - yield?: () => Promise; -}; - -type SetImmediateLike = (callback: () => void) => unknown; - -const globalScheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler; -const globalSetImmediate = (globalThis as { setImmediate?: SetImmediateLike }) - .setImmediate; -let schedulerYield: (() => Promise) | undefined; - -if (globalScheduler && typeof globalScheduler.yield === "function") { - schedulerYield = globalScheduler.yield.bind(globalScheduler); -} +const scheduler = ( + globalThis as unknown as { + readonly scheduler?: { readonly yield?: unknown }; + } +).scheduler; const yieldImpl: () => Promise = - schedulerYield ?? - (typeof globalSetImmediate !== "undefined" - ? () => new Promise((resolve) => globalSetImmediate(resolve)) - : () => new Promise((r) => setTimeout(r, 0))); // Safari + 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 /** * Creates a {@link Task} from a callback-based API. @@ -1836,7 +1877,7 @@ export const callback = ok: Callback; err: Callback; signal: AbortSignal; - deps: RunnerDeps; + deps: RunDeps; }>, ): Task => (run) => @@ -1881,10 +1922,10 @@ export const sleep = (duration: Duration): Task => * * Like * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race}, - * the first task to complete (whether success or failure) wins. All other tasks - * are aborted. Use {@link any} if you need the first task to succeed instead. + * the first Task to complete (whether success or failure) wins. All other Tasks + * are aborted. Use {@link any} if you need the first Task to succeed instead. * - * Requires a non-empty array — racing zero tasks has no meaningful result + * Requires a non-empty array — racing zero Tasks has no meaningful result * (there's no "first to complete" without participants). This is enforced at * compile time for non-empty tuple types. For other arrays, guard with * {@link isNonEmptyArray}: @@ -1909,7 +1950,7 @@ export const sleep = (duration: Duration): Task => * ``` * * Always runs with unlimited concurrency — a sequential race makes no sense - * since the first task would always "win". + * since the first Task would always "win". * * @group Composition */ @@ -1925,7 +1966,8 @@ export const race = ]>( InferTaskOk, InferTaskErr, InferTaskDeps -> => parallel(pool(tasks, { stopOn: "first", collect: false, abortReason })); +> => + concurrently(pool(tasks, { stopOn: "first", collect: false, abortReason })); /** * Abort reason for tasks that lose a {@link race}. * @@ -1944,8 +1986,8 @@ export const raceLostError: RaceLostError = { type: "RaceLostError" }; /** * Wraps a {@link Task} with a time limit. * - * Returns {@link TimeoutError} if the task doesn't complete within the specified - * duration. The original task is aborted when the timeout fires. + * Returns {@link TimeoutError} if the Task doesn't complete within the specified + * duration. The original Task is aborted when the timeout fires. * * ### Example * @@ -2040,9 +2082,8 @@ export interface RetryError extends Typed<"RetryError"> { /** * Wraps a {@link Task} with retry logic. * - * Retries the task according to the {@link Schedule}'s timing and termination - * rules. Use {@link RetryOptions.retryable} to filter which errors should - * trigger retries. + * Retries the Task according to the {@link Schedule}'s rules. Use + * {@link RetryOptions.retryable} to filter which errors should trigger retries. * * All non-abort errors are wrapped in {@link RetryError}: * @@ -2136,12 +2177,12 @@ export const retry = }); if (delay > 0) { const sleepResult = await run(sleep(delay)); - if (isErr(sleepResult)) return sleepResult; + if (!sleepResult.ok) return sleepResult; } } const result = await run(task); - if (!isErr(result)) return result; + if (result.ok) return result; if (AbortError.is(result.error)) return err(result.error); @@ -2185,13 +2226,13 @@ export interface RepeatAttempt extends ScheduleStep { /** * Repeats a {@link Task} according to a {@link Schedule}. * - * Runs the task, then checks the schedule to determine if it should repeat. The + * Runs the Task, then checks the schedule to determine if it should repeat. The * schedule controls how many repetitions occur and the delay between them. - * Continues until the schedule returns `Err(Done)` or the task fails. + * Continues until the schedule returns `Err(Done)` or the Task fails. * * With `take(n)`, the task runs n+1 times (initial run plus n repetitions). * - * Also works with {@link NextTask} — when the task returns `Err(Done)`, + * Also works with {@link NextTask} — when the Task returns `Err(Done)`, * repeat stops and propagates the done signal. * * ### Example @@ -2268,14 +2309,28 @@ export const repeat = * * Similar to * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers | Promise.withResolvers}, - * but integrated with {@link Task} and {@link Runner} for cancellation support. + * but integrated with {@link Task} and {@link Run} for cancellation support. * - * Use for bridging callback-based APIs or coordinating between tasks. + * Use for bridging callback-based APIs or coordinating between Tasks. * - * Disposing aborts all waiting tasks with an {@link AbortError} with + * Disposing aborts all waiting Tasks with an {@link AbortError} with * {@link deferredDisposedError} reason. * - * @group Concurrency Primitives + * ### Example + * + * ```ts + * const deferred = createDeferred(); + * + * // Start waiting for the value + * const fiber = run(deferred.task); + * + * // Resolve from elsewhere (callback, another task, etc.) + * deferred.resolve(ok("value")); + * + * const result = await fiber; // ok("value") + * ``` + * + * @group Concurrency primitives * @see {@link createDeferred} */ export interface Deferred extends Disposable { @@ -2291,21 +2346,7 @@ export interface Deferred extends Disposable { /** * Creates a {@link Deferred}. * - * ### Example - * - * ```ts - * const deferred = createDeferred(); - * - * // Start waiting for the value - * const fiber = run(deferred.task); - * - * // Resolve from elsewhere (callback, another task, etc.) - * deferred.resolve(ok("value")); - * - * const result = await fiber; // ok("value") - * ``` - * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const createDeferred = (): Deferred => { let resolved: Result | null = null; @@ -2351,91 +2392,10 @@ export const createDeferred = (): Deferred => { }; }; -/** - * Registry of {@link Deferred} tasks addressed by IDs. - * - * Useful for request-response protocols where requests carry an ID and - * responses arrive later with the same ID. - * - * @group Concurrency primitives - * @see {@link createDeferreds} - */ -export interface Deferreds extends Disposable { - /** Registers a new deferred task and returns its ID with the task. */ - readonly register: () => { - readonly id: Id; - readonly task: Task; - }; - - /** Resolves and removes deferred by ID. Returns false when ID is unknown. */ - readonly resolve: ( - id: Id, - result: Result, - ) => boolean; - - /** Resolves and removes all pending deferreds with the same result. */ - readonly resolveAll: ( - result: Result, - ) => void; -} - -/** - * Creates a {@link Deferreds} registry. - * - * ### Example - * - * ```ts - * const deferreds = createDeferreds(deps); - * const { id, task } = deferreds.register(); - * - * // Later, when response for id arrives: - * deferreds.resolve(id, ok("value")); - * - * const result = await run(task); // ok("value") - * ``` - * - * @group Concurrency primitives - */ -export const createDeferreds = ( - deps: RandomBytesDep, -): Deferreds => { - const deferredMap = new Map>(); - - return { - register: () => { - const id = createId<"Deferred">(deps); - const deferred = createDeferred(); - deferredMap.set(id, deferred); - return { id, task: deferred.task }; - }, - - resolve: (id, result) => { - const deferred = deferredMap.get(id); - if (!deferred) return false; - deferredMap.delete(id); - return deferred.resolve(result); - }, - - resolveAll: (result) => { - for (const deferred of deferredMap.values()) { - deferred.resolve(result); - } - deferredMap.clear(); - }, - - [Symbol.dispose]: () => { - for (const deferred of deferredMap.values()) { - deferred[Symbol.dispose](); - } - deferredMap.clear(); - }, - }; -}; - /** * Abort reason used when a {@link Deferred} is disposed. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const DeferredDisposedError = /*#__PURE__*/ typed( "DeferredDisposedError", @@ -2446,7 +2406,7 @@ export interface DeferredDisposedError /** * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const deferredDisposedError: DeferredDisposedError = { type: "DeferredDisposedError", @@ -2461,9 +2421,9 @@ export const deferredDisposedError: DeferredDisposedError = { * Use it to pause execution based on a condition. Unlike a {@link Deferred} * (which triggers once), a {@link Gate} can be opened and closed repeatedly. * - * Disposing aborts all waiting tasks with {@link deferredDisposedError}. + * Disposing aborts all waiting Tasks with {@link deferredDisposedError}. * - * @group Concurrency Primitives + * @group Concurrency primitives * @see {@link createGate} */ export interface Gate extends Disposable { @@ -2476,7 +2436,7 @@ export interface Gate extends Disposable { /** * Creates a {@link Gate} that starts closed. * - * Useful for "stop/go" logic where multiple tasks need to wait for a state + * Useful for "stop/go" logic where multiple Tasks need to wait for a state * change. * * ### Example @@ -2499,7 +2459,7 @@ export interface Gate extends Disposable { * }; * ``` * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const createGate = (): Gate => { let isOpen = false; @@ -2541,17 +2501,59 @@ export const createGate = (): Gate => { * For mutual exclusion (limiting to exactly one {@link Task}), use {@link Mutex} * instead. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export interface Semaphore extends Disposable { /** * Executes a {@link Task} while holding a semaphore permit. * - * The task waits until a permit is available. If the semaphore is disposed - * while waiting or running, the task is aborted with an {@link AbortError} + * The Task waits until a permit is available. If the semaphore is disposed + * while waiting or running, the Task is aborted with an {@link AbortError} * whose reason is {@link semaphoreDisposedError}. */ readonly withPermit: (task: Task) => Task; + + /** + * Executes a {@link Task} while holding a specified number of permits. + * + * If insufficient permits are available, waits in FIFO order until permits + * become available. If disposed while waiting or running, the Task is aborted + * with {@link semaphoreDisposedError}. + * + * Use this for weighted concurrency where a Task represents a resource + * demand, not just "one more Task". One permit is one resource unit. + * + * Example: with capacity `10`, a lightweight operation can reserve `1` permit + * while a heavy operation reserves `4` permits. This models shared budgets + * such as DB connections, API credits, memory/CPU buckets, or batch + * processing slots. + * + * {@link Semaphore.withPermit} is equivalent to `withPermits(1)`. + */ + readonly withPermits: ( + permits: Concurrency, + ) => (task: Task) => Task; + + /** Returns the current semaphore state for monitoring/debugging. */ + readonly snapshot: () => SemaphoreSnapshot; +} + +/** Snapshot returned by {@link Semaphore.snapshot}. */ +export interface SemaphoreSnapshot { + /** Total permits configured at creation. */ + readonly permits: Concurrency; + + /** Currently held permits. */ + readonly taken: NonNegativeInt; + + /** Number of currently waiting Tasks. */ + readonly waiting: NonNegativeInt; + + /** Currently available permits. */ + readonly available: NonNegativeInt; + + /** Whether the semaphore has been disposed. */ + readonly disposed: boolean; } /** @@ -2560,7 +2562,7 @@ export interface Semaphore extends Disposable { * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * const semaphore = createSemaphore(PositiveInt.orThrow(2)); * @@ -2592,75 +2594,102 @@ export interface Semaphore extends Disposable { * // [demo] end 3 * ``` * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const createSemaphore = (permits: Concurrency): Semaphore => { - const fibers = new Set(); - const queue = new Set>>(); + interface Waiter { + readonly permits: PositiveInt; + readonly resolve: Callback>; + } - let availablePermits: number = permits; + const fibers = new Set(); + const waiters: Array = []; + let taken = NonNegativeInt.orThrow(0); let disposed = false; - return { - withPermit: - (task: Task): Task => - async (run) => { - assert( - availablePermits === 0 || queue.size === 0, - "Semaphore invariant violated: queue must be empty when permits are available.", - ); - if (disposed) return err(semaphoreDisposedAbortError); - - if (availablePermits === 0) { - const acquired = await new Promise>( - (resolve) => { - queue.add(resolve); - run.onAbort((reason) => { - queue.delete(resolve); - resolve(err(createAbortError(reason))); - }); - }, - ); - if (!acquired.ok) return acquired; - } else { - availablePermits -= 1; - } + const withPermits = + (requestedPermits: Concurrency) => + (task: Task): Task => + async (run) => { + const requested = PositiveInt.orThrow(requestedPermits); + + assert( + requested <= permits, + "Requested permits must not exceed semaphore capacity.", + ); + + if (disposed) return err(semaphoreDisposedAbortError); + + if (waiters.length > 0 || taken + requested > permits) { + const waiter = Promise.withResolvers>(); + const waiting: Waiter = { + permits: requested, + resolve: waiter.resolve, + }; + waiters.push(waiting); + run.onAbort((reason) => { + const i = waiters.indexOf(waiting); + if (i >= 0) waiters.splice(i, 1); + waiter.resolve(err(createAbortError(reason))); + }); + + const permit = await waiter.promise; + if (!permit.ok) return permit; + } else { + taken = NonNegativeInt.orThrow(taken + requested); + } - let fiber: Fiber | null = null; - try { - fiber = run(task); - fibers.add(fiber); - return await fiber; - } finally { + let fiber: Fiber | null = null; + using _ = { + [Symbol.dispose]: () => { if (fiber) fibers.delete(fiber); - const next = queue.values().next(); - if (!next.done) { - queue.delete(next.value); - next.value(ok()); - } else { - availablePermits += 1; + taken = NonNegativeInt.orThrow(taken - requested); + + while (waiters.length > 0) { + const waiter = waiters[0]; + if (taken + waiter.permits > permits) break; + waiters.shift(); + taken = NonNegativeInt.orThrow(taken + waiter.permits); + waiter.resolve(ok()); } + }, + }; - assert( - availablePermits === 0 || queue.size === 0, - "Queue must be empty when permits are available.", - ); - } - }, + fiber = run(task); + fibers.add(fiber); + return await fiber; + }; + + return { + withPermits, + + withPermit: (task: Task): Task => + withPermits(1)(task), + + snapshot: () => ({ + permits, + taken, + waiting: NonNegativeInt.orThrow(waiters.length), + available: NonNegativeInt.orThrow(permits - taken), + disposed, + }), [Symbol.dispose]: () => { if (disposed) return; disposed = true; + using stack = new DisposableStack(); for (const fiber of fibers) { - fiber.abort(semaphoreDisposedError); + stack.adopt(fiber, (fiber) => { + fiber.abort(semaphoreDisposedError); + }); } - for (const resolve of queue) { - resolve(err(semaphoreDisposedAbortError)); + for (const waiter of waiters) { + waiter.resolve(err(semaphoreDisposedAbortError)); } - queue.clear(); + waiters.length = 0; }, }; }; @@ -2668,7 +2697,7 @@ export const createSemaphore = (permits: Concurrency): Semaphore => { /** * Abort reason used when a {@link Semaphore} is disposed. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const SemaphoreDisposedError = /*#__PURE__*/ typed( "SemaphoreDisposedError", @@ -2679,7 +2708,7 @@ export interface SemaphoreDisposedError /** * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const semaphoreDisposedError: SemaphoreDisposedError = { type: "SemaphoreDisposedError", @@ -2689,6 +2718,124 @@ const semaphoreDisposedAbortError: AbortError = createAbortError( semaphoreDisposedError, ); +/** + * A keyed {@link Semaphore} registry. + * + * Provides semaphore operations per key while preserving the same API shape as + * {@link Semaphore}. + * + * @group Concurrency primitives + */ +export interface SemaphoreByKey extends Disposable { + /** + * Executes a {@link Task} while holding one permit for a specific key. + * + * Behaves like {@link Semaphore.withPermit}, scoped to `key`. + */ + readonly withPermit: (key: K, task: Task) => Task; + + /** + * Executes a {@link Task} while holding permits for a specific key. + * + * Behaves like {@link Semaphore.withPermits}, scoped to `key`. + */ + readonly withPermits: ( + key: K, + permits: Concurrency, + ) => (task: Task) => Task; + + /** Returns current semaphore state for a key, or `null` if absent. */ + readonly snapshot: (key: K) => SemaphoreSnapshot | null; +} + +/** + * Creates a {@link SemaphoreByKey}. + * + * Each key gets its own semaphore with the same permit capacity. + * + * @group Concurrency primitives + */ +export const createSemaphoreByKey = ( + permits: Concurrency, +): SemaphoreByKey => { + type KeyedSemaphore = Semaphore & { + __disposing: boolean; + }; + + const semaphoresByKey = new Map(); + 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; + }; + + const withPermits = + (key: K, requestedPermits: Concurrency) => + (task: Task): Task => + async (run: Run) => { + if (disposed) return err(semaphoreDisposedAbortError); + + let semaphore = getActiveSemaphore(key); + if (!semaphore) { + semaphore = Object.assign(createSemaphore(permits), { + __disposing: false, + }); + 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; + + const drainedSnapshot = semaphore.snapshot(); + if (drainedSnapshot.taken !== 0 || drainedSnapshot.waiting !== 0) { + semaphore.__disposing = false; + return; + } + + semaphoresByKey.delete(key); + semaphore[Symbol.dispose](); + }, + }; + + return await run(semaphore.withPermits(requestedPermits)(task)); + }; + + return { + withPermit: (key: K, task: Task): Task => + withPermits(key, 1)(task), + + withPermits, + + snapshot: (key) => getActiveSemaphore(key)?.snapshot() ?? null, + + [Symbol.dispose]: () => { + if (disposed) return; + disposed = true; + + using stack = new DisposableStack(); + for (const semaphore of semaphoresByKey.values()) { + stack.use(semaphore); + } + semaphoresByKey.clear(); + }, + }; +}; + /** * A mutex (mutual exclusion) that ensures only one {@link Task} runs at a time. * @@ -2697,7 +2844,7 @@ const semaphoreDisposedAbortError: AbortError = createAbortError( * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * const mutex = createMutex(); * @@ -2722,32 +2869,190 @@ const semaphoreDisposedAbortError: AbortError = createAbortError( * // end 2 * ``` * - * @group Concurrency Primitives + * @group Concurrency primitives */ export interface Mutex extends Disposable { /** * Executes a {@link Task} while holding the mutex lock. * - * Only one task can hold the lock at a time. Other tasks wait until the lock + * Only one Task can hold the lock at a time. Other Tasks wait until the lock * is released. */ readonly withLock: (task: Task) => Task; + + /** Returns the current mutex state for monitoring/debugging. */ + readonly snapshot: () => SemaphoreSnapshot; } /** * Creates a {@link Mutex}. * - * @group Concurrency Primitives + * @group Concurrency primitives */ export const createMutex = (): Mutex => { const semaphore = createSemaphore(minPositiveInt); return { withLock: semaphore.withPermit, + snapshot: semaphore.snapshot, [Symbol.dispose]: semaphore[Symbol.dispose], }; }; +/** + * A keyed {@link Mutex} registry. + * + * Provides mutex operations per key. + * + * @group Concurrency primitives + */ +export interface MutexByKey extends Disposable { + /** + * Executes a {@link Task} while holding the mutex lock for a specific key. + * + * Behaves like {@link Mutex.withLock}, scoped to `key`. + */ + readonly withLock: (key: K, task: Task) => Task; + + /** Returns the current mutex state for `key`, or `null` if absent. */ + readonly snapshot: (key: K) => SemaphoreSnapshot | null; +} + +/** + * Creates a {@link MutexByKey}. + * + * @group Concurrency primitives + */ +export const createMutexByKey = < + K extends string = string, +>(): MutexByKey => { + const semaphoreByKey = createSemaphoreByKey(minPositiveInt); + + return { + withLock: (key: K, task: Task): Task => + semaphoreByKey.withPermit(key, task), + snapshot: semaphoreByKey.snapshot, + [Symbol.dispose]: semaphoreByKey[Symbol.dispose], + }; +}; + +/** + * {@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. + * + * 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. + * + * @group Concurrency primitives + */ +export interface MutexRef extends Disposable { + /** Returns the current state. */ + readonly get: Task; + + /** Sets the state. */ + readonly set: (state: T) => Task; + + /** Sets the state and returns the previous state. */ + readonly getAndSet: (state: T) => Task; + + /** Sets the state and returns the current state after the update. */ + readonly setAndGet: (state: T) => Task; + + /** Updates the state. */ + readonly update: ( + updater: (current: T) => Task, + ) => Task; + + /** Updates the state and returns the previous state. */ + readonly getAndUpdate: ( + updater: (current: T) => Task, + ) => Task; + + /** Updates the state and returns the current state after the update. */ + readonly updateAndGet: ( + updater: (current: T) => Task, + ) => Task; + + /** Modifies the state and returns a computed result from the transition. */ + readonly modify: ( + updater: (current: T) => Task, + ) => Task; +} + +/** + * Creates a {@link MutexRef} with the given initial state. + * + * @group Concurrency primitives + */ +export const createMutexRef = (initialState: T): MutexRef => { + let currentState = initialState; + const mutex = createMutex(); + + return { + get: mutex.withLock(() => ok(currentState)), + + set: (state) => + mutex.withLock(() => { + currentState = state; + return ok(); + }), + + getAndSet: (state) => + mutex.withLock(() => { + const previousState = currentState; + currentState = state; + return ok(previousState); + }), + + setAndGet: (state) => + mutex.withLock(() => { + currentState = state; + return ok(currentState); + }), + + update: (updater) => + mutex.withLock(async (run) => { + const nextState = await run(updater(currentState)); + if (!nextState.ok) return nextState; + currentState = nextState.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); + }), + + updateAndGet: (updater) => + mutex.withLock(async (run) => { + const nextState = await run(updater(currentState)); + if (!nextState.ok) return nextState; + currentState = nextState.value; + return ok(currentState); + }), + + modify: (updater) => + mutex.withLock(async (run) => { + const nextState = await run(updater(currentState)); + if (!nextState.ok) return nextState; + const [result, updatedState] = nextState.value; + currentState = updatedState; + return ok(result); + }), + + [Symbol.dispose]: mutex[Symbol.dispose], + }; +}; + /** * Cross-platform leader lock abstraction. * @@ -2776,7 +3081,7 @@ export interface LeaderLockDep { * @group Concurrency primitives */ export const createInMemoryLeaderLock = (): LeaderLock => { - const mutexes = createInstances(); + const mutexByName = createMutexByKey(); return { acquire: (name) => async (run) => { @@ -2784,40 +3089,16 @@ export const createInMemoryLeaderLock = (): LeaderLock => { // keep the lock held until lease disposal. const onAcquired = Promise.withResolvers(); const onRelease = Promise.withResolvers(); - const onAborted = Promise.withResolvers(); - let isAcquired = false; - - run.onAbort((reason) => { - if (isAcquired) return; - onRelease.resolve(); - onAborted.resolve(createAbortError(reason)); - }); void run.daemon( - mutexes.ensure(name, createMutex).withLock(async () => { - isAcquired = true; + mutexByName.withLock(name, async () => { onAcquired.resolve(); await onRelease.promise; return ok(); }), ); - const acquiredOrAbort = await Promise.race([ - onAcquired.promise.then(() => ({ type: "acquired" as const })), - onAborted.promise.then((error) => ({ - type: "aborted" as const, - error, - })), - ]); - - if (acquiredOrAbort.type === "aborted") { - return err(acquiredOrAbort.error); - } - - if (run.signal.aborted) { - onRelease.resolve(); - return err(run.signal.reason as AbortError); - } + await onAcquired.promise; return ok({ [Symbol.dispose]: () => { @@ -2852,7 +3133,7 @@ export interface CollectOptions { /** * Fails fast on first error across multiple {@link Task}s. * - * Sequential by default — use {@link parallel} to run concurrently. + * Sequential by default — use {@link concurrently} to run concurrently. * * ### Example * @@ -2950,7 +3231,7 @@ export function all( /** * Abort reason used by {@link all} when aborting remaining tasks. * - * Used when a task fails and other tasks need to be aborted. + * Used when a Task fails and other Tasks need to be aborted. * * @group Composition */ @@ -2969,10 +3250,10 @@ export const allAbortError: AllAbortError = { type: "AllAbortError" }; * * Like * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled | Promise.allSettled}, - * all tasks run to completion regardless of individual failures. Returns an + * all Tasks run to completion regardless of individual failures. Returns an * array of {@link Result}s preserving the original order. * - * Sequential by default. Use {@link parallel} for parallel execution. + * Sequential by default. Use {@link concurrently} for concurrent execution. * * ### Example * @@ -3100,10 +3381,12 @@ export const allSettledAbortError: AllSettledAbortError = { type: "AllSettledAbortError", }; +type NoCollect = ReturnType<() => void>; + /** * Maps values to {@link Task}s, failing fast on first error. * - * Sequential by default — use {@link parallel} for parallel execution. + * Sequential by default — use {@link concurrently} for concurrent execution. * * ### Example * @@ -3171,7 +3454,7 @@ export function map( items: MapInput, fn: (a: A) => Task, { abortReason = mapAbortError, ...options }: CollectOptions = {}, -): Task { +): Task | Record | NoCollect, E, D> { const mapped = mapInput(items, fn); return all( mapped as Iterable>, @@ -3203,7 +3486,7 @@ export const mapAbortError: MapAbortError = { * Maps values to {@link Task}s, completing all regardless of failures. * * Returns an array of {@link Result}s preserving the original order. Sequential - * by default — use {@link parallel} for parallel execution. + * by default — use {@link concurrently} for concurrent execution. * * ### Example * @@ -3280,7 +3563,13 @@ export function mapSettled( items: MapInput, task: (a: A) => Task, options?: CollectOptions, -): Task { +): Task< + | ReadonlyArray> + | Record> + | NoCollect, + never, + D +> { const mapped = mapInput(items, task); return allSettled( mapped as Iterable>, @@ -3293,10 +3582,10 @@ export function mapSettled( * * Like * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any | Promise.any}, - * the first task to succeed wins. All other tasks are aborted. If all tasks + * the first Task to succeed wins. All other Tasks are aborted. If all Tasks * fail, returns the last error (by input order). * - * Sequential by default. Use {@link parallel} for parallel execution. + * Sequential by default. Use {@link concurrently} for concurrent execution. * * Think of it like `Array.prototype.some()` — it stops on the first success. * This is in contrast to {@link race}, which returns the first task to complete @@ -3307,7 +3596,7 @@ export function mapSettled( * ```ts * // Try multiple endpoints concurrently, first success wins * const result = await run( - * parallel( + * concurrently( * any([fetchFromPrimary, fetchFromSecondary, fetchFromTertiary]), * ), * ); @@ -3345,15 +3634,15 @@ export function any( * * - `"input"` returns the error from the last task in the input array. This is * stable under concurrency and generally produces deterministic tests. - * - `"completion"` returns the error from the task that finished last. This + * - `"completion"` returns the error from the Task that finished last. This * reflects timing but can vary across runs when task timing varies. * * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * const result = await run( - * parallel(any([a, b, c], { allFailed: "completion" })), + * concurrently(any([a, b, c], { allFailed: "completion" })), * ); * ``` */ @@ -3421,7 +3710,7 @@ const collect = ( }; /** - * When to stop processing tasks in {@link pool}. + * When to stop processing Tasks in {@link pool}. * * - `"first"` — stop on first result (success or error), used by {@link race} * - `"error"` — stop on first error, used by {@link all} and {@link map} @@ -3439,14 +3728,14 @@ const mapInput = ( isIterable(input) ? mapArray(arrayFrom(input), fn) : mapObject(input, fn); /** - * Worker pool respecting {@link Runner.concurrency}. + * Worker pool respecting {@link Run.concurrency}. * - * Spawns only as many workers as allowed, avoiding idle fibers waiting for + * Spawns only as many workers as allowed, avoiding idle Fibers waiting for * permits. * - * Workers run as daemons so callers don't block on unabortable tasks. When + * Workers run as daemons so callers don't block on unabortable Tasks. When * abort is requested, pool returns immediately. Structured concurrency is - * preserved because the root {@link Runner} still waits for all daemons. + * preserved because the root {@link Run} still waits for all daemons. * * The `stopOn` option determines when to stop: * @@ -3515,7 +3804,7 @@ function pool( abortReason: unknown; allFailed?: AnyAllFailed; }, -): Task { +): Task | T | NoCollect, E> { const tasks = arrayFrom(tasksIterable); const { length } = tasks; if (length === 0) return () => ok(emptyArray); @@ -3556,7 +3845,7 @@ function pool( if (!stopped) { stopped = result; abortWorkers( - isErr(result) && AbortError.is(result.error) + !result.ok && AbortError.is(result.error) ? result.error.reason : abortReason, ); @@ -3602,8 +3891,16 @@ function pool( // For all/allSettled/map/mapSettled with collect: false (no allFailed handler) if (!allFailed) return ok(); - // biome-ignore lint/style/noNonNullAssertion: Context - return allFailed === "completion" ? lastResult! : lastIndexResult!; + if (allFailed === "completion") { + assert( + lastResult, + "Expected completion result for allFailed=completion.", + ); + return lastResult; + } + + assert(lastIndexResult, "Expected last index result for allFailed=index."); + return lastIndexResult; }; } @@ -3626,7 +3923,7 @@ export interface FetchError extends InferType {} * ### Example * * ```ts - * await using run = createRunner(); + * await using run = createRun(); * * const result = await run(fetch("https://api.example.com/users")); * @@ -3676,3 +3973,5 @@ export const fetch = // Safari doesn't support it yet, Node.js probably never will (use setImmediate). // For Safari, scheduler-polyfill can be used. // https://www.npmjs.com/package/scheduler-polyfill + +// TODO: Do we really need specialized aborts? diff --git a/packages/common/src/Test.ts b/packages/common/src/Test.ts index cf765ce88..b7e3752c9 100644 --- a/packages/common/src/Test.ts +++ b/packages/common/src/Test.ts @@ -106,7 +106,7 @@ export const testCreateRun: typeof testCreateRunner = testCreateRunner; // Functions are ok. export const testEntropy32 = /*#__PURE__*/ Entropy32.orThrow( - new globalThis.Uint8Array([ + /*#__PURE__*/ new globalThis.Uint8Array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, ]), diff --git a/packages/common/src/Time.ts b/packages/common/src/Time.ts index a8a739266..113b2668e 100644 --- a/packages/common/src/Time.ts +++ b/packages/common/src/Time.ts @@ -143,7 +143,7 @@ const maxMillisWithInfinity = 281474976710655; */ export const Millis = /*#__PURE__*/ brand( "Millis", - lessThan(maxMillisWithInfinity)(NonNegativeInt), + /*#__PURE__*/ lessThan(maxMillisWithInfinity)(NonNegativeInt), ); export type Millis = typeof Millis.Type; diff --git a/packages/common/src/Type.ts b/packages/common/src/Type.ts index a0022948c..dac1bec1d 100644 --- a/packages/common/src/Type.ts +++ b/packages/common/src/Type.ts @@ -1706,7 +1706,7 @@ export type NameError = SimpleNameError; */ export const SimplePassword = /*#__PURE__*/ brand( "SimplePassword", - minLength(8)(maxLength(64)(TrimmedString)), + /*#__PURE__*/ minLength(8)(/*#__PURE__*/ maxLength(64)(TrimmedString)), ); export type SimplePassword = typeof SimplePassword.Type; @@ -1917,7 +1917,10 @@ export const formatTableIdError = ); /** Binary representation of an {@link Id}. */ -export const IdBytes = /*#__PURE__*/ brand("IdBytes", length(16)(Uint8Array)); +export const IdBytes = /*#__PURE__*/ brand( + "IdBytes", + /*#__PURE__*/ length(16)(Uint8Array), +); export type IdBytes = typeof IdBytes.Type; export const idBytesTypeValueLength = 16 as NonNegativeInt; diff --git a/packages/common/src/WebSocket.ts b/packages/common/src/WebSocket.ts index 1007ffb98..50a7a5550 100644 --- a/packages/common/src/WebSocket.ts +++ b/packages/common/src/WebSocket.ts @@ -310,3 +310,28 @@ const nativeToStringState: Record = { [globalThis.WebSocket.CLOSING]: "closing", [globalThis.WebSocket.CLOSED]: "closed", }; + +export interface TestCreateWebSocketOptions { + readonly throwOnCreate?: boolean; +} + +/** + * Test helper that creates a deterministic {@link CreateWebSocket} adapter. + */ +export const testCreateWebSocket = + ({ + throwOnCreate = false, + }: TestCreateWebSocketOptions = {}): CreateWebSocket => + () => + async () => { + if (throwOnCreate) { + throw new Error("testCreateWebSocket: throwOnCreate"); + } + + return ok({ + send: () => ok(), + getReadyState: () => "open", + isOpen: () => true, + [Symbol.dispose]: () => {}, + }); + }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7122d9217..336e92827 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 "./Instances.js"; export * from "./Listeners.js"; // Local-first essentials. export type { EvoluError } from "./local-first/Error.js"; @@ -29,15 +28,22 @@ export type { UnuseOwner, } from "./local-first/Evolu.js"; export { AppName, createEvolu } from "./local-first/Evolu.js"; -export * as kysely from "./local-first/Kysely.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, + kyselySql, +} from "./local-first/Query.js"; export type { AnyStandardSchemaV1, EvoluSchema, diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 88b8dbcbf..6c2e2666b 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -19,8 +19,7 @@ import type { } from "../Crypto.js"; import { getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; -import { getOk, ok, type Result } from "../Result.js"; -import { spaced } from "../Schedule.js"; +import { ok, type Result } from "../Result.js"; import type { CreateSqliteDriverDep, SqliteDep, @@ -33,8 +32,7 @@ import { type SqliteValue, sql, } from "../Sqlite.js"; -import type { LeaderLockDep } from "../Task.js"; -import { type AsyncDisposableStack, repeat, type Task } from "../Task.js"; +import type { AsyncDisposableStack, LeaderLockDep, Task } from "../Task.js"; import { type Millis, millisToDateIso, type TimeDep } from "../Time.js"; import type { Name } from "../Type.js"; import { @@ -165,20 +163,10 @@ export const initDbWorker = console.info("leaderLock acquired"); port.postMessage({ type: "LeaderAcquired", name }); - const heartbeatFiber = run.daemon( - repeat(() => { - port.postMessage({ type: "LeaderHeartbeat", name }); - return ok(); - }, spaced("5s")), - ); - try { - return await run.addDeps({ - port, - timestampConfig: { maxDrift: defaultTimestampMaxDrift }, - })(startDbWorker(name, sqliteSchema, encryptionKey)); - } finally { - heartbeatFiber.abort(); - } + return await run.addDeps({ + port, + timestampConfig: { maxDrift: defaultTimestampMaxDrift }, + })(startDbWorker(name, sqliteSchema, encryptionKey)); }); }; @@ -201,9 +189,11 @@ const startDbWorker = const console = run.deps.console.child(name).child("DbWorker"); console.info("startDbWorker"); - const sqlite = getOk( - await stack.use(createSqlite(name, { mode: "encrypted", encryptionKey })), + const sqliteResult = await stack.use( + createSqlite(name, { mode: "encrypted", encryptionKey }), ); + if (!sqliteResult.ok) return sqliteResult; + const sqlite = sqliteResult.value; console.info("SQLite created"); const baseSqliteStorage = createBaseSqliteStorage({ sqlite, ...run.deps }); @@ -281,10 +271,14 @@ const startDbWorker = }); break; case "Export": - result = ok({ - type: "Export", - file: deps.sqlite.export(), - }); + { + const exported = deps.sqlite.export(); + const file = new Uint8Array(exported); + result = ok({ + type: "Export", + file, + }); + } break; } diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 9d2239569..49017c1d6 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -664,6 +664,7 @@ export const createEvolu = { type: "CreateEvolu", name, + appOwner, evoluPort: evoluChannel.port2.native, dbWorkerPort: dbWorkerChannel.port2.native, }, @@ -763,11 +764,12 @@ export const createEvolu = break; } case "OnExport": { - if (exportDatabasePending) { - exportDatabasePending.resolve(message.file); - exportDatabasePending = null; - } - // Silently ignore late OnExport after disposal + assert( + exportDatabasePending, + "OnExport received without pending export.", + ); + exportDatabasePending.resolve(message.file); + exportDatabasePending = null; break; } default: diff --git a/packages/common/src/local-first/Kysely.ts b/packages/common/src/local-first/Kysely.ts deleted file mode 100644 index f6be22cee..000000000 --- a/packages/common/src/local-first/Kysely.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Kysely query builder extensions for Evolu. - * - * @module - */ - -import type { - AliasableExpression, - Expression, - RawBuilder, - SelectQueryNode, - Simplify, -} from "kysely"; -import { - AliasNode, - ColumnNode, - ExpressionWrapper, - IdentifierNode, - ReferenceNode, - sql, - TableNode, - ValueNode, -} from "kysely"; -import { kyselyJsonIdentifier } from "./Query.js"; - -export type { NotNull } from "kysely"; -export { sql } from "kysely"; - -/** - * A SQLite helper for aggregating a subquery into a JSON array. - * - * ### Example - * - * ```ts - * import { kysely } from "@evolu/common"; - * - * // TODO: Update for Evolu - * const result = await db - * .selectFrom("person") - * .select((eb) => [ - * "id", - * kysely - * .jsonArrayFrom( - * eb - * .selectFrom("pet") - * .select(["pet.id as pet_id", "pet.name"]) - * .whereRef("pet.owner_id", "=", "person.id") - * .orderBy("pet.name"), - * ) - * .as("pets"), - * ]) - * .execute(); - * - * result[0]?.id; - * result[0]?.pets[0].pet_id; - * result[0]?.pets[0].name; - * ``` - * - * The generated SQL (SQLite): - * - * ```sql - * select "id", ( - * select coalesce(json_group_array(json_object( - * 'pet_id', "agg"."pet_id", - * 'name', "agg"."name" - * )), '[]') from ( - * select "pet"."id" as "pet_id", "pet"."name" - * from "pet" - * where "pet"."owner_id" = "person"."id" - * order by "pet"."name" - * ) as "agg" - * ) as "pets" - * from "person" - * ``` - */ -// Kysely expects strict AST. -export function jsonArrayFrom( - expr: SelectQueryBuilderExpression, -): RawBuilder>> { - return sql`(select ${sql.lit(kyselyJsonIdentifier)} || coalesce(json_group_array(json_object(${sql.join( - getSqliteJsonObjectArgs(expr.toOperationNode(), "agg"), - )})), '[]') from ${expr} as agg)`; -} - -/** - * A SQLite helper for turning a subquery into a JSON object. - * - * The subquery must only return one row. - * - * ### Example - * - * ```ts - * import { kysely } from "@evolu/common"; - * - * // TODO: Update for Evolu - * const result = await db - * .selectFrom("person") - * .select((eb) => [ - * "id", - * jsonObjectFrom( - * eb - * .selectFrom("pet") - * .select(["pet.id as pet_id", "pet.name"]) - * .whereRef("pet.owner_id", "=", "person.id") - * .where("pet.is_favorite", "=", true), - * ).as("favorite_pet"), - * ]) - * .execute(); - * - * result[0]?.id; - * result[0]?.favorite_pet?.pet_id; - * result[0]?.favorite_pet?.name; - * ``` - * - * The generated SQL (SQLite): - * - * ```sql - * select "id", ( - * select json_object( - * 'pet_id', "obj"."pet_id", - * 'name', "obj"."name" - * ) from ( - * select "pet"."id" as "pet_id", "pet"."name" - * from "pet" - * where "pet"."owner_id" = "person"."id" - * and "pet"."is_favorite" = ? - * ) as obj - * ) as "favorite_pet" - * from "person"; - * ``` - */ -// Kysely expects strict AST. -export function jsonObjectFrom( - expr: SelectQueryBuilderExpression, -): RawBuilder | null> { - return sql`(select ${sql.lit(kyselyJsonIdentifier)} || json_object(${sql.join( - getSqliteJsonObjectArgs(expr.toOperationNode(), "obj"), - )}) from ${expr} as obj)`; -} - -/** - * The SQLite `json_object` function. - * - * ### Example - * - * ```ts - * import { kysely } from "@evolu/common"; - * - * // TODO: Update for Evolu - * const result = await db - * .selectFrom("person") - * .select((eb) => [ - * "id", - * kysely - * .jsonBuildObject({ - * first: eb.ref("first_name"), - * last: eb.ref("last_name"), - * full: kysely.sql`first_name || ' ' || last_name`, - * }) - * .as("name"), - * ]) - * .execute(); - * - * result[0]?.id; - * result[0]?.name.first; - * result[0]?.name.last; - * result[0]?.name.full; - * ``` - * - * The generated SQL (SQLite): - * - * ```sql - * select "id", json_object( - * 'first', first_name, - * 'last', last_name, - * 'full', "first_name" || ' ' || "last_name" - * ) as "name" - * from "person" - * ``` - */ -// Kysely expects strict AST. -export function jsonBuildObject>>( - obj: O, -): RawBuilder< - Simplify<{ - [K in keyof O]: O[K] extends Expression ? V : never; - }> -> { - return sql`${sql.lit(kyselyJsonIdentifier)} || json_object(${sql.join( - Object.keys(obj).flatMap((k) => [sql.lit(k), obj[k]]), - )})`; -} - -interface SelectQueryBuilderExpression extends AliasableExpression { - get isSelectQueryBuilder(): true; - toOperationNode(): SelectQueryNode; -} - -function getSqliteJsonObjectArgs( - node: SelectQueryNode, - table: string, -): Array | string> { - try { - return getJsonObjectArgs(node, table); - } catch { - throw new Error( - "SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections due to limitations of the json_object function. selectAll() is not allowed in the subquery.", - ); - } -} - -export function getJsonObjectArgs( - node: SelectQueryNode, - table: string, -): Array | string> { - const args: Array | string> = []; - - for (const { selection: s } of node.selections ?? []) { - if (ReferenceNode.is(s) && ColumnNode.is(s.column)) { - args.push( - colName(s.column.column.name), - colRef(table, s.column.column.name), - ); - } else if (ColumnNode.is(s)) { - args.push(colName(s.column.name), colRef(table, s.column.name)); - } else if (AliasNode.is(s) && IdentifierNode.is(s.alias)) { - args.push(colName(s.alias.name), colRef(table, s.alias.name)); - } else { - throw new Error(`can't extract column names from the select query node`); - } - } - - return args; -} - -function colName(col: string): Expression { - return new ExpressionWrapper(ValueNode.createImmediate(col)); -} - -function colRef(table: string, col: string): Expression { - return new ExpressionWrapper( - ReferenceNode.create(ColumnNode.create(col), TableNode.create(table)), - ); -} diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index f571f2b37..31d60f17a 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -2031,26 +2031,26 @@ export const ProtocolValueType = { // 0-19 small ints // SQLite types - String: NonNegativeInt.orThrow(20), - Number: NonNegativeInt.orThrow(21), - Null: NonNegativeInt.orThrow(22), - Bytes: NonNegativeInt.orThrow(23), + String: /*#__PURE__*/ NonNegativeInt.orThrow(20), + Number: /*#__PURE__*/ NonNegativeInt.orThrow(21), + Null: /*#__PURE__*/ NonNegativeInt.orThrow(22), + Bytes: /*#__PURE__*/ NonNegativeInt.orThrow(23), // We can add more types for other DBs or anything else later. // Optimized types - NonNegativeInt: NonNegativeInt.orThrow(30), + NonNegativeInt: /*#__PURE__*/ NonNegativeInt.orThrow(30), // String optimizations - EmptyString: NonNegativeInt.orThrow(31), // 1 byte vs 2 bytes (50% reduction) - Base64Url: NonNegativeInt.orThrow(32), - Id: NonNegativeInt.orThrow(33), - Json: NonNegativeInt.orThrow(34), + EmptyString: /*#__PURE__*/ NonNegativeInt.orThrow(31), // 1 byte vs 2 bytes (50% reduction) + Base64Url: /*#__PURE__*/ NonNegativeInt.orThrow(32), + Id: /*#__PURE__*/ NonNegativeInt.orThrow(33), + Json: /*#__PURE__*/ NonNegativeInt.orThrow(34), // new Date().toISOString() - 24 bytes // encoded with fixed length - 8 bytes // encode as NonNegativeInt - 6 bytes (additional 25% reduction) - DateIsoWithNonNegativeTime: NonNegativeInt.orThrow(35), - DateIsoWithNegativeTime: NonNegativeInt.orThrow(36), // 9 bytes + DateIsoWithNonNegativeTime: /*#__PURE__*/ NonNegativeInt.orThrow(35), + DateIsoWithNegativeTime: /*#__PURE__*/ NonNegativeInt.orThrow(36), // 9 bytes // TODO: Operations (from 40) // Increment, Decrement, Patch, whatever. diff --git a/packages/common/src/local-first/Query.ts b/packages/common/src/local-first/Query.ts index 0393b4fe0..1f7c5468d 100644 --- a/packages/common/src/local-first/Query.ts +++ b/packages/common/src/local-first/Query.ts @@ -1,9 +1,26 @@ /** - * Query execution and caching. + * Query helpers, execution, and caching. * * @module */ +import type { + AliasableExpression, + Expression, + Simplify as KyselySimplify, + RawBuilder, + SelectQueryNode, +} from "kysely"; +import { + AliasNode, + ColumnNode, + ExpressionWrapper, + IdentifierNode, + sql as kyselySqlBuilder, + ReferenceNode, + TableNode, + ValueNode, +} from "kysely"; import type { Brand } from "../Brand.js"; import { bytesToHex, hexToBytes } from "../Buffer.js"; import { createRandomBytes } from "../Crypto.js"; @@ -14,6 +31,9 @@ import { eqSqliteValue, type SqliteValue, sql } from "../Sqlite.js"; import { createId, String } from "../Type.js"; import type { Simplify } from "../Types.js"; +export type { NotNull as KyselyNotNull } from "kysely"; +export { sql as kyselySql } from "kysely"; + /** * A type-safe SQL query. * @@ -103,10 +123,161 @@ export type InferRow = T extends Query ? R : never; export interface Row { readonly [key: string]: | SqliteValue - | Row // for jsonObjectFrom from kysely/helpers/sqlite - | ReadonlyArray; // for jsonArrayFrom from kysely/helpers/sqlite + | Row // for evoluJsonObjectFrom + | ReadonlyArray; // for evoluJsonArrayFrom } +/** + * An improved Evolu version of Kysely's SQLite `jsonArrayFrom` helper. + * + * Kysely's `ParseJSONResultsPlugin` heuristically parses any result string that + * looks like JSON. Evolu instead prefixes JSON produced by these helpers with a + * per-runtime identifier and only parses values carrying that prefix, avoiding + * accidental parsing of ordinary string columns that merely happen to start + * with `{` or `[`. + * + * ### Example + * + * ```ts + * import { evoluJsonArrayFrom } from "@evolu/common"; + * + * const result = await db + * .selectFrom("person") + * .select((eb) => [ + * "id", + * evoluJsonArrayFrom( + * eb + * .selectFrom("pet") + * .select(["pet.id as pet_id", "pet.name"]) + * .whereRef("pet.owner_id", "=", "person.id") + * .orderBy("pet.name"), + * ).as("pets"), + * ]) + * .execute(); + * + * result[0]?.id; + * result[0]?.pets[0].pet_id; + * result[0]?.pets[0].name; + * ``` + */ +export const evoluJsonArrayFrom = ( + expr: SelectQueryBuilderExpression, +): RawBuilder>> => + kyselySqlBuilder`(select ${kyselySqlBuilder.lit(kyselyJsonIdentifier)} || coalesce(json_group_array(json_object(${kyselySqlBuilder.join( + getSqliteJsonObjectArgs(expr.toOperationNode(), "agg"), + )})), '[]') from ${expr} as agg)`; + +/** + * An improved Evolu version of Kysely's SQLite `jsonObjectFrom` helper. + * + * Kysely's `ParseJSONResultsPlugin` heuristically parses any result string that + * looks like JSON. Evolu instead prefixes JSON produced by these helpers with a + * per-runtime identifier and only parses values carrying that prefix, avoiding + * accidental parsing of ordinary string columns that merely happen to start + * with `{` or `[`. + * + * The subquery must only return one row. + * + * ### Example + * + * ```ts + * import { evoluJsonObjectFrom } from "@evolu/common"; + * + * const result = await db + * .selectFrom("person") + * .select((eb) => [ + * "id", + * evoluJsonObjectFrom( + * eb + * .selectFrom("pet") + * .select(["pet.id as pet_id", "pet.name"]) + * .whereRef("pet.owner_id", "=", "person.id") + * .where("pet.is_favorite", "=", true), + * ).as("favorite_pet"), + * ]) + * .execute(); + * + * result[0]?.id; + * result[0]?.favorite_pet?.pet_id; + * result[0]?.favorite_pet?.name; + * ``` + */ +export const evoluJsonObjectFrom = ( + expr: SelectQueryBuilderExpression, +): RawBuilder | null> => + kyselySqlBuilder`(select ${kyselySqlBuilder.lit(kyselyJsonIdentifier)} || json_object(${kyselySqlBuilder.join( + getSqliteJsonObjectArgs(expr.toOperationNode(), "obj"), + )}) from ${expr} as obj)`; + +/** + * An improved Evolu version of Kysely's SQLite `jsonBuildObject` helper. + * + * Kysely's `ParseJSONResultsPlugin` heuristically parses any result string that + * looks like JSON. Evolu instead prefixes JSON produced by these helpers with a + * per-runtime identifier and only parses values carrying that prefix, avoiding + * accidental parsing of ordinary string columns that merely happen to start + * with `{` or `[`. + * + * ### Example + * + * ```ts + * import { evoluJsonBuildObject, kyselySql } from "@evolu/common"; + * + * const result = await db + * .selectFrom("person") + * .select((eb) => [ + * "id", + * evoluJsonBuildObject({ + * first: eb.ref("first_name"), + * last: eb.ref("last_name"), + * full: kyselySql`first_name || ' ' || last_name`, + * }).as("name"), + * ]) + * .execute(); + * + * result[0]?.id; + * result[0]?.name.first; + * result[0]?.name.last; + * result[0]?.name.full; + * ``` + */ +export const evoluJsonBuildObject = < + O extends Record>, +>( + obj: O, +): RawBuilder< + KyselySimplify<{ + [K in keyof O]: O[K] extends Expression ? V : never; + }> +> => + kyselySqlBuilder`${kyselySqlBuilder.lit(kyselyJsonIdentifier)} || json_object(${kyselySqlBuilder.join( + Object.keys(obj).flatMap((k) => [kyselySqlBuilder.lit(k), obj[k]]), + )})`; + +export const getJsonObjectArgs = ( + node: SelectQueryNode, + table: string, +): Array | string> => { + const args: Array | string> = []; + + for (const { selection: s } of node.selections ?? []) { + if (ReferenceNode.is(s) && ColumnNode.is(s.column)) { + args.push( + colName(s.column.column.name), + colRef(table, s.column.column.name), + ); + } else if (ColumnNode.is(s)) { + args.push(colName(s.column.name), colRef(table, s.column.name)); + } else if (AliasNode.is(s) && IdentifierNode.is(s.alias)) { + args.push(colName(s.alias.name), colRef(table, s.alias.name)); + } else { + throw new Error(`can't extract column names from the select query node`); + } + } + + return args; +}; + /** Rows returned by a query. */ export type QueryRows = ReadonlyArray< Readonly> @@ -215,9 +386,27 @@ export const applyPatches = ( * See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948 */ export const kyselyJsonIdentifier = /*#__PURE__*/ createId({ - randomBytes: createRandomBytes(), + randomBytes: /*#__PURE__*/ createRandomBytes(), }); +interface SelectQueryBuilderExpression extends AliasableExpression { + get isSelectQueryBuilder(): true; + toOperationNode(): SelectQueryNode; +} + +const getSqliteJsonObjectArgs = ( + node: SelectQueryNode, + table: string, +): Array | string> => { + try { + return getJsonObjectArgs(node, table); + } catch { + throw new Error( + "SQLite evoluJsonArrayFrom and evoluJsonObjectFrom can only handle explicit selections due to limitations of the json_object function. selectAll() is not allowed in the subquery.", + ); + } +}; + export const parseSqliteJsonArray = ( arr: ReadonlyArray, ): ReadonlyArray => { @@ -230,7 +419,7 @@ export const parseSqliteJsonArray = ( const parse = (obj: unknown): unknown => { if (String.is(obj) && obj.startsWith(kyselyJsonIdentifier)) { - return JSON.parse(obj.slice(kyselyJsonIdentifier.length)); + return parse(JSON.parse(obj.slice(kyselyJsonIdentifier.length))); } if (Array.isArray(obj)) { @@ -253,3 +442,11 @@ const parseObject = ( } return result as ReadonlyRecord; }; + +const colName = (col: string): Expression => + new ExpressionWrapper(ValueNode.createImmediate(col)); + +const colRef = (table: string, col: string): Expression => + new ExpressionWrapper( + ReferenceNode.create(ColumnNode.create(col), TableNode.create(table)), + ); diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index dd1ea6001..b63b6dd7b 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -12,15 +12,12 @@ import { } from "../Array.js"; import { assert } from "../Assert.js"; import type { TimingSafeEqualDep } from "../Crypto.js"; -import { createInstances } from "../Instances.js"; -import { createRefCount } from "../RefCount.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; import type { SqliteDep } from "../Sqlite.js"; import { sql } from "../Sqlite.js"; -import type { Mutex } from "../Task.js"; -import { createMutex } from "../Task.js"; -import { PositiveInt, type SimpleName } from "../Type.js"; +import { createMutexByKey } from "../Task.js"; +import { type Name, PositiveInt } from "../Type.js"; import { type Awaitable, isPromiseLike } from "../Types.js"; import { type OwnerId, @@ -37,7 +34,6 @@ import type { } from "./Storage.js"; import { createBaseSqliteStorage, - getNextStoredBytes, getOwnerUsage, getTimestampInsertStrategy, updateOwnerUsage, @@ -51,7 +47,7 @@ export interface RelayConfig extends StorageConfig { * Implementations can use this for identification purposes (e.g., database * file name, logging). */ - readonly name?: SimpleName; + readonly name?: Name; /** * Optional callback to check if an {@link OwnerId} is allowed to access the @@ -115,9 +111,8 @@ export const createRelaySqliteStorage = (config: StorageConfig): Storage => { const sqliteStorageBase = createBaseSqliteStorage(deps); - /** Mutex instances cached per OwnerId to prevent concurrent writes. */ - const ownerMutexes = createInstances(); - const ownerMutexRefs = createRefCount(); + /** Mutex keyed by OwnerId to prevent concurrent writes. */ + const mutexByOwnerId = createMutexByKey(); return { ...sqliteStorageBase, @@ -164,119 +159,108 @@ export const createRelaySqliteStorage = writeMessages: (ownerIdBytes, messages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); - const messagesWithTimestampBytes = mapArray(messages, (m) => ({ - timestamp: timestampToTimestampBytes(m.timestamp), - change: m.change, - })); - const ownerMutex = ownerMutexes.ensure(ownerId, createMutex); - ownerMutexRefs.increment(ownerId); - - const result = await (async () => { - try { - return await run( - ownerMutex.withLock( - 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()), - ); + const messagesWithTimestampBytes = filterArray( + mapArray(messages, (m) => ({ + timestamp: timestampToTimestampBytes(m.timestamp), + change: m.change, + })), + (message) => message.change.length > 0, + ); - // Nothing to write - if (!isNonEmptyArray(newMessages)) { - return ok(); - } + if (!isNonEmptyArray(messagesWithTimestampBytes)) { + return ok(); + } - const usage = getOwnerUsage(deps)( + 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)( + ownerIdBytes, + firstInArray(newMessages).timestamp, + ); + if (!usage.ok) return usage; + + const { storedBytes } = usage.value; + + const incomingBytes = newMessages.reduce( + (sum, m) => sum + m.change.length, + 0, + ); + const newStoredBytes = PositiveInt.orThrow( + (storedBytes ?? 0) + incomingBytes, + ); + + const quotaResult = config.isOwnerWithinQuota( + ownerId, + newStoredBytes, + ); + const isWithinQuota = isPromiseLike(quotaResult) + ? await quotaResult + : quotaResult; + if (!isWithinQuota) { + return err({ type: "StorageQuotaError", ownerId }); + } + + let { firstTimestamp, lastTimestamp } = usage.value; + + return deps.sqlite.transaction(() => { + for (const { timestamp, change } of newMessages) { + let strategy; + [strategy, firstTimestamp, lastTimestamp] = + getTimestampInsertStrategy( + timestamp, + firstTimestamp, + lastTimestamp, + ); + sqliteStorageBase.insertTimestamp( ownerIdBytes, - firstInArray(newMessages).timestamp, - ); - if (!usage.ok) return usage; - - const { storedBytes } = usage.value; - - const incomingBytesSum = newMessages.reduce( - (sum, m) => sum + m.change.length, - 0, - ); - if (incomingBytesSum <= 0) return ok(); - const incomingBytesResult = - PositiveInt.from(incomingBytesSum); - if (!incomingBytesResult.ok) { - return err({ type: "StorageQuotaError", ownerId }); - } - const incomingBytes = incomingBytesResult.value; - const newStoredBytes = getNextStoredBytes( - storedBytes, - incomingBytes, - ); - - const quotaResult = config.isOwnerWithinQuota( - ownerId, - newStoredBytes, + timestamp, + strategy, ); - const isWithinQuota = isPromiseLike(quotaResult) - ? await quotaResult - : quotaResult; - if (!isWithinQuota) { - return err({ type: "StorageQuotaError", ownerId }); - } - - let { firstTimestamp, lastTimestamp } = usage.value; - - return deps.sqlite.transaction(() => { - for (const { timestamp, change } of newMessages) { - let strategy; - [strategy, firstTimestamp, lastTimestamp] = - getTimestampInsertStrategy( - timestamp, - firstTimestamp, - lastTimestamp, - ); - sqliteStorageBase.insertTimestamp( - ownerIdBytes, - timestamp, - strategy, - ); - deps.sqlite.exec(sql` + 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(); - }); - }, - ), - ); - } finally { - ownerMutexRefs.decrement(ownerId); - if (!ownerMutexRefs.has(ownerId)) { - ownerMutexes.delete(ownerId); - } - } - })(); + } + + updateOwnerUsage(deps)( + ownerIdBytes, + newStoredBytes, + firstTimestamp, + lastTimestamp, + ); + + return ok(); + }); + }, + ), + ); if (!result.ok) { if (result.error.type === "AbortError") return ok(); - return err(result.error); + return err({ type: "StorageQuotaError", ownerId }); } return ok(); @@ -311,6 +295,8 @@ export const createRelaySqliteStorage = `); sqliteStorageBase.deleteOwner(ownerId); + + return ok(); }); }, }; diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index 3c4ed63dc..cebe8fe15 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -30,10 +30,14 @@ import { type StandardSchemaV1, } from "../Type.js"; import type { Simplify } from "../Types.js"; -import type { jsonArrayFrom, jsonObjectFrom } from "./Kysely.js"; import type { AppOwner } from "./Owner.js"; import { OwnerId } from "./Owner.js"; -import type { Query, Row } from "./Query.js"; +import type { + evoluJsonArrayFrom, + evoluJsonObjectFrom, + Query, + Row, +} from "./Query.js"; import { serializeQuery } from "./Query.js"; import type { CrdtMessage, DbChange } from "./Storage.js"; import type { TimestampBytes } from "./Timestamp.js"; @@ -161,7 +165,7 @@ export type CreateQuery = ( export const SystemColumns = /*#__PURE__*/ object({ createdAt: DateIso, updatedAt: DateIso, - isDeleted: nullOr(SqliteBoolean), + isDeleted: /*#__PURE__*/ nullOr(SqliteBoolean), ownerId: OwnerId, }); export interface SystemColumns extends InferType {} @@ -359,7 +363,7 @@ export type OptionalColumnKeys = { }[keyof T]; export const systemColumns = /*#__PURE__*/ readonly( - new Set(Object.keys(SystemColumns.props)), + /*#__PURE__*/ new Set(/*#__PURE__*/ Object.keys(SystemColumns.props)), ); export const systemColumnsWithId = /*#__PURE__*/ readonly([ @@ -394,7 +398,8 @@ export const evoluSchemaToSqliteSchema = ( * Creates a query builder from a {@link EvoluSchema}. * * Supports Kysely relation-style query composition (nested objects/arrays via - * JSON subqueries), such as {@link jsonObjectFrom} and {@link jsonArrayFrom} from + * JSON subqueries), such as {@link evoluJsonObjectFrom} and + * {@link evoluJsonArrayFrom}. These helpers are Evolu's safer SQLite variants of * the * {@link https://kysely.dev/docs/recipes/relations | Kysely relations recipe}. * @@ -486,7 +491,7 @@ export const getEvoluSqliteSchema = (deps: SqliteDep) => (): SqliteSchema => getSqliteSchema(deps)({ excludeIndexNamePrefix: "evolu_" }); // https://kysely.dev/docs/recipes/splitting-query-building-and-execution -export const kysely = new Kysely.Kysely({ +export const kysely = /*#__PURE__*/ new Kysely.Kysely({ dialect: { createAdapter: () => new Kysely.SqliteAdapter(), createDriver: () => new Kysely.DummyDriver(), diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 101c2b6c0..8605ccee6 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -13,16 +13,17 @@ import { } from "../Array.js"; import { assert } from "../Assert.js"; import { createCallbacks } from "../Callbacks.js"; -import type { Console, ConsoleEntry, ConsoleLevel } from "../Console.js"; -import { createInstances } from "../Instances.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"; -import type { SqliteExportFile } from "../Sqlite.js"; -import { type Fiber, type Run, repeat, type Task } from "../Task.js"; -import type { Millis, TimeDep } from "../Time.js"; +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 { SharedWorker as CommonSharedWorker, MessagePort, @@ -31,7 +32,7 @@ import type { WorkerDeps, } from "../Worker.js"; import type { EvoluError } from "./Error.js"; -import type { OwnerId } from "./Owner.js"; +import type { OwnerId, OwnerTransport } from "./Owner.js"; import { makePatches, type Patch, @@ -40,6 +41,7 @@ import { } from "./Query.js"; import type { MutationChange } from "./Schema.js"; import type { CrdtMessage } from "./Storage.js"; +import type { SyncOwner } from "./Sync.js"; export type SharedWorker = CommonSharedWorker; @@ -47,6 +49,12 @@ export interface SharedWorkerDep { readonly sharedWorker: SharedWorker; } +interface TransportsDep { + readonly transports: SharedTransportResources; +} + +export type SharedWorkerDeps = WorkerDeps & CreateWebSocketDep & TimeDep; + export type SharedWorkerInput = | { readonly type: "InitTab"; @@ -56,6 +64,7 @@ export type SharedWorkerInput = | { readonly type: "CreateEvolu"; readonly name: Name; + readonly appOwner?: SyncOwner; readonly evoluPort: NativeMessagePort; readonly dbWorkerPort: NativeMessagePort; }; @@ -99,17 +108,19 @@ export type EvoluOutput = } | { readonly type: "OnExport"; - readonly file: SqliteExportFile; + readonly file: Uint8Array; }; export const initSharedWorker = ( self: SharedWorkerSelf, - ): Task => + ): Task => async (run) => { - const { createMessagePort, consoleStoreOutputEntry } = run.deps; + const { createMessagePort, consoleStoreOutputEntry, createWebSocket } = + run.deps; const console = run.deps.console.child("SharedWorker"); + // TODO: Use heartbeat to detect and prune dead ports. const tabPorts = new Set>(); const queuedTabOutputs: Array = []; @@ -118,22 +129,36 @@ export const initSharedWorker = else for (const port of tabPorts) port.postMessage(output); }; - await using stack = run.stack(); + const createTransportId = (transport: OwnerTransport): string => + `${transport.type}:${transport.url}`; - // Shared worker instance lifecycle is managed by per-evolu heartbeats. - const sharedEvolus = stack.use(createInstances()); + await using stack = run.stack(); - const unsubscribeConsoleStoreOutputEntry = - consoleStoreOutputEntry.subscribe(() => { - const entry = consoleStoreOutputEntry.get(); - if (entry) postTabOutput({ type: "OnConsoleEntry", entry }); - }); - stack.defer(() => { - unsubscribeConsoleStoreOutputEntry(); - return ok(); - }); + 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, + }), + ); - console.info("initSharedWorker"); + const runWithSharedEvoluDeps = run.addDeps({ transports }); + const sharedEvolusByName = new Map(); + const sharedEvolusMutexByName = stack.use(createMutexByKey()); self.onConnect = (port) => { console.debug("onConnect"); @@ -155,19 +180,51 @@ export const initSharedWorker = } case "CreateEvolu": { - sharedEvolus - .ensure(message.name, () => - createSharedEvolu({ - run, - console, - name: message.name, - postTabOutput, - onDispose: () => { - sharedEvolus.delete(message.name); - }, - }), - ) - .addPorts(message.evoluPort, message.dbWorkerPort); + void runWithSharedEvoluDeps.daemon( + sharedEvolusMutexByName.withLock(message.name, async () => { + let sharedEvolu = sharedEvolusByName.get(message.name); + + if (sharedEvolu == null) { + const result = await runWithSharedEvoluDeps.daemon( + createSharedEvolu({ + name: message.name, + ...(message.appOwner + ? { appOwner: message.appOwner } + : {}), + postTabOutput, + onDispose: () => { + void runWithSharedEvoluDeps.daemon( + sharedEvolusMutexByName.withLock( + message.name, + async () => { + const maybeSharedEvolu = sharedEvolusByName.get( + message.name, + ); + if (!maybeSharedEvolu) return ok(); + + try { + await maybeSharedEvolu[Symbol.asyncDispose](); + } finally { + sharedEvolusByName.delete(message.name); + } + + return ok(); + }, + ), + ); + }, + }), + ); + if (!result.ok) return result; + + sharedEvolu = result.value; + sharedEvolusByName.set(message.name, sharedEvolu); + } + + sharedEvolu.addPorts(message.evoluPort, message.dbWorkerPort); + return ok(); + }), + ); break; } default: @@ -176,10 +233,19 @@ export const initSharedWorker = }; }; + stack.defer( + consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) postTabOutput({ type: "OnConsoleEntry", entry }); + }), + ); + + console.info("initSharedWorker"); + return ok(stack.move()); }; -interface SharedEvolu extends Disposable { +interface SharedEvolu extends AsyncDisposable { readonly addPorts: ( evoluPort: NativeMessagePort, dbWorkerPort: NativeMessagePort, @@ -227,7 +293,10 @@ export type QueuedResponse = } | { readonly type: "Export"; - readonly file: SqliteExportFile; + readonly file: Uint8Array; + } + | { + readonly type: "CreateSyncMessages"; }; export interface QueuedResult { @@ -235,283 +304,188 @@ export interface QueuedResult { readonly response: QueuedResponse; } -export const dbWorkerHeartbeatIntervalMs = 5_000; -export const dbWorkerHeartbeatTimeoutMs = 30_000; - -// createSharedEvolu could be Task, but Instances doesn't support it yet. -const createSharedEvolu = ({ - run, - console, - name, - postTabOutput, - onDispose, -}: { - run: Run; - console: Console; - name: Name; - postTabOutput: Callback; - onDispose: () => void; -}): SharedEvolu => { - const { createMessagePort } = run.deps; - - const evoluPorts = new Map>(); - const dbWorkerPorts = new Set>(); - const dbWorkerPortByEvoluPortId = new Map< - Id, - MessagePort - >(); - const rowsByQueryByEvoluPortId = new Map(); - const queue: Array = []; - const callbacks = createCallbacks(run.deps); - - let activeDbWorkerPort = null as MessagePort< - DbWorkerInput, - DbWorkerOutput - > | null; - let activeDbWorkerLastHeartbeatAt = 0 as Millis; - const lastHeartbeatByDbWorkerPort = new Map< - MessagePort, - Millis - >(); - - let queueProcessingFiber: Fiber | null = - null; - let activeQueueCallback: { - readonly callbackId: Id; - readonly evoluPortId: Id; - } | null = null; - - const dropQueuedRequestsForEvoluPort = (evoluPortId: Id): void => { - for (let i = queue.length - 1; i >= 0; i -= 1) { - if (queue[i]?.evoluPortId === evoluPortId) queue.splice(i, 1); - } - }; - - const cancelActiveQueueForEvoluPort = (evoluPortId: Id): void => { - if (activeQueueCallback?.evoluPortId !== evoluPortId) return; - - callbacks.cancel(activeQueueCallback.callbackId); - activeQueueCallback = null; - queueProcessingFiber?.abort(); - queueProcessingFiber = null; - - if (queue[0]?.evoluPortId === evoluPortId) queue.shift(); - }; - - const cleanupEvoluPort = ( - evoluPortId: Id, - disposeDbWorkerPort: boolean, - ): void => { - dropQueuedRequestsForEvoluPort(evoluPortId); - cancelActiveQueueForEvoluPort(evoluPortId); - - const dbWorkerPortForEvolu = dbWorkerPortByEvoluPortId.get(evoluPortId); - if (dbWorkerPortForEvolu) { - dbWorkerPortByEvoluPortId.delete(evoluPortId); +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) => { + const console = run.deps.console.child(name).child("SharedWorker"); + const { createMessagePort, transports } = run.deps; - if (disposeDbWorkerPort) { - dbWorkerPorts.delete(dbWorkerPortForEvolu); + const evoluPorts = new Map>(); + const dbWorkerPorts = new Set>(); + const rowsByQueryByEvoluPortId = new Map(); + const queue: Array = []; + const callbacks = createCallbacks(run.deps); - if (activeDbWorkerPort === dbWorkerPortForEvolu) { - cancelActiveQueue(); - activeDbWorkerPort = null; - } + let activeDbWorkerPort = null as MessagePort< + DbWorkerInput, + DbWorkerOutput + > | null; - lastHeartbeatByDbWorkerPort.delete(dbWorkerPortForEvolu); - dbWorkerPortForEvolu[Symbol.dispose](); - } - } + let queueProcessingFiber: Fiber | null = null; + let activeQueueCallbackId: Id | null = null; + let activeLeaderTimeout: TimeoutId | null = null; - evoluPorts.delete(evoluPortId); - rowsByQueryByEvoluPortId.delete(evoluPortId); - }; + const ownerTransports = appOwner?.transports ?? emptyArray; - const cancelActiveQueue = (): void => { - if (activeQueueCallback) { - callbacks.cancel(activeQueueCallback.callbackId); - activeQueueCallback = null; + if (appOwner) { + await run(transports.addConsumer(appOwner, ownerTransports)); } - queueProcessingFiber?.abort(); - queueProcessingFiber = null; - }; - - const markDbWorkerHeartbeat = ( - dbWorkerPort: MessagePort, - ): void => { - const now = run.deps.time.now(); - lastHeartbeatByDbWorkerPort.set(dbWorkerPort, now); - if (activeDbWorkerPort === dbWorkerPort) - activeDbWorkerLastHeartbeatAt = now; - }; - const setActiveDbWorkerPort = ( - dbWorkerPort: MessagePort, - ): void => { - activeDbWorkerPort = dbWorkerPort; - const now = run.deps.time.now(); - activeDbWorkerLastHeartbeatAt = now; - lastHeartbeatByDbWorkerPort.set(dbWorkerPort, now); - }; + const clearActiveLeaderTimeout = (): void => { + if (!activeLeaderTimeout) return; + run.deps.time.clearTimeout(activeLeaderTimeout); + activeLeaderTimeout = null; + }; - const clearActiveDbWorkerIfStale = (): void => { - if (!activeDbWorkerPort) return; - const elapsed = run.deps.time.now() - activeDbWorkerLastHeartbeatAt; - if (elapsed <= dbWorkerHeartbeatTimeoutMs) return; + const touchActiveLeaderTimeout = (): void => { + clearActiveLeaderTimeout(); + if (!queueProcessingFiber) return; - console.warn("leaderHeartbeatTimeout", { - name, - timeoutMs: dbWorkerHeartbeatTimeoutMs, - elapsedMs: elapsed, - }); - activeDbWorkerPort = null; - cancelActiveQueue(); - }; + activeLeaderTimeout = run.deps.time.setTimeout(() => { + clearActiveLeaderTimeout(); - const pruneStaleDbWorkerPorts = (): void => { - const now = run.deps.time.now(); + queueProcessingFiber?.abort(); + queueProcessingFiber = null; - for (const [dbWorkerPort, lastHeartbeatAt] of lastHeartbeatByDbWorkerPort) { - const elapsed = now - lastHeartbeatAt; - if (elapsed <= dbWorkerHeartbeatTimeoutMs) continue; + if (activeQueueCallbackId) { + callbacks.cancel(activeQueueCallbackId); + activeQueueCallbackId = null; + } - const wasActive = dbWorkerPort === activeDbWorkerPort; - if (wasActive) { + // Wait for a new leader after heartbeat timeout. activeDbWorkerPort = null; - cancelActiveQueue(); - } + }, "35s"); + }; - const staleEvoluPortIds: Array = []; - for (const [ - evoluPortId, - mappedDbWorkerPort, - ] of dbWorkerPortByEvoluPortId) { - if (mappedDbWorkerPort === dbWorkerPort) { - staleEvoluPortIds.push(evoluPortId); + const removeQueuedItemsForDisposedPorts = (): void => { + for (let i = queue.length - 1; i >= 0; i -= 1) { + if (!evoluPorts.has(queue[i].evoluPortId)) { + queue.splice(i, 1); } } - for (const evoluPortId of staleEvoluPortIds) { - cleanupEvoluPort(evoluPortId, false); - } - - lastHeartbeatByDbWorkerPort.delete(dbWorkerPort); - dbWorkerPorts.delete(dbWorkerPort); - dbWorkerPort[Symbol.dispose](); + }; - console.warn("prunedStaleDbWorkerPort", { - name, - timeoutMs: dbWorkerHeartbeatTimeoutMs, - elapsedMs: elapsed, - }); + const ensureQueueProcessing = (): void => { + removeQueuedItemsForDisposedPorts(); - if (evoluPorts.size === 0) { - onDispose(); + if ( + queueProcessingFiber || + !isNonEmptyArray(queue) || + !activeDbWorkerPort + ) { return; } - } - }; - const heartbeatFiber = run.daemon( - repeat(() => { - pruneStaleDbWorkerPorts(); - clearActiveDbWorkerIfStale(); - return ok(); - }, spaced("1s")), - ); - - const ensureQueueProcessing = (): void => { - if ( - queueProcessingFiber || - !isNonEmptyArray(queue) || - !activeDbWorkerPort - ) { - return; - } + const first = firstInArray(queue); - const first = firstInArray(queue); - - const callbackId = callbacks.register(({ evoluPortId, response }) => { - activeQueueCallback = null; - queueProcessingFiber?.abort(); - queueProcessingFiber = null; - - const evoluPort = evoluPorts.get(evoluPortId); - - switch (response.type) { - case "Mutate": - case "Query": { - if (evoluPort) - evoluPort.postMessage({ - type: "OnPatchesByQuery", - patchesByQuery: createPatchesByQuery( - evoluPortId, - response.rowsByQuery, - ), - onCompleteIds: - first.request.type === "Mutate" - ? first.request.onCompleteIds - : emptyArray, - }); + const callbackId = callbacks.register(({ evoluPortId, response }) => { + clearActiveLeaderTimeout(); + activeQueueCallbackId = null; + queueProcessingFiber?.abort(); + queueProcessingFiber = null; + + const evoluPort = evoluPorts.get(evoluPortId); - if (response.type === "Mutate") { - for (const [otherEvoluPortId, otherEvoluPort] of evoluPorts) { - if (otherEvoluPortId === evoluPortId) continue; - otherEvoluPort.postMessage({ type: "RefreshQueries" }); + switch (response.type) { + case "Mutate": + case "Query": { + if (evoluPort) + evoluPort.postMessage({ + type: "OnPatchesByQuery", + patchesByQuery: createPatchesByQuery( + evoluPortId, + response.rowsByQuery, + ), + onCompleteIds: + first.request.type === "Mutate" + ? first.request.onCompleteIds + : emptyArray, + }); + + if (response.type === "Mutate") { + for (const [otherEvoluPortId, otherEvoluPort] of evoluPorts) { + if (otherEvoluPortId === evoluPortId) continue; + otherEvoluPort.postMessage({ type: "RefreshQueries" }); + } } + break; } - break; - } - case "Export": - if (evoluPort) - evoluPort.postMessage( - { - type: "OnExport", - file: response.file, - }, - [response.file.buffer], - ); - - break; - default: - console.error("Unknown queued response", response); - } + case "Export": + if (evoluPort) + evoluPort.postMessage( + { + type: "OnExport", + file: response.file, + }, + [response.file.buffer], + ); - // Complete the current queue item and continue with the next one. - shiftFromArray(queue); - ensureQueueProcessing(); - }); - activeQueueCallback = { callbackId, evoluPortId: first.evoluPortId }; - - queueProcessingFiber = run.daemon( - repeat(() => { - assert(activeDbWorkerPort, "Expected an active DbWorker"); - activeDbWorkerPort.postMessage({ callbackId, ...first }); - return ok(); - }, spaced("5s")), // 5s seems to be a good balance - ); - }; + break; + case "CreateSyncMessages": + break; + default: + console.error("Unknown queued response", response); + } - const createPatchesByQuery = ( - evoluPortId: Id, - rowsByQuery: RowsByQueryMap, - ): ReadonlyMap> => { - const previousRowsByQuery = rowsByQueryByEvoluPortId.get(evoluPortId); - const nextRowsByQuery = new Map(previousRowsByQuery ?? emptyArray); - const patchesByQuery = new Map>(); - - for (const [query, rows] of rowsByQuery) { - nextRowsByQuery.set(query, rows); - patchesByQuery.set( - query, - makePatches(previousRowsByQuery?.get(query), rows), + // Complete the current queue item and continue with the next one. + shiftFromArray(queue); + removeQueuedItemsForDisposedPorts(); + ensureQueueProcessing(); + }); + activeQueueCallbackId = callbackId; + + queueProcessingFiber = run.daemon( + repeat(() => { + assert(activeDbWorkerPort, "Expected an active DbWorker"); + activeDbWorkerPort.postMessage({ callbackId, ...first }); + return ok(); + }, spaced("5s")), // 5s seems to be a good balance ); - } + touchActiveLeaderTimeout(); + }; - rowsByQueryByEvoluPortId.set(evoluPortId, nextRowsByQuery); - return patchesByQuery; - }; + const createPatchesByQuery = ( + evoluPortId: Id, + rowsByQuery: RowsByQueryMap, + ): ReadonlyMap> => { + const previousRowsByQuery = rowsByQueryByEvoluPortId.get(evoluPortId); + const nextRowsByQuery = new Map(previousRowsByQuery ?? emptyArray); + const patchesByQuery = new Map>(); + + for (const [query, rows] of rowsByQuery) { + nextRowsByQuery.set(query, rows); + patchesByQuery.set( + query, + makePatches(previousRowsByQuery?.get(query), rows), + ); + } - return { - addPorts: (nativeEvoluPort, nativeDbWorkerPort) => { + rowsByQueryByEvoluPortId.set(evoluPortId, nextRowsByQuery); + return patchesByQuery; + }; + + const addPorts = ( + nativeEvoluPort: NativeMessagePort, + nativeDbWorkerPort: NativeMessagePort, + ): void => { const evoluPort = createMessagePort( nativeEvoluPort, ); @@ -523,30 +497,24 @@ const createSharedEvolu = ({ evoluPorts.set(evoluPortId, evoluPort); dbWorkerPorts.add(dbWorkerPort); - dbWorkerPortByEvoluPortId.set(evoluPortId, dbWorkerPort); dbWorkerPort.onMessage = (message) => { switch (message.type) { case "LeaderAcquired": { + activeDbWorkerPort = dbWorkerPort; console.info("leaderAcquired"); - setActiveDbWorkerPort(dbWorkerPort); + touchActiveLeaderTimeout(); ensureQueueProcessing(); break; } case "LeaderHeartbeat": { - markDbWorkerHeartbeat(dbWorkerPort); - if (!activeDbWorkerPort) { - console.info("leaderHeartbeat adopted"); - setActiveDbWorkerPort(dbWorkerPort); - ensureQueueProcessing(); + if (activeDbWorkerPort === dbWorkerPort && queueProcessingFiber) { + touchActiveLeaderTimeout(); } + ensureQueueProcessing(); break; } case "OnQueuedResponse": { - if (dbWorkerPort !== activeDbWorkerPort) { - console.debug("ignoredQueuedResponseFromInactiveDbWorker"); - break; - } callbacks.execute(message.callbackId, { evoluPortId: message.evoluPortId, response: message.response, @@ -559,7 +527,7 @@ const createSharedEvolu = ({ break; } default: - console.error("Unknown db worker output", message); + exhaustiveCheck(message); } }; @@ -572,11 +540,37 @@ const createSharedEvolu = ({ hadLastPort: evoluPorts.size === 1, }); - cleanupEvoluPort(evoluPortId, true); + const isActiveQueueItemDisposed = + isNonEmptyArray(queue) && queue[0].evoluPortId === evoluPortId; + + evoluPorts.delete(evoluPortId); + rowsByQueryByEvoluPortId.delete(evoluPortId); + + for (let i = queue.length - 1; i >= 0; i -= 1) { + if (queue[i].evoluPortId === evoluPortId) { + queue.splice(i, 1); + } + } + + if (isActiveQueueItemDisposed) { + clearActiveLeaderTimeout(); + queueProcessingFiber?.abort(); + queueProcessingFiber = null; + + if (activeQueueCallbackId) { + callbacks.cancel(activeQueueCallbackId); + activeQueueCallbackId = null; + } + + // Require leader reacquire before dispatching remaining queue. + activeDbWorkerPort = null; + } - if (activeDbWorkerPort) ensureQueueProcessing(); if (evoluPorts.size === 0) onDispose(); + ensureQueueProcessing(); + // TODO: Decided what to do with DbWorker but probably dispose it, but + // https://bugs.webkit.org/show_bug.cgi?id=301520 break; } @@ -588,27 +582,35 @@ const createSharedEvolu = ({ break; } default: - console.error("Unknown evolu input", evoluMessage); + exhaustiveCheck(evoluMessage); } }; - }, - - [Symbol.dispose]: () => { - heartbeatFiber.abort(); - queueProcessingFiber?.abort(); - queueProcessingFiber = null; - callbacks[Symbol.dispose](); - activeQueueCallback = null; - activeDbWorkerPort = null; - lastHeartbeatByDbWorkerPort.clear(); - queue.length = 0; - evoluPorts.clear(); - rowsByQueryByEvoluPortId.clear(); - dbWorkerPortByEvoluPortId.clear(); - dbWorkerPorts.clear(); - }, + }; + + return ok({ + addPorts, + + [Symbol.asyncDispose]: async () => { + if (appOwner) { + await run(transports.removeConsumer(appOwner, ownerTransports)); + } + + clearActiveLeaderTimeout(); + queueProcessingFiber?.abort(); + queueProcessingFiber = null; + if (activeQueueCallbackId) { + callbacks.cancel(activeQueueCallbackId); + activeQueueCallbackId = null; + } + callbacks[Symbol.dispose](); + activeDbWorkerPort = null; + queue.length = 0; + evoluPorts.clear(); + rowsByQueryByEvoluPortId.clear(); + dbWorkerPorts.clear(); + }, + }); }; -}; // | (Typed<"reset"> & { // readonly onCompleteId: CallbackId; diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index a811668d6..205f0cf34 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -193,7 +193,9 @@ export type Fingerprint = Uint8Array & Brand<"Fingerprint">; export const fingerprintSize = /*#__PURE__*/ NonNegativeInt.orThrow(12); /** A fingerprint of an empty range. */ -export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint; +export const zeroFingerprint = /*#__PURE__*/ new Uint8Array( + fingerprintSize, +) as Fingerprint; export interface BaseRange { readonly upperBound: RangeUpperBound; @@ -308,7 +310,7 @@ export const DbChange = /*#__PURE__*/ object({ id: Id, values: ValidDbChangeValues, isInsert: Boolean, - isDelete: nullOr(Boolean), + isDelete: /*#__PURE__*/ nullOr(Boolean), }); export interface DbChange extends InferType {} diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index 0d859487e..f74e43e15 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -20,7 +20,6 @@ import type { } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; import { createUnknownError } from "../Error.js"; -import { createInstances } from "../Instances.js"; import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; import { createRefCount } from "../RefCount.js"; @@ -186,22 +185,23 @@ export const createSync = TimestampConfigDep, ) => (config: SyncConfig): Sync => { - let isDisposed = false; - const disposalDelayResult = - config.disposalDelayMs == null - ? ok(Millis.orThrow(100)) - : Millis.from(config.disposalDelayMs); - + const disposalDelayMs = config.disposalDelayMs ?? 100; assert( - disposalDelayResult.ok, - "Invalid SyncConfig.disposalDelayMs: expected a non-negative integer.", + Number.isInteger(disposalDelayMs) && disposalDelayMs >= 0, + "Invalid SyncConfig.disposalDelayMs", ); - const disposalDelay = disposalDelayResult.value; + const disposalDelay = Millis.orThrow(disposalDelayMs); + + let isDisposed = false; + const syncRun = createRun(deps); + const syncOwnersById = new Map(); + const syncOwnerRefs = createRefCount(); + const webSocketsByTransportKey = new Map(); /** Returns owner data only if actively assigned to at least one transport. */ const getSyncOwner = (ownerId: OwnerId): SyncOwner | null => { if (isDisposed) return null; - return resources.getConsumer(ownerId); + return syncOwnersById.get(ownerId) ?? null; }; const storage = createClientStorage({ @@ -219,13 +219,13 @@ export const createSync = const run = createRun(deps); let socket: WebSocket | null = null; - let isDisposed = false; + let resourceDisposed = false; const pendingSends: Array< string | ArrayBufferLike | Blob | ArrayBufferView > = []; const flushPendingSends = (): void => { - if (isDisposed || !socket || pendingSends.length === 0) return; + if (resourceDisposed || !socket || pendingSends.length === 0) return; for (const data of pendingSends.splice(0, pendingSends.length)) { const result = socket.send(data); @@ -240,7 +240,7 @@ export const createSync = const webSocket: WebSocket = { send: (data) => { - if (isDisposed) return err({ type: "WebSocketSendError" }); + if (resourceDisposed) return err({ type: "WebSocketSendError" }); if (!socket) { pendingSends.push(data); return ok(); @@ -250,16 +250,17 @@ export const createSync = /* v8 ignore next */ getReadyState: () => { - if (isDisposed) return "closed"; + if (resourceDisposed) return "closed"; return socket?.getReadyState() ?? "connecting"; }, - isOpen: () => !isDisposed && (socket?.isOpen() ?? false), + isOpen: () => !resourceDisposed && (socket?.isOpen() ?? false), [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; + if (resourceDisposed) return; + resourceDisposed = true; pendingSends.length = 0; + webSocketsByTransportKey.delete(transportKey); socket?.[Symbol.dispose](); void run[Symbol.asyncDispose](); }, @@ -270,12 +271,12 @@ export const createSync = binaryType: "arraybuffer", onOpen: () => { - if (isDisposed) return; + if (resourceDisposed) return; - const currentWebSocket = resources.getResource(transportKey); + const currentWebSocket = webSocketsByTransportKey.get(transportKey); if (!currentWebSocket) return; - const ownerIds = resources.getConsumersForResource(transportKey); + const ownerIds = resources.getConsumerIdsForResource(transportKey); deps.console.log("[sync]", "onOpen", { transportKey, ownerIds }); for (const ownerId of ownerIds) { @@ -304,9 +305,9 @@ export const createSync = onMessage: (data: string | ArrayBuffer | Blob) => { // Only handle ArrayBuffer data for sync messages. - if (isDisposed || !(data instanceof ArrayBuffer)) return; + if (resourceDisposed || !(data instanceof ArrayBuffer)) return; - const currentWebSocket = resources.getResource(transportKey); + const currentWebSocket = webSocketsByTransportKey.get(transportKey); if (!currentWebSocket) return; const input = new Uint8Array(data); @@ -322,6 +323,7 @@ export const createSync = ).then( (result) => { if (!result.ok) { + if (isDisposed || resourceDisposed) return; if (result.error.type !== "AbortError") { config.onError(createUnknownError(result.error)); } @@ -333,7 +335,7 @@ export const createSync = /* v8 ignore start */ // Defensive cleanup for a resolved socket after disposal. - if (isDisposed) { + if (resourceDisposed || isDisposed) { socket[Symbol.dispose](); } /* v8 ignore stop */ @@ -344,6 +346,7 @@ export const createSync = }, ); + webSocketsByTransportKey.set(transportKey, webSocket); return webSocket; }; @@ -353,37 +356,53 @@ export const createSync = OwnerTransport, SyncOwner, OwnerId - >(deps)({ - createResource, - getResourceKey: createTransportKey, - getConsumerId: (owner) => owner.id, + >({ + createResource: async (transport: OwnerTransport) => + createResource(transport), + getResourceId: createTransportKey, + getConsumerId: (owner: SyncOwner) => owner.id, disposalDelay, + time: deps.time, + }); - onConsumerAdded: (owner, webSocket) => { - deps.console.log("[sync]", "onConsumerAdded", { - ownerId: owner.id, - isOpen: webSocket.isOpen(), - }); + const sendSubscribeForOwner = (owner: SyncOwner): void => { + if (isDisposed || !syncOwnerRefs.has(owner.id)) return; - // The onOpen handler will sync it. - if (!webSocket.isOpen()) return; - const message = createProtocolMessageForSync({ + let message: Uint8Array | null = null; + try { + message = createProtocolMessageForSync({ storage, console: deps.console, })(owner.id, SubscriptionFlags.Subscribe); - if (message) webSocket.send(message); - }, - - onConsumerRemoved: (owner, webSocket) => { - deps.console.log("[sync]", "onConsumerRemoved", { + } catch (error) { + deps.console.warn("[sync]", "sendSubscribeForOwner failed", { ownerId: owner.id, - isOpen: webSocket.isOpen(), + error: createUnknownError(error), }); + return; + } + if (!message) return; - const message = createProtocolMessageForUnsubscribe(owner.id); - webSocket.send(message); - }, - }); + const transports = owner.transports ?? config.transports; + for (const transport of transports) { + const webSocket = webSocketsByTransportKey.get( + createTransportKey(transport), + ); + if (webSocket?.isOpen()) webSocket.send(message); + } + }; + + const sendUnsubscribeForOwner = (owner: SyncOwner): void => { + if (isDisposed) return; + const message = createProtocolMessageForUnsubscribe(owner.id); + const transports = owner.transports ?? config.transports; + for (const transport of transports) { + const webSocket = webSocketsByTransportKey.get( + createTransportKey(transport), + ); + if (webSocket) webSocket.send(message); + } + }; const sync: Sync = { useOwner: (use, owner) => { @@ -400,16 +419,60 @@ export const createSync = const transports = owner.transports ?? config.transports; if (use) { - resources.addConsumer(owner, transports); + const hadOpenTransportAtUseTime = transports.some((transport) => + webSocketsByTransportKey + .get(createTransportKey(transport)) + ?.isOpen(), + ); + + syncOwnerRefs.increment(owner.id); + syncOwnersById.set(owner.id, owner); + void syncRun(resources.addConsumer(owner, transports)).then( + (result) => { + if (!result.ok) { + if ((result.error as { type?: string }).type !== "AbortError") { + config.onError(createUnknownError(result.error)); + } + return; + } + if (!syncOwnerRefs.has(owner.id)) return; + if (hadOpenTransportAtUseTime) sendSubscribeForOwner(owner); + }, + (error: unknown) => { + config.onError(createUnknownError(error)); + }, + ); } else { - const result = resources.removeConsumer(owner, transports); - if (!result.ok) { + sendUnsubscribeForOwner(owner); + + const hasMissingTransport = transports.some( + (transport) => + !webSocketsByTransportKey.has(createTransportKey(transport)), + ); + if (hasMissingTransport) { deps.console.warn("[sync]", "Failed to remove consumer", { - transports, ownerId: owner.id, - error: result.error, + error: { type: "ResourceNotFoundError" }, }); } + + syncOwnerRefs.decrement(owner.id); + if (!syncOwnerRefs.has(owner.id)) syncOwnersById.delete(owner.id); + void syncRun(resources.removeConsumer(owner, transports)).then( + (result) => { + if (result.ok) return; + if ((result.error as { type?: string }).type !== "AbortError") { + deps.console.warn("[sync]", "Failed to remove consumer", { + ownerId: owner.id, + error: result.error, + }); + config.onError(createUnknownError(result.error)); + } + }, + (error: unknown) => { + config.onError(createUnknownError(error)); + }, + ); } }, @@ -467,7 +530,7 @@ export const createSync = for (const transport of transports) { const transportKey = createTransportKey(transport); - const webSocket = resources.getResource(transportKey); + const webSocket = webSocketsByTransportKey.get(transportKey); if (!webSocket) continue; if (webSocket.isOpen()) { @@ -484,7 +547,12 @@ export const createSync = [Symbol.dispose]: () => { if (isDisposed) return; isDisposed = true; - resources[Symbol.dispose](); + syncOwnersById.clear(); + // Note: syncOwnerRefs doesn't have a clear method, but entries become + // unreachable once syncOwnersById is cleared and no new useOwner calls + // are accepted due to isDisposed check. + void resources[Symbol.asyncDispose](); + void syncRun[Symbol.asyncDispose](); }, }; @@ -552,10 +620,7 @@ const createClientStorage = strict; `); - const ownerMutexes = createInstances< - OwnerId, - ReturnType - >(); + const ownerMutexes = new Map>(); const ownerMutexRefs = createRefCount(); const storage: ClientStorage = { @@ -592,7 +657,13 @@ const createClientStorage = writeMessages: (ownerIdBytes, encryptedMessages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); - const ownerMutex = ownerMutexes.ensure(ownerId, createMutex); + const ownerMutex = + ownerMutexes.get(ownerId) ?? + (() => { + const mutex = createMutex(); + ownerMutexes.set(ownerId, mutex); + return mutex; + })(); ownerMutexRefs.increment(ownerId); const result = await (async () => { @@ -761,9 +832,14 @@ const createClientStorage = })(); if (!result.ok) { - if (result.error.type !== "AbortError") { - config.onError(result.error); - throw new Error(result.error.type, { cause: result.error }); + const error = result.error as { type?: string }; + if (error.type !== "AbortError") { + config.onError( + result.error as Parameters[0], + ); + throw new Error(error.type ?? "UnknownError", { + cause: result.error, + }); } return ok(); } diff --git a/packages/common/src/local-first/Timestamp.ts b/packages/common/src/local-first/Timestamp.ts index c79173371..9de38f746 100644 --- a/packages/common/src/local-first/Timestamp.ts +++ b/packages/common/src/local-first/Timestamp.ts @@ -60,7 +60,7 @@ export interface TimestampTimeOutOfRangeError export const Counter = /*#__PURE__*/ brand( "Counter", - lessThanOrEqualTo(65535)(NonNegativeInt), + /*#__PURE__*/ lessThanOrEqualTo(65535)(NonNegativeInt), ); export type Counter = typeof Counter.Type; @@ -271,7 +271,7 @@ export const receiveTimestamp = /** Sortable bytes representation of {@link Timestamp}. */ export const TimestampBytes = /*#__PURE__*/ brand( "TimestampBytes", - length(16)(Uint8Array), + /*#__PURE__*/ length(16)(Uint8Array), ); export type TimestampBytes = typeof TimestampBytes.Type; diff --git a/packages/common/test/Instances.test.ts b/packages/common/test/Instances.test.ts deleted file mode 100644 index 2bd3badfb..000000000 --- a/packages/common/test/Instances.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { expect, test } from "vitest"; -import { lazyVoid } from "../src/Function.js"; -import { createInstances } from "../src/Instances.js"; - -interface TestInstance extends Disposable { - readonly id: string; -} - -test("creates and returns new instance on first call", () => { - const instances = createInstances(); - let createCount = 0; - - const instance = instances.ensure("test", () => { - createCount++; - return { - id: "test-1", - [Symbol.dispose]: lazyVoid, - }; - }); - - expect(instance.id).toBe("test-1"); - expect(createCount).toBe(1); -}); - -test("returns existing instance on second call with same key", () => { - const instances = createInstances(); - let createCount = 0; - - const instance1 = instances.ensure("test", () => { - createCount++; - return { - id: "test-1", - [Symbol.dispose]: lazyVoid, - }; - }); - - const instance2 = instances.ensure("test", () => { - createCount++; - return { - id: "test-2", - [Symbol.dispose]: lazyVoid, - }; - }); - - expect(instance1).toBe(instance2); - expect(createCount).toBe(1); -}); - -test("calls onCacheHit when returning existing instance", () => { - interface TestInstance extends Disposable { - readonly value: string; - readonly update: (newValue: string) => void; - } - - const instances = createInstances(); - let hitCount = 0; - - const createInstance = (initialValue: string): TestInstance => { - let value = initialValue; - return { - get value() { - return value; - }, - update: (newValue: string) => { - value = newValue; - }, - [Symbol.dispose]: lazyVoid, - }; - }; - - const instance1 = instances.ensure("test", () => createInstance("initial")); - expect(instance1.value).toBe("initial"); - - const instance2 = instances.ensure( - "test", - () => createInstance("new"), - (existing) => { - hitCount++; - existing.update("updated"); - }, - ); - - expect(instance2.value).toBe("updated"); - expect(hitCount).toBe(1); - expect(instance1).toBe(instance2); -}); - -test("maintains separate instances for different keys", () => { - const instances = createInstances(); - - const instance1 = instances.ensure("key1", () => ({ - id: "instance-1", - [Symbol.dispose]: lazyVoid, - })); - - const instance2 = instances.ensure("key2", () => ({ - id: "instance-2", - [Symbol.dispose]: lazyVoid, - })); - - expect(instance1).not.toBe(instance2); - expect(instance1.id).toBe("instance-1"); - expect(instance2.id).toBe("instance-2"); -}); - -test("get returns instance if it exists", () => { - const instances = createInstances(); - - instances.ensure("test", () => ({ - id: "test-1", - [Symbol.dispose]: lazyVoid, - })); - - const retrieved = instances.get("test"); - expect(retrieved).not.toBeNull(); - expect(retrieved?.id).toBe("test-1"); -}); - -test("get returns null if instance does not exist", () => { - const instances = createInstances(); - const retrieved = instances.get("nonexistent"); - expect(retrieved).toBeNull(); -}); - -test("has returns true if instance exists", () => { - const instances = createInstances(); - - instances.ensure("test", () => ({ - id: "test-1", - [Symbol.dispose]: lazyVoid, - })); - - expect(instances.has("test")).toBe(true); -}); - -test("has returns false if instance does not exist", () => { - const instances = createInstances(); - expect(instances.has("nonexistent")).toBe(false); -}); - -test("delete deletes and disposes the instance", () => { - interface TestInstance extends Disposable { - readonly id: string; - disposed: boolean; - } - - const instances = createInstances(); - - const instance = instances.ensure("test", () => ({ - id: "test-1", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - expect(instances.has("test")).toBe(true); - expect(instance.disposed).toBe(false); - - const result = instances.delete("test"); - - expect(result).toBe(true); - expect(instances.has("test")).toBe(false); - expect(instance.disposed).toBe(true); -}); - -test("delete returns false if instance does not exist", () => { - interface TestInstance extends Disposable { - readonly id: string; - } - - const instances = createInstances(); - const result = instances.delete("nonexistent"); - expect(result).toBe(false); -}); - -test("Symbol.dispose disposes all instances", () => { - interface TestInstance extends Disposable { - readonly id: string; - disposed: boolean; - } - - const instances = createInstances(); - - const instance1 = instances.ensure("test1", () => ({ - id: "test-1", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - const instance2 = instances.ensure("test2", () => ({ - id: "test-2", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - expect(instances.has("test1")).toBe(true); - expect(instances.has("test2")).toBe(true); - expect(instance1.disposed).toBe(false); - expect(instance2.disposed).toBe(false); - - instances[Symbol.dispose](); - - expect(instances.has("test1")).toBe(false); - expect(instances.has("test2")).toBe(false); - expect(instance1.disposed).toBe(true); - expect(instance2.disposed).toBe(true); -}); - -test("using block syntax disposes all instances", () => { - interface TestInstance extends Disposable { - readonly id: string; - disposed: boolean; - } - - let instance1: TestInstance | null = null; - let instance2: TestInstance | null = null; - - { - using instances = createInstances(); - - instance1 = instances.ensure("test1", () => ({ - id: "test-1", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - instance2 = instances.ensure("test2", () => ({ - id: "test-2", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - expect(instance1.disposed).toBe(false); - expect(instance2.disposed).toBe(false); - } - - // After the block, instances should be disposed - expect(instance1.disposed).toBe(true); - expect(instance2.disposed).toBe(true); -}); - -test("delete still deletes instance from map even if dispose throws", () => { - interface TestInstance extends Disposable { - readonly id: string; - disposed: boolean; - } - - const instances = createInstances(); - - const instance = instances.ensure("test", () => ({ - id: "test-1", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - throw new Error("Disposal failed"); - }, - })); - - expect(instances.has("test")).toBe(true); - expect(() => instances.delete("test")).toThrow("Disposal failed"); - expect(instances.has("test")).toBe(false); - expect(instance.disposed).toBe(true); -}); - -test("Symbol.dispose attempts to dispose all instances even if some throw", () => { - interface TestInstance extends Disposable { - readonly id: string; - disposed: boolean; - } - - const instances = createInstances(); - - const instance1 = instances.ensure("test1", () => ({ - id: "test-1", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - throw new Error("Disposal 1 failed"); - }, - })); - - const instance2 = instances.ensure("test2", () => ({ - id: "test-2", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - }, - })); - - const instance3 = instances.ensure("test3", () => ({ - id: "test-3", - disposed: false, - [Symbol.dispose]: function () { - this.disposed = true; - throw new Error("Disposal 3 failed"); - }, - })); - - expect(() => { - instances[Symbol.dispose](); - }).toThrow(); - - expect(instance1.disposed).toBe(true); - expect(instance2.disposed).toBe(true); - expect(instance3.disposed).toBe(true); - expect(instances.has("test1")).toBe(false); - expect(instances.has("test2")).toBe(false); - expect(instances.has("test3")).toBe(false); -}); - -test("Symbol.dispose throws single error if only one disposal fails", () => { - interface TestInstance extends Disposable { - readonly id: string; - } - - const instances = createInstances(); - - instances.ensure("test1", () => ({ - id: "test-1", - [Symbol.dispose]: () => { - throw new Error("Single disposal error"); - }, - })); - - instances.ensure("test2", () => ({ - id: "test-2", - [Symbol.dispose]: lazyVoid, - })); - - expect(() => { - instances[Symbol.dispose](); - }).toThrow("Single disposal error"); -}); - -test("Symbol.dispose throws AggregateError if multiple disposals fail", () => { - interface TestInstance extends Disposable { - readonly id: string; - } - - const instances = createInstances(); - - instances.ensure("test1", () => ({ - id: "test-1", - [Symbol.dispose]: () => { - throw new Error("Error 1"); - }, - })); - - instances.ensure("test2", () => ({ - id: "test-2", - [Symbol.dispose]: () => { - throw new Error("Error 2"); - }, - })); - - try { - instances[Symbol.dispose](); - expect.fail("Should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(AggregateError); - if (error instanceof AggregateError) { - expect(error.errors).toHaveLength(2); - expect(error.errors[0]).toBeInstanceOf(Error); - expect(error.errors[1]).toBeInstanceOf(Error); - expect((error.errors[0] as Error).message).toBe("Error 1"); - expect((error.errors[1] as Error).message).toBe("Error 2"); - } - } -}); diff --git a/packages/common/test/Ref.test.ts b/packages/common/test/Ref.test.ts index 05b4d1fe4..76a476762 100644 --- a/packages/common/test/Ref.test.ts +++ b/packages/common/test/Ref.test.ts @@ -1,75 +1,133 @@ import { describe, expect, test } from "vitest"; -import { eqStrict } from "../src/Eq.js"; import { createRef } from "../src/Ref.js"; -describe("createRef", () => { +describe("get", () => { test("get returns initial state", () => { const ref = createRef(42); expect(ref.get()).toBe(42); }); +}); + +describe("set", () => { + test("updates state", () => { + const ref = createRef(0); + ref.set(1); + expect(ref.get()).toBe(1); + }); + + test("always assigns the provided state", () => { + const ref = createRef(1); + ref.set(1); + expect(ref.get()).toBe(1); + }); +}); + +describe("getAndSet", () => { + test("returns previous state and updates state", () => { + 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", () => { + const ref = createRef(1); + + expect(ref.getAndSet(1)).toBe(1); + expect(ref.get()).toBe(1); + }); +}); - describe("set", () => { - test("updates state", () => { - const ref = createRef(0); - ref.set(1); - expect(ref.get()).toBe(1); - }); - - test("returns true when state changes", () => { - const ref = createRef(0); - expect(ref.set(1)).toBe(true); - }); - - test("returns true even for same value without eq", () => { - const ref = createRef(1); - expect(ref.set(1)).toBe(true); - }); - - test("with eq returns false for equal values", () => { - const ref = createRef(1, eqStrict); - expect(ref.set(1)).toBe(false); - expect(ref.get()).toBe(1); - }); - - test("with eq returns true for different values", () => { - const ref = createRef(1, eqStrict); - expect(ref.set(2)).toBe(true); - expect(ref.get()).toBe(2); - }); - }); - - describe("modify", () => { - test("updates state", () => { - const ref = createRef(0); - ref.modify((n) => n + 1); - expect(ref.get()).toBe(1); - }); - - test("returns true when state changes", () => { - const ref = createRef(0); - expect(ref.modify((n) => n + 1)).toBe(true); - }); - - test("with eq returns false for equal values", () => { - const ref = createRef(1, eqStrict); - expect(ref.modify((n) => n)).toBe(false); - }); - - test("with eq returns true for different values", () => { - const ref = createRef(1, eqStrict); - expect(ref.modify((n) => n + 1)).toBe(true); - expect(ref.get()).toBe(2); - }); - }); - - test("with custom eq", () => { - const eqModulo10 = (a: number, b: number) => a % 10 === b % 10; - const ref = createRef(5 as number, eqModulo10); - - expect(ref.set(15)).toBe(false); // 5 % 10 === 15 % 10 +describe("setAndGet", () => { + test("returns updated state", () => { + const ref = createRef(1); + + expect(ref.setAndGet(2)).toBe(2); + expect(ref.get()).toBe(2); + }); + + test("returns current state when next state is equal", () => { + const ref = createRef(1); + + expect(ref.setAndGet(1)).toBe(1); + expect(ref.get()).toBe(1); + }); + + test("assigns the provided state", () => { + const ref = createRef(5); + + expect(ref.setAndGet(5)).toBe(5); expect(ref.get()).toBe(5); - expect(ref.set(16)).toBe(true); // 5 % 10 !== 16 % 10 + expect(ref.setAndGet(16)).toBe(16); expect(ref.get()).toBe(16); }); }); + +describe("update", () => { + test("updates state", () => { + const ref = createRef(1); + + ref.update((n) => n + 1); + + expect(ref.get()).toBe(2); + }); + + test("can keep the same state", () => { + const ref = createRef(1); + + ref.update((n) => n); + + expect(ref.get()).toBe(1); + }); +}); + +describe("getAndUpdate", () => { + test("returns previous state and updates state", () => { + 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", () => { + const ref = createRef(1); + + expect(ref.getAndUpdate((n) => n)).toBe(1); + expect(ref.get()).toBe(1); + }); +}); + +describe("updateAndGet", () => { + test("returns updated state", () => { + 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", () => { + const ref = createRef(1); + + expect(ref.updateAndGet((n) => n)).toBe(1); + expect(ref.get()).toBe(1); + }); +}); + +describe("modify", () => { + test("returns a computed result and updates state", () => { + const ref = createRef(0); + const result = ref.modify((current) => [current, current + 1]); + + expect(result).toBe(0); + expect(ref.get()).toBe(1); + }); + + test("can keep the same state while returning a result", () => { + const ref = createRef(1); + const result = ref.modify((current) => [`current:${current}`, current]); + + expect(result).toBe("current:1"); + expect(ref.get()).toBe(1); + }); +}); diff --git a/packages/common/test/Resources.test.ts b/packages/common/test/Resources.test.ts index 21e2ab3da..7863752b0 100644 --- a/packages/common/test/Resources.test.ts +++ b/packages/common/test/Resources.test.ts @@ -82,6 +82,52 @@ test("ignores addConsumer calls with empty resource list", () => { 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(); diff --git a/packages/common/test/Store.test.ts b/packages/common/test/Store.test.ts index 499597139..685f61b25 100644 --- a/packages/common/test/Store.test.ts +++ b/packages/common/test/Store.test.ts @@ -1,141 +1,259 @@ import { describe, expect, test, vi } from "vitest"; import { createStore } from "../src/Store.js"; -describe("createStore", () => { +describe("get", () => { test("get returns initial state", () => { const store = createStore(42); expect(store.get()).toBe(42); }); +}); + +describe("set", () => { + test("updates state", () => { + const store = createStore(0); + store.set(1); + expect(store.get()).toBe(1); + }); + + test("notifies listeners when state changes", () => { + const store = createStore(0); + const listener = vi.fn(); + store.subscribe(listener); + + store.set(1); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + store.set(1); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("getAndSet", () => { + test("returns previous state and updates state", () => { + const store = createStore(1); + + expect(store.getAndSet(2)).toBe(1); + expect(store.get()).toBe(2); + }); + + test("notifies listeners when state changes", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + expect(store.getAndSet(2)).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + expect(store.getAndSet(1)).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("setAndGet", () => { + test("returns updated state", () => { + const store = createStore(1); + + expect(store.setAndGet(2)).toBe(2); + expect(store.get()).toBe(2); + }); + + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + expect(store.setAndGet(1)).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("update", () => { + test("updates state", () => { + const store = createStore(1); + + store.update((n) => n + 1); + + expect(store.get()).toBe(2); + }); + + test("notifies listeners when state changes", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + store.update((n) => n + 1); - describe("set", () => { - test("updates state", () => { - const store = createStore(0); - store.set(1); - expect(store.get()).toBe(1); - }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); - test("returns true when state changes", () => { - const store = createStore(0); - expect(store.set(1)).toBe(true); - }); + store.update((n) => n); - test("returns false for equal values", () => { - const store = createStore(1); - expect(store.set(1)).toBe(false); - }); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("getAndUpdate", () => { + test("returns previous state and updates state", () => { + const store = createStore(1); + + expect(store.getAndUpdate((n: number) => n + 1)).toBe(1); + expect(store.get()).toBe(2); + }); + + test("notifies listeners when state changes", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + expect(store.getAndUpdate((n: number) => n + 1)).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); + + expect(store.getAndUpdate((n) => n)).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); - test("notifies listeners when state changes", () => { - const store = createStore(0); - const listener = vi.fn(); - store.subscribe(listener); +describe("updateAndGet", () => { + test("returns updated state", () => { + const store = createStore(1); - store.set(1); + expect(store.updateAndGet((n: number) => n + 1)).toBe(2); + expect(store.get()).toBe(2); + }); - expect(listener).toHaveBeenCalledTimes(1); - }); + test("does not notify listeners when state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); - test("does not notify listeners when state is equal", () => { - const store = createStore(1); - const listener = vi.fn(); - store.subscribe(listener); + expect(store.updateAndGet((n) => n)).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); +}); - store.set(1); +describe("modify", () => { + test("returns a computed result and updates state", () => { + const store = createStore(0); + const result = store.modify((current) => [current, current + 1]); - expect(listener).not.toHaveBeenCalled(); - }); + expect(result).toBe(0); + expect(store.get()).toBe(1); }); - describe("modify", () => { - test("updates state", () => { - const store = createStore(0); - store.modify((n) => n + 1); - expect(store.get()).toBe(1); - }); + test("returns computed result and updates state", () => { + const store = createStore(1); - test("returns true when state changes", () => { - const store = createStore(0); - expect(store.modify((n) => n + 1)).toBe(true); - }); + const result = store.modify((current: number) => [ + `previous:${current}`, + current + 1, + ]); - test("returns false for equal values", () => { - const store = createStore(1); - expect(store.modify((n) => n)).toBe(false); - }); + expect(result).toBe("previous:1"); + expect(store.get()).toBe(2); + }); - test("notifies listeners when state changes", () => { - const store = createStore(0); - const listener = vi.fn(); - store.subscribe(listener); + test("notifies listeners when state changes", () => { + const store = createStore(0); + const listener = vi.fn(); + store.subscribe(listener); - store.modify((n) => n + 1); + const result = store.modify((current: number) => [current, current + 1]); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(result).toBe(0); + expect(listener).toHaveBeenCalledTimes(1); + }); - test("does not notify listeners when state is equal", () => { - const store = createStore(1); - const listener = vi.fn(); - store.subscribe(listener); + test("does not notify listeners when next state is equal", () => { + const store = createStore(1); + const listener = vi.fn(); + store.subscribe(listener); - store.modify((n) => n); + const result = store.modify((current) => [current, current]); - expect(listener).not.toHaveBeenCalled(); - }); + expect(result).toBe(1); + expect(listener).not.toHaveBeenCalled(); }); +}); - describe("subscribe", () => { - test("returns unsubscribe function", () => { - const store = createStore(0); - const listener = vi.fn(); - const unsubscribe = store.subscribe(listener); +describe("subscribe", () => { + test("returns unsubscribe function", () => { + const store = createStore(0); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); - store.set(1); - expect(listener).toHaveBeenCalledTimes(1); + store.set(1); + expect(listener).toHaveBeenCalledTimes(1); - unsubscribe(); - store.set(2); - expect(listener).toHaveBeenCalledTimes(1); - }); + unsubscribe(); + store.set(2); + expect(listener).toHaveBeenCalledTimes(1); + }); - test("supports multiple listeners", () => { - const store = createStore(0); - const listener1 = vi.fn(); - const listener2 = vi.fn(); + test("supports multiple listeners", () => { + const store = createStore(0); + const listener1 = vi.fn(); + const listener2 = vi.fn(); - store.subscribe(listener1); - store.subscribe(listener2); + store.subscribe(listener1); + store.subscribe(listener2); - store.set(1); + store.set(1); - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledTimes(1); - }); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); }); +}); - describe("dispose", () => { - test("clears all listeners", () => { - const store = createStore(0); - const listener = vi.fn(); - store.subscribe(listener); +describe("dispose", () => { + test("clears all listeners", () => { + const store = createStore(0); + const listener = vi.fn(); + store.subscribe(listener); - store[Symbol.dispose](); - store.set(1); + store[Symbol.dispose](); + store.set(1); - expect(listener).not.toHaveBeenCalled(); - }); + expect(listener).not.toHaveBeenCalled(); }); +}); - test("with custom eq", () => { +describe("custom eq", () => { + test("suppresses notifications for equal states under the provided equality", () => { const eqModulo10 = (a: number, b: number) => a % 10 === b % 10; const store = createStore(5 as number, eqModulo10); const listener = vi.fn(); store.subscribe(listener); - expect(store.set(15)).toBe(false); // 5 % 10 === 15 % 10 - expect(store.get()).toBe(5); + store.set(15); + expect(store.get()).toBe(15); expect(listener).not.toHaveBeenCalled(); - expect(store.set(16)).toBe(true); // 5 % 10 !== 16 % 10 + store.set(16); expect(store.get()).toBe(16); expect(listener).toHaveBeenCalledTimes(1); }); diff --git a/packages/common/test/Task.test.ts b/packages/common/test/Task.test.ts index 07627bdb5..986757aa7 100644 --- a/packages/common/test/Task.test.ts +++ b/packages/common/test/Task.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, expect, expectTypeOf, test } from "vitest"; +import { assert, describe, expect, expectTypeOf, test, vi } from "vitest"; import { emptyArray, isNonEmptyArray, @@ -20,7 +20,6 @@ import { } from "../src/Schedule.js"; import type { Fiber, - FiberState, InferFiberErr, InferFiberOk, InferTaskDone, @@ -28,9 +27,10 @@ import type { InferTaskOk, NextTask, RetryError, - Runner, - RunnerConfigDep, - RunnerDeps, + Run, + RunConfigDep, + RunDeps, + RunState, Task, } from "../src/Task.js"; import { @@ -43,27 +43,29 @@ import { allSettled, any, callback, + concurrently, createDeferred, - createDeferreds, createGate, createInMemoryLeaderLock, createMutex, + createMutexByKey, + createMutexRef, createRun, - createRunner, createSemaphore, + createSemaphoreByKey, type DeferredDisposedError, deferredDisposedError, fetch, MapAbortError, map, mapSettled, - parallel, RaceLostError, - type RunnerEvent, + type RunEvent, race, repeat, retry, - runnerClosingError, + runStoppedError, + semaphoreDisposedError, sleep, TimeoutError, timeout, @@ -71,13 +73,23 @@ import { unabortableMask, yieldNow, } from "../src/Task.js"; -import { testCreateDeps, testCreateRunner, testName } from "../src/Test.js"; +import { + testCreateDeps, + testCreateRun, + testName, + testWaitForMacrotask, +} from "../src/Test.js"; import { createTime, Millis, msLongTask, testCreateTime } from "../src/Time.js"; import type { Typed } from "../src/Type.js"; import { type Id, minPositiveInt, Name, PositiveInt } from "../src/Type.js"; -const eventsEnabled: RunnerConfigDep = { - runnerConfig: { eventsEnabled: createRef(true) }, +const eventsEnabled: RunConfigDep = { + runConfig: { eventsEnabled: createRef(true) }, +}; + +const must = (value: T | null | undefined): T => { + assert(value != null); + return value; }; interface MyError extends Typed<"MyError"> {} @@ -112,7 +124,7 @@ describe("NextTask", () => { }); test("models three outcomes: value, done, error", async () => { - await using run = createRunner(); + await using run = createRun(); const valueTask: NextTask = () => ok(42); const doneTask: NextTask = () => err(done()); @@ -129,7 +141,7 @@ describe("NextTask", () => { }); test("type narrows correctly in pattern matching", async () => { - await using run = createRunner(); + await using run = createRun(); const task: NextTask = () => err({ type: "Done", done: "summary" }); @@ -158,7 +170,7 @@ describe("NextTask", () => { }); test("simulates iterator pattern with pull-based protocol", async () => { - await using run = createRunner(); + await using run = createRun(); const items = [1, 2, 3]; let index = 0; @@ -184,10 +196,10 @@ describe("NextTask", () => { }); }); -describe("Runner", () => { +describe("Run", () => { describe("run", () => { test("executes task and returns result", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = () => ok("hello"); @@ -225,7 +237,7 @@ describe("Runner", () => { describe("error handling", () => { test("synchronous throw does not leak fiber", async () => { - await using run = createRunner(); + await using run = createRun(); const syncThrowingTask = () => { throw new Error("sync throw"); @@ -243,7 +255,7 @@ describe("Runner", () => { }); test("rejected promise does not leak fiber", async () => { - await using run = createRunner(); + await using run = createRun(); const rejectingTask = () => Promise.reject(new Error("rejected")); @@ -262,28 +274,28 @@ describe("Runner", () => { describe("deps", () => { test("exposes injected time", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); expect(run.deps.time).toBe(time); }); test("exposes injected console", async () => { const console = testCreateConsole(); - await using run = testCreateRunner({ console }); + await using run = testCreateRun({ console }); expect(run.deps.console).toBe(console); }); test("exposes injected random", async () => { const random = testCreateRandom(); - await using run = testCreateRunner({ random }); + await using run = testCreateRun({ random }); expect(run.deps.random).toBe(random); }); test("exposes injected randomBytes", async () => { const deps = testCreateDeps(); - await using run = testCreateRunner(deps); + await using run = testCreateRun(deps); expect(run.deps.randomBytes).toBe(deps.randomBytes); }); @@ -300,8 +312,8 @@ describe("Runner", () => { const createDb = (): Db => ({ query: (sql) => `result:${sql}` }); - test("extends runner with additional deps for one-shot usage", async () => { - await using run = createRunner(); + test("extends run with additional deps for one-shot usage", async () => { + await using run = createRun(); const db = createDb(); @@ -313,11 +325,11 @@ describe("Runner", () => { expect(result).toEqual(ok("result:SELECT 1")); }); - test("extends runner with additional deps for reusable usage", async () => { - await using _run = createRunner(); + test("extends run with additional deps for reusable usage", async () => { + await using run = createRun(); const db = createDb(); - const run: Runner = _run.addDeps({ db }); + const runWithDb: Run = run.addDeps({ db }); const task1: Task = (run) => ok(run.deps.db.query("SELECT 1")); @@ -325,15 +337,15 @@ describe("Runner", () => { const task2: Task = (run) => ok(run.deps.db.query("SELECT 2")); - const result1 = await run(task1); - const result2 = await run(task2); + const result1 = await runWithDb(task1); + const result2 = await runWithDb(task2); expect(result1).toEqual(ok("result:SELECT 1")); expect(result2).toEqual(ok("result:SELECT 2")); }); test("child tasks inherit extended deps", async () => { - await using run = createRunner(); + await using run = createRun(); const db = createDb(); @@ -352,7 +364,7 @@ describe("Runner", () => { }); test("supports multiple deps at once", async () => { - await using run = createRunner(); + await using run = createRun(); interface CacheDep { readonly cache: { get: (key: string) => string }; @@ -371,8 +383,8 @@ describe("Runner", () => { expect(result).toEqual(ok("result:db-cache")); }); - test("returns same runner instance", async () => { - await using run = createRunner(); + test("returns same run instance", async () => { + await using run = createRun(); const db = createDb(); const runWithDb = run.addDeps({ db }); @@ -381,7 +393,7 @@ describe("Runner", () => { }); test("type error when overriding existing dep", async () => { - await using run = createRunner(); + await using run = createRun(); const db = createDb(); const runWithDb = run.addDeps({ db }); @@ -392,8 +404,8 @@ describe("Runner", () => { ); }); - test("type error when overriding RunnerDeps", async () => { - await using run = createRunner(); + test("type error when overriding RunDeps", async () => { + await using run = createRun(); // @ts-expect-error - cannot override built-in time dep expect(() => run.addDeps({ time: { now: () => 0 } })).toThrow( @@ -401,15 +413,15 @@ describe("Runner", () => { ); }); - test("runner with more deps is assignable to runner with fewer deps", async () => { - await using run = createRunner(); + test("run with more deps is assignable to run with fewer deps", async () => { + await using run = createRun(); const runWithBoth = run.addDeps({ createDb, db: createDb(), }); - const runWithDb: Runner = runWithBoth; + const runWithDb: Run = runWithBoth; const task: Task = (run) => ok(run.deps.db.query("SELECT 1")); @@ -423,9 +435,9 @@ describe("Runner", () => { describe("onEvent", () => { test("emits childAdded when child is added", async () => { const deps = testCreateDeps(); - await using run = testCreateRunner({ ...deps, ...eventsEnabled }); + await using run = testCreateRun({ ...deps, ...eventsEnabled }); - const events: Array = []; + const events: Array = []; const taskComplete = Promise.withResolvers>(); run.onEvent = (event) => { @@ -446,10 +458,10 @@ describe("Runner", () => { await fiber; }); - test("emits completing, completed, childRemoved when child completes", async () => { - await using run = testCreateRunner(eventsEnabled); + test("emits disposing, settled, childRemoved when child completes", async () => { + await using run = testCreateRun(eventsEnabled); - const events: Array = []; + const events: Array = []; const taskComplete = Promise.withResolvers>(); const fiber = run(() => taskComplete.promise); @@ -467,25 +479,25 @@ describe("Runner", () => { "ChildRemoved", ]); - const [completing, completed, childRemoved] = events; + const [disposing, settled, childRemoved] = events; - assert(completing.data.type === "StateChanged"); - expect(completing.data.state.type).toBe("Completing"); + assert(disposing.data.type === "StateChanged"); + expect(disposing.data.state.type).toBe("Disposing"); - assert(completed.data.type === "StateChanged"); - expect(completed.data.state.type).toBe("Completed"); - assert(completed.data.state.type === "Completed"); - expect(completed.data.state.result).toEqual(ok()); - expect(completed.data.state.outcome).toEqual(ok()); + assert(settled.data.type === "StateChanged"); + expect(settled.data.state.type).toBe("Settled"); + assert(settled.data.state.type === "Settled"); + expect(settled.data.state.result).toEqual(ok()); + expect(settled.data.state.outcome).toEqual(ok()); assert(childRemoved.data.type === "ChildRemoved"); expect(childRemoved.data.childId).toBe(fiber.run.id); }); test("bubbles up through parent chain", async () => { - await using run = testCreateRunner(eventsEnabled); + await using run = testCreateRun(eventsEnabled); - const events: Array<{ level: string; event: RunnerEvent }> = []; + const events: Array<{ level: string; event: RunEvent }> = []; run.onEvent = (event) => { events.push({ level: "root", event }); @@ -533,9 +545,9 @@ describe("Runner", () => { }); test("not emitted when eventsEnabled is false", async () => { - await using run = createRunner(); // Events disabled by default + await using run = createRun(); // Events disabled by default - const events: Array = []; + const events: Array = []; run.onEvent = (event) => { events.push(event); @@ -550,7 +562,7 @@ describe("Runner", () => { describe("snapshot", () => { test("returns same reference when nothing changes", async () => { - await using run = createRunner(); + await using run = createRun(); const snapshot1 = run.snapshot(); const snapshot2 = run.snapshot(); @@ -559,7 +571,7 @@ describe("Runner", () => { }); test("returns new reference when children change", async () => { - await using run = createRunner(); + await using run = createRun(); const taskComplete = Promise.withResolvers>(); @@ -583,7 +595,7 @@ describe("Runner", () => { }); test("preserves child snapshot references when sibling changes", async () => { - await using run = createRunner(); + await using run = createRun(); const task1Complete = Promise.withResolvers>(); const task2Complete = Promise.withResolvers>(); @@ -613,13 +625,9 @@ describe("Runner", () => { }); test("structural sharing during rapid concurrent completions", async () => { - await using run = createRunner(); + await using run = createRun(); - const taskCompletes: Array<{ - promise: Promise>; - resolve: (value: Result) => void; - reject: (reason?: any) => void; - }> = []; + const taskCompletes: Array>> = []; // Start 5 concurrent fibers const fibers = Array.from({ length: 5 }, () => { @@ -662,7 +670,7 @@ describe("Runner", () => { describe("defer", () => { test("runs task when disposed", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -684,8 +692,28 @@ describe("Runner", () => { 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 = createRunner(); + await using run = createRun(); const events: Array = []; const taskStarted = Promise.withResolvers(); @@ -724,32 +752,25 @@ describe("Runner", () => { const results: Array = []; { - await using run = createRunner(); + await using run = createRun(); const makeTask = (id: string): Task => async ({ signal }) => { const taskComplete = Promise.withResolvers>(); - let settled = false; const timeout = setTimeout(() => { - if (!settled) { - settled = true; - results.push(`${id} completed`); - taskComplete.resolve(ok(id)); - } + results.push(`${id} completed`); + taskComplete.resolve(ok(id)); }, 1000); signal.addEventListener( "abort", () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - results.push(`${id} aborted`); - taskComplete.resolve(err(signal.reason)); - } + clearTimeout(timeout); + results.push(`${id} aborted`); + taskComplete.resolve(err(signal.reason)); }, { once: true }, ); @@ -760,30 +781,30 @@ describe("Runner", () => { run(makeTask("task1")); run(makeTask("task2")); } - // runner disposed here + // run disposed here expect(results).toEqual(["task1 aborted", "task2 aborted"]); }); - test("transitions running → completing → completed", async () => { - const run = createRunner(); + test("transitions running → disposing → settled", async () => { + const run = createRun(); - expectTypeOf(run.getState()).toEqualTypeOf(); + expectTypeOf(run.getState()).toEqualTypeOf(); expect(run.getState().type).toBe("Running"); const taskStarted = Promise.withResolvers(); const taskCanFinish = Promise.withResolvers(); - let stateInAbortHandler: FiberState | undefined; - let stateAfterAwait: FiberState | undefined; + let stateInAbortHandler: RunState | undefined; + let stateAfterAwait: RunState | undefined; const task: Task = async (run) => { run.signal.addEventListener("abort", () => { - stateInAbortHandler = run.parent?.getState(); + stateInAbortHandler = must(run.parent).getState(); }); taskStarted.resolve(); await taskCanFinish.promise; - stateAfterAwait = run.parent?.getState(); + stateAfterAwait = must(run.parent).getState(); return ok(); }; @@ -791,31 +812,31 @@ describe("Runner", () => { await taskStarted.promise; const disposePromise = run[Symbol.asyncDispose](); - expect(run.getState().type).toBe("Completing"); + expect(run.getState().type).toBe("Disposing"); taskCanFinish.resolve(); await disposePromise; - expect(stateInAbortHandler?.type).toBe("Completing"); - expect(stateAfterAwait?.type).toBe("Completing"); - expect(run.getState().type).toBe("Completed"); + expect(must(stateInAbortHandler).type).toBe("Disposing"); + expect(must(stateAfterAwait).type).toBe("Disposing"); + expect(run.getState().type).toBe("Settled"); }); - test("defaults completed result and outcome to ok", async () => { - const run = createRunner(); + test("defaults settled result and outcome to ok", async () => { + const run = createRun(); await run[Symbol.asyncDispose](); const state = run.getState(); expect(state).toEqual({ - type: "Completed", + type: "Settled", result: ok(), outcome: ok(), }); }); test("is idempotent", async () => { - await using run = createRunner(); + await using run = createRun(); const promise1 = run[Symbol.asyncDispose](); const promise2 = run[Symbol.asyncDispose](); @@ -823,11 +844,11 @@ describe("Runner", () => { expect(promise1).toBe(promise2); }); - test("does not run new tasks when completing", async () => { - const run = createRunner(); + test("does not run new tasks when disposing", async () => { + const run = createRun(); run[Symbol.asyncDispose](); - expect(run.getState().type).toBe("Completing"); + expect(run.getState().type).toBe("Disposing"); let regularRan = false; let unabortableRan = false; @@ -858,21 +879,21 @@ describe("Runner", () => { expect(unabortableRan).toBe(false); expect(unabortableMaskRan).toBe(false); - expect(regularFiber.run.getState().type).toBe("Completed"); - expect(unabortableFiber.run.getState().type).toBe("Completed"); - expect(unabortableMaskFiber.run.getState().type).toBe("Completed"); + expect(regularFiber.run.getState().type).toBe("Settled"); + expect(unabortableFiber.run.getState().type).toBe("Settled"); + expect(unabortableMaskFiber.run.getState().type).toBe("Settled"); - const expected = err({ type: "AbortError", reason: runnerClosingError }); + const expected = err({ type: "AbortError", reason: runStoppedError }); expect(regularResult).toEqual(expected); expect(unabortableResult).toEqual(expected); expect(unabortableMaskResult).toEqual(expected); }); - test("does not run new tasks when completed", async () => { - const run = createRunner(); + test("does not run new tasks when settled", async () => { + const run = createRun(); await run[Symbol.asyncDispose](); - expect(run.getState().type).toBe("Completed"); + expect(run.getState().type).toBe("Settled"); let regularRan = false; let unabortableRan = false; @@ -903,11 +924,11 @@ describe("Runner", () => { expect(unabortableRan).toBe(false); expect(unabortableMaskRan).toBe(false); - expect(regularFiber.run.getState().type).toBe("Completed"); - expect(unabortableFiber.run.getState().type).toBe("Completed"); - expect(unabortableMaskFiber.run.getState().type).toBe("Completed"); + expect(regularFiber.run.getState().type).toBe("Settled"); + expect(unabortableFiber.run.getState().type).toBe("Settled"); + expect(unabortableMaskFiber.run.getState().type).toBe("Settled"); - const expected = err({ type: "AbortError", reason: runnerClosingError }); + const expected = err({ type: "AbortError", reason: runStoppedError }); expect(regularResult).toEqual(expected); expect(unabortableResult).toEqual(expected); expect(unabortableMaskResult).toEqual(expected); @@ -916,7 +937,7 @@ describe("Runner", () => { describe("onAbort", () => { test("passes the abort reason directly, not wrapped in AbortError", async () => { - await using run = createRunner(); + await using run = createRun(); const receivedReason = Promise.withResolvers(); const taskStarted = Promise.withResolvers(); @@ -941,7 +962,7 @@ describe("Runner", () => { }); test("receives undefined when aborted without reason", async () => { - await using run = createRunner(); + await using run = createRun(); const receivedReason = Promise.withResolvers(); const taskStarted = Promise.withResolvers(); @@ -966,7 +987,7 @@ describe("Runner", () => { }); test("invokes callback immediately when already aborted", async () => { - await using run = createRunner(); + await using run = createRun(); const receivedReason = Promise.withResolvers(); const allowRegister = Promise.withResolvers(); @@ -992,9 +1013,10 @@ describe("Runner", () => { // listener is removed. We capture the cleanup signal and verify it's // aborted after disposal. - await using run = createRunner(); + await using run = createRun(); let cleanupSignal: AbortSignal | null = null; + // eslint-disable-next-line @typescript-eslint/unbound-method const originalAddEventListener = AbortSignal.prototype.addEventListener; let childSignal: AbortSignal | null = null; @@ -1024,20 +1046,21 @@ describe("Runner", () => { // Cleanup signal should exist and be aborted after disposal expect(cleanupSignal).not.toBeNull(); - expect((cleanupSignal as any)?.aborted).toBe(true); + expect(must(cleanupSignal).aborted).toBe(true); } finally { AbortSignal.prototype.addEventListener = originalAddEventListener; } }); test.sequential("removes parent abort listener via signal option for cleanup", async () => { - // This test verifies that child runners use `signal: requestController.signal` + // This test verifies that child runs use `signal: requestController.signal` // for parent abort listener cleanup. When a child completes, the listener // on parent.requestSignal should be removed automatically. - await using run = createRunner(); + await using run = createRun(); let cleanupSignal: AbortSignal | null = null; + // eslint-disable-next-line @typescript-eslint/unbound-method const originalAddEventListener = AbortSignal.prototype.addEventListener; // We need to capture the parent's requestSignal to identify the right listener @@ -1078,22 +1101,177 @@ describe("Runner", () => { // Cleanup signal should exist and be aborted after child disposal expect(cleanupSignal).not.toBeNull(); - expect((cleanupSignal as any)?.aborted).toBe(true); + expect(must(cleanupSignal).aborted).toBe(true); } finally { AbortSignal.prototype.addEventListener = originalAddEventListener; } }); }); + + describe("create", () => { + test("created run outlives parent task", async () => { + const time = testCreateTime(); + await using run = testCreateRun({ time }); + + const events: Array = []; + let childFiber: Fiber | undefined; + + const childTask: Task = async (run) => { + events.push("child started"); + const result = await run(sleep("5s")); + if (!result.ok) return result; + events.push("child completed"); + return ok(); + }; + + const parentTask: Task = async (run) => { + events.push("parent started"); + childFiber = run.create()(childTask); + + const result = await run(sleep("3s")); + if (!result.ok) return result; + + events.push("parent completed"); + return ok(); + }; + + const parentFiber = run(parentTask); + + expect(events).toEqual(["parent started", "child started"]); + + time.advance("3s"); + + expect(await parentFiber).toEqual(ok()); + expect(events).toEqual([ + "parent started", + "child started", + "parent completed", + ]); + expect(must(childFiber).run.getState().type).toBe("Running"); + + time.advance("2s"); + + expect(await must(childFiber)).toEqual(ok()); + expect(events).toEqual([ + "parent started", + "child started", + "parent completed", + "child completed", + ]); + }); + + test("shares deps with the creating run", async () => { + interface CustomDep { + readonly custom: { readonly value: string }; + } + + await using run = createRun(); + + let receivedValue: string | undefined; + const childTask: Task = (run) => + ok(run.deps.custom.value); + + const parentTask: Task = async (run) => { + const runWithDep = run.addDeps({ custom: { value: "from-create" } }); + const createdRun = runWithDep.create(); + + const result = await createdRun(childTask); + if (!result.ok) return result; + + receivedValue = result.value; + return ok(); + }; + + expect(await run(parentTask)).toEqual(ok()); + expect(receivedValue).toBe("from-create"); + }); + + test("disposing created run aborts running tasks and later calls", async () => { + await using run = testCreateRun(); + + const events: Array = []; + let createdRun: Run | undefined; + + expect( + await run((run) => { + createdRun = run.create(); + return ok(); + }), + ).toEqual(ok()); + + const childFiber = must(createdRun)(async (run) => { + events.push("child started"); + const result = await run(sleep("10s")); + if (!result.ok) { + events.push("child aborted"); + return result; + } + + events.push("child completed"); + return ok(); + }); + + expect(events).toEqual(["child started"]); + + await must(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")); + expect(lateResult).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + }); + + test("created run is aborted when root Run disposes", async () => { + const time = testCreateTime(); + const run = testCreateRun({ time }); + + const events: Array = []; + let createdRun: Run | undefined; + + expect( + await run((run) => { + createdRun = run.create(); + return ok(); + }), + ).toEqual(ok()); + + const childFiber = must(createdRun)(async (run) => { + events.push("child started"); + const result = await run(sleep("10s")); + if (!result.ok) { + events.push("child aborted"); + return result; + } + + events.push("child completed"); + return ok(); + }); + + expect(events).toEqual(["child started"]); + + await run[Symbol.asyncDispose](); + + expect(await childFiber).toEqual( + err({ type: "AbortError", reason: runStoppedError }), + ); + expect(events).toEqual(["child started", "child aborted"]); + }); + }); }); describe("Fiber", () => { test("is awaitable", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = () => Promise.resolve(ok(42)); const fiber = run(task); - expectTypeOf(fiber).toEqualTypeOf>(); + expectTypeOf(fiber).toEqualTypeOf>(); const result = await fiber; @@ -1103,11 +1281,11 @@ describe("Fiber", () => { describe("abort", () => { test("before run short-circuits child task", async () => { - await using run = createRunner(); + await using run = createRun(); let taskRan = false; let signalAbortedBeforeInnerRun = false; - let innerFiberState: FiberState | undefined; + let innerFiberState: RunState | undefined; const fiber = run(async (run) => { await Promise.resolve(); @@ -1130,7 +1308,7 @@ describe("Fiber", () => { expect(signalAbortedBeforeInnerRun).toBe(true); expect(taskRan).toBe(false); - assert(innerFiberState?.type === "Completed"); + assert(innerFiberState?.type === "Settled"); expect(innerFiberState.result).toEqual( err({ type: "AbortError", reason: "stop" }), ); @@ -1138,7 +1316,7 @@ describe("Fiber", () => { }); test("during run signals abort via AbortSignal", async () => { - await using run = createRunner(); + await using run = createRun(); let signalAbortedInHandler = false; @@ -1179,7 +1357,7 @@ describe("Fiber", () => { describe("dispose", () => { test("aborts task via using", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = async ({ signal }) => { const taskComplete = Promise.withResolvers>(); @@ -1215,8 +1393,8 @@ describe("Fiber", () => { }); }); - test("getState returns running while running, completed with result after completion", async () => { - await using run = createRunner(); + test("getState returns running while running, settled with result after completion", async () => { + await using run = createRun(); const taskComplete = Promise.withResolvers>(); @@ -1228,13 +1406,13 @@ describe("Fiber", () => { await fiber; const state = fiber.getState(); - expectTypeOf(state).toEqualTypeOf>(); - assert(state.type === "Completed"); + expectTypeOf(state).toEqualTypeOf>(); + assert(state.type === "Settled"); expect(state.result).toEqual(ok(42)); }); - test("completed state outcome equals result when not aborted", async () => { - await using run = createRunner(); + test("settled state outcome equals result when not aborted", async () => { + await using run = createRun(); const taskComplete = Promise.withResolvers>(); @@ -1246,19 +1424,19 @@ describe("Fiber", () => { await fiber; const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); expect(state.outcome).toEqual(state.result); }); - test("completed state outcome preserves original result when aborted", async () => { - await using run = createRunner(); + test("settled state outcome preserves original result when aborted", async () => { + await using run = createRun(); const fiber = run(() => ok("data")); fiber.abort("stop"); await fiber; const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); // result returns AbortError expect(state.result).toEqual(err({ type: "AbortError", reason: "stop" })); // outcome preserves what the task actually returned @@ -1267,7 +1445,7 @@ describe("Fiber", () => { describe("run", () => { test("id matches run.id inside task", async () => { - await using run = createRunner(); + await using run = createRun(); let parentFiberId: Id | null = null; let childFiber: Fiber | null = null; @@ -1287,13 +1465,13 @@ describe("Fiber", () => { await parentFiber; - expect(parentFiberId).toBe((parentFiber as any).run.id); - expect(childFiberId).toBe((childFiber as any)?.run.id); + expect(parentFiberId).toBe(parentFiber.run.id); + expect(childFiberId).toBe(must(childFiber).run.id); expect(parentFiberId).not.toBe(childFiberId); }); - test("snapshot returns running state while running, completed with result after completion", async () => { - await using run = createRunner(); + test("snapshot returns running state while running, settled with result after completion", async () => { + await using run = createRun(); const taskComplete = Promise.withResolvers>(); @@ -1303,17 +1481,17 @@ describe("Fiber", () => { taskComplete.resolve(ok(42)); await fiber; const snapshotState = fiber.run.snapshot().state; - assert(snapshotState.type === "Completed"); + assert(snapshotState.type === "Settled"); expect(snapshotState.result).toEqual(ok(42)); }); }); describe("daemon", () => { - test("called directly on root runner", async () => { + test("called directly on root Run", async () => { const events: Array = []; const daemonCanComplete = Promise.withResolvers(); - await using run = createRunner(); + await using run = createRun(); const daemonTask: Task = async () => { events.push("daemon started"); @@ -1322,7 +1500,7 @@ describe("Fiber", () => { return ok(); }; - // Call daemon directly on root runner (not from inside a task) + // Call daemon directly on root Run (not from inside a task) const fiber = run.daemon(daemonTask); expect(events).toEqual(["daemon started"]); @@ -1338,7 +1516,7 @@ describe("Fiber", () => { const daemonCanComplete = Promise.withResolvers(); let daemonFiber: Fiber; - await using run = createRunner(); + await using run = createRun(); const daemonTask: Task = async () => { events.push("daemon started"); @@ -1365,8 +1543,7 @@ describe("Fiber", () => { // Let daemon complete and wait for it daemonCanComplete.resolve(); - // biome-ignore lint/style/noNonNullAssertion: Test utility - await daemonFiber!; + await must(daemonFiber); expect(events).toEqual([ "parent started", @@ -1376,9 +1553,9 @@ describe("Fiber", () => { ]); }); - test("aborted when root runner disposes", async () => { + test("aborted when root Run disposes", async () => { const events: Array = []; - const run = createRunner(); + const run = createRun(); const daemonTask: Task = async ({ signal }) => { events.push("daemon started"); @@ -1410,18 +1587,18 @@ describe("Fiber", () => { await run(parentTask); expect(events).toEqual(["daemon started"]); - // Dispose root runner + // Dispose root Run await run[Symbol.asyncDispose](); expect(events).toEqual(["daemon started", "daemon aborted"]); }); - test("from nested task runs on root runner", async () => { + test("from nested task runs on root Run", async () => { const events: Array = []; const daemonCanComplete = Promise.withResolvers(); let daemonFiber: Fiber; - await using run = createRunner(); + await using run = createRun(); const daemonTask: Task = async () => { events.push("daemon started"); @@ -1457,8 +1634,7 @@ describe("Fiber", () => { ]); daemonCanComplete.resolve(); - // biome-ignore lint/style/noNonNullAssertion: Test utility - await daemonFiber!; + await must(daemonFiber); expect(events).toEqual([ "parent started", @@ -1475,7 +1651,7 @@ describe("Fiber", () => { readonly custom: { readonly value: string }; } - await using run = createRunner(); + await using run = createRun(); let receivedValue: string | undefined; @@ -1485,9 +1661,9 @@ describe("Fiber", () => { }; // Parent task adds deps and spawns daemon - const parentTask: Task = async (_run) => { - const run = _run.addDeps({ custom: { value: "from-addDeps" } }); - await run.daemon(daemonTask); + const parentTask: Task = async (run) => { + const runWithDep = run.addDeps({ custom: { value: "from-addDeps" } }); + await runWithDep.daemon(daemonTask); return ok(); }; @@ -1513,7 +1689,7 @@ describe("Fiber", () => { describe("unabortable", () => { test("without abort completes", async () => { - await using run = createRunner(); + await using run = createRun(); const okResult = await run(unabortable(() => ok(42))); const errResult = await run(unabortable(() => err({ type: "MyError" }))); @@ -1523,7 +1699,7 @@ describe("unabortable", () => { }); test("with abort before run masks signal and completes", async () => { - await using run = createRunner(); + await using run = createRun(); let taskRan = false; let innerResult: Result | null = null; @@ -1555,12 +1731,12 @@ describe("unabortable", () => { expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); // Outcome preserves what the task actually returned const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); expect(state.outcome).toEqual(innerResult); }); test("with abort during run masks signal and completes", async () => { - await using run = createRunner(); + await using run = createRun(); const canComplete = Promise.withResolvers(); let signalAbortedAtStart = true; @@ -1591,7 +1767,7 @@ describe("unabortable", () => { describe("unabortableMask", () => { test("without abort completes", async () => { - await using run = createRunner(); + await using run = createRun(); let abortableRan = false; @@ -1612,7 +1788,7 @@ describe("unabortableMask", () => { }); test("with abort before run still runs unabortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; let signalAbortedBeforeMask = false; @@ -1651,7 +1827,7 @@ describe("unabortableMask", () => { }); test("with abort during run masks signal, skips abortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const acquireStarted = Promise.withResolvers(); @@ -1702,7 +1878,7 @@ describe("unabortableMask", () => { }); test("nested unabortableMask: outer abortable restores to fully abortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const innerStarted = Promise.withResolvers(); @@ -1759,14 +1935,14 @@ describe("unabortableMask", () => { }); test("restore throws when used outside its unabortableMask", async () => { - await using run = createRunner(); + await using run = createRun(); let restoreFromInner: ((task: Task) => Task) | undefined; const task = unabortableMask( (_restore1) => async (run) => await run( - unabortableMask((restore2) => (_run) => { + unabortableMask((restore2) => () => { // restore2 restores to mask=1 restoreFromInner = restore2; @@ -1781,7 +1957,7 @@ describe("unabortableMask", () => { // Using restore2 outside its intended scope would increase abort mask // (root mask=0, override=1). This must crash. - expect(() => run((restoreFromInner as any)?.(() => ok()))).toThrow( + expect(() => run(must(restoreFromInner)(() => ok()))).toThrow( "restore used outside its unabortableMask", ); }); @@ -1798,15 +1974,16 @@ describe("AsyncDisposableStack", () => { 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 Runner", () => { + describe("stack via Run", () => { test("run.stack() creates AsyncDisposableStack", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -1833,7 +2010,7 @@ describe("AsyncDisposableStack", () => { describe("defer", () => { test("runs task on dispose", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -1855,8 +2032,29 @@ describe("AsyncDisposableStack", () => { 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 = createRunner(); + await using run = createRun(); const task: Task = async (run) => { await using stack = run.stack(); @@ -1876,7 +2074,7 @@ describe("AsyncDisposableStack", () => { }); test("runs multiple deferred tasks in LIFO order", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -1901,7 +2099,7 @@ describe("AsyncDisposableStack", () => { }); test("is unabortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const taskStarted = Promise.withResolvers(); @@ -1936,7 +2134,7 @@ describe("AsyncDisposableStack", () => { describe("disposeAsync", () => { test("disposes the stack", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -1963,7 +2161,7 @@ describe("AsyncDisposableStack", () => { describe("disposed", () => { test("returns false before dispose, true after", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = async (run) => { const stack = run.stack(); @@ -1981,13 +2179,15 @@ describe("AsyncDisposableStack", () => { describe("use", () => { test("acquires and disposes resource", async () => { - await using run = createRunner(); + 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(); @@ -2000,7 +2200,7 @@ describe("AsyncDisposableStack", () => { }); test("acquires multiple resources in LIFO disposal order", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2035,7 +2235,7 @@ describe("AsyncDisposableStack", () => { }); test("propagates acquire error and releases acquired resources", async () => { - await using run = createRunner(); + await using run = createRun(); interface AcquireError extends Typed<"AcquireError"> {} @@ -2069,7 +2269,7 @@ describe("AsyncDisposableStack", () => { }); test("releases acquired resources when acquire throws", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2099,7 +2299,7 @@ describe("AsyncDisposableStack", () => { }); test("acquisition is unabortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canComplete = Promise.withResolvers(); @@ -2110,6 +2310,7 @@ describe("AsyncDisposableStack", () => { 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"); }, @@ -2142,7 +2343,7 @@ describe("AsyncDisposableStack", () => { }); test("accepts sync Disposable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2177,7 +2378,7 @@ describe("AsyncDisposableStack", () => { }); test("accepts null without registering disposal", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = async (run) => { await using stack = run.stack(); @@ -2189,7 +2390,7 @@ describe("AsyncDisposableStack", () => { }); test("accepts undefined without registering disposal", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = async (run) => { await using stack = run.stack(); @@ -2201,7 +2402,7 @@ describe("AsyncDisposableStack", () => { }); test("accepts direct value (sync)", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2209,6 +2410,7 @@ describe("AsyncDisposableStack", () => { await using stack = run.stack(); const resource: AsyncDisposable = { + // eslint-disable-next-line @typescript-eslint/require-await [Symbol.asyncDispose]: async () => { events.push("released"); }, @@ -2229,32 +2431,32 @@ describe("AsyncDisposableStack", () => { }); test("accepts disposable callable (not mistaken for Task)", async () => { - await using run = createRunner(); + await using run = createRun(); - let childRunner: Runner | null = null; - let stateWhileWorking: FiberState | null = null; + let childRun: Run | null = null; + let stateWhileWorking: RunState | null = null; const task: Task = async (run) => { await using stack = run.stack(); - // Runner is a callable with Symbol.asyncDispose + // Run is a callable with Symbol.asyncDispose // use must detect the symbol, not use typeof === "function" - childRunner = createRunner(); - stack.use(childRunner); + childRun = createRun(); + stack.use(childRun); - stateWhileWorking = childRunner.getState(); + stateWhileWorking = childRun.getState(); return ok(); }; const result = await run(task); expect(result).toEqual(ok()); - expect((stateWhileWorking as any)?.type).toBe("Running"); - expect((childRunner as any)?.getState().type).toBe("Completed"); + expect(must(stateWhileWorking).type).toBe("Running"); + expect(must(childRun).getState().type).toBe("Settled"); }); test("accepts moved native stack", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2285,7 +2487,7 @@ describe("AsyncDisposableStack", () => { describe("adopt", () => { test("acquires value via task and registers task-based disposal", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2320,7 +2522,7 @@ describe("AsyncDisposableStack", () => { }); test("requires release task without domain errors", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = async (run) => { await using stack = run.stack(); @@ -2349,7 +2551,7 @@ describe("AsyncDisposableStack", () => { }); test("disposal is unabortable", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const taskStarted = Promise.withResolvers(); @@ -2388,7 +2590,7 @@ describe("AsyncDisposableStack", () => { }); test("does not register disposal if acquire fails", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2423,7 +2625,7 @@ describe("AsyncDisposableStack", () => { describe("move", () => { test("transfers ownership to returned AsyncDisposableStack", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2468,7 +2670,7 @@ describe("AsyncDisposableStack", () => { }); test("cleans up on early return after move is possible", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canContinue = Promise.withResolvers(); @@ -2517,7 +2719,7 @@ describe("AsyncDisposableStack", () => { describe("cleanup runs on root scope", () => { test("defer cleanup survives factory task scope", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2553,7 +2755,7 @@ describe("AsyncDisposableStack", () => { }); test("adopt disposal survives factory task scope", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2628,7 +2830,7 @@ describe("AsyncDisposableStack", () => { }; test("disposal runs when stack disposes", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2647,7 +2849,7 @@ describe("AsyncDisposableStack", () => { }); test("disposal completes even when parent task is aborted", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const workStarted = Promise.withResolvers(); @@ -2665,7 +2867,7 @@ describe("AsyncDisposableStack", () => { createResourceFactory(events, async (run) => { events.push("disposal started"); await canComplete.promise; - // Verify runner works inside disposal task + // Verify run works inside disposal task await run(cleanupHelper); events.push("disposal completed"); return ok(); @@ -2701,7 +2903,7 @@ describe("AsyncDisposableStack", () => { }); test("disposal survives factory task scope ending", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2723,7 +2925,7 @@ describe("AsyncDisposableStack", () => { describe("yieldNow", () => { test("is polyfilled properly", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -2754,15 +2956,91 @@ describe("yieldNow", () => { "yield-resolved", ]); }); + + test("uses scheduler.yield when available", async () => { + const globals = globalThis as unknown as { + scheduler: { yield?: () => Promise } | undefined; + setImmediate: ((...args: Array) => unknown) | undefined; + }; + const originalScheduler = globals.scheduler; + const originalSetImmediate = globals.setImmediate; + + const schedulerYield = vi.fn(() => Promise.resolve()); + + try { + globals.scheduler = { yield: schedulerYield }; + globals.setImmediate = undefined; + vi.resetModules(); + + const taskModule = await import("../src/Task.js"); + + await using run = taskModule.createRun(); + expect(await run(taskModule.yieldNow)).toEqual(ok()); + } finally { + globals.scheduler = originalScheduler; + globals.setImmediate = originalSetImmediate; + vi.resetModules(); + } + }); + + test("maps setImmediate failures to AbortError", async () => { + await using run = createRun(); + + const globals = globalThis as unknown as { + setImmediate?: (...args: Array) => unknown; + }; + const originalSetImmediate = globals.setImmediate; + const setImmediateError = new Error("setImmediate failed"); + + if (originalSetImmediate == null) return; + + try { + globals.setImmediate = () => { + throw setImmediateError; + }; + + expect(await run(yieldNow)).toEqual( + err({ + type: "AbortError", + reason: setImmediateError, + }), + ); + } finally { + globals.setImmediate = originalSetImmediate; + } + }); + + test("uses setTimeout fallback when scheduler and setImmediate are unavailable", async () => { + const globals = globalThis as unknown as { + scheduler: unknown; + setImmediate: ((...args: Array) => unknown) | undefined; + }; + const originalScheduler = globals.scheduler; + const originalSetImmediate = globals.setImmediate; + + try { + globals.scheduler = undefined; + globals.setImmediate = undefined; + vi.resetModules(); + + const taskModule = await import("../src/Task.js"); + + await using run = taskModule.createRun(); + expect(await run(taskModule.yieldNow)).toEqual(ok()); + } finally { + globals.scheduler = originalScheduler; + globals.setImmediate = originalSetImmediate; + vi.resetModules(); + } + }); }); describe("callback", () => { test("resolves with ok value", async () => { - await using run = createRunner(); + await using run = createRun(); const task = callback(({ ok }) => { ok("hello"); - return undefined; }); const result = await run(task); @@ -2772,11 +3050,10 @@ describe("callback", () => { test("resolves with err value", async () => { interface MyError extends Typed<"MyError"> {} - await using run = createRunner(); + await using run = createRun(); const task = callback(({ err }) => { err({ type: "MyError" }); - return undefined; }); const result = await run(task); @@ -2784,7 +3061,7 @@ describe("callback", () => { }); test("runs cleanup on abort", async () => { - await using run = createRunner(); + await using run = createRun(); let cleanedUp = false; @@ -2805,23 +3082,22 @@ describe("callback", () => { }); test("provides signal for abort-aware APIs", async () => { - await using run = createRunner(); + await using run = createRun(); let signalAbortedDuringTask = true; const task = callback(({ ok, signal }) => { signalAbortedDuringTask = signal.aborted; ok(); - return undefined; }); await run(task); expect(signalAbortedDuringTask).toBe(false); }); - test("provides RunnerDeps for testable time", async () => { + test("provides RunDeps for testable time", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); const task = callback(({ ok, deps: { time } }) => { const id = time.setTimeout(ok, "100ms"); @@ -2836,13 +3112,12 @@ describe("callback", () => { }); test("abort resolves immediately without waiting", async () => { - await using run = createRunner(); + await using run = createRun(); const start = Date.now(); const task = callback(() => { // Never resolves - return undefined; }); const fiber = run(task); @@ -2859,7 +3134,7 @@ describe("callback", () => { describe("sleep", () => { test("completes after duration", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); const fiber = run(sleep("100ms")); @@ -2870,7 +3145,7 @@ describe("sleep", () => { }); test("returns AbortError and clears timeout when aborted", async () => { - await using run = createRunner(); + await using run = createRun(); const start = Date.now(); const fiber = run(sleep("1h")); @@ -2881,7 +3156,7 @@ describe("sleep", () => { expect(result).toEqual(err({ type: "AbortError", reason: "cancelled" })); const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); expect(state.outcome).toEqual( err({ type: "AbortError", reason: "cancelled" }), ); @@ -2891,7 +3166,7 @@ describe("sleep", () => { describe("race", () => { test("returns first task to succeed and aborts others", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -2913,7 +3188,7 @@ describe("race", () => { }); test("returns first task to fail and aborts others", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -2939,7 +3214,7 @@ describe("race", () => { }); test("aborts others when one throws", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -2960,7 +3235,7 @@ describe("race", () => { }); test("infers union of Ok and Err types from heterogeneous tasks", async () => { - await using run = createRunner(); + await using run = createRun(); interface ErrorA extends Typed<"ErrorA"> {} interface ErrorB extends Typed<"ErrorB"> {} @@ -2978,7 +3253,7 @@ describe("race", () => { }); test("works with Iterable via isNonEmptyArray", async () => { - await using run = createRunner(); + await using run = createRun(); // Simulate tasks from an Iterable (e.g., Set, Map.values(), generator) const taskSet = new Set>([ @@ -3001,7 +3276,7 @@ describe("race", () => { // Not using `await using` because disposal waits for all fibers to complete, // including the unabortable loser (10s). We want to verify race() returns // promptly without blocking on unabortable tasks. - const run = createRunner(); + const run = createRun(); let loserCompleted = false; @@ -3023,7 +3298,7 @@ describe("race", () => { }); test("propagates external abort to all raced tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const task1ObservedAbort = Promise.withResolvers(); const task2ObservedAbort = Promise.withResolvers(); @@ -3062,7 +3337,7 @@ describe("race", () => { }); test("uses custom abortReason for losing tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -3086,7 +3361,7 @@ describe("race", () => { describe("timeout", () => { test("completes when task finishes before timeout", async () => { - await using run = createRunner(); + await using run = createRun(); const fast = () => ok(); @@ -3100,7 +3375,7 @@ describe("timeout", () => { test("returns TimeoutError when task exceeds duration", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); const slow = sleep("100ms"); @@ -3114,7 +3389,7 @@ describe("timeout", () => { test("aborts task when timeout fires", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); const abortReasonCapture = Promise.withResolvers(); @@ -3139,7 +3414,7 @@ describe("timeout", () => { test("uses custom abortReason when provided", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); const customReason = { type: "CustomTimeout" }; const abortReasonCapture = Promise.withResolvers(); @@ -3164,7 +3439,7 @@ describe("timeout", () => { test("returns TimeoutError immediately when unabortable task exceeds duration", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); let taskCompleted = false; const completionCapture = Promise.withResolvers(); @@ -3196,7 +3471,7 @@ describe("timeout", () => { describe("retry", () => { test("succeeds on first attempt", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task = () => { @@ -3211,7 +3486,7 @@ describe("retry", () => { }); test("succeeds after retries", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task = () => { @@ -3227,7 +3502,7 @@ describe("retry", () => { }); test("returns RetryError when all attempts exhausted", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task = () => { @@ -3249,7 +3524,7 @@ describe("retry", () => { }); test("returns RetryError not raw error (type test)", async () => { - await using run = createRunner(); + await using run = createRun(); const task: Task = () => err({ type: "MyError" }); const retried = retry(task, take(1)(spaced("1ms"))); @@ -3268,7 +3543,7 @@ describe("retry", () => { }); test("calls onRetry before each retry", async () => { - await using run = createRunner(); + await using run = createRun(); const retryLog: Array<{ error: MyError; @@ -3312,7 +3587,7 @@ describe("retry", () => { }); test("respects retryable predicate", async () => { - await using run = createRunner(); + await using run = createRun(); interface RetryableError extends Typed<"RetryableError"> {} interface NonRetryableError extends Typed<"NonRetryableError"> {} @@ -3343,7 +3618,7 @@ describe("retry", () => { }); test("never retries AbortError", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task = () => { @@ -3361,7 +3636,7 @@ describe("retry", () => { }); test("propagates abort to running task", async () => { - await using run = createRunner(); + await using run = createRun(); const taskStarted = Promise.withResolvers(); @@ -3384,7 +3659,7 @@ describe("retry", () => { }); test("uses exponential backoff schedule", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task = () => { @@ -3400,7 +3675,7 @@ describe("retry", () => { }); test("schedule can filter by error type", async () => { - await using run = createRunner(); + await using run = createRun(); interface RetryableError extends Typed<"RetryableError"> {} interface FatalError extends Typed<"FatalError"> {} @@ -3435,7 +3710,7 @@ describe("retry", () => { }); test("abort during retry delay returns AbortError", async () => { - await using run = createRunner(); + await using run = createRun(); let attempts = 0; const task: Task = () => { @@ -3462,7 +3737,7 @@ describe("retry", () => { describe("repeat", () => { test("runs task n+1 times with take(n)", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const task = () => { @@ -3478,7 +3753,7 @@ describe("repeat", () => { }); test("returns last successful value when schedule exhausted", async () => { - await using run = createRunner(); + await using run = createRun(); const values = ["first", "second", "third", "fourth"]; let index = 0; @@ -3491,7 +3766,7 @@ describe("repeat", () => { }); test("stops and returns error when task fails", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const task = () => { @@ -3507,7 +3782,7 @@ describe("repeat", () => { }); test("respects repeatable predicate", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const task = () => { @@ -3526,7 +3801,7 @@ describe("repeat", () => { }); test("calls onRepeat before each repeat", async () => { - await using run = createRunner(); + await using run = createRun(); const repeatLog: Array<{ value: number; @@ -3569,7 +3844,7 @@ describe("repeat", () => { }); test("can be aborted", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const task: Task = async () => { @@ -3592,7 +3867,7 @@ describe("repeat", () => { }); test("uses forever schedule when unlimited", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const task: Task = async () => { @@ -3623,7 +3898,7 @@ describe("repeat", () => { test("does not sleep when delay is zero", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); let count = 0; const task = () => { @@ -3639,7 +3914,7 @@ describe("repeat", () => { test("aborts while waiting between repeats", async () => { const time = testCreateTime(); - await using run = testCreateRunner({ time }); + await using run = testCreateRun({ time }); let count = 0; const task = () => { @@ -3658,7 +3933,7 @@ describe("repeat", () => { }); test("stops on Done from NextTask", async () => { - await using run = createRunner(); + await using run = createRun(); let count = 0; const next: NextTask = () => { @@ -3674,7 +3949,7 @@ describe("repeat", () => { }); test("processes queue until empty (NextTask pattern)", async () => { - await using run = createRunner(); + await using run = createRun(); const queue = [1, 2, 3]; const processed: Array = []; @@ -3729,8 +4004,8 @@ describe("DI", () => { }; }; - // Custom deps must extend RunnerDeps - type AppDeps = RunnerDeps & HttpDep & DbDep; + // Custom deps must extend RunDeps + type AppDeps = RunDeps & HttpDep & DbDep; // Tasks declare deps in type parameter D, access via run.deps const fetchUser = @@ -3747,7 +4022,7 @@ describe("DI", () => { return run(db.save(data)); }; - // Composition - deps flow through Runner automatically + // Composition - deps flow through Run automatically const syncUser = (id: string): Task => async (run) => { @@ -3760,14 +4035,14 @@ describe("DI", () => { const deps = testCreateDeps(); const http = createTestHttp({ "/users/1": "Alice" }); - await using run = createRunner({ ...deps, http }); + await using run = createRun({ ...deps, http }); const result = await run(fetchUser("1")); expect(result).toEqual(ok("Alice")); }); - test("createRunner with custom deps infers type from argument", async () => { + test("createRun with custom deps infers type from argument", async () => { interface Config { readonly apiUrl: string; } @@ -3780,10 +4055,10 @@ describe("DI", () => { const config: Config = { apiUrl: "https://api.example.com" }; const customDeps = { ...deps, config }; - await using run = createRunner(customDeps); + await using run = createRun(customDeps); // Type is inferred from argument - expectTypeOf(run).toEqualTypeOf>(); + expectTypeOf(run).toEqualTypeOf>(); const task: Task = (run) => ok(run.deps.config.apiUrl); @@ -3793,17 +4068,17 @@ describe("DI", () => { expect(result).toEqual(ok("https://api.example.com")); }); - test("createRunner without args returns Runner", async () => { - await using run = createRunner(); + test("createRun without args returns Run", async () => { + await using run = createRun(); - expectTypeOf(run).toEqualTypeOf>(); + expectTypeOf(run).toEqualTypeOf>(); }); - test("runner rejects task with missing deps", async () => { + test("run rejects task with missing deps", async () => { const task: Task = () => ok(); - await using run = createRunner(); + await using run = createRun(); - // @ts-expect-error Property 'http' is missing in type 'RunnerDeps'... + // @ts-expect-error Property 'http' is missing in type 'RunDeps'... run(task); }); @@ -3811,14 +4086,14 @@ describe("DI", () => { const deps = testCreateDeps(); const http = createTestHttp({ "/users/1": "Alice" }); - await using run = createRunner({ ...deps, http }); + await using run = createRun({ ...deps, http }); const fiber = run(fetchUser("1")); expectTypeOf(fiber).toEqualTypeOf< - Fiber + Fiber >(); - expectTypeOf(fiber.run).toEqualTypeOf>(); + expectTypeOf(fiber.run).toEqualTypeOf>(); const result = await fiber.run(fetchUser("1")); expect(result).toEqual(ok("Alice")); @@ -3829,7 +4104,7 @@ describe("DI", () => { const http = createTestHttp({ "/users/1": "Alice" }); const db = createTestDb(); - await using run = createRunner({ ...deps, http, db }); + await using run = createRun({ ...deps, http, db }); const result = await run(syncUser("1")); @@ -3885,9 +4160,9 @@ describe("DI", () => { const syncAllWithLogging: Task = withLogging("syncAll", syncUsers(["1", "2"])); - type AllDeps = RunnerDeps & HttpDep & DbDep & LoggerDep; + type AllDeps = RunDeps & HttpDep & DbDep & LoggerDep; - await using run = createRunner({ ...deps, http, db, logger }); + await using run = createRun({ ...deps, http, db, logger }); const result = await run(syncAllWithLogging); @@ -3900,7 +4175,7 @@ describe("DI", () => { const deps = testCreateDeps(); const http = createTestHttp({ "/users/1": "Alice" }); - await using run = createRunner({ ...deps, http }); + await using run = createRun({ ...deps, http }); // timeout should preserve D from wrapped task const fetchWithTimeout = timeout(fetchUser("1"), "5s"); @@ -3915,7 +4190,7 @@ describe("DI", () => { "/users/1": "Alice", "/users/2": "Bob", }); - await using run = createRunner({ ...deps, http }); + await using run = createRun({ ...deps, http }); // race should preserve D from all tasks const result = await run(race([fetchUser("1"), fetchUser("2")])); @@ -3947,12 +4222,11 @@ describe("DI", () => { () => { attempts++; if (attempts < 3) return err({ type: "NetworkError" }); - // biome-ignore lint/style/noNonNullAssertion: Test utility - return ok(url.split("/").pop()!); + return ok(must(url.split("/").pop())); }, }; - await using run = createRunner({ + await using run = createRun({ ...deps, http, time: createTime(), @@ -3975,9 +4249,9 @@ describe("DI", () => { }); describe("concurrency", () => { - describe("parallel", () => { + describe("concurrently", () => { test("defaults to max concurrency when passed only a task", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -3992,10 +4266,9 @@ describe("concurrency", () => { }; const fiber = run( - parallel(all([createTask(1), createTask(2), createTask(3)])), + concurrently(all([createTask(1), createTask(2), createTask(3)])), ); - await Promise.resolve(); expect(events).toEqual(["start 1", "start 2", "start 3"]); canFinish.resolve(); @@ -4005,7 +4278,7 @@ describe("concurrency", () => { }); test("inherits concurrency", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -4020,7 +4293,7 @@ describe("concurrency", () => { }; const fiber = run( - parallel(2, (run) => + concurrently(2, (run) => run( all([createTask(1), createTask(2), createTask(3), createTask(4)]), ), @@ -4037,8 +4310,8 @@ describe("concurrency", () => { expect(result).toEqual(ok([1, 2, 3, 4])); }); - test("nested parallel overrides parent", async () => { - await using run = createRunner(); + test("nested concurrently overrides parent concurrency", async () => { + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -4053,9 +4326,9 @@ describe("concurrency", () => { }; const fiber = run( - parallel(5, (run) => + concurrently(5, (run) => run( - parallel(1, (run) => + concurrently(1, (run) => run(all([createTask(1), createTask(2), createTask(3)])), ), ), @@ -4074,7 +4347,7 @@ describe("concurrency", () => { }); test("default concurrency is sequential", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -4104,7 +4377,7 @@ describe("concurrency", () => { }); test("abort propagates to all tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -4120,7 +4393,7 @@ describe("concurrency", () => { }; const fiber = run( - parallel(all([createTask(1), createTask(2), createTask(3)])), + concurrently(all([createTask(1), createTask(2), createTask(3)])), ); await Promise.resolve(); @@ -4143,7 +4416,7 @@ describe("concurrency", () => { // Not using `await using` because disposal waits for all fibers to complete, // including the unabortable task (10s). We want to verify all() returns // promptly on error without blocking on unabortable tasks. - const run = createRunner(); + const run = createRun(); let unabortableCompleted = false; @@ -4157,7 +4430,9 @@ describe("concurrency", () => { }); const start = Date.now(); - const result = await run(parallel(all([unabortableTask, failingTask]))); + const result = await run( + concurrently(all([unabortableTask, failingTask])), + ); const elapsed = Date.now() - start; // all returns promptly with error, doesn't wait for unabortable task @@ -4169,7 +4444,7 @@ describe("concurrency", () => { describe("Deferred", () => { test("resolves with ok", async () => { - await using run = createRunner(); + await using run = createRun(); const { task, resolve } = createDeferred(); @@ -4181,7 +4456,7 @@ describe("concurrency", () => { }); test("resolves with error", async () => { - await using run = createRunner(); + await using run = createRun(); const { task, resolve } = createDeferred(); @@ -4193,7 +4468,7 @@ describe("concurrency", () => { }); test("resolves with AbortError when fiber aborted", async () => { - await using run = createRunner(); + await using run = createRun(); const { task } = createDeferred(); @@ -4212,7 +4487,7 @@ describe("concurrency", () => { }); test("resolve still works after fiber abort", async () => { - await using run = createRunner(); + await using run = createRun(); const { task, resolve } = createDeferred(); @@ -4227,7 +4502,7 @@ describe("concurrency", () => { }); test("aborting one does not affect other", async () => { - await using run = createRunner(); + await using run = createRun(); const { task, resolve } = createDeferred(); @@ -4255,7 +4530,7 @@ describe("concurrency", () => { }); test("dispose aborts waiting fibers", async () => { - await using run = createRunner(); + await using run = createRun(); const deferred = createDeferred(); @@ -4272,7 +4547,7 @@ describe("concurrency", () => { }); test("task returns immediately when already resolved", async () => { - await using run = createRunner(); + await using run = createRun(); const { task, resolve } = createDeferred(); @@ -4284,94 +4559,24 @@ describe("concurrency", () => { }); }); - describe("Deferreds", () => { - test("register and resolve by id", async () => { + describe("Gate", () => { + test("wait blocks until gate is opened", async () => { await using run = createRun(); - const deferreds = createDeferreds(run.deps); - const { id, task } = deferreds.register(); - - const fiber = run(task); - expect(deferreds.resolve(id, ok("value"))).toBe(true); + const gate = createGate(); + const events: Array = []; - const result = await fiber; - expect(result).toEqual(ok("value")); - }); + const fiber = run(async (run) => { + events.push("waiting"); + const result = await run(gate.wait); + if (!result.ok) return result; + events.push("passed"); + return ok(); + }); - test("resolve returns false when id is already resolved", () => { - const deferreds = createDeferreds(testCreateDeps()); - const { id } = deferreds.register(); - - expect(deferreds.resolve(id, ok("value"))).toBe(true); - expect(deferreds.resolve(id, ok("again"))).toBe(false); - }); - - test("register returns unique ids", () => { - const deferreds = createDeferreds(testCreateDeps()); - - const first = deferreds.register(); - const second = deferreds.register(); - - expect(first.id).not.toBe(second.id); - }); - - test("resolveAll completes all pending tasks", async () => { - await using run = createRun(); - - const deferreds = createDeferreds(run.deps); - const first = deferreds.register(); - const second = deferreds.register(); - - const fiber1 = run(first.task); - const fiber2 = run(second.task); - - deferreds.resolveAll(ok("all")); - - const result1 = await fiber1; - const result2 = await fiber2; - - expect(result1).toEqual(ok("all")); - expect(result2).toEqual(ok("all")); - }); - - test("dispose aborts pending tasks with DeferredDisposedError", async () => { - await using run = createRun(); - - const deferreds = createDeferreds(run.deps); - const first = deferreds.register(); - const second = deferreds.register(); - - const fiber1 = run(first.task); - const fiber2 = run(second.task); - - deferreds[Symbol.dispose](); - - const result1 = await fiber1; - const result2 = await fiber2; - - expect(result1).toEqual(err(deferredDisposedError)); - expect(result2).toEqual(err(deferredDisposedError)); - }); - }); - - describe("Gate", () => { - test("wait blocks until gate is opened", async () => { - await using run = createRunner(); - - const gate = createGate(); - const events: Array = []; - - const fiber = run(async (run) => { - events.push("waiting"); - const result = await run(gate.wait); - if (!result.ok) return result; - events.push("passed"); - return ok(); - }); - - await Promise.resolve(); - expect(events).toEqual(["waiting"]); - expect(gate.isOpen()).toBe(false); + await Promise.resolve(); + expect(events).toEqual(["waiting"]); + expect(gate.isOpen()).toBe(false); gate.open(); await fiber; @@ -4381,7 +4586,7 @@ describe("concurrency", () => { }); test("wait returns immediately when gate is already open", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); gate.open(); @@ -4392,7 +4597,7 @@ describe("concurrency", () => { }); test("multiple tasks proceed when gate opens", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); const events: Array = []; @@ -4428,7 +4633,7 @@ describe("concurrency", () => { }); test("close makes future tasks wait", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); const events: Array = []; @@ -4458,7 +4663,7 @@ describe("concurrency", () => { }); test("abort while waiting returns AbortError", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); @@ -4482,7 +4687,7 @@ describe("concurrency", () => { }); test("dispose aborts waiting tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); @@ -4545,7 +4750,7 @@ describe("concurrency", () => { }); test("wait returns DeferredDisposedError after dispose", async () => { - await using run = createRunner(); + await using run = createRun(); const gate = createGate(); gate[Symbol.dispose](); @@ -4557,8 +4762,20 @@ describe("concurrency", () => { }); describe("Semaphore", () => { + test("snapshot exposes initial state", () => { + const semaphore = createSemaphore(2); + + expect(semaphore.snapshot()).toEqual({ + permits: 2, + taken: 0, + waiting: 0, + available: 2, + disposed: false, + }); + }); + test("runs a task", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); @@ -4567,8 +4784,156 @@ describe("concurrency", () => { expect(result).toEqual(ok("ran")); }); + test("withPermits acquires multiple permits", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(3); + const started = Promise.withResolvers(); + const canFinish = Promise.withResolvers(); + + const fiber = run( + semaphore.withPermits(2)(async () => { + started.resolve(); + await canFinish.promise; + return ok(); + }), + ); + + await started.promise; + expect(semaphore.snapshot()).toEqual({ + permits: 3, + taken: 2, + waiting: 0, + available: 1, + disposed: false, + }); + + canFinish.resolve(); + await fiber; + + expect(semaphore.snapshot()).toEqual({ + permits: 3, + taken: 0, + waiting: 0, + available: 3, + disposed: false, + }); + }); + + test("withPermits rejects requests larger than capacity", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + await expect( + run(semaphore.withPermits(PositiveInt.orThrow(2))(() => ok())), + ).rejects.toThrow("Requested permits must not exceed semaphore capacity"); + }); + + test("strict FIFO blocks smaller waiter behind larger head waiter", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(3); + const holderCanFinish = Promise.withResolvers(); + const holderStarted = Promise.withResolvers(); + const events: Array = []; + + const holder = run( + semaphore.withPermits(PositiveInt.orThrow(2))(async () => { + holderStarted.resolve(); + await holderCanFinish.promise; + events.push("holder done"); + return ok(); + }), + ); + + await holderStarted.promise; + + const largeWaiter = run( + semaphore.withPermits(PositiveInt.orThrow(2))(() => { + events.push("large waiter ran"); + return ok(); + }), + ); + + const smallWaiter = run( + semaphore.withPermits(PositiveInt.orThrow(1))(() => { + events.push("small waiter ran"); + return ok(); + }), + ); + + await Promise.resolve(); + + // No waiter can run yet: only 1 permit free, head waiter needs 2. + expect(events).toEqual([]); + expect(semaphore.snapshot()).toEqual({ + permits: 3, + taken: 2, + waiting: 2, + available: 1, + disposed: false, + }); + + holderCanFinish.resolve(); + + await Promise.all([holder, largeWaiter, smallWaiter]); + expect(events).toEqual([ + "holder done", + "large waiter ran", + "small waiter ran", + ]); + }); + + test("snapshot reflects taken and waiting counts", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + const canFinish = Promise.withResolvers(); + const started = Promise.withResolvers(); + + const fiber1 = run( + semaphore.withPermit(async () => { + started.resolve(); + await canFinish.promise; + return ok(); + }), + ); + + await started.promise; + expect(semaphore.snapshot()).toEqual({ + permits: 1, + taken: 1, + waiting: 0, + available: 0, + disposed: false, + }); + + const fiber2 = run(semaphore.withPermit(() => ok())); + await Promise.resolve(); + + expect(semaphore.snapshot()).toEqual({ + permits: 1, + taken: 1, + waiting: 1, + available: 0, + disposed: false, + }); + + canFinish.resolve(); + await Promise.all([fiber1, fiber2]); + + expect(semaphore.snapshot()).toEqual({ + permits: 1, + taken: 0, + waiting: 0, + available: 1, + disposed: false, + }); + }); + test("limits concurrent tasks to permit count", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(2); const events: Array = []; @@ -4619,7 +4984,7 @@ describe("concurrency", () => { }); test("queues tasks when permits exhausted", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); const events: Array = []; @@ -4659,7 +5024,7 @@ describe("concurrency", () => { }); test("returns task result", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); @@ -4673,7 +5038,7 @@ describe("concurrency", () => { }); test("releases permit when task succeeds", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); @@ -4693,7 +5058,7 @@ describe("concurrency", () => { }); test("releases permit when task fails", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); @@ -4712,8 +5077,71 @@ describe("concurrency", () => { expect(secondRan).toBe(true); }); + test("releases permit when task throws", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + await expect( + run( + semaphore.withPermit(() => { + throw new Error("boom"); + }), + ), + ).rejects.toThrow("boom"); + + const afterThrow = await run(semaphore.withPermit(() => ok("after"))); + expect(afterThrow).toEqual(ok("after")); + }); + + test("releases permit when task rejects", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + await expect( + run(semaphore.withPermit(() => Promise.reject(new Error("rejected")))), + ).rejects.toThrow("rejected"); + + const afterReject = await run(semaphore.withPermit(() => ok("after"))); + expect(afterReject).toEqual(ok("after")); + }); + + test("releases permit when task start throws before fiber exists", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + let restoreFromInner: + | ((task: Task) => Task) + | undefined; + + const setup = unabortableMask( + (_restore1) => async (run) => + await run( + unabortableMask((restore2) => () => { + restoreFromInner = restore2; + return ok(); + }), + ), + ); + + expect(await run(setup)).toEqual(ok()); + expect(restoreFromInner).toBeDefined(); + + await expect( + run(semaphore.withPermit(must(restoreFromInner)(() => ok()))), + ).rejects.toThrow("restore used outside its unabortableMask"); + + expect(await run(semaphore.withPermit(() => ok("after-throw")))).toEqual( + ok("after-throw"), + ); + + semaphore[Symbol.dispose](); + }); + test("abort while waiting removes from queue", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); const events: Array = []; @@ -4763,7 +5191,7 @@ describe("concurrency", () => { }); test("abort while running aborts task", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); let abortReceived = false; @@ -4787,8 +5215,32 @@ describe("concurrency", () => { expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); }); + test("releases permit when running task is aborted", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + const fiber = run( + semaphore.withPermit( + callback(({ ok, signal }) => { + signal.addEventListener("abort", () => ok(undefined), { + once: true, + }); + }), + ), + ); + + await Promise.resolve(); + fiber.abort("stop"); + + expect(await fiber).toEqual(err({ type: "AbortError", reason: "stop" })); + + const afterAbort = await run(semaphore.withPermit(() => ok("after"))); + expect(afterAbort).toEqual(ok("after")); + }); + test("dispose aborts running tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const semaphore = createSemaphore(1); const events: Array = []; @@ -4822,205 +5274,1021 @@ describe("concurrency", () => { ); }); - test("dispose aborts waiting tasks", async () => { - await using run = createRunner(); + test("dispose aborts waiting tasks", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + + const task1Started = Promise.withResolvers(); + + // Hold the permit with a task that listens for abort + const fiber1 = run( + semaphore.withPermit(({ signal }) => { + task1Started.resolve(); + return new Promise>((resolve) => { + signal.addEventListener("abort", () => { + resolve(err({ type: "AbortError", reason: signal.reason })); + }); + }); + }), + ); + + await task1Started.promise; + + // Waiting task + const fiber2 = run(semaphore.withPermit(() => ok("should not run"))); + + semaphore[Symbol.dispose](); + + const [result1, result2] = await Promise.all([fiber1, fiber2]); + + // Running task was aborted + expect(result1).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + + // Waiting task was aborted + expect(result2).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + }); + + test("dispose still aborts other tasks when one abort path throws", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(2); + let secondTaskAborted = false; + + const started1 = Promise.withResolvers(); + const started2 = Promise.withResolvers(); + + const fiber1 = run( + semaphore.withPermit(({ signal }) => { + started1.resolve(); + return new Promise>((resolve) => { + signal.addEventListener("abort", () => { + resolve(err({ type: "AbortError", reason: signal.reason })); + }); + }); + }), + ); + + const fiber2 = run( + semaphore.withPermit(({ signal }) => { + started2.resolve(); + return new Promise>((resolve) => { + signal.addEventListener("abort", () => { + secondTaskAborted = true; + resolve(err({ type: "AbortError", reason: signal.reason })); + }); + }); + }), + ); + + await Promise.all([started1.promise, started2.promise]); + + const originalAbort = fiber1.abort.bind(fiber1); + (fiber1 as { abort: (reason?: unknown) => void }).abort = (reason) => { + originalAbort(reason); + throw new Error("abort failed"); + }; + + expect(() => { + semaphore[Symbol.dispose](); + }).not.toThrow(); + + const [result1, result2] = await Promise.all([fiber1, fiber2]); + expect(secondTaskAborted).toBe(true); + expect(result1).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + expect(result2).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + }); + + test("acquire after dispose returns SemaphoreDisposedError", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + semaphore[Symbol.dispose](); + + const result = await run( + semaphore.withPermit(() => ok("should not run")), + ); + + expect(result).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + }); + + test("dispose is idempotent", () => { + const semaphore = createSemaphore(1); + + semaphore[Symbol.dispose](); + semaphore[Symbol.dispose](); + semaphore[Symbol.dispose](); + + // Should not throw + }); + + test("preserves FIFO order for queued tasks", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(1); + const events: Array = []; + + const task1Started = Promise.withResolvers(); + const task1CanFinish = Promise.withResolvers(); + + // Hold the permit + const fiber1 = run( + semaphore.withPermit(async () => { + task1Started.resolve(); + await task1CanFinish.promise; + events.push("task 1"); + return ok(); + }), + ); + + await task1Started.promise; + + // Queue tasks in order + const fiber2 = run( + semaphore.withPermit(() => { + events.push("task 2"); + return ok(); + }), + ); + const fiber3 = run( + semaphore.withPermit(() => { + events.push("task 3"); + return ok(); + }), + ); + const fiber4 = run( + semaphore.withPermit(() => { + events.push("task 4"); + return ok(); + }), + ); + + task1CanFinish.resolve(); + await Promise.all([fiber1, fiber2, fiber3, fiber4]); + + expect(events).toEqual(["task 1", "task 2", "task 3", "task 4"]); + }); + + test("multiple permits allow concurrent execution", async () => { + await using run = createRun(); + + const semaphore = createSemaphore(3); + let concurrent = 0; + let maxConcurrent = 0; + + const taskFinished = Promise.withResolvers(); + let finishedCount = 0; + + const createTask = (): Task => async () => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await Promise.resolve(); + concurrent -= 1; + finishedCount += 1; + if (finishedCount === 5) taskFinished.resolve(); + return ok(); + }; + + // Run 5 tasks with 3 permits + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + + await taskFinished.promise; + + expect(maxConcurrent).toBe(3); + }); + }); + + describe("SemaphoreByKey", () => { + test("runs tasks independently for different keys", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a" | "b">(1); + const started: Array = []; + const canFinishA = Promise.withResolvers(); + const canFinishB = Promise.withResolvers(); + + const fiberA = run( + semaphoreByKey.withPermit("a", async () => { + started.push("a"); + await canFinishA.promise; + return ok(); + }), + ); + + const fiberB = run( + semaphoreByKey.withPermit("b", async () => { + started.push("b"); + await canFinishB.promise; + return ok(); + }), + ); + + await Promise.resolve(); + expect(started.sort()).toEqual(["a", "b"]); + + canFinishA.resolve(); + canFinishB.resolve(); + await Promise.all([fiberA, fiberB]); + }); + + test("serializes tasks for the same key", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + const events: Array = []; + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const fiber1 = run( + semaphoreByKey.withPermit("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("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("snapshot is removed when key becomes idle", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(2); + + expect(semaphoreByKey.snapshot("a")).toBeNull(); + + const result = await run(semaphoreByKey.withPermit("a", () => ok())); + expect(result).toEqual(ok()); + + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("snapshot returns state while key is active", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + const release = Promise.withResolvers(); + const started = Promise.withResolvers(); + + const fiber = run( + semaphoreByKey.withPermit("a", async () => { + started.resolve(); + await release.promise; + return ok(); + }), + ); + + await started.promise; + + expect(semaphoreByKey.snapshot("a")).toEqual({ + permits: 1, + taken: 1, + waiting: 0, + available: 0, + disposed: false, + }); + + release.resolve(); + await fiber; + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("keeps key alive when next waiter acquires permit", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + const firstCanFinish = Promise.withResolvers(); + const secondCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + + const first = run( + semaphoreByKey.withPermit("a", async () => { + firstStarted.resolve(); + await firstCanFinish.promise; + return ok(); + }), + ); + + await firstStarted.promise; + + const second = run( + semaphoreByKey.withPermit("a", async () => { + secondStarted.resolve(); + await secondCanFinish.promise; + return ok(); + }), + ); + + await Promise.resolve(); + firstCanFinish.resolve(); + + await secondStarted.promise; + await first; + + expect(semaphoreByKey.snapshot("a")).toEqual({ + permits: 1, + taken: 1, + waiting: 0, + available: 0, + disposed: false, + }); + + secondCanFinish.resolve(); + await second; + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("removes key when task throws", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + + await expect( + run( + semaphoreByKey.withPermit("a", () => { + throw new Error("boom"); + }), + ), + ).rejects.toThrow("boom"); + + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("removes key when task rejects", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + + await expect( + run( + semaphoreByKey.withPermit("a", () => + Promise.reject(new Error("rejected")), + ), + ), + ).rejects.toThrow("rejected"); + + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("removes key when running task is aborted", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + + const fiber = run( + semaphoreByKey.withPermit( + "a", + callback(({ ok, signal }) => { + signal.addEventListener("abort", () => ok(undefined), { + once: true, + }); + }), + ), + ); + + await Promise.resolve(); + expect(semaphoreByKey.snapshot("a")).not.toBeNull(); + + fiber.abort("stop"); + const result = await fiber; + + expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("removes key after waiting task is aborted", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const first = run( + semaphoreByKey.withPermit("a", async () => { + firstStarted.resolve(); + await firstCanFinish.promise; + return ok(); + }), + ); + + await firstStarted.promise; + + const waiting = run(semaphoreByKey.withPermit("a", () => ok())); + await Promise.resolve(); + + expect(semaphoreByKey.snapshot("a")).toEqual({ + permits: 1, + taken: 1, + waiting: 1, + available: 0, + disposed: false, + }); + + waiting.abort("cancel waiting"); + const waitingResult = await waiting; + expect(waitingResult).toEqual( + err({ type: "AbortError", reason: "cancel waiting" }), + ); + + expect(semaphoreByKey.snapshot("a")).toEqual({ + permits: 1, + taken: 1, + waiting: 0, + available: 0, + disposed: false, + }); + + firstCanFinish.resolve(); + await first; + + expect(semaphoreByKey.snapshot("a")).toBeNull(); + }); + + test("dispose aborts keyed semaphores and future acquire fails", async () => { + await using run = createRun(); + + const semaphoreByKey = createSemaphoreByKey<"a">(1); + + const runningFiber = run( + semaphoreByKey.withPermit( + "a", + callback(({ ok, signal }) => { + signal.addEventListener("abort", () => ok(undefined), { + once: true, + }); + }), + ), + ); + + const waitingFiber = run(semaphoreByKey.withPermit("a", () => ok())); + + await Promise.resolve(); + + semaphoreByKey[Symbol.dispose](); + semaphoreByKey[Symbol.dispose](); + + await expect(Promise.all([runningFiber, waitingFiber])).resolves.toEqual([ + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ]); + + const afterDispose = await run( + semaphoreByKey.withPermit("a", () => ok()), + ); + + expect(afterDispose).toEqual( + err({ + type: "AbortError", + reason: { type: "SemaphoreDisposedError" }, + }), + ); + }); + }); + + describe("Mutex", () => { + test("runs tasks sequentially", async () => { + await using run = createRun(); + + const mutex = createMutex(); + const events: Array = []; + + const firstStarted = Promise.withResolvers(); + const firstFinish = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + + const firstTask: Task = async () => { + events.push("start 1"); + firstStarted.resolve(); + await firstFinish.promise; + events.push("end 1"); + return ok(); + }; + + const secondTask: Task = () => { + events.push("start 2"); + secondStarted.resolve(); + events.push("end 2"); + return ok(); + }; + + const firstFiber = run(mutex.withLock(firstTask)); + await firstStarted.promise; + + const secondFiber = run(mutex.withLock(secondTask)); + + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + firstFinish.resolve(); + await firstFiber; + await secondStarted.promise; + await secondFiber; + + expect(events).toEqual(["start 1", "end 1", "start 2", "end 2"]); + + mutex[Symbol.dispose](); + }); + + test("snapshot reflects lock, waiters, and disposal", async () => { + await using run = createRun(); + + const mutex = createMutex(); + const canFinish = Promise.withResolvers(); + const started = Promise.withResolvers(); + + const fiber1 = run( + mutex.withLock(async () => { + started.resolve(); + await canFinish.promise; + return ok(); + }), + ); + + await started.promise; + expect(mutex.snapshot()).toEqual({ + permits: 1, + taken: 1, + waiting: 0, + available: 0, + disposed: false, + }); + + const fiber2 = run(mutex.withLock(() => ok())); + await Promise.resolve(); + + expect(mutex.snapshot()).toEqual({ + permits: 1, + taken: 1, + waiting: 1, + available: 0, + disposed: false, + }); + + canFinish.resolve(); + await Promise.all([fiber1, fiber2]); + + expect(mutex.snapshot()).toEqual({ + permits: 1, + taken: 0, + waiting: 0, + available: 1, + disposed: false, + }); + + mutex[Symbol.dispose](); + + expect(mutex.snapshot()).toEqual({ + permits: 1, + taken: 0, + waiting: 0, + available: 1, + disposed: true, + }); + }); + }); + + describe("MutexByKey", () => { + test("runs tasks independently for different keys", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<"a" | "b">(); + const started: Array = []; + const canFinishA = Promise.withResolvers(); + const canFinishB = Promise.withResolvers(); + + const fiberA = run( + mutexByKey.withLock("a", async () => { + started.push("a"); + await canFinishA.promise; + return ok(); + }), + ); + + const fiberB = run( + mutexByKey.withLock("b", async () => { + started.push("b"); + await canFinishB.promise; + return ok(); + }), + ); + + await Promise.resolve(); + expect(started.sort()).toEqual(["a", "b"]); + + canFinishA.resolve(); + canFinishB.resolve(); + await Promise.all([fiberA, fiberB]); + }); + + test("serializes tasks for the same key", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<"a">(); + const events: Array = []; + const firstCanFinish = Promise.withResolvers(); + const firstStarted = Promise.withResolvers(); + + const fiber1 = run( + mutexByKey.withLock("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("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("releases lock when task throws", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<"a">(); + + await expect( + run( + mutexByKey.withLock("a", () => { + throw new Error("boom"); + }), + ), + ).rejects.toThrow("boom"); + + const afterThrow = await run(mutexByKey.withLock("a", () => ok("after"))); + expect(afterThrow).toEqual(ok("after")); + }); + + test("releases lock when task rejects", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<"a">(); + + await expect( + run( + mutexByKey.withLock("a", () => Promise.reject(new Error("rejected"))), + ), + ).rejects.toThrow("rejected"); + + const afterReject = await run( + mutexByKey.withLock("a", () => ok("after")), + ); + expect(afterReject).toEqual(ok("after")); + }); + + test("releases lock when running task is aborted", async () => { + await using run = createRun(); + + const mutexByKey = createMutexByKey<"a">(); + + const fiber = run( + mutexByKey.withLock( + "a", + callback(({ ok, signal }) => { + signal.addEventListener("abort", () => ok(undefined), { + once: true, + }); + }), + ), + ); + + await Promise.resolve(); + fiber.abort("stop"); + + expect(await fiber).toEqual(err({ type: "AbortError", reason: "stop" })); + + const afterAbort = await run(mutexByKey.withLock("a", () => ok("after"))); + expect(afterAbort).toEqual(ok("after")); + }); - const semaphore = createSemaphore(1); + test("dispose aborts keyed locks and future acquire fails", async () => { + await using run = createRun(); - const task1Started = Promise.withResolvers(); + const mutexByKey = createMutexByKey<"a">(); - // Hold the permit with a task that listens for abort - const fiber1 = run( - semaphore.withPermit(({ signal }) => { - task1Started.resolve(); - return new Promise>((resolve) => { - signal.addEventListener("abort", () => { - resolve(err({ type: "AbortError", reason: signal.reason })); + const runningFiber = run( + mutexByKey.withLock( + "a", + callback(({ ok, signal }) => { + signal.addEventListener("abort", () => ok(undefined), { + once: true, }); - }); - }), + }), + ), ); - await task1Started.promise; - - // Waiting task - const fiber2 = run(semaphore.withPermit(() => ok("should not run"))); + const waitingFiber = run(mutexByKey.withLock("a", () => ok())); - semaphore[Symbol.dispose](); + await Promise.resolve(); - const [result1, result2] = await Promise.all([fiber1, fiber2]); + mutexByKey[Symbol.dispose](); + mutexByKey[Symbol.dispose](); - // Running task was aborted - expect(result1).toEqual( + await expect(Promise.all([runningFiber, waitingFiber])).resolves.toEqual([ err({ type: "AbortError", reason: { type: "SemaphoreDisposedError" }, }), - ); - - // Waiting task was aborted - expect(result2).toEqual( err({ type: "AbortError", reason: { type: "SemaphoreDisposedError" }, }), - ); - }); - - test("acquire after dispose returns SemaphoreDisposedError", async () => { - await using run = createRunner(); - - const semaphore = createSemaphore(1); - semaphore[Symbol.dispose](); + ]); - const result = await run( - semaphore.withPermit(() => ok("should not run")), - ); + const afterDispose = await run(mutexByKey.withLock("a", () => ok())); - expect(result).toEqual( + expect(afterDispose).toEqual( err({ type: "AbortError", reason: { type: "SemaphoreDisposedError" }, }), ); }); + }); - test("dispose is idempotent", () => { - const semaphore = createSemaphore(1); + describe("MutexRef", () => { + interface PrefixDep { + readonly prefix: string; + } - semaphore[Symbol.dispose](); - semaphore[Symbol.dispose](); - semaphore[Symbol.dispose](); + describe("get", () => { + test("returns initial state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(42); - // Should not throw + expect(await run(ref.get)).toEqual(ok(42)); + }); }); - test("preserves FIFO order for queued tasks", async () => { - await using run = createRunner(); + describe("set", () => { + test("updates state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(0); - const semaphore = createSemaphore(1); - const events: Array = []; + expect(await run(ref.set(1))).toEqual(ok()); + expect(await run(ref.get)).toEqual(ok(1)); + }); + }); - const task1Started = Promise.withResolvers(); - const task1CanFinish = Promise.withResolvers(); + describe("getAndSet", () => { + test("returns previous state and updates state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - // Hold the permit - const fiber1 = run( - semaphore.withPermit(async () => { - task1Started.resolve(); - await task1CanFinish.promise; - events.push("task 1"); - return ok(); - }), - ); + expect(await run(ref.getAndSet(2))).toEqual(ok(1)); + expect(await run(ref.get)).toEqual(ok(2)); + }); + }); - await task1Started.promise; + describe("setAndGet", () => { + test("returns updated state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - // Queue tasks in order - const fiber2 = run( - semaphore.withPermit(() => { - events.push("task 2"); - return ok(); - }), - ); - const fiber3 = run( - semaphore.withPermit(() => { - events.push("task 3"); - return ok(); - }), - ); - const fiber4 = run( - semaphore.withPermit(() => { - events.push("task 4"); - return ok(); - }), - ); + expect(await run(ref.setAndGet(2))).toEqual(ok(2)); + expect(await run(ref.get)).toEqual(ok(2)); + }); + }); - task1CanFinish.resolve(); - await Promise.all([fiber1, fiber2, fiber3, fiber4]); + describe("update", () => { + test("updates state with a taskful updater", async () => { + await using run = testCreateRun({ prefix: "dep" }); + using ref = createMutexRef("value"); - expect(events).toEqual(["task 1", "task 2", "task 3", "task 4"]); + expect( + await run( + ref.update( + (current) => + ({ deps }) => + ok(`${deps.prefix}-${current}`), + ), + ), + ).toEqual(ok()); + + expect(await run(ref.get)).toEqual(ok("dep-value")); + }); + + test("keeps state unchanged when updater fails", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + expect( + await run(ref.update(() => () => err({ type: "MyError" }))), + ).toEqual(err({ type: "MyError" })); + + expect(await run(ref.get)).toEqual(ok(1)); + }); + + test("serializes access through the mutex", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + let releaseUpdate: () => void = () => { + throw new Error("releaseUpdate must be assigned before use"); + }; + const updateStarted = Promise.withResolvers(); + + const updateFiber = run( + ref.update((current) => async () => { + updateStarted.resolve(); + await new Promise((resolve) => { + releaseUpdate = resolve; + }); + return ok(current + 1); + }), + ); + + await updateStarted.promise; + + let getSettled = false; + const getFiber = run(ref.get).then((result) => { + getSettled = true; + return result; + }); + + await testWaitForMacrotask(); + expect(getSettled).toBe(false); + + releaseUpdate(); + + expect(await updateFiber).toEqual(ok()); + expect(await getFiber).toEqual(ok(2)); + }); }); - test("multiple permits allow concurrent execution", async () => { - await using run = createRunner(); + describe("getAndUpdate", () => { + test("returns previous state and updates state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - const semaphore = createSemaphore(3); - let concurrent = 0; - let maxConcurrent = 0; + expect(await run(ref.getAndUpdate((n) => () => ok(n + 1)))).toEqual( + ok(1), + ); + expect(await run(ref.get)).toEqual(ok(2)); + }); - const taskFinished = Promise.withResolvers(); - let finishedCount = 0; + test("returns error and keeps state unchanged", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - const createTask = (): Task => async () => { - concurrent += 1; - maxConcurrent = Math.max(maxConcurrent, concurrent); - await Promise.resolve(); - concurrent -= 1; - finishedCount += 1; - if (finishedCount === 5) taskFinished.resolve(); - return ok(); - }; + expect( + await run(ref.getAndUpdate(() => () => err({ type: "MyError" }))), + ).toEqual(err({ type: "MyError" })); - // Run 5 tasks with 3 permits - run(semaphore.withPermit(createTask())); - run(semaphore.withPermit(createTask())); - run(semaphore.withPermit(createTask())); - run(semaphore.withPermit(createTask())); - run(semaphore.withPermit(createTask())); + expect(await run(ref.get)).toEqual(ok(1)); + }); + }); - await taskFinished.promise; + describe("updateAndGet", () => { + test("returns updated state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - expect(maxConcurrent).toBe(3); + expect(await run(ref.updateAndGet((n) => () => ok(n + 1)))).toEqual( + ok(2), + ); + expect(await run(ref.get)).toEqual(ok(2)); + }); + + test("returns error and keeps state unchanged", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); + + expect( + await run(ref.updateAndGet(() => () => err({ type: "MyError" }))), + ).toEqual(err({ type: "MyError" })); + + expect(await run(ref.get)).toEqual(ok(1)); + }); }); - }); - describe("Mutex", () => { - test("runs tasks sequentially", async () => { - await using run = createRunner(); + describe("modify", () => { + test("returns a computed result and updates state", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - const mutex = createMutex(); - const events: Array = []; + expect( + await run( + ref.modify( + (current) => () => ok([`current:${current}`, current + 1]), + ), + ), + ).toEqual(ok("current:1")); - const firstStarted = Promise.withResolvers(); - const firstFinish = Promise.withResolvers(); - const secondStarted = Promise.withResolvers(); + expect(await run(ref.get)).toEqual(ok(2)); + }); - const firstTask: Task = async () => { - events.push("start 1"); - firstStarted.resolve(); - await firstFinish.promise; - events.push("end 1"); - return ok(); - }; + test("returns error and keeps state unchanged", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - const secondTask: Task = () => { - events.push("start 2"); - secondStarted.resolve(); - events.push("end 2"); - return ok(); - }; + expect( + await run(ref.modify(() => () => err({ type: "MyError" }))), + ).toEqual(err({ type: "MyError" })); - const firstFiber = run(mutex.withLock(firstTask)); - await firstStarted.promise; + expect(await run(ref.get)).toEqual(ok(1)); + }); - const secondFiber = run(mutex.withLock(secondTask)); + test("infers updater error and deps types", () => { + const ref = createMutexRef(1); + const modifyTask = ref.modify( + (current): Task => + (run) => + ok([run.deps.prefix, current] as const), + ); + const updateTask = ref.update( + (): Task => () => err({ type: "MyError" }), + ); - await Promise.resolve(); - expect(events).toEqual(["start 1"]); + expectTypeOf(modifyTask).toEqualTypeOf< + Task + >(); + expectTypeOf(updateTask).toEqualTypeOf>(); + }); + }); - firstFinish.resolve(); - await firstFiber; - await secondStarted.promise; - await secondFiber; + describe("dispose", () => { + test("aborts operations with semaphoreDisposedError", async () => { + await using run = testCreateRun(); + using ref = createMutexRef(1); - expect(events).toEqual(["start 1", "end 1", "start 2", "end 2"]); + ref[Symbol.dispose](); - mutex[Symbol.dispose](); + expect(await run(ref.get)).toEqual( + err({ type: "AbortError", reason: semaphoreDisposedError }), + ); + }); }); }); - describe("createInMemoryLeaderLock", () => { + describe("InMemoryLeaderLock", () => { test("acquire waits until previous lease is disposed", async () => { await using run = createRun(); const leaderLock = createInMemoryLeaderLock(); @@ -5065,35 +6333,12 @@ describe("concurrency", () => { if (a.ok) a.value[Symbol.dispose](); if (b.ok) b.value[Symbol.dispose](); }); - - test("aborted waiter does not keep lock held", 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 waiting = run(leaderLock.acquire(testName)); - await run(yieldNow); - - waiting.abort("cancelled"); - await expect(waiting).resolves.toEqual( - err({ type: "AbortError", reason: "cancelled" }), - ); - - first.value[Symbol.dispose](); - - const next = await run(timeout(leaderLock.acquire(testName), "100ms")); - expect(next.ok).toBe(true); - if (next.ok) next.value[Symbol.dispose](); - }); }); }); describe("all", () => { test("runs tasks sequentially by default", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5122,7 +6367,7 @@ describe("all", () => { }); test("returns emptyArray for empty array", async () => { - await using run = createRunner(); + await using run = createRun(); const emptyTasks: Array> = []; const result = await run(all(emptyTasks)); @@ -5131,7 +6376,7 @@ describe("all", () => { }); test("returns emptyRecord for empty record", async () => { - await using run = createRunner(); + await using run = createRun(); const emptyTasks: Record> = {}; const result = await run(all(emptyTasks)); @@ -5140,7 +6385,7 @@ describe("all", () => { }); test("fails fast on first error", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFail = Promise.withResolvers(); @@ -5168,9 +6413,8 @@ describe("all", () => { return err({ type: "MyError" }); }; - const fiber = run(parallel(all([slowTask, failingTask]))); + const fiber = run(concurrently(all([slowTask, failingTask]))); - await Promise.resolve(); expect(events).toEqual(["slow start", "fail start"]); // Let failing task fail @@ -5184,7 +6428,7 @@ describe("all", () => { }); test("aborts others when a task throws", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -5200,9 +6444,9 @@ describe("all", () => { throw new Error("boom"); }; - await expect(run(parallel(all([slowTask, throwingTask])))).rejects.toThrow( - "boom", - ); + await expect( + run(concurrently(all([slowTask, throwingTask]))), + ).rejects.toThrow("boom"); const slowAbortReason = await slowObservedAbort.promise; assert(AbortError.is(slowAbortReason)); @@ -5210,7 +6454,7 @@ describe("all", () => { }); test("propagates abort cause to other tasks", async () => { - await using run = createRunner(); + await using run = createRun(); const abortCause = { type: "TestAbort" }; const causes: Array = []; @@ -5227,7 +6471,7 @@ describe("all", () => { err({ type: "AbortError", reason: abortCause }); const fiber = run( - parallel(3, all([waitForAbort, abortingTask, waitForAbort])), + concurrently(3, all([waitForAbort, abortingTask, waitForAbort])), ); const result = await fiber; @@ -5237,7 +6481,7 @@ describe("all", () => { }); test("limits concurrency with explicit number", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -5252,14 +6496,13 @@ describe("all", () => { }; const fiber = run( - parallel( + concurrently( 2, all([createTask(1), createTask(2), createTask(3), createTask(4)]), ), ); // Only 2 tasks should start - await Promise.resolve(); expect(events).toEqual(["start 1", "start 2"]); canFinish.resolve(); @@ -5271,7 +6514,7 @@ describe("all", () => { }); test("supports struct input and returns object with same keys", async () => { - await using run = createRunner(); + await using run = createRun(); const taskA: Task = () => ok(42); const taskB: Task = () => ok("hello"); @@ -5283,7 +6526,7 @@ describe("all", () => { }); test("struct preserves types", async () => { - await using run = createRunner(); + await using run = createRun(); const struct = { num: (() => ok(42)) as Task, @@ -5298,7 +6541,7 @@ describe("all", () => { }); test("struct fails fast on first error", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFail = Promise.withResolvers(); @@ -5321,9 +6564,8 @@ describe("all", () => { return err({ type: "MyError" }); }; - const fiber = run(parallel(all({ good: goodTask, bad: badTask }))); + const fiber = run(concurrently(all({ good: goodTask, bad: badTask }))); - await Promise.resolve(); expect(events).toEqual(["good start", "bad start"]); canFail.resolve(); @@ -5333,15 +6575,15 @@ describe("all", () => { }); test("struct returns empty object for empty input", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run(all({})); expect(result).toEqual(ok({})); }); - test("struct respects parallel", async () => { - await using run = createRunner(); + test("struct respects configured concurrency", async () => { + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -5356,14 +6598,13 @@ describe("all", () => { }; const fiber = run( - parallel( + concurrently( minPositiveInt, all({ a: createTask("a"), b: createTask("b"), c: createTask("c") }), ), ); // Sequential: only one at a time - await Promise.resolve(); expect(events).toEqual(["start a"]); canFinish.resolve(); @@ -5373,7 +6614,7 @@ describe("all", () => { }); test("tuple preserves types", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( all([() => ok(42), () => ok("hello"), () => ok(true)]), @@ -5387,7 +6628,7 @@ describe("all", () => { }); test("struct preserves readonly properties", async () => { - await using run = createRunner(); + await using run = createRun(); const readonlyStruct = { num: (() => ok(42)) as Task, @@ -5405,7 +6646,7 @@ describe("all", () => { }); test("non-empty arrays preserve types", async () => { - await using run = createRunner(); + await using run = createRun(); const tasks: NonEmptyReadonlyArray> = [() => ok(1)]; const result = await run(all(tasks)); @@ -5418,7 +6659,7 @@ describe("all", () => { test("returns promptly on external abort even when blocked", async () => { // Not using `await using` because disposal waits for all fibers to complete, // including the unabortable task (10s). - const run = createRunner(); + const run = createRun(); let unabortableCompleted = false; @@ -5459,7 +6700,7 @@ describe("all", () => { }); test("collect: false discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5480,7 +6721,7 @@ describe("all", () => { }); test("collect: false struct discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5503,7 +6744,7 @@ describe("all", () => { describe("allSettled", () => { test("returns emptyArray for empty array", async () => { - await using run = createRunner(); + await using run = createRun(); const emptyTasks: Array> = []; const result = await run(allSettled(emptyTasks)); @@ -5512,7 +6753,7 @@ describe("allSettled", () => { }); test("returns emptyRecord for empty record", async () => { - await using run = createRunner(); + await using run = createRun(); const emptyTasks: Record> = {}; const result = await run(allSettled(emptyTasks)); @@ -5521,7 +6762,7 @@ describe("allSettled", () => { }); test("runs tasks sequentially by default", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5550,7 +6791,7 @@ describe("allSettled", () => { }); test("runs all tasks even when some fail", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5577,7 +6818,7 @@ describe("allSettled", () => { }); test("non-empty arrays preserve types", async () => { - await using run = createRunner(); + await using run = createRun(); const tasks: NonEmptyReadonlyArray> = [() => ok(1)]; const result = await run(allSettled(tasks)); @@ -5590,7 +6831,7 @@ describe("allSettled", () => { }); test("supports struct input", async () => { - await using run = createRunner(); + await using run = createRun(); const taskA: Task = () => ok(42); const taskB: Task = () => err({ type: "MyError" }); @@ -5608,7 +6849,7 @@ describe("allSettled", () => { }); test("struct returns empty object for empty input", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run(allSettled({})); @@ -5616,7 +6857,7 @@ describe("allSettled", () => { }); test("struct preserves types", async () => { - await using run = createRunner(); + await using run = createRun(); const struct = { num: (() => ok(42)) as Task, @@ -5635,7 +6876,7 @@ describe("allSettled", () => { }); test("struct preserves readonly properties", async () => { - await using run = createRunner(); + await using run = createRun(); const readonlyStruct = { num: (() => ok(42)) as Task, @@ -5652,8 +6893,8 @@ describe("allSettled", () => { } }); - test("struct respects parallel", async () => { - await using run = createRunner(); + test("struct respects configured concurrency", async () => { + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -5668,7 +6909,7 @@ describe("allSettled", () => { }; const fiber = run( - parallel( + concurrently( minPositiveInt, allSettled({ a: createTask("a"), @@ -5679,7 +6920,6 @@ describe("allSettled", () => { ); // Sequential: only one at a time - await Promise.resolve(); expect(events).toEqual(["start a"]); canFinish.resolve(); @@ -5694,8 +6934,8 @@ describe("allSettled", () => { ); }); - test("respects parallel", async () => { - await using run = createRunner(); + test("respects configured concurrency", async () => { + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -5710,11 +6950,13 @@ describe("allSettled", () => { }; const fiber = run( - parallel(2, allSettled([createTask(1), createTask(2), createTask(3)])), + concurrently( + 2, + allSettled([createTask(1), createTask(2), createTask(3)]), + ), ); // Only 2 tasks should start - await Promise.resolve(); expect(events).toEqual(["start 1", "start 2"]); canFinish.resolve(); @@ -5724,7 +6966,7 @@ describe("allSettled", () => { }); test("tuple preserves types", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( allSettled([ @@ -5746,7 +6988,7 @@ describe("allSettled", () => { }); test("aborts others when a task throws", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -5763,7 +7005,7 @@ describe("allSettled", () => { }; await expect( - run(parallel(allSettled([slowTask, throwingTask]))), + run(concurrently(allSettled([slowTask, throwingTask]))), ).rejects.toThrow("boom"); const slowAbortReason = await slowObservedAbort.promise; @@ -5772,7 +7014,7 @@ describe("allSettled", () => { }); test("collect: false discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5793,7 +7035,7 @@ describe("allSettled", () => { }); test("collect: false struct discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5816,7 +7058,7 @@ describe("allSettled", () => { describe("map", () => { test("returns emptyArray for empty array", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( map( @@ -5831,7 +7073,7 @@ describe("map", () => { }); test("returns emptyRecord for empty record", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( map( @@ -5846,7 +7088,7 @@ describe("map", () => { }); test("maps items to tasks and collects results", async () => { - await using run = createRunner(); + await using run = createRun(); const items = [1, 2, 3]; const double = @@ -5860,7 +7102,7 @@ describe("map", () => { }); test("runs sequentially by default", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5885,8 +7127,8 @@ describe("map", () => { ]); }); - test("respects parallel", async () => { - await using run = createRunner(); + test("respects configured concurrency", async () => { + await using run = createRun(); const events: Array = []; @@ -5899,7 +7141,7 @@ describe("map", () => { return ok(id); }; - await run(parallel(2, map([1, 2, 3], trackingTask))); + await run(concurrently(2, map([1, 2, 3], trackingTask))); // With concurrency 2, tasks 1 and 2 start together expect(events[0]).toBe("start 1"); @@ -5907,7 +7149,7 @@ describe("map", () => { }); test("fails fast on first error", async () => { - await using run = createRunner(); + await using run = createRun(); const mayFail = (n: number): Task => @@ -5920,7 +7162,7 @@ describe("map", () => { }); test("aborts others when a task fails", async () => { - await using run = createRunner(); + await using run = createRun(); const slowObservedAbort = Promise.withResolvers(); @@ -5940,7 +7182,9 @@ describe("map", () => { n === 2 ? err({ type: "MyError" }) : ok(n); const result = await run( - parallel(map([1, 2], (n) => (n === 1 ? slowTask(n) : failingTask(n)))), + concurrently( + map([1, 2], (n) => (n === 1 ? slowTask(n) : failingTask(n))), + ), ); expect(result).toEqual(err({ type: "MyError" })); @@ -5951,7 +7195,7 @@ describe("map", () => { }); test("supports struct input and returns object with same keys", async () => { - await using run = createRunner(); + await using run = createRun(); const double = (n: number): Task => @@ -5964,7 +7208,7 @@ describe("map", () => { }); test("collect: false discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -5983,7 +7227,7 @@ describe("map", () => { }); test("collect: false struct discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6004,7 +7248,7 @@ describe("map", () => { describe("mapSettled", () => { test("returns emptyArray for empty array", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( mapSettled( @@ -6019,7 +7263,7 @@ describe("mapSettled", () => { }); test("returns emptyRecord for empty record", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run( mapSettled( @@ -6034,7 +7278,7 @@ describe("mapSettled", () => { }); test("maps items and collects all results even if some fail", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const items = [1, 2, 3]; @@ -6052,7 +7296,7 @@ describe("mapSettled", () => { }); test("supports struct input and returns object with same keys", async () => { - await using run = createRunner(); + await using run = createRun(); const mayFail = (n: number): Task => @@ -6067,7 +7311,7 @@ describe("mapSettled", () => { }); test("runs sequentially by default", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6092,8 +7336,8 @@ describe("mapSettled", () => { ]); }); - test("respects parallel", async () => { - await using run = createRunner(); + test("respects configured concurrency", async () => { + await using run = createRun(); const events: Array = []; @@ -6106,7 +7350,7 @@ describe("mapSettled", () => { return ok(id); }; - await run(parallel(2, mapSettled([1, 2, 3], trackingTask))); + await run(concurrently(2, mapSettled([1, 2, 3], trackingTask))); // With concurrency 2, tasks 1 and 2 start together expect(events[0]).toBe("start 1"); @@ -6114,7 +7358,7 @@ describe("mapSettled", () => { }); test("collect: false discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6133,7 +7377,7 @@ describe("mapSettled", () => { }); test("collect: false struct discards results", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6156,15 +7400,25 @@ describe("mapSettled", () => { describe("any", () => { test("returns first success", async () => { - await using run = createRunner(); + await using run = createRun(); const result = await run(any([() => ok(1), () => ok(2), () => ok(3)])); expect(result).toEqual(ok(1)); }); + test("returns empty array for empty runtime input", async () => { + await using run = createRun(); + + const emptyTasks = [] as unknown as NonEmptyReadonlyArray< + Task + >; + + expect(await run(any(emptyTasks))).toEqual(ok(emptyArray)); + }); + test("returns first success with concurrent execution", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canFinish = Promise.withResolvers(); @@ -6181,7 +7435,7 @@ describe("any", () => { return ok("fast"); }; - const fiber = run(parallel(any([slow, fast]))); + const fiber = run(concurrently(any([slow, fast]))); await Promise.resolve(); canFinish.resolve(); @@ -6190,7 +7444,7 @@ describe("any", () => { }); test("returns last error when all fail", async () => { - await using run = createRunner(); + await using run = createRun(); interface MyError { readonly type: "MyError"; @@ -6209,7 +7463,7 @@ describe("any", () => { }); test("returns last error when all fail with concurrent execution", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6226,7 +7480,7 @@ describe("any", () => { }; const result = await run( - parallel( + concurrently( any([createFailingTask(1), createFailingTask(2), createFailingTask(3)]), ), ); @@ -6238,7 +7492,7 @@ describe("any", () => { }); test("returns last error by input order, not completion order", async () => { - await using run = createRunner(); + await using run = createRun(); interface MyError { readonly type: "MyError"; @@ -6255,7 +7509,7 @@ describe("any", () => { const fast: Task = () => err({ type: "MyError", id: "fast" }); - const fiber = run(parallel(any([slow, fast]))); + const fiber = run(concurrently(any([slow, fast]))); await Promise.resolve(); canFinish.resolve(); @@ -6264,7 +7518,7 @@ describe("any", () => { }); test("can return last error by completion order", async () => { - await using run = createRunner(); + await using run = createRun(); interface MyError { readonly type: "MyError"; @@ -6281,7 +7535,9 @@ describe("any", () => { const fast: Task = () => err({ type: "MyError", id: "fast" }); - const fiber = run(parallel(any([slow, fast], { allFailed: "completion" }))); + const fiber = run( + concurrently(any([slow, fast], { allFailed: "completion" })), + ); await Promise.resolve(); canFinish.resolve(); @@ -6290,7 +7546,7 @@ describe("any", () => { }); test("aborts others when first succeeds", async () => { - await using run = createRunner(); + await using run = createRun(); const slowAbortReason = Promise.withResolvers(); @@ -6306,7 +7562,7 @@ describe("any", () => { const fast: Task = () => ok("fast"); - const result = await run(parallel(any([slow, fast]))); + const result = await run(concurrently(any([slow, fast]))); expect(result).toEqual(ok("fast")); const cause = await slowAbortReason.promise; @@ -6314,7 +7570,7 @@ describe("any", () => { }); test("skips failures until success", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; @@ -6351,7 +7607,7 @@ describe("any", () => { describe("fetch", () => { test("returns AbortError when aborted", async () => { - await using run = createRunner(); + await using run = createRun(); const fiber = run(fetch("https://example.com")); fiber.abort("cancelled"); @@ -6363,6 +7619,46 @@ describe("fetch", () => { }), ); }); + + test("maps non-abort failures to FetchError", async () => { + await using run = createRun(); + + const originalFetch = globalThis.fetch; + const failure = new Error("network failure"); + + try { + globalThis.fetch = (() => + Promise.reject(failure)) as typeof globalThis.fetch; + + expect(await run(fetch("https://example.com"))).toEqual( + err({ type: "FetchError", error: failure }), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("normalizes WebKit abort message to AbortError", async () => { + await using run = createRun(); + + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (() => + new Promise((_resolve, reject) => { + queueMicrotask(() => reject(new Error("Fetch is aborted"))); + })) as typeof globalThis.fetch; + + const fiber = run(fetch("https://example.com")); + fiber.abort("cancelled-by-user"); + + expect(await fiber).toEqual( + err({ type: "AbortError", reason: "cancelled-by-user" }), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe("examples TODO", () => { @@ -6378,12 +7674,14 @@ describe("examples TODO", () => { readonly fetch: typeof globalThis.fetch; } - // Simulated fetch task - typed but never executed - const fetch = ( - _url: string, - ): Task => { - throw new Error("Not implemented - type test only"); - }; + // 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"), + }); test("timeout adds TimeoutError to error union", () => { const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); @@ -6395,7 +7693,7 @@ describe("examples TODO", () => { >(); }); - test("retry wraps errors in RetryError", () => { + test("retry wraps errors in RetryError", async () => { const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); const fetchWithRetry = (url: string) => @@ -6410,6 +7708,23 @@ describe("examples TODO", () => { NativeFetchDep > >(); + + const deps: RunDeps & NativeFetchDep = { + ...testCreateDeps(), + fetch: globalThis.fetch, + time: createTime(), + }; + + await using run = createRun(deps); + + const urls = [ + "https://api.example.com/users", + "https://api.example.com/posts", + "https://api.example.com/comments", + ]; + + // At most 2 concurrent requests + const _result = await run(concurrently(2, map(urls, fetchWithRetry))); }); test("all with NonEmptyReadonlyArray returns NonEmptyReadonlyArray", () => { @@ -6432,7 +7747,7 @@ describe("examples TODO", () => { describe("createSemaphore", () => { test("limits concurrency with sleep helper", async () => { - await using run = createRunner({ + await using run = createRun({ console: testCreateConsole({ level: "silent" }), }); @@ -6462,7 +7777,7 @@ describe("examples TODO", () => { describe("yieldNow", () => { test("keeps UI responsive when processing large arrays", async () => { - await using run = createRunner(); + await using run = createRun(); const largeArray = Array.from({ length: 50_000 }, (_, i) => i); let processedCount = 0; @@ -6493,7 +7808,7 @@ describe("examples TODO", () => { }); test("enables stack-safe recursion", async () => { - await using run = createRunner(); + await using run = createRun(); // When processing a large amount of work recursively (via `run(childTask)`), // yield periodically so the recursion stays stack-safe. @@ -6522,7 +7837,7 @@ describe("examples TODO", () => { describe("Fiber.abort", () => { test("abort wins, outcome preserves original result", async () => { - await using run = createRunner(); + await using run = createRun(); const fiber = run(() => ok("data")); fiber.abort("stop"); @@ -6530,12 +7845,12 @@ describe("examples TODO", () => { expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); expect(state.outcome).toEqual(ok("data")); }); test("unabortable preserves result and outcome", async () => { - await using run = createRunner(); + await using run = createRun(); const fiber = run(unabortable(() => ok("data"))); fiber.abort("stop"); @@ -6543,14 +7858,14 @@ describe("examples TODO", () => { expect(result).toEqual(ok("data")); const state = fiber.getState(); - assert(state.type === "Completed"); + assert(state.type === "Settled"); expect(state.outcome).toEqual(ok("data")); }); }); describe("unabortable", () => { test("analytics tracking completes despite abort", async () => { - await using run = createRunner(); + await using run = createRun(); const events: Array = []; const canComplete = Promise.withResolvers(); diff --git a/packages/common/test/TreeShaking.test.ts b/packages/common/test/TreeShaking.test.ts index 417d896a1..2c48b2bb5 100644 --- a/packages/common/test/TreeShaking.test.ts +++ b/packages/common/test/TreeShaking.test.ts @@ -184,13 +184,13 @@ const normalizeBundleSize = ( } if (fixture === "task-example") { - if (gzip >= 5650 && gzip <= 5750) gzip = 5692; - if (raw >= 15250 && raw <= 15650) raw = 15511; + if (gzip >= 5350 && gzip <= 5450) gzip = 5395; + if (raw >= 14250 && raw <= 14500) raw = 14356; } if (fixture === "type-object") { - if (gzip >= 1950 && gzip <= 2100) gzip = 2006; - if (raw >= 6000 && raw <= 6200) raw = 6082; + if (gzip >= 1880 && gzip <= 1980) gzip = 1937; + if (raw >= 5700 && raw <= 5850) raw = 5769; } return { gzip, raw }; @@ -215,12 +215,12 @@ describe("tree-shaking", () => { "raw": 1602, }, "task-example": { - "gzip": 5692, - "raw": 15511, + "gzip": 5395, + "raw": 14356, }, "type-object": { - "gzip": 2006, - "raw": 6082, + "gzip": 1937, + "raw": 5769, }, } `); diff --git a/packages/common/test/WebSocket.test.ts b/packages/common/test/WebSocket.test.ts index cc9a685d7..d6afb79a9 100644 --- a/packages/common/test/WebSocket.test.ts +++ b/packages/common/test/WebSocket.test.ts @@ -1,4 +1,4 @@ -import { afterEach, assert, beforeEach, expect, test, vi } from "vitest"; +import { afterAll, assert, beforeAll, expect, test, vi } from "vitest"; import { utf8ToBytes } from "../src/Buffer.js"; import { isServer } from "../src/Platform.js"; import { spaced, take } from "../src/Schedule.js"; @@ -18,7 +18,7 @@ const getServerUrl = (path = ""): string => { return `ws://127.0.0.1:${port}${path ? `/${path}` : ""}`; }; -beforeEach(async () => { +beforeAll(async () => { if (isServer) { const { createServer } = await import("./_globalSetup.js"); port = await createServer(); @@ -28,7 +28,7 @@ beforeEach(async () => { } }); -afterEach(async () => { +afterAll(async () => { if (port === undefined) return; const currentPort = port; port = undefined; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 02420202d..112e2eb04 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -36,14 +36,16 @@ import { SqliteBoolean, } from "../../src/Sqlite.js"; import { createInMemoryLeaderLock } from "../../src/Task.js"; -import { testCreateRun, testName } from "../../src/Test.js"; +import { testCreateRun } from "../../src/Test.js"; import { createIdFromString, id, NonEmptyString100, nullOr, + testName, } from "../../src/Type.js"; import type { ExtractType } from "../../src/Types.js"; +import { testCreateWebSocket } from "../../src/WebSocket.js"; import { createMessageChannel, createMessagePort, @@ -479,9 +481,7 @@ describe("unit tests", () => { }); await testWaitForWorkerMessage(); - expect( - run.deps.evoluInputs.filter((input) => input.type === "Export"), - ).toEqual([{ type: "Export" }]); + expect(run.deps.evoluInputs).toEqual([{ type: "Export" }]); }); test("posts Dispose with pending mutation microtask batch", async () => { @@ -963,20 +963,12 @@ describe("unit tests", () => { await testWaitForWorkerMessage(); - const firstMutate = run.deps.evoluInputs[0]; - assert( - firstMutate?.type === "Mutate", - "Expected first input to be Mutate", - ); - expect(firstMutate.changes[0]?.ownerId).toBe(evolu.appOwner.id); - expect(run.deps.evoluInputs).toMatchInlineSnapshot( [ { changes: [ { id: expect.any(String), - ownerId: expect.any(String), }, ], }, @@ -989,7 +981,7 @@ describe("unit tests", () => { "id": Any, "isDelete": null, "isInsert": true, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "Todo 1", @@ -1027,25 +1019,15 @@ describe("unit tests", () => { await testWaitForWorkerMessage(); - const firstMutate = run.deps.evoluInputs[0]; - assert( - firstMutate?.type === "Mutate", - "Expected first input to be Mutate", - ); - expect(firstMutate.changes[0]?.ownerId).toBe(evolu.appOwner.id); - expect(firstMutate.changes[1]?.ownerId).toBe(evolu.appOwner.id); - expect(run.deps.evoluInputs).toMatchInlineSnapshot( [ { changes: [ { id: updateId, - ownerId: expect.any(String), }, { id: upsertId, - ownerId: expect.any(String), }, ], }, @@ -1058,7 +1040,7 @@ describe("unit tests", () => { "id": "VPIPiOGb2m2OlsM-pg18CA", "isDelete": true, "isInsert": false, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "Updated", @@ -1068,7 +1050,7 @@ describe("unit tests", () => { "id": "j4rh6UkYDIqXKLCOX4ru2A", "isDelete": null, "isInsert": true, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "Upserted", @@ -1105,30 +1087,18 @@ describe("unit tests", () => { await testWaitForWorkerMessage(); - const firstMutate = run.deps.evoluInputs[0]; - assert( - firstMutate?.type === "Mutate", - "Expected first input to be Mutate", - ); - expect(firstMutate.changes[0]?.ownerId).toBe(evolu.appOwner.id); - expect(firstMutate.changes[1]?.ownerId).toBe(evolu.appOwner.id); - expect(firstMutate.changes[2]?.ownerId).toBe(evolu.appOwner.id); - expect(run.deps.evoluInputs).toMatchInlineSnapshot( [ { changes: [ { id: expect.any(String), - ownerId: expect.any(String), }, { id: updateId, - ownerId: expect.any(String), }, { id: upsertId, - ownerId: expect.any(String), }, ], }, @@ -1141,7 +1111,7 @@ describe("unit tests", () => { "id": Any, "isDelete": null, "isInsert": true, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "A", @@ -1151,7 +1121,7 @@ describe("unit tests", () => { "id": "fOTG65tQ_ZYHpSBp3GbogA", "isDelete": null, "isInsert": false, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "B", @@ -1161,7 +1131,7 @@ describe("unit tests", () => { "id": "3I1Sfwp5IxdacWcpAna5qg", "isDelete": null, "isInsert": true, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "C", @@ -1191,20 +1161,12 @@ describe("unit tests", () => { await testWaitForWorkerMessage(); - const firstMutate = run.deps.evoluInputs[0]; - assert( - firstMutate?.type === "Mutate", - "Expected first input to be Mutate", - ); - expect(firstMutate.changes[0]?.ownerId).toBe(testAppOwner.id); - expect(run.deps.evoluInputs).toMatchInlineSnapshot( [ { changes: [ { id: expect.any(String), - ownerId: expect.any(String), }, ], onCompleteIds: [expect.any(String)], @@ -1218,7 +1180,7 @@ describe("unit tests", () => { "id": Any, "isDelete": null, "isInsert": true, - "ownerId": Any, + "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", "table": "todo", "values": { "title": "With callback", @@ -1238,7 +1200,7 @@ describe("unit tests", () => { }); describe("exportDatabase", () => { - test("ignores OnExport when there is no pending export", async () => { + test("throws when OnExport arrives without pending export", async () => { const channels: Array<{ readonly port1: { onMessage: ((message: EvoluOutput) => void) | null; @@ -1263,9 +1225,7 @@ describe("unit tests", () => { type: "OnExport", file: new Uint8Array(), }); - }).not.toThrow(); - - expect(run.deps.evoluInputs).toEqual([]); + }).toThrow("OnExport received without pending export."); }); test("exports database for one caller", async () => { @@ -1380,6 +1340,7 @@ describe("integration tests", () => { consoleStoreOutputEntry: consoleStoreOutput.entry, createMessageChannel, createMessagePort, + createWebSocket: testCreateWebSocket({ throwOnCreate: true }), }); const driver = await run.orThrow( @@ -1543,7 +1504,7 @@ describe("integration tests", () => { "name": "evolu_config", "rows": [ { - "clock": uint8:[0,0,0,0,0,0,0,1,68,242,158,172,29,147,215,38], + "clock": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], }, ], }, @@ -1552,18 +1513,18 @@ describe("integration tests", () => { "rows": [ { "column": "title", - "id": uint8:[70,31,136,134,35,155,236,16,187,58,231,146,197,162,133,46], + "id": uint8:[160,113,55,152,72,115,160,45,137,237,156,223,234,49,112,82], "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,68,242,158,172,29,147,215,38], + "timestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], "value": "Integration todo", }, { "column": "createdAt", - "id": uint8:[70,31,136,134,35,155,236,16,187,58,231,146,197,162,133,46], + "id": uint8:[160,113,55,152,72,115,160,45,137,237,156,223,234,49,112,82], "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,68,242,158,172,29,147,215,38], + "timestamp": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], "value": "1970-01-01T00:00:00.000Z", }, ], @@ -1577,11 +1538,11 @@ describe("integration tests", () => { "rows": [ { "c": 1, - "h1": 221168146061724, - "h2": 120619144524474, + "h1": 104312911511672, + "h2": 160957934804849, "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,68,242,158,172,29,147,215,38], + "t": uint8:[0,0,0,0,0,0,0,1,160,113,55,152,72,115,160,45], }, ], }, @@ -1589,8 +1550,8 @@ describe("integration tests", () => { "name": "evolu_usage", "rows": [ { - "firstTimestamp": uint8:[0,0,0,0,0,0,0,1,68,242,158,172,29,147,215,38], - "lastTimestamp": uint8:[0,0,0,0,0,0,0,1,68,242,158,172,29,147,215,38], + "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], "ownerId": uint8:[213,187,31,214,138,191,248,80,138,181,64,156,48,57,155,184], "storedBytes": 105, }, @@ -1601,7 +1562,7 @@ describe("integration tests", () => { "rows": [ { "createdAt": "1970-01-01T00:00:00.000Z", - "id": "Rh-IhiOb7BC7OueSxaKFLg", + "id": "oHE3mEhzoC2J7Zzf6jFwUg", "isCompleted": null, "isDeleted": null, "ownerId": "1bsf1oq_-FCKtUCcMDmbuA", diff --git a/packages/common/test/local-first/Kysely.test.ts b/packages/common/test/local-first/Kysely.test.ts index 401de0df1..33838ba9b 100644 --- a/packages/common/test/local-first/Kysely.test.ts +++ b/packages/common/test/local-first/Kysely.test.ts @@ -12,12 +12,12 @@ import { import { describe, expect, test } from "vitest"; import { getJsonObjectArgs, - jsonArrayFrom, - jsonBuildObject, - jsonObjectFrom, - sql, -} from "../../src/local-first/Kysely.js"; -import { kyselyJsonIdentifier } from "../../src/local-first/Query.js"; + evoluJsonArrayFrom as jsonArrayFrom, + evoluJsonBuildObject as jsonBuildObject, + evoluJsonObjectFrom as jsonObjectFrom, + kyselyJsonIdentifier, + kyselySql as sql, +} from "../../src/local-first/Query.js"; const createSelectQueryNode = ( selections: ReadonlyArray< @@ -106,14 +106,10 @@ describe("Kysely helpers", () => { expect(() => jsonArrayFrom(createSelectExpression(invalidNode) as never), - ).toThrow( - "SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections", - ); + ).toThrow(/can only handle explicit selections/); expect(() => jsonObjectFrom(createSelectExpression(invalidNode) as never), - ).toThrow( - "SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections", - ); + ).toThrow(/can only handle explicit selections/); }); test("jsonArrayFrom, jsonObjectFrom, and jsonBuildObject include Evolu JSON prefix", () => { diff --git a/packages/common/test/local-first/Query.test.ts b/packages/common/test/local-first/Query.test.ts index ac25fcb07..5350e5130 100644 --- a/packages/common/test/local-first/Query.test.ts +++ b/packages/common/test/local-first/Query.test.ts @@ -1,15 +1,40 @@ +import { ColumnNode, type SelectQueryNode } from "kysely"; import { expect, test } from "vitest"; import type { Row } from "../../src/local-first/Query.js"; import { applyPatches, deserializeQuery, + evoluJsonArrayFrom, + evoluJsonBuildObject, + evoluJsonObjectFrom, + getJsonObjectArgs, kyselyJsonIdentifier, + kyselySql, makePatches, serializeQuery, testQuery, testQuery2, } from "../../src/local-first/Query.js"; +import { createQueryBuilder } from "../../src/local-first/Schema.js"; import { type SafeSql, type SqliteQuery, sql } from "../../src/Sqlite.js"; +import { id, NonEmptyString100 } from "../../src/Type.js"; + +const PersonId = id("Person"); +const PetId = id("Pet"); + +const QuerySchema = { + person: { + id: PersonId, + name: NonEmptyString100, + }, + pet: { + id: PetId, + name: NonEmptyString100, + ownerId: PersonId, + }, +}; + +const createQuery = createQueryBuilder(QuerySchema); test("Query", () => { const query1 = serializeQuery<{ a: 1 }>(sql`select "a" as "kind";`); @@ -69,6 +94,147 @@ test("serializeQuery sorts options and deserializeQuery restores them", () => { expect(deserializeQuery(serialized)).toStrictEqual(sqlQuery); }); +test("evoluJsonArrayFrom compiles a prefixed SQLite JSON array query", () => { + const query = createQuery((db) => + db + .selectFrom("person") + .select(["person.id"]) + .select((eb) => [ + evoluJsonArrayFrom( + eb + .selectFrom("pet") + .select(["pet.id as petId", "pet.name", "ownerId"]) + .whereRef("pet.ownerId", "=", "person.id"), + ).as("pets"), + ]), + ); + + const sqlQuery = deserializeQuery(query); + + expect(sqlQuery.sql).toContain("json_group_array(json_object("); + expect(sqlQuery.sql).toContain(kyselyJsonIdentifier); + expect(sqlQuery.sql).toContain('"agg"."petId"'); + expect(sqlQuery.sql).toContain('"agg"."name"'); + expect(sqlQuery.sql).toContain('"agg"."ownerId"'); +}); + +test("evoluJsonObjectFrom compiles a prefixed SQLite JSON object query", () => { + const query = createQuery((db) => + db + .selectFrom("person") + .select(["person.id"]) + .select((eb) => [ + evoluJsonObjectFrom( + eb + .selectFrom("pet") + .select(["id as petId", "name"]) + .whereRef("pet.ownerId", "=", "person.id"), + ).as("favoritePet"), + ]), + ); + + const sqlQuery = deserializeQuery(query); + + expect(sqlQuery.sql).toContain("json_object("); + expect(sqlQuery.sql).toContain(kyselyJsonIdentifier); + expect(sqlQuery.sql).toContain('"obj"."petId"'); + expect(sqlQuery.sql).toContain('"obj"."name"'); +}); + +test("evoluJsonBuildObject compiles a prefixed SQLite json_object expression", () => { + const query = createQuery((db) => + db.selectFrom("person").select((eb) => [ + evoluJsonBuildObject({ + first: eb.ref("name"), + full: kyselySql`name || '!'`, + }).as("profile"), + ]), + ); + + const sqlQuery = deserializeQuery(query); + + expect(sqlQuery.sql).toContain("json_object("); + expect(sqlQuery.sql).toContain(kyselyJsonIdentifier); + expect(sqlQuery.sql).toContain("'first'"); + expect(sqlQuery.sql).toContain("'full'"); +}); + +test("getJsonObjectArgs handles alias, column, and reference selections", () => { + let operationNode: SelectQueryNode | undefined; + + createQuery((db) => { + const subquery = db + .selectFrom("pet") + .select((eb) => [eb.ref("id").as("petId"), "name", "pet.ownerId"]); + + operationNode = subquery.toOperationNode(); + return db.selectFrom("pet").select(["pet.id"]); + }); + + expect(operationNode).toBeDefined(); + if (!operationNode) throw new Error("Expected operation node"); + + const args = getJsonObjectArgs(operationNode, "agg"); + + expect(args).toHaveLength(6); +}); + +test("getJsonObjectArgs handles unqualified column selections", () => { + const operationNode = { + selections: [{ selection: ColumnNode.create("name") }], + } as unknown as SelectQueryNode; + + const args = getJsonObjectArgs(operationNode, "agg"); + + expect(args).toHaveLength(2); +}); + +test("getJsonObjectArgs rejects selections it cannot map to json_object", () => { + let operationNode: SelectQueryNode | undefined; + + createQuery((db) => { + const subquery = db.selectFrom("pet").selectAll(); + operationNode = subquery.toOperationNode(); + return db.selectFrom("pet").select(["pet.id"]); + }); + + expect(operationNode).toBeDefined(); + if (!operationNode) throw new Error("Expected operation node"); + const node = operationNode; + + expect(() => getJsonObjectArgs(node, "agg")).toThrow( + "can't extract column names from the select query node", + ); +}); + +test("getJsonObjectArgs returns empty array for nodes without selections", () => { + let operationNode: SelectQueryNode | undefined; + + createQuery((db) => { + operationNode = db.selectFrom("pet").toOperationNode(); + return db.selectFrom("pet").select(["pet.id"]); + }); + + expect(operationNode).toBeDefined(); + if (!operationNode) throw new Error("Expected operation node"); + + expect(getJsonObjectArgs(operationNode, "agg")).toEqual([]); +}); + +test("evoluJsonArrayFrom rejects selectAll subqueries", () => { + expect(() => + createQuery((db) => + db + .selectFrom("person") + .select((eb) => [ + evoluJsonArrayFrom(eb.selectFrom("pet").selectAll()).as("pets"), + ]), + ), + ).toThrow( + "SQLite evoluJsonArrayFrom and evoluJsonObjectFrom can only handle explicit selections due to limitations of the json_object function. selectAll() is not allowed in the subquery.", + ); +}); + test("makePatches", () => { const row: Row = { a: 1 }; const rows: ReadonlyArray = [row]; @@ -156,3 +322,40 @@ test("applyPatches parses prefixed JSON in strings, arrays, and objects", () => }, ]); }); + +test("applyPatches recursively parses prefixed JSON inside decoded JSON", () => { + const encodeJson = (value: unknown): string => + `${kyselyJsonIdentifier}${JSON.stringify(value)}`; + + const result = applyPatches( + [ + { + op: "replaceAll", + value: [ + { + nestedObject: encodeJson({ + items: [ + { + detail: encodeJson({ status: "ok" }), + }, + ], + }), + }, + ], + }, + ], + [], + ); + + expect(result).toEqual([ + { + nestedObject: { + items: [ + { + detail: { status: "ok" }, + }, + ], + }, + }, + ]); +}); diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 7f82081e9..a349198f9 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -6,7 +6,6 @@ import { createRandom, createRelation, createSqlite, - getOk, isPromiseLike, type OwnerId, ok, @@ -71,14 +70,16 @@ export const startRelay = isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig): Task => - async (_run) => { - await using stack = _run.stack(); - const console = _run.deps.console.child("relay"); + async (run) => { + await using stack = run.stack(); + const console = run.deps.console.child("relay"); const dbFileExists = existsSync(`${name}.db`); - const sqlite = getOk(await stack.use(createSqlite(name))); - const deps = { ..._run.deps, sqlite }; + const sqliteResult = await stack.use(createSqlite(name)); + if (!sqliteResult.ok) return sqliteResult; + + const deps = { ...run.deps, sqlite: sqliteResult.value }; if (!dbFileExists) { createBaseSqliteStorageTables(deps); @@ -92,7 +93,7 @@ export const startRelay = // Use root daemon runner for WS callbacks; task-scoped runner closes // after startRelay returns and would reject message handling with // RunnerClosingError. - const run = _run.daemon.addDeps({ storage }); + const daemonRun = run.daemon.addDeps({ storage }); const server = createServer(); const wss = new WebSocketServer({ @@ -198,7 +199,7 @@ export const startRelay = if (!Uint8Array.is(message)) return; void (async () => { - const response = await run( + const response = await daemonRun( applyProtocolMessageAsRelay(message, options), ); if (!response.ok) { diff --git a/packages/react-native/src/Polyfills.ts b/packages/react-native/src/Polyfills.ts index ea58f63ba..fdd74a41c 100644 --- a/packages/react-native/src/Polyfills.ts +++ b/packages/react-native/src/Polyfills.ts @@ -34,10 +34,10 @@ export const installPolyfills = (): void => { const installPromisePolyfills = () => { const PromiseStatic = Promise as PromiseConstructor & { withResolvers?: () => PromiseWithResolvers; - try?: ( - func: (...args: ReadonlyArray) => unknown, - ...args: ReadonlyArray - ) => Promise; + try?: >( + func: (...args: U) => T | PromiseLike, + ...args: U + ) => Promise>; }; // @see https://github.com/facebook/hermes/pull/1452 @@ -55,13 +55,13 @@ const installPromisePolyfills = () => { // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/try if (typeof PromiseStatic.try !== "function") { - PromiseStatic.try = ( - func: (...args: ReadonlyArray) => unknown, - ...args: ReadonlyArray - ): Promise => - new Promise((resolve, reject) => { + PromiseStatic.try = >( + func: (...args: U) => T | PromiseLike, + ...args: U + ): Promise> => + new Promise>((resolve, reject) => { try { - resolve(func(...args)); + resolve(func(...args) as Awaited); } catch (error) { reject(error); } diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 478bd03ef..5477e1c2e 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.7", + "svelte": "^5.53.10", "svelte-check": "^4.4.3", "typescript": "^5.9.3" }, "peerDependencies": { "@evolu/common": "^7.4.1", "@evolu/web": "^2.4.0", - "svelte": ">=5.53.7" + "svelte": ">=5.53.10" }, "publishConfig": { "access": "public" diff --git a/packages/vue/package.json b/packages/vue/package.json index a1ae052f1..b061395d4 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -41,7 +41,7 @@ }, "peerDependencies": { "@evolu/common": "^7.4.1", - "vue": ">=3.5.29" + "vue": ">=3.5.30" }, "publishConfig": { "access": "public" diff --git a/packages/vue/src/provideEvolu.ts b/packages/vue/src/provideEvolu.ts index 4cac1f50e..6d8f7c258 100644 --- a/packages/vue/src/provideEvolu.ts +++ b/packages/vue/src/provideEvolu.ts @@ -10,7 +10,7 @@ import { * Stores the Evolu instance for a Vue component. This is most useful at the * root component where provide/inject doesn't work. */ -export const evoluInstanceMap = new WeakMap< +export const evoluInstanceMap = /*#__PURE__*/ new WeakMap< ComponentInternalInstance, Evolu >(); diff --git a/packages/web/src/Task.ts b/packages/web/src/Task.ts index d72061721..ec4abac90 100644 --- a/packages/web/src/Task.ts +++ b/packages/web/src/Task.ts @@ -1,5 +1,5 @@ /** - * Browser-specific Task utilities. + * Web platform-specific Task utilities. * * @module */ @@ -77,7 +77,7 @@ export const createLeaderLock = (): LeaderLock => ({ }); /** - * Creates {@link Run} for the browser with global error handling. + * 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. @@ -97,7 +97,7 @@ export const createLeaderLock = (): LeaderLock => ({ * await stack.use(startApp()); * ``` * - * @group Browser Runner + * @group Web Platform Runner */ export const createRun: CreateRunner = ( deps?: D,