diff --git a/.changeset/update-effect-4.0.1.md b/.changeset/update-effect-4.0.1.md new file mode 100644 index 000000000..97a17687b --- /dev/null +++ b/.changeset/update-effect-4.0.1.md @@ -0,0 +1,6 @@ +--- +"effect-app": patch +"@effect-app/cli": patch +--- + +Upgrade Effect packages to npm `4.0.1`. diff --git a/package.json b/package.json index 021d4d772..957a67a38 100644 --- a/package.json +++ b/package.json @@ -50,15 +50,15 @@ "@effect-app/infra": "workspace:*", "@effect/language-service": "0.87.3", "@effect/tsgo": "^0.47.2", - "@effect/platform-node": "4.0.0", - "@effect/vitest": "4.0.0", + "@effect/platform-node": "4.0.1", + "@effect/vitest": "4.0.1", "@tsconfig/strictest": "^2.0.8", "@types/node": "25.9.1", "@typescript-eslint/eslint-plugin": "8.60.0", "@typescript-eslint/parser": "8.60.0", "@typescript/native-preview": "7.0.0-dev.20260626.1", "dprint": "^0.54.0", - "effect": "4.0.0", + "effect": "4.0.1", "effect-app": "workspace:*", "eslint": "^10.4.1", "json5": "^2.2.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index b50c935f9..a255c9b93 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,8 +14,8 @@ "effect-app-cli": "./bin.js" }, "dependencies": { - "@effect/platform-node": "4.0.0", - "effect": "4.0.0", + "@effect/platform-node": "4.0.1", + "effect": "4.0.1", "js-yaml": "4.2.0" }, "devDependencies": { diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 7c8c66e93..c84733006 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -10,13 +10,13 @@ "effect-app": "workspace:*" }, "devDependencies": { - "@effect/atom-vue": "^4.0.0", - "@effect/platform-node": "4.0.0", - "@effect/vitest": "4.0.0", + "@effect/atom-vue": "^4.0.1", + "@effect/platform-node": "4.0.1", + "@effect/vitest": "4.0.1", "@tanstack/vue-query": "5.96.2", "@types/node": "25.9.1", "@vitejs/plugin-vue": "^6.0.7", - "effect": "^4.0.0", + "effect": "^4.0.1", "typescript": "~6.0.3", "vitest": "^4.1.7", "vue": "^3.5.35" diff --git a/packages/effect-app/package.json b/packages/effect-app/package.json index 9edad6b94..c33bee31b 100644 --- a/packages/effect-app/package.json +++ b/packages/effect-app/package.json @@ -26,7 +26,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { - "effect": "^4.0.0" + "effect": "^4.0.1" }, "typesVersions": { "*": { diff --git a/packages/infra/package.json b/packages/infra/package.json index 86b9a53b6..3b387e678 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -21,7 +21,7 @@ "devDependencies": { "@azure/cosmos": "^4.9.3", "@azure/service-bus": "^7.9.5", - "@effect/sql-sqlite-node": "4.0.0", + "@effect/sql-sqlite-node": "4.0.1", "@sentry/opentelemetry": "10.55.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "25.9.1", @@ -39,12 +39,12 @@ "peerDependencies": { "@azure/cosmos": "^4.9.3", "@azure/service-bus": "^7.9.5", - "@effect/vitest": "^4.0.0", + "@effect/vitest": "^4.0.1", "@sendgrid/helpers": "^8.0.0", "@sendgrid/mail": "^8.1.6", "@sentry/node": "10.55.0", "@sentry/opentelemetry": "10.55.0", - "effect": "^4.0.0", + "effect": "^4.0.1", "jwt-decode": "^4.0.0", "redis": "^3.1.2", "redlock": "^4.2.0" diff --git a/packages/vue-components/package.json b/packages/vue-components/package.json index cac8f534b..32c98f321 100644 --- a/packages/vue-components/package.json +++ b/packages/vue-components/package.json @@ -25,7 +25,7 @@ }, "peerDependencies": { "@mdi/js": "^7.4.47", - "effect": "^4.0.0", + "effect": "^4.0.1", "intl-messageformat": "^11.2.7", "mdi-js": "^1.0.1", "primeflex": "^4.0.0", diff --git a/packages/vue/package.json b/packages/vue/package.json index 7dd2f1ddc..3825bf83e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -18,14 +18,14 @@ "query-string": "^9.4.0" }, "peerDependencies": { - "@effect/atom-vue": "^4.0.0", - "@effect/platform-browser": "^4.0.0", + "@effect/atom-vue": "^4.0.1", + "@effect/platform-browser": "^4.0.1", "@sentry/browser": "^10.55.0", - "effect": "^4.0.0", + "effect": "^4.0.1", "vue": "^3.5.35" }, "devDependencies": { - "@effect/vitest": "4.0.0", + "@effect/vitest": "4.0.1", "@formatjs/icu-messageformat-parser": "^3.5.10", "@types/node": "25.9.1", "@vitejs/plugin-vue": "^6.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e81f65faf..10c319114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,14 +45,14 @@ importers: specifier: 0.87.3 version: 0.87.3 "@effect/platform-node": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(redis@6.2.1(@opentelemetry/api@1.9.1)) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) "@effect/tsgo": specifier: ^0.47.2 version: 0.47.2 "@effect/vitest": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) "@tsconfig/strictest": specifier: ^2.0.8 version: 2.0.8 @@ -72,8 +72,8 @@ importers: specifier: ^0.54.0 version: 0.54.0 effect: - specifier: 4.0.0 - version: 4.0.0 + specifier: 4.0.1 + version: 4.0.1 effect-app: specifier: workspace:* version: link:packages/effect-app @@ -117,11 +117,11 @@ importers: packages/cli: dependencies: "@effect/platform-node": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(redis@6.2.1(@opentelemetry/api@1.9.1)) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) effect: - specifier: 4.0.0 - version: 4.0.0 + specifier: 4.0.1 + version: 4.0.1 js-yaml: specifier: 4.2.0 version: 4.2.0 @@ -153,14 +153,14 @@ importers: version: link:../effect-app devDependencies: "@effect/atom-vue": - specifier: ^4.0.0 - version: 4.0.0(effect@4.0.0)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) + specifier: ^4.0.1 + version: 4.0.1(effect@4.0.1)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) "@effect/platform-node": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(redis@6.2.1(@opentelemetry/api@1.9.1)) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) "@effect/vitest": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) "@tanstack/vue-query": specifier: 5.96.2 version: 5.96.2(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) @@ -171,8 +171,8 @@ importers: specifier: ^6.0.7 version: 6.0.7(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) effect: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.0.1 + version: 4.0.1 typescript: specifier: ~6.0.3 version: 6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e) @@ -192,8 +192,8 @@ importers: specifier: ^4.4.0 version: 4.4.0 effect: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.0.1 + version: 4.0.1 nanoid: specifier: ^5.1.11 version: 5.1.11 @@ -331,8 +331,8 @@ importers: packages/infra: dependencies: "@effect/vitest": - specifier: ^4.0.0 - version: 4.0.0(effect@4.0.0)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: ^4.0.1 + version: 4.0.1(effect@4.0.1)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) "@faker-js/faker": specifier: ^8.4.1 version: 8.4.1 @@ -346,8 +346,8 @@ importers: specifier: 10.55.0 version: 10.55.0(supports-color@8.1.1) effect: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.0.1 + version: 4.0.1 effect-app: specifier: workspace:* version: link:../effect-app @@ -374,8 +374,8 @@ importers: specifier: ^7.9.5 version: 7.9.5(supports-color@8.1.1) "@effect/sql-sqlite-node": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1) "@sentry/opentelemetry": specifier: 10.55.0 version: 10.55.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) @@ -420,11 +420,11 @@ importers: packages/vue: dependencies: "@effect/atom-vue": - specifier: ^4.0.0 - version: 4.0.0(effect@4.0.0)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) + specifier: ^4.0.1 + version: 4.0.1(effect@4.0.1)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) "@effect/platform-browser": - specifier: ^4.0.0 - version: 4.0.0(effect@4.0.0) + specifier: ^4.0.1 + version: 4.0.1(effect@4.0.1) "@formatjs/intl": specifier: ^4.1.12 version: 4.1.12 @@ -441,8 +441,8 @@ importers: specifier: ^5.4.4 version: 5.4.4 effect: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.0.1 + version: 4.0.1 effect-app: specifier: workspace:* version: link:../effect-app @@ -454,8 +454,8 @@ importers: version: 3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)) devDependencies: "@effect/vitest": - specifier: 4.0.0 - version: 4.0.0(effect@4.0.0)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 4.0.1 + version: 4.0.1(effect@4.0.1)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0))) "@formatjs/icu-messageformat-parser": specifier: ^3.5.10 version: 3.5.10 @@ -494,8 +494,8 @@ importers: specifier: ^1.32.0 version: 1.32.0(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e))) effect: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.0.1 + version: 4.0.1 effect-app: specifier: workspace:* version: link:../effect-app @@ -1188,12 +1188,12 @@ packages: } hasBin: true - "@effect/atom-vue@4.0.0": + "@effect/atom-vue@4.0.1": resolution: { - integrity: sha512-ZlVU+qGJA/2C0ZsBS9ytSBdSM93aLecjXnMp/s0qvoMEf+kKRlrrYfD7dRpCrD56JqI0IcGJ9C8Y74Lu2Sz6AQ==, + integrity: sha512-pYSTQv0/W/UkZasvV51B5MmNfYmkWJALUBsbVAHd0tNqALRAE3K5M6wGks7N29gnl6swQz3UkMYX9XWqHiMcCg==, } peerDependencies: - effect: ^4.0.0 + effect: ^4.0.1 vue: ^3.5.35 "@effect/language-service@0.87.3": @@ -1202,20 +1202,12 @@ packages: } hasBin: true - "@effect/platform-browser@4.0.0": + "@effect/platform-browser@4.0.1": resolution: { - integrity: sha512-OHo7EPpF3RVPiI+mjoU9yd05LdCdKPjzDXqs5wRgF9CsdVvwN76B0zgt54DxPUcvMYri1tVJOtLCVnK0tRkNQQ==, + integrity: sha512-RsnobWEnffdkRH/wZ3ARIAgMxtGLyfrD0R3iVS+dwQCdYrSbotlJHEoVHEgzcOn5RzMsHG6FHkfdgRMwvcM7kg==, } peerDependencies: - effect: ^4.0.0 - - "@effect/platform-node-shared@4.0.0": - resolution: { - integrity: sha512-VBXHJU9UXVZPdQGGDRjGXpo+TpOzRwZuMrdKAtIxi1qwcRftmacZ1zTgIrzBa0Z2RA2KVkoOecQUsTlt/nJZhw==, - } - engines: { node: ">=18.0.0" } - peerDependencies: - effect: ^4.0.0 + effect: ^4.0.1 "@effect/platform-node-shared@4.0.0-beta.86": resolution: { @@ -1225,14 +1217,13 @@ packages: peerDependencies: effect: ^4.0.0-beta.86 - "@effect/platform-node@4.0.0": + "@effect/platform-node-shared@4.0.1": resolution: { - integrity: sha512-/L+4MUbl8FejPKLHLROYSOebvHnfixkP8KiblSD/DE5uH5xENZBHaux4nH6XMnRP+lr8ajpEJFS8sUrmmxFJeQ==, + integrity: sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg==, } engines: { node: ">=18.0.0" } peerDependencies: - effect: ^4.0.0 - redis: ">=5.0.0 <7.0.0" + effect: ^4.0.1 "@effect/platform-node@4.0.0-beta.86": resolution: { @@ -1243,12 +1234,21 @@ packages: effect: ^4.0.0-beta.86 ioredis: ^5.7.0 - "@effect/sql-sqlite-node@4.0.0": + "@effect/platform-node@4.0.1": + resolution: { + integrity: sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ==, + } + engines: { node: ">=18.0.0" } + peerDependencies: + effect: ^4.0.1 + redis: ">=5.0.0 <7.0.0" + + "@effect/sql-sqlite-node@4.0.1": resolution: { - integrity: sha512-xyUSLY7v55iMgGxrjFrTo5ngixgQ3jLmOQ+AX8CCazO/caxuMM0oq8zb974FAcvQHRrpMEWU7Vnoo+rxct7PAQ==, + integrity: sha512-VBlNvfFRxyyiu1viNygxA0ZVGZcpxBHA3+QLobaLQSXgAP0submgo98IzioWTHzdZUc9Xodi7ICci5ehhbD2fQ==, } peerDependencies: - effect: ^4.0.0 + effect: ^4.0.1 "@effect/tsgo-darwin-arm64@0.47.2": resolution: { @@ -1305,12 +1305,12 @@ packages: } hasBin: true - "@effect/vitest@4.0.0": + "@effect/vitest@4.0.1": resolution: { - integrity: sha512-kGElCPFGtP+CgMCwV/HjJWqtE3zYo6I1+8MSA54l+EI24lPU9p2Efa+TBp1jfkg9j711KKzEqgSAy3VzTsb76Q==, + integrity: sha512-DbY9kf/018zBxRIX7rJwBnPVPzSarnUpcshm0vfYPFiDkSa7AFrXTh2BlDNUbrdrmqcIOBhVZ7GkEtxf8WiQrg==, } peerDependencies: - effect: ^4.0.0 + effect: ^4.0.1 vitest: ">=5.0.0 <6.0.0" "@emnapi/core@1.10.0": @@ -5734,14 +5734,14 @@ packages: engines: { node: ">=14" } hasBin: true - effect@4.0.0: + effect@4.0.0-beta.86: resolution: { - integrity: sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ==, + integrity: sha512-+Y16hy3LhAju/FM3dz3Xp+aGi8dw9AtEzswcvwxREpeEPq+rL8TFS1PgWAO1+/3kKKH4bq9NdBGtfhHzM2K7Ow==, } - effect@4.0.0-beta.86: + effect@4.0.1: resolution: { - integrity: sha512-+Y16hy3LhAju/FM3dz3Xp+aGi8dw9AtEzswcvwxREpeEPq+rL8TFS1PgWAO1+/3kKKH4bq9NdBGtfhHzM2K7Ow==, + integrity: sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw==, } electron-to-chromium@1.5.267: @@ -9656,20 +9656,6 @@ packages: utf-8-validate: optional: true - ws@8.21.3: - resolution: { - integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==, - } - engines: { node: ">=10.0.0" } - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.22.0: resolution: { integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==, @@ -10333,41 +10319,31 @@ snapshots: - ioredis - utf-8-validate - "@effect/atom-vue@4.0.0(effect@4.0.0)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))": + "@effect/atom-vue@4.0.1(effect@4.0.1)(vue@3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)))": dependencies: - effect: 4.0.0 + effect: 4.0.1 vue: 3.5.35(typescript@6.0.3(patch_hash=6a20b8df080eb8a9c34008e1802ecfd839f4e90e07f1ee97f78694dbd95a520e)) "@effect/language-service@0.87.3": {} - "@effect/platform-browser@4.0.0(effect@4.0.0)": + "@effect/platform-browser@4.0.1(effect@4.0.1)": dependencies: - effect: 4.0.0 - - "@effect/platform-node-shared@4.0.0(effect@4.0.0)": - dependencies: - "@types/ws": 8.18.1 - effect: 4.0.0 - ws: 8.22.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + effect: 4.0.1 "@effect/platform-node-shared@4.0.0-beta.86(effect@4.0.0-beta.86)": dependencies: "@types/ws": 8.18.1 effect: 4.0.0-beta.86 - ws: 8.21.3 + ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate - "@effect/platform-node@4.0.0(effect@4.0.0)(redis@6.2.1(@opentelemetry/api@1.9.1))": + "@effect/platform-node-shared@4.0.1(effect@4.0.1)": dependencies: - "@effect/platform-node-shared": 4.0.0(effect@4.0.0) - effect: 4.0.0 - redis: 6.2.1(@opentelemetry/api@1.9.1) - undici: 8.11.2 + "@types/ws": 8.18.1 + effect: 4.0.1 + ws: 8.22.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -10383,9 +10359,19 @@ snapshots: - bufferutil - utf-8-validate - "@effect/sql-sqlite-node@4.0.0(effect@4.0.0)": + "@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1))": + dependencies: + "@effect/platform-node-shared": 4.0.1(effect@4.0.1) + effect: 4.0.1 + redis: 6.2.1(@opentelemetry/api@1.9.1) + undici: 8.11.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + "@effect/sql-sqlite-node@4.0.1(effect@4.0.1)": dependencies: - effect: 4.0.0 + effect: 4.0.1 "@effect/tsgo-darwin-arm64@0.47.2": optional: true @@ -10418,9 +10404,9 @@ snapshots: "@effect/tsgo-win32-arm64": 0.47.2 "@effect/tsgo-win32-x64": 0.47.2 - "@effect/vitest@4.0.0(effect@4.0.0)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)))": + "@effect/vitest@4.0.1(effect@4.0.1)(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)))": dependencies: - effect: 4.0.0 + effect: 4.0.1 vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.15(@types/node@25.9.1)(esbuild@0.28.0)(sass@1.100.0)(terser@5.45.0)(tsx@4.22.4)(yaml@2.9.0)) "@emnapi/core@1.10.0": @@ -13053,8 +13039,6 @@ snapshots: minimatch: 9.0.1 semver: 7.7.3 - effect@4.0.0: {} - effect@4.0.0-beta.86: dependencies: "@standard-schema/spec": 1.1.0 @@ -13068,6 +13052,8 @@ snapshots: uuid: 14.0.2 yaml: 2.9.0 + effect@4.0.1: {} + electron-to-chromium@1.5.267: optional: true @@ -16154,8 +16140,6 @@ snapshots: ws@8.20.0: {} - ws@8.21.3: {} - ws@8.22.0: {} wsl-utils@0.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 13281d192..0ac4f8615 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,13 +17,13 @@ minimumReleaseAgeExclude: - "@effect/tsgo-win32-x64@0.47.2" - "@effect/tsgo@0.47.2" - "@effect/language-service@0.87.3" - - "@effect/atom-vue@4.0.0" - - "@effect/platform-browser@4.0.0" - - "@effect/platform-node-shared@4.0.0" - - "@effect/platform-node@4.0.0" - - "@effect/sql-sqlite-node@4.0.0" - - "@effect/vitest@4.0.0" - - effect@4.0.0 + - "@effect/atom-vue@4.0.1" + - "@effect/platform-browser@4.0.1" + - "@effect/platform-node-shared@4.0.1" + - "@effect/platform-node@4.0.1" + - "@effect/sql-sqlite-node@4.0.1" + - "@effect/vitest@4.0.1" + - effect@4.0.1 overrides: date-fns: ^4.4.0 fast-check: ^4.9.0 diff --git a/repos/effect/.changeset/config.json b/repos/effect/.changeset/config.json index 2b10fe8a6..de158b048 100644 --- a/repos/effect/.changeset/config.json +++ b/repos/effect/.changeset/config.json @@ -16,6 +16,7 @@ [ "effect", "@effect/ai-anthropic", + "@effect/ai-cloudflare", "@effect/ai-openai", "@effect/ai-typesafe", "@effect/ai-openai-compat", diff --git a/repos/effect/MIGRATION.md b/repos/effect/MIGRATION.md index 65ec53756..98b2c635c 100644 --- a/repos/effect/MIGRATION.md +++ b/repos/effect/MIGRATION.md @@ -86,7 +86,7 @@ minimal Effect program bundles to ~6.3 KB (minified + gzipped). With Schema, - [Cause: Flattened Structure](./migration/cause.md) - [Error Handling: `catch*` Renamings](./migration/error-handling.md) - [Forking: Renamed Combinators and New Options](./migration/forking.md) -- [Effect Subtyping → Yieldable](./migration/yieldable.md) +- [Effect Subtyping Changes](./migration/yieldable.md) - [Fiber Keep-Alive: Automatic Process Lifetime Management](./migration/fiber-keep-alive.md) - [Layer Memoization Across `Effect.provide` Calls](./migration/layer-memoization.md) - [FiberRef: `FiberRef` → `Context.Reference`](./migration/fiberref.md) diff --git a/repos/effect/README.md b/repos/effect/README.md index fff448c8d..8df9d3431 100644 --- a/repos/effect/README.md +++ b/repos/effect/README.md @@ -79,6 +79,7 @@ This monorepo contains the core `effect` package alongside integration packages | [`@effect/sql-sqlite-wasm`](packages/sql/sqlite-wasm) | SQL client for SQLite compiled to WebAssembly | [docs](https://effect.website/docs/v4/api/sql-sqlite-wasm) | | [`@effect/ai-anthropic`](packages/ai/anthropic) | Anthropic provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-anthropic) | | [`@effect/ai-openai`](packages/ai/openai) | OpenAI provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-openai) | +| [`@effect/ai-cloudflare`](packages/ai/cloudflare) | Cloudflare decision provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-cloudflare) | | [`@effect/ai-typesafe`](packages/ai/typesafe) | TypeSafe decision provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-typesafe) | | [`@effect/ai-openai-compat`](packages/ai/openai-compat) | OpenAI-compatible API provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-openai-compat) | | [`@effect/ai-openrouter`](packages/ai/openrouter) | OpenRouter provider for the Effect AI modules | [docs](https://effect.website/docs/v4/api/ai-openrouter) | diff --git a/repos/effect/migration/annotations/effect__Schema.yaml b/repos/effect/migration/annotations/effect__Schema.yaml index 202849859..ca05fc5aa 100644 --- a/repos/effect/migration/annotations/effect__Schema.yaml +++ b/repos/effect/migration/annotations/effect__Schema.yaml @@ -792,7 +792,7 @@ effect/Schema#Number: note: The API remains public in v4, but its type/value declaration was consolidated; use the v4 declaration and update inferred types/signature as needed. effect/Schema#NumberFromString: replacement: Schema.NumberFromString - note: The API remains public in v4, but its type/value declaration was consolidated; use the v4 declaration and update inferred types/signature as needed. + note: "Decoding now uses JavaScript number coercion: unlike v3, `\"abc\"` decodes to `NaN`, while `\"\"` and whitespace-only strings decode to `0`. `FiniteFromString` rejects non-finite results, so it rejects invalid strings but also the `\"NaN\"` and infinity spellings accepted by v3; it still decodes blank strings to `0`. Use a custom codec to preserve the v3 acceptance rules exactly." effect/Schema#Object: replacement: Schema.ObjectKeyword note: Rename the object keyword schema. @@ -839,14 +839,14 @@ effect/Schema#OptionFromUndefinedOr: replacement: Schema.OptionFromUndefinedOr note: The API remains public in v4, but its type/value declaration was consolidated; use the v4 declaration and update inferred types/signature as needed. effect/Schema#parseJson: - replacement: Schema.UnknownFromJsonString / Schema.fromJsonString(schema) - note: Use `UnknownFromJsonString` without an inner schema or `fromJsonString(schema)` with one. + replacement: Schema.fromJsonString(Schema.Unknown) / Schema.fromJsonString(schema) + note: Use `fromJsonString(Schema.Unknown)` without an inner schema or `fromJsonString(schema)` with one. effect/Schema#ParseJsonOptions: replacement: none note: The old parse-json options type was removed; configure `fromJsonString` and its underlying getter directly. effect/Schema#parseNumber: replacement: Schema.NumberFromString - note: Use the built-in string-to-number codec. + note: "Use the built-in string-to-number codec. Unlike v3 `parseNumber`, invalid strings decode to `NaN`, while blank strings decode to `0`. `FiniteFromString` rejects non-finite results but also the `\"NaN\"` and infinity spellings accepted by v3; exact compatibility requires a custom codec." effect/Schema#partial: replacement: schema.mapFields(Struct.map(Schema.optional)) note: Map struct fields with `Schema.optional`. diff --git a/repos/effect/migration/annotations/effect__SchemaAST.yaml b/repos/effect/migration/annotations/effect__SchemaAST.yaml index 51b523da9..5b2207ec2 100644 --- a/repos/effect/migration/annotations/effect__SchemaAST.yaml +++ b/repos/effect/migration/annotations/effect__SchemaAST.yaml @@ -326,7 +326,7 @@ replacement: "none" note: "The symbol annotation was removed; use message or expected annotations." "effect/SchemaAST#ParseJsonSchemaId": - replacement: "Schema.UnknownFromJsonString" + replacement: "Schema.fromJsonString(Schema.Unknown)" note: "Use the built-in JSON string codec instead of checking the old schema ID." "effect/SchemaAST#ParseOptions": replacement: "SchemaAST.ParseOptions" diff --git a/repos/effect/migration/schema.md b/repos/effect/migration/schema.md index 2d87cc380..8f7aec0e6 100644 --- a/repos/effect/migration/schema.md +++ b/repos/effect/migration/schema.md @@ -19,7 +19,7 @@ This document maps v3 Schema APIs to their v4 equivalents. Simple renames and ar | `compose(schemaB)` | `decodeTo(schemaB)` | rename | | `annotations(ann)` | `annotate(ann)` | rename | | `decodingFallback` annotation | `catchDecoding(...)` | rename | -| `parseJson()` | `UnknownFromJsonString` | rename | +| `parseJson()` | `fromJsonString(Unknown)` | rename | | `parseJson(schema)` | `fromJsonString(schema)` | rename | | `pattern(regex)` | `check(isPattern(regex))` | rename | | `nonEmptyString` | `isNonEmpty` | rename | diff --git a/repos/effect/migration/v3-to-v4.md b/repos/effect/migration/v3-to-v4.md index 8e617743b..632f133dc 100644 --- a/repos/effect/migration/v3-to-v4.md +++ b/repos/effect/migration/v3-to-v4.md @@ -2,9 +2,9 @@ # v3 to v4 Migration Reference -Base: `origin/v3` (`0963a1c17f5be8e26676ed5ffc9c542bd26f97f0`) +Base: `origin/v3` (`e730f59d29db5f1e312be6476a95d07bcad45788`) -Head: `HEAD` (`072cdc42a84421694593035d65d0f3375d1a17fd`) +Head: `HEAD` (`a84d4f65d978bf33aabc38227e477d187ff05c2c`) This file is generated from the API diff and `migration/annotations/*.yaml`. @@ -14435,7 +14435,7 @@ Schema.toFormatter(schema) - `Schema.Number` -> `Schema.Number`: The API remains public in v4, but its type/value declaration was consolidated; use the v4 declaration and update inferred types/signature as needed. -- `Schema.NumberFromString` -> `Schema.NumberFromString`: The API remains public in v4, but its type/value declaration was consolidated; use the v4 declaration and update inferred types/signature as needed. +- `Schema.NumberFromString` -> `Schema.NumberFromString`: Decoding now uses JavaScript number coercion: unlike v3, `"abc"` decodes to `NaN`, while `""` and whitespace-only strings decode to `0`. `FiniteFromString` rejects non-finite results, so it rejects invalid strings but also the `"NaN"` and infinity spellings accepted by v3; it still decodes blank strings to `0`. Use a custom codec to preserve the v3 acceptance rules exactly. - `Schema.Object` -> `Schema.ObjectKeyword`: Rename the object keyword schema. @@ -14923,9 +14923,9 @@ Schema.toFormatter(schema) - `Schema.optionalWith` -> `Schema.optional / Schema.optionalKey / Schema.withDecodingDefaultType`: Choose `optional` or `optionalKey`; use the decoding-default helpers and an explicit nullable transformation as required by the old options. -- `Schema.parseJson` -> `Schema.UnknownFromJsonString / Schema.fromJsonString(schema)`: Use `UnknownFromJsonString` without an inner schema or `fromJsonString(schema)` with one. +- `Schema.parseJson` -> `Schema.fromJsonString(Schema.Unknown) / Schema.fromJsonString(schema)`: Use `fromJsonString(Schema.Unknown)` without an inner schema or `fromJsonString(schema)` with one. -- `Schema.parseNumber` -> `Schema.NumberFromString`: Use the built-in string-to-number codec. +- `Schema.parseNumber` -> `Schema.NumberFromString`: Use the built-in string-to-number codec. Unlike v3 `parseNumber`, invalid strings decode to `NaN`, while blank strings decode to `0`. `FiniteFromString` rejects non-finite results but also the `"NaN"` and infinity spellings accepted by v3; exact compatibility requires a custom codec. - `Schema.partial` -> `schema.mapFields(Struct.map(Schema.optional))`: Map struct fields with `Schema.optional`. @@ -15111,7 +15111,7 @@ Schema.toFormatter(schema) - `SchemaAST.ParseIssueTitleAnnotationId` -> `none`: The symbol annotation was removed; use message or expected annotations. -- `SchemaAST.ParseJsonSchemaId` -> `Schema.UnknownFromJsonString`: Use the built-in JSON string codec instead of checking the old schema ID. +- `SchemaAST.ParseJsonSchemaId` -> `Schema.fromJsonString(Schema.Unknown)`: Use the built-in JSON string codec instead of checking the old schema ID. - `SchemaAST.ParseOptions` -> `SchemaAST.ParseOptions`: Pass parsing options at runtime. onExcessProperty supports ignore or error, not preserve; model extra values with an explicit Record or StructWithRest. propertyOrder was removed. concurrency follows Effect.forEach semantics for tuple, array, struct, record, and struct-with-rest children, applies independently at each nested product, and does not make Union candidates concurrent. Output key order is unspecified, including in values passed to checks. Handle required presentation or serialization order explicitly outside the parser. diff --git a/repos/effect/migration/yieldable.md b/repos/effect/migration/yieldable.md index f57781328..1434479de 100644 --- a/repos/effect/migration/yieldable.md +++ b/repos/effect/migration/yieldable.md @@ -1,96 +1,96 @@ -# Effect Subtyping (v3) → Yieldable (v4) +# Effect subtyping changes in v4 -In v3, many types were structural subtypes of `Effect` — they carried the +In v3, many types were structural subtypes of `Effect`. They carried the Effect type ID at runtime and could be used anywhere an `Effect` was expected. This included `Ref`, `Deferred`, `Fiber`, `FiberRef`, `Config`, `Option`, `Either`, `Context.Tag`, and others. -While convenient, this created a class of subtle bugs. Because these types -_were_ Effects, they could be silently passed to Effect combinators when you -intended to pass the value itself. For example, passing a `Ref` where you meant -to pass the value inside the `Ref`, or accidentally mapping over a `Deferred` -as an Effect instead of awaiting it. +While convenient, this could cause subtle bugs. For example, passing a `Ref` +to an Effect combinator would read its value instead of treating the ref itself +as a value. -v4 replaces this with the **`Yieldable`** trait: a narrower contract that -allows `yield*` in generators but does **not** make the type assignable to -`Effect`. +In v4, several of these types are no longer Effect subtypes. Use explicit +conversion or module functions to obtain an Effect. There is no `Yieldable` +trait that makes non-Effect values usable in `Effect.gen`. -## The `Yieldable` Interface +## Option and Result require conversion -```ts -interface Yieldable { - asEffect(): Effect - [Symbol.iterator](): EffectIterator -} -``` - -Some example types that implement `Yieldable`: - -- `Effect` itself -- `Option` — yields the value or fails with `NoSuchElementError` -- `Result` — yields the success or fails with the error -- `Config` — yields the config value or fails with `ConfigError` -- `Context.Service` — yields the service from the environment +`Option` and `Result` (the replacement for v3's `Either`) are plain values, +not Effects. They do not have an `.asEffect()` method, and cannot be yielded +directly in `Effect.gen`. Their iterators work with `Option.gen` and +`Result.gen`, respectively, not with `Effect.gen`. -Some example types that are **no longer** Effect subtypes and do **not** -implement `Yieldable`: +Use `Effect.fromOption` or `Effect.fromResult` in Effect code: -- `Ref` — use `Ref.get(ref)` to read -- `Deferred` — use `Deferred.await(deferred)` to wait -- `Fiber` — use `Fiber.join(fiber)` to await +- `Effect.fromOption` succeeds with the contained value for `Some` and fails + with `Cause.NoSuchElementError` for `None`. An optional `onNone` callback + supplies a custom error. +- `Effect.fromResult` preserves the success value or failure error. -## `yield*` Still Works - -`yield*` in `Effect.gen` works with any `Yieldable`. The runtime calls -`.asEffect()` internally when yielding. +**v3**: Option is an Effect subtype, so this compiles: ```ts import { Effect, Option } from "effect" -// The type of program is `Effect` -const program = Effect.gen(function*() { - // yield* works with Yieldable types — same as v3 - const value = yield* Option.some(42) - return value // 42 -}) +// Option is assignable to Effect +const program = Effect.map(Option.some(42), (n) => n + 1) ``` -## Effect Combinators Require `.asEffect()` +**v4**: Convert explicitly, both in combinators and in generators: -In v3, you could pass a `Yieldable` type directly to Effect combinators because -it was a subtype of `Effect`. In v4, you must explicitly convert with -`.asEffect()`. +```ts +import { Effect, Option, Result } from "effect" -**v3** — Option is an Effect subtype, so this compiles: +// Effect +const program = Effect.map(Effect.fromOption(Option.some(42)), (n) => n + 1) -```ts -import { Effect, Option } from "effect" +// Effect +const program2 = Effect.gen(function*() { + const n = yield* Effect.fromOption(Option.some(42)) + return n + 1 +}) -// Option is assignable to Effect -const program = Effect.map(Option.some(42), (n) => n + 1) +// Effect +const program3 = Effect.gen(function*() { + const n = yield* Effect.fromResult(Result.succeed(123)) + return n + 1 +}) + +// Effect +const failed = Effect.fromResult(Result.fail("failed")) + +// Effect +const missing = Effect.fromOption(Option.none(), () => "missing") ``` -**v4** — Option is not an Effect, so you must convert explicitly: +## Config and services remain Effects + +`Config` and `Context.Service` remain Effect subtypes. You can yield them +in `Effect.gen` or pass them directly to Effect combinators without conversion. ```ts -import { Effect, Option } from "effect" +import { Config, Context, Effect } from "effect" -// Option is Yieldable but not Effect — use .asEffect() -const program = Effect.map(Option.some(42).asEffect(), (n) => n + 1) +class Greeting extends Context.Service()("Greeting") {} -// Or more idiomatically, use a generator: -const program2 = Effect.gen(function*() { - const n = yield* Option.some(42) - return n + 1 -}) +const program = Effect.gen(function*() { + const name = yield* Config.String("NAME").pipe(Config.withDefault("world")) + const greeting = yield* Greeting + return `${greeting.message}, ${name}!` +}).pipe(Effect.provideService(Greeting, { message: "Hello" })) + +const port = Effect.map(Config.Port("PORT").pipe(Config.withDefault(3000)), (n) => n + 1) ``` -## Types No Longer Subtypes of Effect +`Effectable.Class.asEffect()` is a mechanism for defining custom Effects, +not a conversion method available on every iterable value. + +## Types no longer subtypes of Effect Several types that extended `Effect` in v3 no longer do so in v4. Use the appropriate module functions instead. -**v3** — `Ref` extends `Effect`, yielding the current value: +**v3**: `Ref` extends `Effect`, yielding the current value: ```ts import { Effect, Ref } from "effect" @@ -101,7 +101,7 @@ const program = Effect.gen(function*() { }) ``` -**v4** — `Ref` is a plain value, use `Ref.get`: +**v4**: `Ref` is a plain value, use `Ref.get`: ```ts import { Effect, Ref } from "effect" @@ -109,10 +109,11 @@ import { Effect, Ref } from "effect" const program = Effect.gen(function*() { const ref = yield* Ref.make(0) const value = yield* Ref.get(ref) + return value }) ``` -**v3** — `Deferred` extends `Effect`, resolving when completed: +**v3**: `Deferred` extends `Effect`, resolving when completed: ```ts import { Deferred, Effect } from "effect" @@ -123,18 +124,20 @@ const program = Effect.gen(function*() { }) ``` -**v4** — `Deferred` is a plain value, use `Deferred.await`: +**v4**: `Deferred` is a plain value, use `Deferred.await`: ```ts import { Deferred, Effect } from "effect" const program = Effect.gen(function*() { const deferred = yield* Deferred.make() + yield* Deferred.succeed(deferred, "done") const value = yield* Deferred.await(deferred) + return value }) ``` -**v3** — `Fiber` extends `Effect`, joining on yield: +**v3**: `Fiber` extends `Effect`, joining on yield: ```ts import { Effect, Fiber } from "effect" @@ -145,29 +148,25 @@ const program = Effect.gen(function*() { }) ``` -**v4** — `Fiber` is a plain value, use `Fiber.join`: +**v4**: `Fiber` is a plain value, use `Fiber.join`: ```ts import { Effect, Fiber } from "effect" const program = Effect.gen(function*() { + const task = Effect.succeed(42) const fiber = yield* Effect.forkChild(task) const result = yield* Fiber.join(fiber) + return result }) ``` -## Why This Changed +## Why this changed The v3 subtyping approach meant the type system could not distinguish between -"I have a Ref" and "I have an Effect that reads the Ref." This ambiguity led -to bugs that were difficult to diagnose: - -- Passing a `Ref` to `Effect.map` would read the ref's value rather than - transforming the ref itself — often not the intended behavior. -- A `Deferred` in a data structure could silently be treated as an Effect, - causing unexpected awaits. -- Combinators like `Effect.all` would accept an array of `Ref` values and - silently read all of them, instead of producing a type error. - -The `Yieldable` trait preserves the ergonomic `yield*` syntax in generators -while making the conversion to `Effect` explicit everywhere else. +"I have a Ref" and "I have an Effect that reads the Ref." For example, +`Effect.all` could accept an array of refs and silently read all of them. + +In v4, explicit functions such as `Ref.get`, `Deferred.await`, `Fiber.join`, +`Effect.fromOption`, and `Effect.fromResult` make these operations visible +and prevent those values from being passed accidentally to Effect combinators. diff --git a/repos/effect/packages/ai/anthropic/CHANGELOG.md b/repos/effect/packages/ai/anthropic/CHANGELOG.md index ddf48575a..f209b0986 100644 --- a/repos/effect/packages/ai/anthropic/CHANGELOG.md +++ b/repos/effect/packages/ai/anthropic/CHANGELOG.md @@ -1,5 +1,15 @@ # @effect/ai-anthropic +## 4.0.1 + +### Patch Changes + +- [#8683](https://github.com/Effect-TS/effect/pull/8683) [`97ee2bd`](https://github.com/Effect-TS/effect/commit/97ee2bd6840bdfb51b65ec35b9a19001ee93db60) Thanks @tim-smart! - Return an `AiError` with `ToolParameterValidationError` instead of a defect when streamed Anthropic tool arguments contain malformed JSON, including provider-executed tool calls. + +- [#8603](https://github.com/Effect-TS/effect/pull/8603) [`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74) Thanks @danieljvdm! - Preserve all Anthropic system instructions and support configurable mid-conversation system messages. Add optional `LanguageModel.supportsSystemMessagesInHistory` to query support with scoped overrides. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/ai/anthropic/codegen.yml b/repos/effect/packages/ai/anthropic/codegen.yml index 81c875a76..12e961dee 100644 --- a/repos/effect/packages/ai/anthropic/codegen.yml +++ b/repos/effect/packages/ai/anthropic/codegen.yml @@ -5,6 +5,8 @@ spec: output: src/Generated.ts name: AnthropicClient patches: + # Allow system messages in conversation history. + - '[{"op":"replace","path":"/components/schemas/BetaInputMessage/properties/role/enum","value":["user","assistant","system"]},{"op":"replace","path":"/components/schemas/InputMessage/properties/role/enum","value":["user","assistant","system"]}]' # Make iterations and speed optional in BetaUsage, iterations optional in BetaMessageDeltaUsage (API may omit these fields) - '[{"op":"remove","path":"/components/schemas/BetaUsage/required/9"},{"op":"remove","path":"/components/schemas/BetaUsage/required/5"},{"op":"remove","path":"/components/schemas/BetaMessageDeltaUsage/required/3"}]' header: | diff --git a/repos/effect/packages/ai/anthropic/package.json b/repos/effect/packages/ai/anthropic/package.json index af34eba6c..fc63c76f4 100644 --- a/repos/effect/packages/ai/anthropic/package.json +++ b/repos/effect/packages/ai/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@effect/ai-anthropic", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "An Anthropic provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts b/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts index eef5fbf71..251009d5c 100644 --- a/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts +++ b/repos/effect/packages/ai/anthropic/src/AnthropicLanguageModel.ts @@ -94,6 +94,13 @@ export class Config extends Context.Service< * Overrides automatic capability detection based on the model identifier. */ readonly structuredOutputs?: boolean | undefined + /** + * Overrides model detection for mid-conversation system messages. + * Set to `false` to send all instructions in the top-level `system` field. + * + * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages + */ + readonly midConversationSystemMessages?: boolean | undefined /** * Whether to use strict JSON schema validation for tool calls. * @@ -724,11 +731,8 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig readonly payload: typeof Generated.BetaCreateMessageParams.Encoded }, AiError.AiError> { const betas = new Set() - const modelCapabilities = getModelCapabilities(config.model!) - const capabilities = Predicate.isNotUndefined(config.structuredOutputs) - ? { ...modelCapabilities, supportsStructuredOutput: config.structuredOutputs } - : modelCapabilities - const { messages, system } = yield* prepareMessages({ betas, options, toolNameMapper }) + const capabilities = getConfigCapabilities(config) + const { messages, system } = yield* prepareMessages({ betas, capabilities, options, toolNameMapper }) const outputFormat = yield* getOutputFormat({ capabilities, options }) const { tools, toolChoice } = yield* prepareTools({ betas, capabilities, config, options }) const params: Mutable = {} @@ -740,6 +744,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig output_config, strictJsonSchema: _strictJsonSchema, structuredOutputs: _structuredOutputs, + midConversationSystemMessages: _midConversationSystemMessages, ...requestConfig } = config const payload: Mutable = { @@ -766,6 +771,10 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig return yield* LanguageModel.make({ codecTransformer: toCodecAnthropic, + supportsSystemMessagesInHistory: Effect.map( + makeConfig, + (config) => getConfigCapabilities(config).supportsMidConversationSystemMessages + ), generateText: Effect.fnUntraced(function*(options) { const config = yield* makeConfig const toolNameMapper = new Tool.NameMapper(options.tools) @@ -859,8 +868,9 @@ export const withConfigOverride: { // ============================================================================= const prepareMessages = Effect.fnUntraced( - function*>({ betas, options, toolNameMapper }: { + function*>({ betas, capabilities, options, toolNameMapper }: { readonly betas: Set + readonly capabilities: ModelCapabilities readonly options: LanguageModel.ProviderOptions readonly toolNameMapper: Tool.NameMapper }): Effect.fn.Return<{ @@ -869,8 +879,19 @@ const prepareMessages = Effect.fnUntraced( }, AiError.AiError> { const groups = groupMessages(options.prompt) - let system: Array | undefined = undefined + // A system message in history must directly follow a user turn and directly + // precede an assistant turn or the end of the prompt, so later instructions + // are held until the next assistant turn. If any of them cannot be placed + // that way, every instruction goes in the top-level `system` field instead: + // inline instructions override top-level ones, so mixing would reorder them. + const inlineSystem = capabilities.supportsMidConversationSystemMessages && + groups.every((group, i) => + i === 0 || group.type !== "system" || groups[i - 1].type === "user" || groups[i + 1]?.type === "user" + ) + + const system: Array = [] const messages: Array = [] + let pendingSystem: Array = [] for (let i = 0; i < groups.length; i++) { const group = groups[i] @@ -878,11 +899,14 @@ const prepareMessages = Effect.fnUntraced( switch (group.type) { case "system": { - system = group.messages.map((message) => ({ - type: "text", - text: message.content, - cache_control: getCacheControl(message) - })) + const target = i === 0 || !inlineSystem ? system : pendingSystem + for (const message of group.messages) { + target.push({ + type: "text", + text: message.content, + cache_control: getCacheControl(message) + }) + } break } @@ -1015,6 +1039,11 @@ const prepareMessages = Effect.fnUntraced( } case "assistant": { + if (pendingSystem.length > 0) { + messages.push({ role: "system", content: pendingSystem }) + pendingSystem = [] + } + const content: Array = [] const mcpToolIds = new Set() @@ -1253,8 +1282,12 @@ const prepareMessages = Effect.fnUntraced( } } + if (pendingSystem.length > 0) { + messages.push({ role: "system", content: pendingSystem }) + } + return { - system, + system: system.length > 0 ? system : undefined, messages } } @@ -2717,13 +2750,22 @@ const makeStreamResponse = Effect.fnUntraced( } } + const toolParams = yield* Effect.try({ + try: () => Tool.unsafeSecureJsonParse(finalParams), + catch: (cause) => + AiError.make({ + module: "AnthropicLanguageModel", + method: "makeStreamResponse", + reason: new AiError.ToolParameterValidationError({ + toolName: contentBlock.name, + description: `Failed to securely JSON parse tool parameters: ${cause}` + }) + }) + }) + const params = contentBlock.providerExecuted === true - ? Tool.unsafeSecureJsonParse(finalParams) - : yield* transformToolCallParams( - options.tools, - contentBlock.name, - Tool.unsafeSecureJsonParse(finalParams) - ) + ? toolParams + : yield* transformToolCallParams(options.tools, contentBlock.name, toolParams) parts.push({ type: "tool-call", @@ -3028,14 +3070,25 @@ const processCitation = Effect.fnUntraced( interface ModelCapabilities { readonly maxOutputTokens: number readonly supportsStructuredOutput: boolean + readonly supportsMidConversationSystemMessages: boolean +} + +const getConfigCapabilities = (config: typeof Config.Service & { readonly model: string }): ModelCapabilities => { + const capabilities = getModelCapabilities(config.model) + return { + ...capabilities, + supportsStructuredOutput: config.structuredOutputs ?? capabilities.supportsStructuredOutput, + supportsMidConversationSystemMessages: config.midConversationSystemMessages ?? + capabilities.supportsMidConversationSystemMessages + } } /** - * Returns the capabilities of a Claude model that are used for defaults and feature selection. - * Legacy models are listed as exceptions so newly released models inherit modern defaults. + * Returns model defaults, optimistically assuming modern capabilities for unknown IDs. * * @see https://docs.claude.com/en/docs/about-claude/models/overview#model-comparison-table * @see https://platform.claude.com/docs/en/build-with-claude/structured-outputs + * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages */ const getModelCapabilities = (modelId: string): ModelCapabilities => { if ( @@ -3045,12 +3098,14 @@ const getModelCapabilities = (modelId: string): ModelCapabilities => { ) { return { maxOutputTokens: 64000, - supportsStructuredOutput: true + supportsStructuredOutput: true, + supportsMidConversationSystemMessages: false } } else if (modelId.includes("claude-opus-4-1")) { return { maxOutputTokens: 32000, - supportsStructuredOutput: true + supportsStructuredOutput: true, + supportsMidConversationSystemMessages: false } } else if ( modelId.includes("claude-sonnet-4-0") || @@ -3059,7 +3114,8 @@ const getModelCapabilities = (modelId: string): ModelCapabilities => { ) { return { maxOutputTokens: 64000, - supportsStructuredOutput: false + supportsStructuredOutput: false, + supportsMidConversationSystemMessages: false } } else if ( modelId.includes("claude-opus-4-0") || @@ -3067,22 +3123,33 @@ const getModelCapabilities = (modelId: string): ModelCapabilities => { ) { return { maxOutputTokens: 32000, - supportsStructuredOutput: false + supportsStructuredOutput: false, + supportsMidConversationSystemMessages: false } } else if (modelId.includes("claude-3-5-haiku")) { return { maxOutputTokens: 8192, - supportsStructuredOutput: false + supportsStructuredOutput: false, + supportsMidConversationSystemMessages: false } } else if (modelId.includes("claude-3-")) { return { maxOutputTokens: 4096, - supportsStructuredOutput: false + supportsStructuredOutput: false, + supportsMidConversationSystemMessages: false } } else { return { maxOutputTokens: 128000, - supportsStructuredOutput: true + supportsStructuredOutput: true, + supportsMidConversationSystemMessages: !( + modelId.includes("claude-opus-4-6") || + modelId.includes("claude-opus-4-7") || + modelId.includes("claude-sonnet-4-6") || + modelId.includes("claude-mythos-preview") || + // Match Sonnet 5, excluding minor versions such as 5.5. + /claude-sonnet-5(?!-\d\b)/.test(modelId) + ) } } } diff --git a/repos/effect/packages/ai/anthropic/src/Generated.ts b/repos/effect/packages/ai/anthropic/src/Generated.ts index e9d060a6e..16969168e 100644 --- a/repos/effect/packages/ai/anthropic/src/Generated.ts +++ b/repos/effect/packages/ai/anthropic/src/Generated.ts @@ -8467,20 +8467,20 @@ export const InputContentBlock = Schema.Union([ ], { mode: "oneOf" }) export type BetaInputMessage = { readonly "content": string | ReadonlyArray - readonly "role": "user" | "assistant" + readonly "role": "user" | "assistant" | "system" } export const BetaInputMessage = Schema.Struct({ "content": Schema.Union([Schema.String, Schema.Array(BetaInputContentBlock)]).annotate({ "title": "Content" }), - "role": Schema.Literals(["user", "assistant"]).annotate({ "title": "Role" }) -}).annotate({ "title": "InputMessage" }) + "role": Schema.Literals(["user", "assistant", "system"]).annotate({ "title": "Role" }) +}).annotate({ "title": "InputMessage", "identifier": "BetaInputMessage" }) export type InputMessage = { readonly "content": string | ReadonlyArray - readonly "role": "user" | "assistant" + readonly "role": "user" | "assistant" | "system" } export const InputMessage = Schema.Struct({ "content": Schema.Union([Schema.String, Schema.Array(InputContentBlock)]).annotate({ "title": "Content" }), - "role": Schema.Literals(["user", "assistant"]).annotate({ "title": "Role" }) -}).annotate({ "title": "InputMessage" }) + "role": Schema.Literals(["user", "assistant", "system"]).annotate({ "title": "Role" }) +}).annotate({ "title": "InputMessage", "identifier": "InputMessage" }) export type BetaCountMessageTokensParams = { readonly "cache_control"?: BetaCacheControlEphemeral | null readonly "context_management"?: BetaContextManagementConfig | null diff --git a/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts b/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts index ef3a0c5fe..50e37c586 100644 --- a/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts +++ b/repos/effect/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts @@ -1,6 +1,6 @@ import { AnthropicClient, AnthropicLanguageModel, AnthropicTool } from "@effect/ai-anthropic" import { assert, describe, it } from "@effect/vitest" -import { Effect, Layer, Redacted, Schema, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Option, Redacted, Schema, Stream } from "effect" import { type AiError, AnthropicStructuredOutput, @@ -185,6 +185,98 @@ describe("AnthropicLanguageModel", () => { assert.deepStrictEqual(toolCall.params, toolParams) })) + for (const blockType of ["tool_use", "server_tool_use"] as const) { + it.effect(`fails with AiError for malformed streamed ${blockType} JSON`, () => + Effect.gen(function*() { + const toolName = blockType === "tool_use" ? "GlobTool" : "web_search" + const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + makeHttpClient((request) => + Effect.succeed(sseResponse(request, [ + { + type: "message_start", + message: { + id: "msg_test_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4-20250514", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + cache_creation: null, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + inference_geo: null, + input_tokens: 10, + output_tokens: 0, + service_tier: null + } + } + }, + { + type: "content_block_start", + index: 0, + content_block: { + type: blockType, + id: blockType === "tool_use" ? "toolu_test_1" : "srvtoolu_test_1", + name: toolName, + input: {} + } + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "{\"pattern\":" } + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "\"*.ts" } + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "max_tokens", stop_sequence: null }, + usage: { + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + input_tokens: null, + output_tokens: 5 + } + }, + { type: "message_stop" } + ])) + ) + )) + ) + const toolkit = Toolkit.make( + Tool.make("GlobTool", { parameters: Schema.Struct({ pattern: Schema.String }) }), + AnthropicTool.WebSearch_20250305({}) + ) + const exit = yield* LanguageModel.streamText({ + prompt: "find ts files", + toolkit, + disableToolCallResolution: true + }).pipe( + Stream.runCollect, + Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")), + Effect.provide(layer), + Effect.exit + ) + + assert.isTrue(Exit.isFailure(exit)) + if (!Exit.isFailure(exit)) { + return yield* Effect.die(new Error("Expected malformed tool JSON to fail")) + } + assert.isFalse(Cause.hasDies(exit.cause)) + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)) + assert.strictEqual(error._tag, "AiError") + assert.strictEqual(error.reason._tag, "ToolParameterValidationError") + })) + } + it.effect("routes invalid tool call params through failureMode: return without failing the stream", () => Effect.gen(function*() { const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( @@ -1119,6 +1211,120 @@ describe("AnthropicLanguageModel", () => { })) }) + describe("system messages", () => { + const getRequest = ( + model: string, + prompt: Prompt.RawInput, + config?: { readonly midConversationSystemMessages?: boolean | undefined } + ) => + Effect.gen(function*() { + let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined + const layer = AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + makeHttpClient((request) => { + capturedRequest = request + return Effect.succeed(jsonResponse(request, { + id: "msg_test_1", + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: "Done" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { + cache_creation: null, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + inference_geo: null, + input_tokens: 1, + output_tokens: 1, + service_tier: null + } + })) + }) + )) + ) + + yield* LanguageModel.generateText({ prompt, disableToolCallResolution: true }).pipe( + Effect.provide(AnthropicLanguageModel.model(model, config)), + Effect.provide(layer) + ) + + if (capturedRequest === undefined) { + return yield* Effect.die(new Error("Expected a captured request")) + } + return yield* getRequestBody(capturedRequest) + }) + + const conversation = Prompt.make([ + { role: "system", content: "A" }, + { role: "user", content: "Question 1" }, + { role: "assistant", content: "Answer 1" }, + { role: "system", content: "B" }, + { role: "user", content: "Question 2" } + ]) + + it.effect("sends later system messages after the next user turn", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-5-5", conversation) + + assert.deepStrictEqual(body.system.map((block: any) => block.text), ["A"]) + assert.deepStrictEqual(body.messages.map((message: any) => message.role), [ + "user", + "assistant", + "user", + "system" + ]) + assert.deepStrictEqual(body.messages.at(-1), { + role: "system", + content: [{ type: "text", text: "B", cache_control: null }] + }) + })) + + it.effect("sends every system message top-level when disabled", () => + Effect.gen(function*() { + const body = yield* getRequest("claude-sonnet-5-5", conversation, { midConversationSystemMessages: false }) + + assert.deepStrictEqual(body.system.map((block: any) => block.text), ["A", "B"]) + assert.deepStrictEqual(body.messages.map((message: any) => message.role), ["user", "assistant", "user"]) + assert.notProperty(body, "midConversationSystemMessages") + })) + + it.effect("sends every system message top-level in order when one cannot follow a user turn", () => + Effect.gen(function*() { + const body = yield* getRequest( + "claude-sonnet-5-5", + Prompt.make([ + { role: "system", content: "A" }, + { role: "user", content: "Question" }, + { role: "system", content: "B" }, + { role: "assistant", content: "Answer" }, + { role: "system", content: "C" } + ]) + ) + + assert.deepStrictEqual(body.system.map((block: any) => block.text), ["A", "B", "C"]) + assert.deepStrictEqual(body.messages.map((message: any) => message.role), ["user", "assistant"]) + })) + + it.effect("reports support for the requested model", () => + Effect.gen(function*() { + const model = yield* LanguageModel.LanguageModel + + assert.isTrue(yield* model.supportsSystemMessagesInHistory!) + assert.isFalse( + yield* model.supportsSystemMessagesInHistory!.pipe( + AnthropicLanguageModel.withConfigOverride({ model: "claude-sonnet-5" }) + ) + ) + }).pipe( + Effect.provide(AnthropicLanguageModel.model("claude-sonnet-5-5")), + Effect.provide(AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") })), + Effect.provide(Layer.succeed(HttpClient.HttpClient, makeHttpClient(() => Effect.die("unexpected request")))) + )) + }) + // The packaged `Memory_20250818` tool ships `customName: "AnthropicMemory"` / // `providerName: "memory"`, and is a client-executed provider tool. These // tests cover the round-trip that was broken on beta.98 (see #2615): diff --git a/repos/effect/packages/ai/cloudflare/CHANGELOG.md b/repos/effect/packages/ai/cloudflare/CHANGELOG.md new file mode 100644 index 000000000..f57ee39b7 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/CHANGELOG.md @@ -0,0 +1,9 @@ +# @effect/ai-cloudflare + +## 4.0.1 + +### Patch Changes + +- [#8677](https://github.com/Effect-TS/effect/pull/8677) [`480bba2`](https://github.com/Effect-TS/effect/commit/480bba2dfebba5b0e58695a9837895dfb65895ee) Thanks @danieljvdm! - Add a Cloudflare Workers AI decision provider for Clef and Clef-flash. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 diff --git a/repos/effect/packages/ai/cloudflare/README.md b/repos/effect/packages/ai/cloudflare/README.md new file mode 100644 index 000000000..ed3b8a026 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/README.md @@ -0,0 +1,44 @@ +# @effect/ai-cloudflare + +Cloudflare Workers AI provider for Effect's `DecisionModel`. Supports text and JSON classification, ordered ratings, and probabilities with Clef and Clef-flash over REST. + +## Installation + +```sh +npm install effect @effect/ai-cloudflare +``` + +## Usage + +Set `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, then provide a Cloudflare decision model: + +```ts +import { CloudflareClient, CloudflareDecisionModel } from "@effect/ai-cloudflare" +import { Effect, Layer, Schema } from "effect" +import { Decision, DecisionModel } from "effect/ai" +import { FetchHttpClient } from "effect/http" + +const Triage = Decision.make({ + input: Schema.String, + decisions: { + urgent: Decision.probability({ + instructions: "Does this request need immediate attention?" + }) + } +}) + +const Clef = CloudflareDecisionModel.layer({ model: "clef-flash" }).pipe( + Layer.provide(CloudflareClient.layerConfig()), + Layer.provide(FetchHttpClient.layer) +) + +const program = DecisionModel.decide(Triage, { + input: "Checkout has been failing for every customer for an hour." +}).pipe(Effect.provide(Clef)) +``` + +Use `"clef"` for the larger model. `CloudflareDecisionModel.model` provides a model descriptor with Cloudflare provider metadata. `CloudflareClient.layer` accepts an explicit account ID and redacted API token; `apiUrl` and `transformClient` customize HTTP access. + +The client unwraps Cloudflare's REST envelope and maps failures to `AiError`. It does not retry automatically. Rate-limit errors include `Retry-After` when provided. The decision model validates answers and rescales small probability drift from four-decimal rounding. + +Cloudflare limits each request to 64 questions, classification to 2–255 choices, and ratings to 2–10 levels. See the [Clef API documentation](https://developers.cloudflare.com/workers-ai/models/clef/) for request limits. diff --git a/repos/effect/packages/ai/cloudflare/package.json b/repos/effect/packages/ai/cloudflare/package.json new file mode 100644 index 000000000..56b7826f8 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/package.json @@ -0,0 +1,69 @@ +{ + "name": "@effect/ai-cloudflare", + "version": "4.0.1", + "type": "module", + "license": "MIT", + "description": "A Cloudflare Workers AI decision provider for Effect", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/ai/cloudflare" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "tags": [ + "typescript", + "ai", + "cloudflare" + ], + "keywords": [ + "typescript", + "ai", + "cloudflare" + ], + "sideEffects": [], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null, + "./index": null, + "./*/index": null + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "AGENTS.md", + "CLAUDE.md", + "ai-docs/**/*" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null, + "./index": null, + "./*/index": null + } + }, + "scripts": { + "codegen": "effect-utils codegen", + "build": "tsc -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps", + "check": "tsc -b tsconfig.json" + }, + "devDependencies": { + "effect": "workspace:^" + }, + "peerDependencies": { + "effect": "workspace:^" + } +} diff --git a/repos/effect/packages/ai/cloudflare/src/CloudflareClient.ts b/repos/effect/packages/ai/cloudflare/src/CloudflareClient.ts new file mode 100644 index 000000000..73eba727b --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/src/CloudflareClient.ts @@ -0,0 +1,149 @@ +/** + * HTTP client for Cloudflare Clef decisions through Workers AI REST. + * + * @stability unstable + * @since 4.0.0 + */ +import * as AiError from "effect/ai/AiError" +import * as Config from "effect/Config" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { flow, identity } from "effect/Function" +import * as HttpClient from "effect/http/HttpClient" +import * as HttpClientRequest from "effect/http/HttpClientRequest" +import * as HttpClientResponse from "effect/http/HttpClientResponse" +import * as Layer from "effect/Layer" +import * as Redacted from "effect/Redacted" +import * as Schema from "effect/Schema" +import * as CloudflareSchema from "./CloudflareSchema.ts" +import * as Errors from "./internal/errors.ts" + +/** + * Low-level Cloudflare decision operations. + * + * @stability unstable + * @category services + * @since 4.0.0 + */ +export interface Service { + readonly client: HttpClient.HttpClient + readonly createDecisions: ( + request: typeof CloudflareSchema.DecisionsRequest.Encoded + ) => Effect.Effect +} + +/** + * Cloudflare Workers AI client service. + * + * @stability unstable + * @category services + * @since 4.0.0 + */ +export class CloudflareClient + extends Context.Service()("@effect/ai-cloudflare/CloudflareClient") +{} + +/** + * Account, authentication, API base URL, and HTTP customization options. + * + * @stability unstable + * @category options + * @since 4.0.0 + */ +export interface Options { + readonly accountId: string + readonly apiKey: Redacted.Redacted + readonly apiUrl?: string | undefined + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined +} + +const decodeResponse = HttpClientResponse.schemaBodyJson(Schema.Union([ + Schema.Struct({ success: Schema.Literal(true), result: CloudflareSchema.DecisionsResponse }), + Schema.Struct({ success: Schema.Literal(false) }) +])) + +/** + * Builds a Cloudflare client without automatic retries. + * + * @stability unstable + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.fnUntraced( + function*(options: Options): Effect.fn.Return { + const base = yield* HttpClient.HttpClient + const client = base.pipe( + HttpClient.mapRequest(flow( + HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.cloudflare.com/client/v4"), + HttpClientRequest.bearerToken(Redacted.value(options.apiKey)), + HttpClientRequest.acceptJson + )), + HttpClient.filterStatusOk, + options.transformClient ?? identity + ) + const path = `/accounts/${encodeURIComponent(options.accountId)}/ai/run/@cf/cloudflare/` + return CloudflareClient.of({ + client, + createDecisions: (payload) => + HttpClientRequest.bodyJson(HttpClientRequest.post(path + payload.model), payload).pipe( + Effect.mapError((error) => + AiError.make({ + module: "CloudflareClient", + method: "createDecisions", + reason: new AiError.InvalidRequestError({ description: String(error) }) + }) + ), + Effect.flatMap(client.execute), + Effect.flatMap((response) => + decodeResponse(response).pipe( + Effect.flatMap((body) => body.success ? Effect.succeed(body.result) : Errors.mapResponseError(response)) + ) + ), + Effect.catchTags({ + HttpClientError: Errors.mapHttpClientError, + SchemaError: (error) => + Effect.fail(AiError.make({ + module: "CloudflareClient", + method: "createDecisions", + reason: AiError.InvalidOutputError.fromSchemaError(error) + })) + }) + ) + }) + } +) + +/** + * Provides a Cloudflare client from explicit options. + * + * @stability unstable + * @category layers + * @since 4.0.0 + */ +export const layer = (options: Options): Layer.Layer => + Layer.effect(CloudflareClient, make(options)) + +/** + * Provides a client from configuration, defaulting to CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. + * + * @stability unstable + * @category layers + * @since 4.0.0 + */ +export const layerConfig = (options?: { + readonly accountId?: Config.Config | undefined + readonly apiKey?: Config.Config> | undefined + readonly apiUrl?: Config.Config | undefined + readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined +}): Layer.Layer => + Layer.effect( + CloudflareClient, + Effect.gen(function*() { + return yield* make({ + accountId: yield* (options?.accountId ?? Config.String("CLOUDFLARE_ACCOUNT_ID")), + apiKey: yield* (options?.apiKey ?? Config.Redacted("CLOUDFLARE_API_TOKEN")), + apiUrl: options?.apiUrl ? yield* options.apiUrl : undefined, + transformClient: options?.transformClient + }) + }) + ) diff --git a/repos/effect/packages/ai/cloudflare/src/CloudflareDecisionModel.ts b/repos/effect/packages/ai/cloudflare/src/CloudflareDecisionModel.ts new file mode 100644 index 000000000..769875dbb --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/src/CloudflareDecisionModel.ts @@ -0,0 +1,111 @@ +/** + * Cloudflare Clef implementation of DecisionModel over Workers AI REST. + * + * @stability unstable + * @since 4.0.0 + */ +import * as DecisionModel from "effect/ai/DecisionModel" +import * as AiModel from "effect/ai/Model" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { CloudflareClient } from "./CloudflareClient.ts" +import type * as CloudflareSchema from "./CloudflareSchema.ts" + +/** + * Cloudflare Clef model identifiers. + * + * @stability unstable + * @category models + * @since 4.0.0 + */ +export type Model = typeof CloudflareSchema.DecisionsRequest.Encoded["model"] + +/** + * Creates a decision model with Cloudflare provider metadata. + * + * @stability unstable + * @category constructors + * @since 4.0.0 + */ +export const model = ( + model: Model +): AiModel.Model<"cloudflare", DecisionModel.DecisionModel, CloudflareClient> => + AiModel.make("cloudflare", model, layer({ model })) + +/** + * Builds a decision service with tolerance for four-decimal probability rounding. + * + * @stability unstable + * @category constructors + * @since 4.0.0 + */ +export const make = Effect.fnUntraced( + function*( + options: { readonly model: Model } + ): Effect.fn.Return { + const client = yield* CloudflareClient + return yield* DecisionModel.make({ + probabilityPrecision: 4, + decide: Effect.fnUntraced(function*({ state, decisions }) { + const questions: Record = Object.create(null) + for (const [key, decision] of Object.entries(decisions)) { + switch (decision._tag) { + case "Classify": + questions[key] = { type: "choice", instructions: decision.instructions, criteria: decision.criteria } + break + case "Rate": + questions[key] = { type: "score", instructions: decision.instructions, criteria: decision.criteria } + break + case "Probability": + questions[key] = { type: "noul", instructions: decision.instructions, criteria: decision.criteria } + break + } + } + const response = yield* client.createDecisions({ model: options.model, state, questions }) + const answers: Record = Object.create(null) + for (const [key, decision] of Object.entries(decisions)) { + const answer = response.answers[key] + switch (answer?.type) { + case "choice": + answers[key] = { + _tag: "Classify", + label: answer.choice, + probabilities: answer.probabilities, + confidence: answer.confidence + } + break + case "score": { + const levels = decision._tag === "Rate" ? decision.criteria : [] + const probabilities: Record = Object.create(null) + for (let index = 0; index < levels.length; index++) { + const probability = answer.probabilities[String(index)] + if (probability !== undefined) probabilities[levels[index]] = probability + } + answers[key] = { _tag: "Rate", rating: answer.score, probabilities, confidence: answer.confidence } + break + } + case "noul": + answers[key] = { _tag: "Probability", probability: answer.noul } + break + } + } + return { + answers, + usage: { inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens } + } + }) + }) + } +) + +/** + * Provides DecisionModel using an existing CloudflareClient. + * + * @stability unstable + * @category layers + * @since 4.0.0 + */ +export const layer = ( + options: { readonly model: Model } +): Layer.Layer => + Layer.effect(DecisionModel.DecisionModel, make(options)) diff --git a/repos/effect/packages/ai/cloudflare/src/CloudflareSchema.ts b/repos/effect/packages/ai/cloudflare/src/CloudflareSchema.ts new file mode 100644 index 000000000..3ae972da0 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/src/CloudflareSchema.ts @@ -0,0 +1,131 @@ +/** + * Codecs for text and JSON decisions from Cloudflare Clef models. + * + * @stability unstable + * @since 4.0.0 + */ +import * as Schema from "effect/Schema" + +/** + * A classification question. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const ChoiceQuestion = Schema.Struct({ + type: Schema.Literal("choice"), + instructions: Schema.String, + criteria: Schema.Record(Schema.String, Schema.String) +}) + +/** + * An ordered rating question. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const ScoreQuestion = Schema.Struct({ + type: Schema.Literal("score"), + instructions: Schema.String, + criteria: Schema.Array(Schema.String) +}) + +/** + * A probability question. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const NoulQuestion = Schema.Struct({ + type: Schema.Literal("noul"), + instructions: Schema.String, + criteria: Schema.optional(Schema.Struct({ false: Schema.String, true: Schema.String })) +}) + +/** + * A Clef classification, rating, or probability question. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const Question = Schema.Union([ChoiceQuestion, ScoreQuestion, NoulQuestion]) + +/** + * A batch of questions about text or JSON state. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const DecisionsRequest = Schema.Struct({ + model: Schema.Union([Schema.Literals(["clef", "clef-flash"]), Schema.StringForLiteralAutocomplete]), + state: Schema.Json, + questions: Schema.Record(Schema.String, Question) +}) + +/** + * A classification distribution. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const ChoiceAnswer = Schema.Struct({ + type: Schema.Literal("choice"), + choice: Schema.String, + probabilities: Schema.Record(Schema.String, Schema.Number), + confidence: Schema.Number +}) + +/** + * A zero-based rating and index-keyed distribution. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const ScoreAnswer = Schema.Struct({ + type: Schema.Literal("score"), + score: Schema.Number, + probabilities: Schema.Record(Schema.String, Schema.Number), + confidence: Schema.Number, + legend: Schema.Record(Schema.String, Schema.String) +}) + +/** + * A Bernoulli probability. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Schema.Number }) + +/** + * A Clef classification, rating, or probability answer. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const Answer = Schema.Union([ChoiceAnswer, ScoreAnswer, NoulAnswer]) + +/** + * Answers and token usage for a batch. + * + * @stability unstable + * @category schemas + * @since 4.0.0 + */ +export const DecisionsResponse = Schema.Struct({ + model: Schema.String, + answers: Schema.Record(Schema.String, Answer), + usage: Schema.Struct({ + input_tokens: Schema.Int, + output_tokens: Schema.Int + }) +}) diff --git a/repos/effect/packages/ai/cloudflare/src/index.ts b/repos/effect/packages/ai/cloudflare/src/index.ts new file mode 100644 index 000000000..bcabe60b4 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/src/index.ts @@ -0,0 +1,23 @@ +/** + * @since 4.0.0 + */ + +// @barrel + +/** + * @stability unstable + * @since 4.0.0 + */ +export * as CloudflareClient from "./CloudflareClient.ts" + +/** + * @stability unstable + * @since 4.0.0 + */ +export * as CloudflareDecisionModel from "./CloudflareDecisionModel.ts" + +/** + * @stability unstable + * @since 4.0.0 + */ +export * as CloudflareSchema from "./CloudflareSchema.ts" diff --git a/repos/effect/packages/ai/cloudflare/src/internal/errors.ts b/repos/effect/packages/ai/cloudflare/src/internal/errors.ts new file mode 100644 index 000000000..b2f5caad4 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/src/internal/errors.ts @@ -0,0 +1,101 @@ +import * as AiError from "effect/ai/AiError" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import type * as HttpClientError from "effect/http/HttpClientError" +import type * as HttpClientRequest from "effect/http/HttpClientRequest" +import type * as HttpClientResponse from "effect/http/HttpClientResponse" +import * as Num from "effect/Number" +import * as Option from "effect/Option" +import * as Redactable from "effect/Redactable" +import * as Schema from "effect/Schema" + +const requestDetails = (request: HttpClientRequest.HttpClientRequest) => ({ + method: request.method, + url: request.url, + urlParams: Array.from(request.urlParams), + hash: Option.getOrUndefined(request.hash), + headers: Redactable.redact(request.headers) as Record +}) + +const ErrorBody = Schema.Struct({ + errors: Schema.Array(Schema.Struct({ code: Schema.Number, message: Schema.String })) +}) + +const decodeErrorBody = Schema.decodeUnknownOption(ErrorBody) + +const retryAfter = (raw: string | undefined): Duration.Duration | undefined => { + if (raw === undefined || raw.trim() === "") return undefined + const seconds = Option.getOrUndefined(Num.parse(raw)) + if (seconds !== undefined) return Number.isFinite(seconds) && seconds >= 0 ? Duration.seconds(seconds) : undefined + const date = Date.parse(raw) + return Number.isNaN(date) ? undefined : Duration.millis(Math.max(0, date - Date.now())) +} + +const fail = (reason: AiError.AiErrorReason) => + Effect.fail(AiError.make({ module: "CloudflareClient", method: "createDecisions", reason })) + +export const mapResponseError = Effect.fnUntraced( + function*(response: HttpClientResponse.HttpClientResponse): Effect.fn.Return { + const { request, status } = response + const body = yield* response.text.pipe(Effect.orElseSucceed(() => undefined)) + const parsed = yield* Effect.try(() => JSON.parse(body ?? "")).pipe(Effect.orElseSucceed(() => undefined)) + const details = Option.getOrUndefined(decodeErrorBody(parsed)) + const http = { + request: requestDetails(request), + response: { status, headers: Redactable.redact(response.headers) as Record }, + body + } + const description = AiError.buildErrorDescription({ + status, + method: request.method, + url: request.url, + body, + message: details?.errors.map((error) => error.message).join("; "), + errorCode: details?.errors[0]?.code, + requestId: response.headers["cf-ray"] + }) + if (status === 429) { + return yield* fail( + new AiError.RateLimitError({ + retryAfter: retryAfter(response.headers["retry-after"]), + http, + metadata: { cloudflare: { rayId: response.headers["cf-ray"] ?? null, errors: details?.errors ?? [] } } + }) + ) + } + if (status === 404 || status === 422) { + return yield* fail(new AiError.InvalidRequestError({ description, http })) + } + if (status >= 200 && status < 300) { + return yield* fail(new AiError.InternalProviderError({ description, http })) + } + return yield* fail(AiError.reasonFromHttpStatus({ status, description, http })) + } +) + +export const mapHttpClientError = Effect.fnUntraced( + function*(error: HttpClientError.HttpClientError): Effect.fn.Return { + const source = error.reason + switch (source._tag) { + case "TransportError": + case "EncodeError": + case "InvalidUrlError": + return yield* fail( + new AiError.NetworkError({ + reason: source._tag, + description: source.description, + request: requestDetails(source.request) + }) + ) + case "DecodeError": + case "EmptyBodyError": + return yield* fail( + new AiError.InvalidOutputError({ + description: source.description ?? "Failed to decode Cloudflare response" + }) + ) + case "StatusCodeError": + return yield* mapResponseError(source.response) + } + } +) diff --git a/repos/effect/packages/ai/cloudflare/test/CloudflareClient.test.ts b/repos/effect/packages/ai/cloudflare/test/CloudflareClient.test.ts new file mode 100644 index 000000000..daef1a985 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/test/CloudflareClient.test.ts @@ -0,0 +1,172 @@ +import * as CloudflareClient from "@effect/ai-cloudflare/CloudflareClient" +import { assert, describe, it } from "@effect/vitest" +import { Config, ConfigProvider, Duration, Effect, Layer, Redacted } from "effect" +import * as HttpClient from "effect/http/HttpClient" +import * as HttpClientError from "effect/http/HttpClientError" +import * as HttpClientRequest from "effect/http/HttpClientRequest" +import * as HttpClientResponse from "effect/http/HttpClientResponse" + +const payload = { + model: "clef-flash", + state: "Checkout is down", + questions: { urgent: { type: "noul", instructions: "Does this need immediate attention?" } } +} as const + +const result = { + model: "clef-flash", + answers: { urgent: { type: "noul", noul: 0.99 } }, + usage: { input_tokens: 120, output_tokens: 0 } +} as const + +const httpLayer = ( + handler: ( + request: HttpClientRequest.HttpClientRequest + ) => Effect.Effect +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.makeWith( + Effect.flatMap(handler), + Effect.succeed as HttpClient.HttpClient.Preprocess + ) + ) + +const clientLayer = (response: (request: HttpClientRequest.HttpClientRequest) => Response) => + CloudflareClient.layer({ accountId: "test-account", apiKey: Redacted.make("test-token") }).pipe( + Layer.provide(httpLayer((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response(request))))) + ) + +const createDecisions = Effect.gen(function*() { + const client = yield* CloudflareClient.CloudflareClient + return yield* client.createDecisions(payload) +}) + +describe("CloudflareClient", () => { + it.effect("posts decisions to the account model endpoint and unwraps the result envelope", () => + Effect.gen(function*() { + const response = yield* createDecisions + assert.deepStrictEqual(response, result) + }).pipe(Effect.provide(clientLayer((request) => { + assert.strictEqual(request.method, "POST") + assert.strictEqual( + request.url, + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/run/@cf/cloudflare/clef-flash" + ) + assert.strictEqual(request.headers.authorization, "Bearer test-token") + assert.strictEqual(request.body._tag, "Uint8Array") + if (request.body._tag === "Uint8Array") { + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), payload) + } + return Response.json({ success: true, result, errors: [], messages: [] }) + })))) + + it.effect("layerConfig reads Cloudflare credentials from the environment", () => + createDecisions.pipe( + Effect.provide(CloudflareClient.layerConfig({ + apiUrl: Config.String("TEST_API_URL"), + transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader("x-test", "configured")) + })), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ + env: { + CLOUDFLARE_ACCOUNT_ID: "configured-account", + CLOUDFLARE_API_TOKEN: "configured-token", + TEST_API_URL: "https://example.com/api" + } + }))), + Effect.provide(httpLayer((request) => { + assert.strictEqual( + request.url, + "https://example.com/api/accounts/configured-account/ai/run/@cf/cloudflare/clef-flash" + ) + assert.strictEqual(request.headers.authorization, "Bearer configured-token") + assert.strictEqual(request.headers["x-test"], "configured") + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ success: true, result }))) + })) + )) + + describe("error mapping", () => { + for ( + const [status, tag] of [ + [401, "AuthenticationError"], + [404, "InvalidRequestError"], + [422, "InvalidRequestError"], + [200, "InternalProviderError"] + ] as const + ) { + it.effect(`maps a failure envelope with HTTP ${status} to ${tag}`, () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.module, "CloudflareClient") + assert.strictEqual(error.method, "createDecisions") + assert.strictEqual(error.reason._tag, tag) + if ("http" in error.reason) { + assert.strictEqual(error.reason.http?.response?.status, status) + assert.notInclude(JSON.stringify(error.reason.http?.request), "test-token") + } + if ("description" in error.reason) { + assert.include(error.reason.description, "Request failed") + assert.include(error.reason.description, "3040") + assert.include(error.reason.description, "test-ray") + } + }).pipe(Effect.provide(clientLayer(() => + Response.json( + { success: false, result: null, errors: [{ code: 3040, message: "Request failed" }] }, + { status, headers: { "cf-ray": "test-ray" } } + ) + )))) + } + + it.effect("maps a failure envelope without errors with HTTP 200 to InternalProviderError", () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.reason._tag, "InternalProviderError") + }).pipe(Effect.provide(clientLayer(() => Response.json({ success: false, result: null }))))) + + it.effect("maps 429 to RateLimitError with Retry-After and Cloudflare metadata", () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.reason._tag, "RateLimitError") + if (error.reason._tag === "RateLimitError") { + assert.strictEqual(Duration.toMillis(error.reason.retryAfter!), 2000) + assert.deepStrictEqual(error.reason.metadata, { + cloudflare: { rayId: "test-ray", errors: [{ code: 3040, message: "Request failed" }] } + }) + } + }).pipe(Effect.provide(clientLayer(() => + Response.json( + { success: false, result: null, errors: [{ code: 3040, message: "Request failed" }] }, + { status: 429, headers: { "cf-ray": "test-ray", "retry-after": "2" } } + ) + )))) + + it.effect("maps a non-JSON failure body by HTTP status", () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.reason._tag, "InternalProviderError") + if (error.reason._tag === "InternalProviderError") { + assert.include(error.reason.description, "Bad gateway") + } + }).pipe(Effect.provide(clientLayer(() => new Response("Bad gateway", { status: 502 }))))) + + it.effect("maps a success body without a result envelope to InvalidOutputError", () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.reason._tag, "InvalidOutputError") + }).pipe(Effect.provide(clientLayer(() => Response.json(result))))) + + it.effect("maps TransportError to NetworkError", () => + Effect.gen(function*() { + const error = yield* Effect.flip(createDecisions) + assert.strictEqual(error.reason._tag, "NetworkError") + }).pipe( + Effect.provide(CloudflareClient.layer({ accountId: "test-account", apiKey: Redacted.make("test-token") })), + Effect.provide(httpLayer((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: new Error("Connection refused") }) + }) + ) + )) + )) + }) +}) diff --git a/repos/effect/packages/ai/cloudflare/test/CloudflareDecisionModel.test.ts b/repos/effect/packages/ai/cloudflare/test/CloudflareDecisionModel.test.ts new file mode 100644 index 000000000..ff4c27d79 --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/test/CloudflareDecisionModel.test.ts @@ -0,0 +1,175 @@ +import * as CloudflareClient from "@effect/ai-cloudflare/CloudflareClient" +import * as CloudflareDecisionModel from "@effect/ai-cloudflare/CloudflareDecisionModel" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Redacted, Schema } from "effect" +import { Decision, DecisionModel, Model } from "effect/ai" +import * as HttpClient from "effect/http/HttpClient" +import type * as HttpClientError from "effect/http/HttpClientError" +import type * as HttpClientRequest from "effect/http/HttpClientRequest" +import * as HttpClientResponse from "effect/http/HttpClientResponse" + +const Triage = Decision.make({ + input: Schema.Struct({ message: Schema.String }), + decisions: { + team: Decision.classify({ + instructions: "Which team should handle this request?", + criteria: { billing: "Payments", technical: "Outages" } + }), + severity: Decision.rate({ + instructions: "How severe is the impact?", + criteria: ["Minor", "Major", "Critical"] + }), + urgent: Decision.probability({ + instructions: "Does this need immediate attention?", + criteria: { false: "Can wait", true: "Act now" } + }) + } +}) + +const input = { message: "Checkout is down" } + +const answers = { + team: { + type: "choice", + choice: "technical", + confidence: 0.9, + probabilities: { billing: 0.1, technical: 0.9 } + }, + severity: { + type: "score", + score: 1.75, + confidence: 0.8, + probabilities: { "0": 0.05, "1": 0.15, "2": 0.8 }, + legend: { "0": "Minor", "1": "Major", "2": "Critical" } + }, + urgent: { type: "noul", noul: 0.99 } +} + +const clientLayer = ( + answers: unknown, + onRequest: (request: HttpClientRequest.HttpClientRequest) => void = () => {}, + model = "clef" +) => + CloudflareClient.layer({ accountId: "test-account", apiKey: Redacted.make("test-token") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + HttpClient.makeWith( + Effect.map((request) => { + onRequest(request) + return HttpClientResponse.fromWeb( + request, + Response.json({ + success: true, + result: { model, answers, usage: { input_tokens: 120, output_tokens: 0 } } + }) + ) + }), + Effect.succeed as HttpClient.HttpClient.Preprocess + ) + )) + ) + +const layer = (answers: unknown) => + CloudflareDecisionModel.layer({ model: "clef" }).pipe(Layer.provide(clientLayer(answers))) + +describe("CloudflareDecisionModel", () => { + it.effect("sends every decision as a question and maps the answers", () => + Effect.gen(function*() { + const result = yield* DecisionModel.decide(Triage, { input }) + assert.strictEqual(yield* Model.ProviderName, "cloudflare") + assert.strictEqual(yield* Model.ModelName, "clef") + assert.strictEqual(result.answers.team.label, "technical") + assert.deepStrictEqual({ ...result.answers.team.probabilities }, { billing: 0.1, technical: 0.9 }) + assert.strictEqual(result.answers.team.confidence, 0.9) + assert.strictEqual(result.answers.severity.label, "Critical") + assert.strictEqual(result.answers.severity.rating, 1.75) + assert.deepStrictEqual({ ...result.answers.severity.probabilities }, { Minor: 0.05, Major: 0.15, Critical: 0.8 }) + assert.strictEqual(result.answers.urgent.probability, 0.99) + assert.strictEqual(result.usage.inputTokens, 120) + assert.strictEqual(result.usage.outputTokens, 0) + }).pipe( + Effect.provide(CloudflareDecisionModel.model("clef")), + Effect.provide(clientLayer(answers, (request) => { + assert.strictEqual(request.body._tag, "Uint8Array") + if (request.body._tag === "Uint8Array") { + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { + model: "clef", + state: input, + questions: { + team: { + type: "choice", + instructions: Triage.decisions.team.instructions, + criteria: Triage.decisions.team.criteria + }, + severity: { + type: "score", + instructions: Triage.decisions.severity.instructions, + criteria: Triage.decisions.severity.criteria + }, + urgent: { + type: "noul", + instructions: Triage.decisions.urgent.instructions, + criteria: Triage.decisions.urgent.criteria + } + } + }) + } + })) + )) + + it.effect("sends an arbitrary model identifier through the decision request", () => { + const model = "clef-next" + return Effect.gen(function*() { + const result = yield* DecisionModel.decide(Triage, { input }) + assert.strictEqual(yield* Model.ModelName, model) + assert.strictEqual(result.answers.urgent.probability, 0.99) + }).pipe( + Effect.provide(CloudflareDecisionModel.model(model)), + Effect.provide(clientLayer(answers, (request) => { + assert.strictEqual( + request.url, + `https://api.cloudflare.com/client/v4/accounts/test-account/ai/run/@cf/cloudflare/${model}` + ) + assert.strictEqual(request.body._tag, "Uint8Array") + if (request.body._tag === "Uint8Array") { + assert.strictEqual(JSON.parse(new TextDecoder().decode(request.body.body)).model, model) + } + }, model)) + ) + }) + + it.effect("accepts probabilities with four-decimal rounding drift", () => + Effect.gen(function*() { + const result = yield* DecisionModel.decide(Triage, { input }) + assert.strictEqual(result.answers.severity.label, "Minor") + assert.closeTo(result.answers.severity.probabilities.Minor, 1 / 3, 1e-10) + }).pipe(Effect.provide(layer({ + ...answers, + severity: { + ...answers.severity, + score: 1, + confidence: 0.3333, + probabilities: { "0": 0.3333, "1": 0.3333, "2": 0.3333 } + } + })))) + + for ( + const [name, invalid] of [ + ["a missing answer", { team: answers.team, severity: answers.severity }], + ["a skipped rating level", { + ...answers, + severity: { ...answers.severity, probabilities: { "0": 0.2, "2": 0.8 } } + }], + ["drift beyond four decimals", { + ...answers, + team: { ...answers.team, probabilities: { billing: 0.1, technical: 0.89 } } + }] + ] as const + ) { + it.effect(`fails with InvalidOutputError for ${name}`, () => + Effect.gen(function*() { + const error = yield* Effect.flip(DecisionModel.decide(Triage, { input })) + assert.strictEqual(error.reason._tag, "InvalidOutputError") + }).pipe(Effect.provide(layer(invalid)))) + } +}) diff --git a/repos/effect/packages/ai/cloudflare/tsconfig.json b/repos/effect/packages/ai/cloudflare/tsconfig.json new file mode 100644 index 000000000..e2a8ca19a --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../../effect" } + ] +} diff --git a/repos/effect/packages/ai/cloudflare/typetest/CloudflareDecisionModel.tst.ts b/repos/effect/packages/ai/cloudflare/typetest/CloudflareDecisionModel.tst.ts new file mode 100644 index 000000000..880636e3e --- /dev/null +++ b/repos/effect/packages/ai/cloudflare/typetest/CloudflareDecisionModel.tst.ts @@ -0,0 +1,20 @@ +import type * as CloudflareClient from "@effect/ai-cloudflare/CloudflareClient" +import * as CloudflareDecisionModel from "@effect/ai-cloudflare/CloudflareDecisionModel" +import { describe, expect, it } from "tstyche" + +declare const client: CloudflareClient.Service +declare const model: string + +describe("Cloudflare model identifiers", () => { + it("accepts arbitrary strings through the public constructors and client", () => { + expect(CloudflareDecisionModel.model).type.toBeCallableWith(model) + expect(CloudflareDecisionModel.make).type.toBeCallableWith({ model }) + expect(CloudflareDecisionModel.layer).type.toBeCallableWith({ model }) + expect(client.createDecisions).type.toBeCallableWith({ model, state: "Checkout is down", questions: {} }) + }) + + it("still accepts the built-in model identifiers", () => { + expect(CloudflareDecisionModel.model).type.toBeCallableWith("clef") + expect(CloudflareDecisionModel.model).type.toBeCallableWith("clef-flash") + }) +}) diff --git a/repos/effect/packages/ai/openai-compat/CHANGELOG.md b/repos/effect/packages/ai/openai-compat/CHANGELOG.md index 716a0ef6b..a1238e469 100644 --- a/repos/effect/packages/ai/openai-compat/CHANGELOG.md +++ b/repos/effect/packages/ai/openai-compat/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/ai-openai-compat +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/ai/openai-compat/package.json b/repos/effect/packages/ai/openai-compat/package.json index 2c3fac74a..eff372e60 100644 --- a/repos/effect/packages/ai/openai-compat/package.json +++ b/repos/effect/packages/ai/openai-compat/package.json @@ -1,6 +1,6 @@ { "name": "@effect/ai-openai-compat", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "An OpenAI compat integration for Effect", diff --git a/repos/effect/packages/ai/openai/CHANGELOG.md b/repos/effect/packages/ai/openai/CHANGELOG.md index 83d7e6618..00ead7db7 100644 --- a/repos/effect/packages/ai/openai/CHANGELOG.md +++ b/repos/effect/packages/ai/openai/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/ai-openai +## 4.0.1 + +### Patch Changes + +- [#8613](https://github.com/Effect-TS/effect/pull/8613) [`f84f41e`](https://github.com/Effect-TS/effect/commit/f84f41e866fa2f5740edb74275facf25d9abf9e4) Thanks @danieljvdm! - Support `prompt_cache_options.comparison_response_id` and expose Responses API prompt cache diagnostics as `finish.metadata.openai.promptCacheDiagnostics` in JSON and streaming responses. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/ai/openai/package.json b/repos/effect/packages/ai/openai/package.json index 63202ff94..2d4f59645 100644 --- a/repos/effect/packages/ai/openai/package.json +++ b/repos/effect/packages/ai/openai/package.json @@ -1,6 +1,6 @@ { "name": "@effect/ai-openai", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "An OpenAI provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts b/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts index bbd09c80c..9758884f3 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -577,6 +577,10 @@ declare module "effect/ai/Response" { * The service tier reported by OpenAI for the response. */ readonly serviceTier?: "default" | "auto" | "flex" | "scale" | "priority" | null + /** + * Prompt cache diagnostics for `prompt_cache_options.comparison_response_id`. + */ + readonly promptCacheDiagnostics?: OpenAiSchema.PromptCacheDiagnostics } | null } } @@ -1841,7 +1845,7 @@ const makeResponse = Effect.fnUntraced( reason: finishReason, usage: getUsage(rawResponse.usage), response: buildHttpResponseDetails(response), - ...toServiceTier(rawResponse.service_tier) + ...toFinishMetadata(rawResponse) }) return parts @@ -1994,7 +1998,7 @@ const makeStreamResponse = Effect.fnUntraced( ), usage: getUsage(event.response.usage), response: buildHttpResponseDetails(response), - ...toServiceTier(event.response.service_tier) + ...toFinishMetadata(event.response) }) break } @@ -2008,7 +2012,7 @@ const makeStreamResponse = Effect.fnUntraced( reason: "error", usage: getUsage(event.response.usage), response: buildHttpResponseDetails(response), - ...toServiceTier(event.response.service_tier) + ...toFinishMetadata(event.response) }) break } @@ -3327,22 +3331,32 @@ const getUsage = (usage: OpenAiSchema.ResponseUsage | null | undefined): Respons type ServiceTier = "default" | "auto" | "flex" | "scale" | "priority" | null -const toServiceTier = (value: string | undefined): { - readonly metadata: { - readonly openai: { - readonly serviceTier: ServiceTier - } - } +const toFinishMetadata = (response: OpenAiSchema.Response): { + readonly metadata: Response.FinishPartMetadata } | undefined => { - switch (value) { + let serviceTier: ServiceTier | undefined + switch (response.service_tier) { case "default": case "auto": case "flex": case "scale": case "priority": - return { metadata: { openai: { serviceTier: value } } } - default: - return undefined + serviceTier = response.service_tier + break + } + + const promptCacheDiagnostics = response.prompt_cache_diagnostics + if (serviceTier === undefined && promptCacheDiagnostics === undefined) { + return undefined + } + + return { + metadata: { + openai: { + ...(serviceTier === undefined ? undefined : { serviceTier }), + ...(promptCacheDiagnostics === undefined ? undefined : { promptCacheDiagnostics }) + } + } } } diff --git a/repos/effect/packages/ai/openai/src/OpenAiSchema.ts b/repos/effect/packages/ai/openai/src/OpenAiSchema.ts index 0c7ff45b2..97a704915 100644 --- a/repos/effect/packages/ai/openai/src/OpenAiSchema.ts +++ b/repos/effect/packages/ai/openai/src/OpenAiSchema.ts @@ -726,7 +726,8 @@ export const CreateResponse = Schema.Struct({ prompt_cache_key: Schema.optional(Schema.String), prompt_cache_options: Schema.optional(Schema.Struct({ mode: Schema.optional(Schema.Literals(["implicit", "explicit"])), - ttl: Schema.optional(Schema.Literal("30m")) + ttl: Schema.optional(Schema.Literal("30m")), + comparison_response_id: Schema.optional(Schema.NullOr(Schema.String)) })), service_tier: Schema.optional(Schema.String), previous_response_id: Schema.optional(Schema.String), @@ -910,6 +911,46 @@ const ResponseError = Schema.Struct({ message: Schema.String }) +/** + * Schema for prompt cache diagnostics comparing two responses. + * + * **Details** + * + * Cache misses report a reason and token estimates, separate from response usage. + * + * @category schemas + * @since 4.0.0 + */ +export const PromptCacheDiagnostics = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("cache_miss"), + reason: Schema.Literals([ + "model_changed", + "prompt_cache_key_changed", + "tools_changed", + "text_format_changed", + "reasoning_effort_changed", + "verbosity_changed", + "context_compacted", + "input_changed", + "service_tier_changed" + ]), + cache_missed_tokens: Schema.Int, + comparison_reusable_tokens: Schema.optionalKey(Schema.Int) + }), + Schema.Struct({ type: Schema.Literal("cache_hit") }), + Schema.Struct({ type: Schema.Literal("comparison_response_not_found") }), + Schema.Struct({ type: Schema.Literal("unavailable") }) +]) + +/** + * Prompt cache comparison result reported by the OpenAI Responses API. + * + * @category models + * @since 4.0.0 + */ +export type PromptCacheDiagnostics = typeof PromptCacheDiagnostics.Type + /** * Schema for an OpenAI Responses API response object. * @@ -920,7 +961,8 @@ const ResponseError = Schema.Struct({ * **Details** * * Response objects include the response id, model, creation time, output items, - * optional token usage, optional incomplete details, and optional service tier. + * optional token usage, optional incomplete details, optional service tier, and + * optional prompt cache diagnostics. * * @see {@link CreateResponse} for the request schema that creates responses * @see {@link ResponseUsage} for token accounting on responses @@ -947,7 +989,8 @@ export const Response = Schema.Struct({ }) ) ), - service_tier: Schema.optionalKey(Schema.String) + service_tier: Schema.optionalKey(Schema.String), + prompt_cache_diagnostics: Schema.optionalKey(PromptCacheDiagnostics) }) /** diff --git a/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 53844a15a..a015ec206 100644 --- a/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/repos/effect/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -169,7 +169,7 @@ describe("OpenAiLanguageModel", () => { }).pipe( Effect.provide(OpenAiLanguageModel.model("gpt-5.6", { prompt_cache_key: "assistant:v1", - prompt_cache_options: { mode: "explicit", ttl: "30m" } + prompt_cache_options: { mode: "explicit", ttl: "30m", comparison_response_id: "resp_baseline" } })) ) @@ -177,7 +177,11 @@ describe("OpenAiLanguageModel", () => { const body = yield* getRequestBody(requests[0]) strictEqual(body.prompt_cache_key, "assistant:v1") - deepStrictEqual(body.prompt_cache_options, { mode: "explicit", ttl: "30m" }) + deepStrictEqual(body.prompt_cache_options, { + mode: "explicit", + ttl: "30m", + comparison_response_id: "resp_baseline" + }) deepStrictEqual(body.input, [{ role: "developer", content: [{ @@ -1243,6 +1247,32 @@ describe("OpenAiLanguageModel", () => { }) describe("response handling", () => { + it.effect("preserves prompt cache diagnostics alongside the service tier", () => + Effect.gen(function*() { + const diagnostics = { + type: "cache_miss", + reason: "tools_changed", + cache_missed_tokens: 1024, + comparison_reusable_tokens: 2048 + } as const + const body = JSON.stringify({ + ...makeDefaultResponse({ service_tier: "priority" }), + prompt_cache_diagnostics: diagnostics + }) + const result = yield* LanguageModel.generateText({ prompt: "test" }).pipe( + Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")), + Effect.provide(OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") })), + Effect.provideService(HttpClient.HttpClient, makeRawResponseClient(body, "application/json")) + ) + + const finish = result.content.find((part) => part.type === "finish") + assert.isDefined(finish) + deepStrictEqual(finish.metadata.openai, { + serviceTier: "priority", + promptCacheDiagnostics: diagnostics + }) + })) + it.effect("extracts text from output_text", () => Effect.gen(function*() { const result = yield* LanguageModel.generateText({ @@ -1729,6 +1759,41 @@ describe("OpenAiLanguageModel", () => { }) describe("streamText", () => { + it.effect.each(["completed", "failed"] as const)( + "preserves prompt cache diagnostics in response.%s", + (status) => + Effect.gen(function*() { + const event = { + type: `response.${status}`, + sequence_number: 1, + response: { + ...makeDefaultResponse({ + status, + error: status === "failed" ? { code: "server_error", message: "generation failed" } : null + }), + service_tier: "fast", + prompt_cache_diagnostics: { type: "cache_hit" } + } + } + const parts = yield* LanguageModel.streamText({ prompt: "test" }).pipe( + Stream.runCollect, + Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")), + Effect.provide(OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") })), + Effect.provideService( + HttpClient.HttpClient, + makeRawResponseClient( + `data: ${JSON.stringify(event)}\n\n`, + "text/event-stream" + ) + ) + ) + + const finish = parts.find((part) => part.type === "finish") + assert.isDefined(finish) + deepStrictEqual(finish.metadata.openai, { promptCacheDiagnostics: { type: "cache_hit" } }) + }) + ) + it.effect("extracts usage information", () => Effect.gen(function*() { const streamEvents = [ diff --git a/repos/effect/packages/ai/openrouter/CHANGELOG.md b/repos/effect/packages/ai/openrouter/CHANGELOG.md index e512e1500..d6447781b 100644 --- a/repos/effect/packages/ai/openrouter/CHANGELOG.md +++ b/repos/effect/packages/ai/openrouter/CHANGELOG.md @@ -1,5 +1,15 @@ # @effect/ai-openrouter +## 4.0.1 + +### Patch Changes + +- [#8713](https://github.com/Effect-TS/effect/pull/8713) [`2304769`](https://github.com/Effect-TS/effect/commit/23047696f9af30aa6ff1c7054e954c493c6eb8cd) Thanks @tim-smart! - Send OpenRouter audio transcription requests as JSON, preserving structured `provider.options`. Other providers are unchanged. + + Replace multipart `file` payloads with `{ model, input_audio: { data: "", format: "wav" } }`. Use `CreateAudioTranscriptionsRequestJson` instead of the removed `CreateAudioTranscriptionsRequestFormData` export. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/ai/openrouter/codegen.yml b/repos/effect/packages/ai/openrouter/codegen.yml index 982fd0872..99597a952 100644 --- a/repos/effect/packages/ai/openrouter/codegen.yml +++ b/repos/effect/packages/ai/openrouter/codegen.yml @@ -11,6 +11,8 @@ excludeAnnotations: - default disableAdditionalProperties: true patches: + # Use JSON for transcriptions to preserve structured provider.options. + - '[{"op":"remove","path":"/paths/~1audio~1transcriptions/post/requestBody/content/multipart~1form-data"}]' # Flatten Responses streaming event allOf wrappers to keep generated schemas referential - '[{"op":"replace","path":"/components/schemas/OpenResponsesCreatedEvent","value":{"type":"object","description":"Event emitted when a response is created","properties":{"response":{"$ref":"#/components/schemas/OpenResponsesResult"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.created"]}},"required":["type","response","sequence_number"]}},{"op":"replace","path":"/components/schemas/OpenResponsesInProgressEvent","value":{"type":"object","description":"Event emitted when a response is in progress","properties":{"response":{"$ref":"#/components/schemas/OpenResponsesResult"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.in_progress"]}},"required":["type","response","sequence_number"]}},{"op":"replace","path":"/components/schemas/StreamEventsResponseCompleted","value":{"type":"object","description":"Event emitted when a response has completed successfully","properties":{"response":{"$ref":"#/components/schemas/OpenResponsesResult"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.completed"]}},"required":["type","response","sequence_number"]}},{"op":"replace","path":"/components/schemas/StreamEventsResponseIncomplete","value":{"type":"object","description":"Event emitted when a response is incomplete","properties":{"response":{"$ref":"#/components/schemas/OpenResponsesResult"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.incomplete"]}},"required":["type","response","sequence_number"]}},{"op":"replace","path":"/components/schemas/StreamEventsResponseFailed","value":{"type":"object","description":"Event emitted when a response has failed","properties":{"response":{"$ref":"#/components/schemas/OpenResponsesResult"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.failed"]}},"required":["type","response","sequence_number"]}},{"op":"replace","path":"/components/schemas/StreamEventsResponseOutputItemAdded","value":{"type":"object","description":"Event emitted when a new output item is added to the response","properties":{"item":{"$ref":"#/components/schemas/OutputItems"},"output_index":{"type":"integer"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.output_item.added"]}},"required":["type","output_index","item","sequence_number"]}},{"op":"replace","path":"/components/schemas/StreamEventsResponseOutputItemDone","value":{"type":"object","description":"Event emitted when an output item is complete","properties":{"item":{"$ref":"#/components/schemas/OutputItems"},"output_index":{"type":"integer"},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.output_item.done"]}},"required":["type","output_index","item","sequence_number"]}},{"op":"replace","path":"/components/schemas/ContentPartAddedEvent","value":{"type":"object","description":"Event emitted when a new content part is added to an output item","properties":{"content_index":{"type":"integer"},"item_id":{"type":"string"},"output_index":{"type":"integer"},"part":{"anyOf":[{"$ref":"#/components/schemas/ResponseOutputText"},{"$ref":"#/components/schemas/ReasoningTextContent"},{"$ref":"#/components/schemas/OpenAIResponsesRefusalContent"}]},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.content_part.added"]}},"required":["type","output_index","item_id","content_index","part","sequence_number"]}},{"op":"replace","path":"/components/schemas/ContentPartDoneEvent","value":{"type":"object","description":"Event emitted when a content part is complete","properties":{"content_index":{"type":"integer"},"item_id":{"type":"string"},"output_index":{"type":"integer"},"part":{"anyOf":[{"$ref":"#/components/schemas/ResponseOutputText"},{"$ref":"#/components/schemas/ReasoningTextContent"},{"$ref":"#/components/schemas/OpenAIResponsesRefusalContent"}]},"sequence_number":{"type":"integer"},"type":{"type":"string","enum":["response.content_part.done"]}},"required":["type","output_index","item_id","content_index","part","sequence_number"]}}]' # Fix ChatAssistantMessage images to include type:"image_url" discriminator and nullable diff --git a/repos/effect/packages/ai/openrouter/package.json b/repos/effect/packages/ai/openrouter/package.json index 6b407bb5a..db373fd35 100644 --- a/repos/effect/packages/ai/openrouter/package.json +++ b/repos/effect/packages/ai/openrouter/package.json @@ -1,6 +1,6 @@ { "name": "@effect/ai-openrouter", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "An OpenRouter provider integration for Effect AI SDK", diff --git a/repos/effect/packages/ai/openrouter/src/Generated.ts b/repos/effect/packages/ai/openrouter/src/Generated.ts index 1edfa865b..e0af6eb6c 100644 --- a/repos/effect/packages/ai/openrouter/src/Generated.ts +++ b/repos/effect/packages/ai/openrouter/src/Generated.ts @@ -27880,42 +27880,6 @@ export const CreateAudioTranscriptionsParams = Schema.Struct({ }) export type CreateAudioTranscriptionsRequestJson = STTRequest export const CreateAudioTranscriptionsRequestJson = STTRequest -export type CreateAudioTranscriptionsRequestFormData = { - readonly "file": string - readonly "language"?: string - readonly "model": string - readonly "response_format"?: "json" | "verbose_json" - readonly "temperature"?: number - readonly "timestamp_granularities[]"?: ReadonlyArray<"word" | "segment"> -} -export const CreateAudioTranscriptionsRequestFormData = Schema.Struct({ - "file": Schema.String.annotate({ - "description": - "The audio file to transcribe. The format is derived from the filename extension or the file part content type. Max 25 MB; send larger files as base64 JSON via input_audio.", - "format": "binary" - }), - "language": Schema.optionalKey( - Schema.String.annotate({ "description": "The language of the input audio (ISO-639-1)." }) - ), - "model": Schema.String.annotate({ "description": "The model to use for transcription." }), - "response_format": Schema.optionalKey( - Schema.Literals(["json", "verbose_json"]).annotate({ - "description": - "The response format. \"json\" (default) returns { text, usage }; \"verbose_json\" additionally returns task, language, duration, and segment-level timestamps (OpenAI-compatible providers only)." - }) - ), - "temperature": Schema.optionalKey( - Schema.Number.annotate({ "description": "The sampling temperature." }).check( - Schema.isFinite().annotate({ "expected": "a finite number" }) - ) - ), - "timestamp_granularities[]": Schema.optionalKey( - Schema.Array(Schema.Literals(["word", "segment"])).annotate({ - "description": - "Timestamp detail levels to include when response_format is \"verbose_json\". \"word\" additionally returns word-level timestamps in the words array." - }) - ) -}) export type CreateAudioTranscriptions200 = STTResponse export const CreateAudioTranscriptions200 = STTResponse export type CreateAudioTranscriptions400 = BadRequestResponse @@ -32916,7 +32880,7 @@ export const make = ( "X-OpenRouter-Title": options.params?.["X-OpenRouter-Title"] ?? undefined, "X-OpenRouter-Categories": options.params?.["X-OpenRouter-Categories"] ?? undefined }), - HttpClientRequest.bodyFormData(options.payload as any), + HttpClientRequest.bodyJsonUnsafe(options.payload), withResponse(options.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(CreateAudioTranscriptions200), "400": decodeError("CreateAudioTranscriptions400", CreateAudioTranscriptions400), @@ -34629,7 +34593,7 @@ export interface OpenRouterClient { readonly "createAudioTranscriptions": ( options: { readonly params?: typeof CreateAudioTranscriptionsParams.Encoded | undefined - readonly payload: typeof CreateAudioTranscriptionsRequestFormData.Encoded + readonly payload: typeof CreateAudioTranscriptionsRequestJson.Encoded readonly config?: Config | undefined } ) => Effect.Effect< diff --git a/repos/effect/packages/ai/openrouter/test/Generated.test.ts b/repos/effect/packages/ai/openrouter/test/Generated.test.ts index 4539750ea..5ecf0b38f 100644 --- a/repos/effect/packages/ai/openrouter/test/Generated.test.ts +++ b/repos/effect/packages/ai/openrouter/test/Generated.test.ts @@ -1,11 +1,43 @@ import { Generated, OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter" -import { describe, it } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { deepStrictEqual } from "@effect/vitest/utils" import { Effect, Schema, Stream } from "effect" import { type AiError, LanguageModel, type Response, Tool, Toolkit } from "effect/ai" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" describe("Generated", () => { + it.effect("sends audio transcriptions as JSON with structured provider options", () => + Effect.gen(function*() { + const payload: typeof Generated.CreateAudioTranscriptionsRequestJson.Encoded = { + model: "openai/whisper-1", + input_audio: { data: "UklGRg==", format: "wav" }, + provider: { options: { openai: { prompt: "Effect" } } }, + language: "en", + response_format: "verbose_json", + timestamp_granularities: ["word"] + } + const response = { text: "Hello Effect", language: "en", duration: 1.5 } + let requests = 0 + const client = Generated.make( + HttpClient.make((request) => { + requests++ + assert.strictEqual(request.method, "POST") + assert.strictEqual(request.url, "https://openrouter.ai/api/v1/audio/transcriptions") + assert.strictEqual(request.headers["content-type"], "application/json") + assert.strictEqual(request.body._tag, "Uint8Array") + if (request.body._tag === "Uint8Array") { + deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), payload) + } + return Effect.succeed(HttpClientResponse.fromWeb( + request, + globalThis.Response.json(response) + )) + }).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://openrouter.ai/api/v1"))) + ) + deepStrictEqual(yield* client.createAudioTranscriptions({ payload }), response) + assert.strictEqual(requests, 1) + })) + it("decodes nullable generation statistics", () => { const response: Generated.GetGeneration200 = { data: { diff --git a/repos/effect/packages/ai/typesafe/CHANGELOG.md b/repos/effect/packages/ai/typesafe/CHANGELOG.md index bbfea7c98..018f63668 100644 --- a/repos/effect/packages/ai/typesafe/CHANGELOG.md +++ b/repos/effect/packages/ai/typesafe/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/ai-typesafe +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/ai/typesafe/package.json b/repos/effect/packages/ai/typesafe/package.json index 566f585b8..bf45aa03a 100644 --- a/repos/effect/packages/ai/typesafe/package.json +++ b/repos/effect/packages/ai/typesafe/package.json @@ -1,6 +1,6 @@ { "name": "@effect/ai-typesafe", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A TypeSafe DecisionModel provider for Effect", diff --git a/repos/effect/packages/atom/react/CHANGELOG.md b/repos/effect/packages/atom/react/CHANGELOG.md index 8db542a47..a669be84e 100644 --- a/repos/effect/packages/atom/react/CHANGELOG.md +++ b/repos/effect/packages/atom/react/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/atom-react +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/atom/react/package.json b/repos/effect/packages/atom/react/package.json index 33ccd8d03..ab851da10 100644 --- a/repos/effect/packages/atom/react/package.json +++ b/repos/effect/packages/atom/react/package.json @@ -1,6 +1,6 @@ { "name": "@effect/atom-react", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "React bindings for the Effect Atom modules", diff --git a/repos/effect/packages/atom/solid/CHANGELOG.md b/repos/effect/packages/atom/solid/CHANGELOG.md index dbe714db3..268e97f5c 100644 --- a/repos/effect/packages/atom/solid/CHANGELOG.md +++ b/repos/effect/packages/atom/solid/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/atom-solid +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/atom/solid/package.json b/repos/effect/packages/atom/solid/package.json index 0bc5b19e1..17fa6dda2 100644 --- a/repos/effect/packages/atom/solid/package.json +++ b/repos/effect/packages/atom/solid/package.json @@ -1,6 +1,6 @@ { "name": "@effect/atom-solid", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "SolidJS bindings for the Effect Atom modules", diff --git a/repos/effect/packages/atom/vue/CHANGELOG.md b/repos/effect/packages/atom/vue/CHANGELOG.md index 100ce4646..9136e5241 100644 --- a/repos/effect/packages/atom/vue/CHANGELOG.md +++ b/repos/effect/packages/atom/vue/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/atom-vue +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/atom/vue/package.json b/repos/effect/packages/atom/vue/package.json index 3a68907c8..414356bd0 100644 --- a/repos/effect/packages/atom/vue/package.json +++ b/repos/effect/packages/atom/vue/package.json @@ -1,6 +1,6 @@ { "name": "@effect/atom-vue", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "Vue bindings for the Effect Atom modules", diff --git a/repos/effect/packages/effect/CHANGELOG.md b/repos/effect/packages/effect/CHANGELOG.md index dd7faa1f6..a1d0f6816 100644 --- a/repos/effect/packages/effect/CHANGELOG.md +++ b/repos/effect/packages/effect/CHANGELOG.md @@ -1,5 +1,71 @@ # effect +## 4.0.1 + +### Patch Changes + +- [#8603](https://github.com/Effect-TS/effect/pull/8603) [`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74) Thanks @danieljvdm! - Preserve all Anthropic system instructions and support configurable mid-conversation system messages. Add optional `LanguageModel.supportsSystemMessagesInHistory` to query support with scoped overrides. + +- [#8680](https://github.com/Effect-TS/effect/pull/8680) [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee) Thanks @danieljvdm! - Cache per-tool response schemas and parameter-mode copies to reduce schema construction in `LanguageModel.generateText` and `LanguageModel.streamText`. The schema cache also applies to direct calls to `Response.Part`, `Response.StreamPart` and `Response.AllParts`. + +- [#8719](https://github.com/Effect-TS/effect/pull/8719) [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d) Thanks @tim-smart! - Treat interruption as abandonment in `Effect.cached`, `Effect.cachedWithTTL`, `Effect.cachedInvalidateWithTTL`, `Cache`, `ScopedCache`, `RcRef`, `RcMap` and the cluster `ResourceMap`. Concurrent callers share one computation that is interrupted only after every caller has been interrupted, interrupted results are never cached, and a call made while an abandoned computation is still finalizing starts a fresh one. + +- [#8734](https://github.com/Effect-TS/effect/pull/8734) [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396) Thanks @pawelblaszczyk5! - Adjust `Effect.cachedInvalidateWithTTL` types to accept callbacks that select a TTL from the computation's `Exit`, including piped calls. + +- [#8659](https://github.com/Effect-TS/effect/pull/8659) [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a) Thanks @samueleguino97! - Prevent entity registration when shard ownership is lost during construction. Pending requests retry on the new owner. + +- [#8662](https://github.com/Effect-TS/effect/pull/8662) [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14) Thanks @tim-smart! - Avoid replaying suspended cluster workflows for unawaited deferred checkpoints while preserving interrupt wakes. + +- [#8637](https://github.com/Effect-TS/effect/pull/8637) [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53) Thanks @tim-smart! - Fix cluster defect recovery to replay unfinished requests without duplicating requests awaiting their first dispatch. Stop replay and interrupt waiting requests on shutdown, and prevent superseded rebuilds from publishing stale handlers or SQL lock connections. + +- [#8741](https://github.com/Effect-TS/effect/pull/8741) [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278) Thanks @tim-smart! - Prevent PostgreSQL deadlocks between cluster message claims and shard or address resets by locking reset rows in message order. + +- [#8707](https://github.com/Effect-TS/effect/pull/8707) [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f) Thanks @tim-smart! - Fix zoned DateTime conversion on runtimes that omit Intl fractional seconds, preserving milliseconds for pre-epoch timestamps. + +- [#8629](https://github.com/Effect-TS/effect/pull/8629) [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f) Thanks @tim-smart! - Preserve a fiber's `AsyncLocalStorage` context when another fiber wakes or interrupts it. Run `Effect.tryPromise` error handlers in the resumed fiber's context. + +- [#8675](https://github.com/Effect-TS/effect/pull/8675) [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1) Thanks @IMax153! - Fix `HashRing.getShards` returning different shard assignments for the same nodes and weights after different add/remove histories. Sum weights in node-key order and break hash ties by node key. Assignments may change for fractional weights or hash ties. + +- [#8706](https://github.com/Effect-TS/effect/pull/8706) [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae) Thanks @tim-smart! - Prevent HttpApiSchemaError from being reported as a server failure for invalid requests. + +- [#8737](https://github.com/Effect-TS/effect/pull/8737) [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c) Thanks @tim-smart! - Log client-abort-only HTTP request causes as "Sent HTTP response" rather than an interrupt error, while preserving response status annotations and logging other causes. + +- [#8685](https://github.com/Effect-TS/effect/pull/8685) [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef) Thanks @tim-smart! - Fix HttpTraceContext decoding of B3 debug and legacy sampling values, and reject all-zero W3C trace and parent IDs. + +- [#8652](https://github.com/Effect-TS/effect/pull/8652) [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e) Thanks @mikearnaldi! - Preserve pending interruptions when an uninterruptible effect fails. Drop typed failures when interruption skips recovery handlers, but keep defects. + +- [#8657](https://github.com/Effect-TS/effect/pull/8657) [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080) Thanks @schickling-assistant! - Fix JSON Schema pattern importing for valid expressions whose RegExp source is canonicalized, including slashes, empty patterns, and literal line terminators. + +- [#8723](https://github.com/Effect-TS/effect/pull/8723) [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda) Thanks @serhii-indyrct! - Fix encoding optional MCP fields whose schemas decode to classes, such as `Implementation.icons`. + +- [#8644](https://github.com/Effect-TS/effect/pull/8644) [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b) Thanks @sbking! - Prevent shard lock refreshes from releasing shards acquired while the refresh is in flight. + +- [#8660](https://github.com/Effect-TS/effect/pull/8660) [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50) Thanks @tim-smart! - Prevent singleton shard reassignment from durably cancelling outstanding RPC requests. + +- [#8705](https://github.com/Effect-TS/effect/pull/8705) [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a) Thanks @tim-smart! - Honor If-Range in HttpStaticServer, returning the full file when the entity-tag is stale or weak instead of serving a partial response. Reject date validators because filesystem metadata cannot establish their strength. + +- [#8704](https://github.com/Effect-TS/effect/pull/8704) [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9) Thanks @tim-smart! - Fix `Stream.scan` to emit its initial state for empty streams. + +- [#8698](https://github.com/Effect-TS/effect/pull/8698) [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7) Thanks @bishil06! - Fix accumulated rate drift in `Stream.throttle` with the `"shape"` strategy by retaining token debt after early wake-ups. Use monotonic time so wall clock changes do not affect throttling. + +- [#8664](https://github.com/Effect-TS/effect/pull/8664) [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d) Thanks @lloydrichards! - Preserve server and client extension capabilities in stateful MCP protocols. + +- [#8653](https://github.com/Effect-TS/effect/pull/8653) [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9) Thanks @lloydrichards! - Keep idle MCP HTTP subscriptions open on Cloudflare Workers with SSE keepalives. + +- [#8679](https://github.com/Effect-TS/effect/pull/8679) [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e) Thanks @tim-smart! - Fix `Pool` usage TTL to expire only idle, unreserved excess items, oldest idle first. Measure idle time from the last release to avoid retiring recently used items. + +- [#8648](https://github.com/Effect-TS/effect/pull/8648) [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66) Thanks @dv-waynehaffenden! - Fix `RpcClient` stream interruption leaving chunk delivery blocked on a full buffer and stalling the shared protocol receive loop. + +- [#8627](https://github.com/Effect-TS/effect/pull/8627) [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae) Thanks @tim-smart! - Keep streaming RPC chunk decode failures local to the affected request and interrupt it on the server, without disconnecting unrelated requests. + +- [#8658](https://github.com/Effect-TS/effect/pull/8658) [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711) Thanks @tim-smart! - Fix `RpcClient` protocol errors incorrectly failing requests started synchronously by error handlers. Only requests pending when the error broadcast begins now receive that error; new requests remain pending for their own responses. + +- [#8714](https://github.com/Effect-TS/effect/pull/8714) [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33) Thanks @jaideeppyne! - Fix RPC stdio shutdown to drain responses after stdin EOF. + +- [#8618](https://github.com/Effect-TS/effect/pull/8618) [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c) Thanks @serhii-indyrct! - Set `ScopedCache` entry expiry before waking lookup waiters, so a zero-TTL result cannot be reused. + +- [#8718](https://github.com/Effect-TS/effect/pull/8718) [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8) Thanks @juliusmarminge! - Pause schedules while `Stream.aggregateWithin`, `Stream.groupedWithin` and `Stream.aggregate` are idle. When a schedule ends, emit the current aggregation and drain sink leftovers without further upstream pulls. + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/effect/SCHEMA.md b/repos/effect/packages/effect/SCHEMA.md index 9fb8b0cf5..5168151ca 100644 --- a/repos/effect/packages/effect/SCHEMA.md +++ b/repos/effect/packages/effect/SCHEMA.md @@ -4669,32 +4669,26 @@ Serialization converts typed values into a format suitable for storage or transm ## JSON Support -#### UnknownFromJsonString +#### fromJsonString -A schema that decodes a JSON-encoded string into an unknown value. +Returns a schema that decodes a JSON string and then decodes the parsed value using the given schema. -This schema takes a string as input and attempts to parse it as JSON during decoding. If parsing succeeds, the result is passed along as an unknown value. If the string is not valid JSON, decoding fails. +Use `Schema.Unknown` to parse the JSON string without validating its structure, or provide a more specific schema to validate the parsed value. -When encoding, any value is converted back into a JSON string using JSON.stringify. If the value is not a valid JSON value, encoding fails. +Decoding fails if the input is not valid JSON or the parsed value does not satisfy the provided schema. -**Example** +During encoding, the schema encodes the value with the provided schema and then converts the result to a JSON string. + +**Example** (Parsing without validation) ```ts import { Schema } from "effect" -Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(`{"a":1,"b":2}`) +Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(`{"a":1,"b":2}`) // => { a: 1, b: 2 } ``` -#### fromJsonString - -Returns a schema that decodes a JSON string and then decodes the parsed value using the given schema. - -This is useful when working with JSON-encoded strings where the actual structure of the value is known and described by an existing schema. - -The resulting schema first parses the input string as JSON, and then runs the provided schema on the parsed result. - -**Example** +**Example** (Parsing and validating a known structure) ```ts import { Schema } from "effect" diff --git a/repos/effect/packages/effect/package.json b/repos/effect/packages/effect/package.json index 6534bb199..d042e717c 100644 --- a/repos/effect/packages/effect/package.json +++ b/repos/effect/packages/effect/package.json @@ -1,7 +1,7 @@ { "name": "effect", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "The missing standard library for TypeScript, for writing production-grade software.", "homepage": "https://effect.website", diff --git a/repos/effect/packages/effect/src/Cache.ts b/repos/effect/packages/effect/src/Cache.ts index 504d98dde..ac406ed5b 100644 --- a/repos/effect/packages/effect/src/Cache.ts +++ b/repos/effect/packages/effect/src/Cache.ts @@ -434,22 +434,19 @@ export const get: { } } const entry = new EntryImpl(fiber, self.lookup(key)) + entry.onInterrupt = () => removeEntry(self, key, entry) let skipCache = false entry.fiber.addObserver((exit) => { + // Release the key once the lookup can no longer be interrupted. + entry.onInterrupt = undefined if (effect.exitHasInterrupts(exit)) { - const current = MutableHashMap.get(self.map, key) - if (Option.isSome(current) && current.value === entry) { - MutableHashMap.remove(self.map, key) - } + removeEntry(self, key, entry) return } const ttl = self.timeToLive(exit, key) if (Duration.isZero(ttl)) { skipCache = true - const current = MutableHashMap.get(self.map, key) - if (Option.isSome(current) && current.value === entry) { - MutableHashMap.remove(self.map, key) - } + removeEntry(self, key, entry) } else if (Duration.isFinite(ttl)) { entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) } @@ -469,6 +466,7 @@ class EntryImpl implements Entry { expiresAt: number | undefined awaiters: number fiber: Fiber.Fiber + onInterrupt: (() => void) | undefined constructor( parent: Fiber.Fiber, @@ -486,11 +484,21 @@ class EntryImpl implements Entry { return effect.onExit(effect.fiberJoin(this.fiber), () => { this.awaiters-- if (this.awaiters > 0 || this.fiber.pollUnsafe()) return effect.void + // Detach before interrupting so new lookups do not join the abandoned fiber + // while its finalizers run. + this.onInterrupt?.() return effect.fiberInterrupt(this.fiber) }) } } +const removeEntry = (self: Cache, key: Key, entry: Entry): void => { + const current = MutableHashMap.get(self.map, key) + if (Option.isSome(current) && current.value === entry) { + MutableHashMap.remove(self.map, key) + } +} + const hasExpired = (entry: Entry, fiber: Fiber.Fiber): boolean => { if (entry.expiresAt === undefined) { return false @@ -1172,25 +1180,23 @@ export const refresh: { (self: Cache, key: Key): Effect.Effect => core.withFiber((fiber) => { const entry = new EntryImpl(fiber, self.lookup(key)) + entry.onInterrupt = () => removeEntry(self, key, entry) const existing = getImpl(self, key, fiber, false) !== undefined if (!existing) { MutableHashMap.set(self.map, key, entry) checkCapacity(self) } entry.fiber.addObserver((exit) => { + // Release the key once the lookup can no longer be interrupted. + entry.onInterrupt = undefined if (effect.exitHasInterrupts(exit)) { - const current = MutableHashMap.get(self.map, key) - if (Option.isSome(current) && current.value === entry) { - MutableHashMap.remove(self.map, key) - } + removeEntry(self, key, entry) return } const ttl = self.timeToLive(exit, key) if (Duration.isZero(ttl)) { - const current = MutableHashMap.get(self.map, key) - if (existing || (Option.isSome(current) && current.value === entry)) { - MutableHashMap.remove(self.map, key) - } + if (existing) MutableHashMap.remove(self.map, key) + else removeEntry(self, key, entry) return effect.void } entry.expiresAt = Duration.isFinite(ttl) diff --git a/repos/effect/packages/effect/src/Cause.ts b/repos/effect/packages/effect/src/Cause.ts index 9cda574ab..d54f5c99b 100644 --- a/repos/effect/packages/effect/src/Cause.ts +++ b/repos/effect/packages/effect/src/Cause.ts @@ -692,7 +692,7 @@ export const map: { export const combine: { (that: Cause): (self: Cause) => Cause (self: Cause, that: Cause): Cause -} = effect.causeCombine +} = core.causeCombine /** * Collapses a `Cause` into a single `unknown` value, picking the "most diff --git a/repos/effect/packages/effect/src/Effect.ts b/repos/effect/packages/effect/src/Effect.ts index e7e8041b6..a4da72c9b 100644 --- a/repos/effect/packages/effect/src/Effect.ts +++ b/repos/effect/packages/effect/src/Effect.ts @@ -7084,6 +7084,11 @@ export const onExitFilter: { * evaluations of the same effect will return the cached result without * re-executing the logic. * + * Concurrent callers share the pending computation, which is interrupted only + * once every caller waiting on it has been interrupted. Interrupted + * computations are never cached, so the next evaluation starts a fresh + * computation. + * * **Example** (Memoizing an effect until invalidated) * * ```ts import.meta.vitest @@ -7145,11 +7150,12 @@ export const cached: (self: Effect) => Effect> * `Duration.Input`. The function runs once after each fresh computation, * including failures, so successes and failures can have different TTLs. It * does not run when the cache is created or when a cached result is reused. - * The callback also receives interruption exits, which are cached for the - * returned duration. + * Interrupted computations are never cached and do not call the function. * * The TTL starts when the computation completes. Concurrent callers share the - * pending computation. A zero TTL expires immediately, and an infinite TTL + * pending computation, which is interrupted only once every caller waiting on + * it has been interrupted. The next evaluation then starts a fresh + * computation. A zero TTL expires immediately, and an infinite TTL * keeps the result indefinitely. * * **Example** (Memoizing an effect with TTL) @@ -7218,8 +7224,8 @@ export const cachedWithTTL: { } = internal.cachedWithTTL /** - * Creates a cached effect result for a specified duration and allows manual - * invalidation before expiration. + * Creates a cached effect result for a fixed duration or a duration computed + * from its `Exit` and allows manual invalidation before expiration. * * **When to use** * @@ -7276,8 +7282,17 @@ export const cachedWithTTL: { * @since 2.0.0 */ export const cachedInvalidateWithTTL: { + ( + timeToLive: (exit: Exit.Exit) => Duration.Input + ): (self: Effect) => Effect<[Effect, Effect]> (timeToLive: Duration.Input): (self: Effect) => Effect<[Effect, Effect]> - (self: Effect, timeToLive: Duration.Input): Effect<[Effect, Effect]> + ( + timeToLive: Duration.Input | ((exit: Exit.Exit) => Duration.Input) + ): (self: Effect) => Effect<[Effect, Effect]> + ( + self: Effect, + timeToLive: Duration.Input | ((exit: Exit.Exit) => Duration.Input) + ): Effect<[Effect, Effect]> } = internal.cachedInvalidateWithTTL // ----------------------------------------------------------------------------- diff --git a/repos/effect/packages/effect/src/HashRing.ts b/repos/effect/packages/effect/src/HashRing.ts index 789bf0500..2288eea34 100644 --- a/repos/effect/packages/effect/src/HashRing.ts +++ b/repos/effect/packages/effect/src/HashRing.ts @@ -150,12 +150,9 @@ export const addMany: { if (entry[1] === weight) continue toRemove ??= new Set() toRemove.add(key) - self.totalWeightCache -= entry[1] - self.totalWeightCache += weight entry[1] = weight } else { self.nodes.set(key, [node, weight]) - self.totalWeightCache += weight } keys.push(key) } @@ -163,6 +160,7 @@ export const addMany: { self.ring = self.ring.filter(([, n]) => !toRemove.has(n)) } addNodesToRing(self, keys, Math.round(weight * self.baseWeight)) + updateTotalWeight(self) return self } ) @@ -177,7 +175,18 @@ function addNodesToRing(self: HashRing, keys ]) } } - self.ring.sort((a, b) => a[0] - b[0]) + // Break hash ties by node key to avoid insertion-order dependence. + self.ring.sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) +} + +// Sum in key order to avoid history-dependent floating-point rounding. +function updateTotalWeight(self: HashRing) { + const keys = Array.from(self.nodes.keys()).sort() + let total = 0 + for (let i = 0; i < keys.length; i++) { + total += self.nodes.get(keys[i])![1] + } + self.totalWeightCache = total } /** @@ -248,7 +257,7 @@ export const remove: { if (entry) { self.nodes.delete(key) self.ring = self.ring.filter(([, n]) => n !== key) - self.totalWeightCache -= entry[1] + updateTotalWeight(self) } return self }) diff --git a/repos/effect/packages/effect/src/Pool.ts b/repos/effect/packages/effect/src/Pool.ts index 39152daa6..2ee2e30cb 100644 --- a/repos/effect/packages/effect/src/Pool.ts +++ b/repos/effect/packages/effect/src/Pool.ts @@ -19,7 +19,6 @@ import * as Fiber from "./Fiber.ts" import { constant, dual, identity } from "./Function.ts" import * as core from "./internal/core.ts" import * as internal from "./internal/effect.ts" -import * as Iterable from "./Iterable.ts" import { type Pipeable, pipeArguments } from "./Pipeable.ts" import { hasProperty } from "./Predicate.ts" import * as Queue from "./Queue.ts" @@ -31,10 +30,12 @@ const TypeId = "~effect/Pool" const Acquire = Symbol() const AcquireContext = Symbol() +const PoolClock = Symbol() interface PoolImpl extends Pool { readonly [Acquire]: Effect.Effect readonly [AcquireContext]: Context.Context + readonly [PoolClock]: Clock } /** @@ -119,7 +120,6 @@ export interface State { isShuttingDown: boolean usage: number readonly resizeSemaphore: Semaphore.Semaphore - // Insertion order determines usage-TTL retirement order; reclaimed items move to the back. readonly items: Set> availableHead: PoolItem | undefined availableTail: PoolItem | undefined @@ -139,8 +139,8 @@ export interface State { * **Details** * * Each item stores the acquisition `Exit`, its finalizer, the current - * reference count, and whether automatic reclaiming has been disabled because - * the item was invalidated. + * reference count, when it last became idle, and whether automatic reclaiming + * has been disabled because the item was invalidated. * * @see {@link Strategy} for the custom strategy callbacks that receive and return pool items * @see {@link State} for the runtime sets that store active, available, and invalidated pool items @@ -152,6 +152,8 @@ export interface PoolItem { readonly exit: Exit.Exit finalizer: Effect.Effect refCount: number + /** Clock time at which the item was acquired or its last borrower released it. */ + idleSince: number disableReclaim: boolean isAvailable: boolean availablePrevious: PoolItem | undefined @@ -257,9 +259,11 @@ export const make = (options: { * pool implementation. A value of `1` waits until existing items are fully * utilized before creating more items. * - * `timeToLiveStrategy` controls when excess items expire: `"creation"` measures - * from item creation, while `"usage"` measures from pool usage. The default is - * `"usage"`. + * `timeToLiveStrategy` defaults to `"usage"`, which expires idle, unreserved + * excess items after `timeToLive` since their last borrower released them + * (or acquisition if never borrowed). It checks once per `timeToLive`, so + * retirement can take up to twice that duration. `"creation"` measures from + * item creation instead. * * **Example** (Creating a connection pool) * @@ -374,6 +378,7 @@ export const makeWithStrategy = (options: { [TypeId]: TypeId, [Acquire]: options.acquire as Effect.Effect, [AcquireContext]: services as Context.Context, + [PoolClock]: yield* Clock, config, state, pipe() { @@ -625,6 +630,9 @@ const releaseItem = (self: Pool, item: PoolItem): Effect.Effec const state = self.state item.refCount-- state.usage-- + if (item.refCount === 0) { + item.idleSince = (self as PoolImpl)[PoolClock].currentTimeMillisUnsafe() + } if (state.invalidated.has(item)) { return invalidatePoolItem(self, item) } @@ -903,6 +911,7 @@ const allocate = (self: Pool): Effect.Effect> => exit, finalizer: Effect.catchCause(Scope.close(scope, exit), reportUnhandledError), refCount: 0, + idleSince: impl[PoolClock].currentTimeMillisUnsafe(), disableReclaim: false, isAvailable: false, availablePrevious: undefined, @@ -999,19 +1008,21 @@ const strategyCreationTTL = Effect.fnUntraced(function*(ttl: Duration.Inpu }) }) -const strategyUsageTTL = (ttl: Duration.Input): Effect.Effect> => - Effect.succeed>({ +const strategyUsageTTL = Effect.fnUntraced(function*(ttl: Duration.Input) { + const clock = yield* Clock + const ttlMillis = Duration.toMillis(Duration.fromInputUnsafe(ttl)) + return identity>({ run: (pool) => { - // `state.items` iterates in insertion order, so its oldest live entry is - // the next to retire. Using it directly means an item stops being - // referenced by this strategy as soon as it leaves the pool. const process: Effect.Effect = Effect.suspend(() => { if (activeSize(pool) <= targetSize(pool)) return Effect.void + const expired = clock.currentTimeMillisUnsafe() - ttlMillis + let oldest: PoolItem | undefined for (const item of pool.state.items) { - if (pool.state.invalidated.has(item)) continue - return Effect.flatMap(invalidatePoolItem(pool, item), () => process) + if (item.refCount > 0 || item.idleSince > expired) continue + if (pool.state.invalidated.has(item) || reservations.has(item)) continue + if (oldest === undefined || item.idleSince < oldest.idleSince) oldest = item } - return Effect.void + return oldest === undefined ? Effect.void : Effect.flatMap(invalidatePoolItem(pool, oldest), () => process) }) return process.pipe( Effect.delay(ttl), @@ -1019,28 +1030,9 @@ const strategyUsageTTL = (ttl: Duration.Input): Effect.Effect Effect.void, - reclaim(pool) { - return Effect.suspend((): Effect.Effect | undefined> => { - if (pool.state.invalidated.size === 0) { - return Effect.undefined - } - const item = Iterable.head( - Iterable.filter(pool.state.invalidated, (item) => !item.disableReclaim && !reservations.has(item)) - ) - if (item._tag === "None") { - return Effect.undefined - } - pool.state.invalidated.delete(item.value) - // Re-adding moves the reclaimed item to the back of the retirement order. - pool.state.items.delete(item.value) - pool.state.items.add(item.value) - if (item.value.refCount < pool.config.concurrency) { - addAvailable(pool, item.value) - } - return Effect.succeed(item.value) - }) - } + reclaim: (_) => Effect.undefined }) +}) const reportUnhandledError = (cause: Cause.Cause) => Effect.withFiber((fiber) => { diff --git a/repos/effect/packages/effect/src/RcMap.ts b/repos/effect/packages/effect/src/RcMap.ts index f9b771f79..fbb97f76a 100644 --- a/repos/effect/packages/effect/src/RcMap.ts +++ b/repos/effect/packages/effect/src/RcMap.ts @@ -370,10 +370,16 @@ export const get: { context.set(key, value) }) context.set(Scope.Scope.key, entry.scope) + const lookupContext: Context.Context = Context.makeUnsafe(context) Effect.suspend(() => self.lookup(key)).pipe( - Effect.runForkWith(Context.makeUnsafe(context)), + Effect.runForkWith(lookupContext), Fiber.runIn(entry.scope) - ).addObserver((exit) => Deferred.doneUnsafe(entry.deferred, exit)) + ).addObserver((exit) => { + // Interruption is abandonment, so the entry is dropped and the next + // get starts a fresh lookup. + if (Exit.hasInterrupts(exit)) removeInterrupted(self, key, entry, lookupContext) + Deferred.doneUnsafe(entry.deferred, exit) + }) } const scope = Context.getUnsafe(parent.context, Scope.Scope) return Scope.addFinalizer(scope, entry.finalizer).pipe( @@ -449,6 +455,24 @@ export const getOption: { }) ) +const removeInterrupted = ( + self: RcMap, + key: K, + entry: State.Entry, + context: Context.Context +) => { + if (self.state._tag === "Closed") return + const o = MutableHashMap.get(self.state.map, key) + if (o._tag === "None" || o.value !== entry) return + MutableHashMap.remove(self.state.map, key) + // Borrowers close the entry on release; an idle entry has none left. + if (entry.refCount > 0) return + closeEntry(entry).pipe( + Effect.runForkWith(context), + Fiber.runIn(self.scope) + ) +} + const closeEntry = (entry: State.Entry) => entry.fiber ? Fiber.interrupt(entry.fiber).pipe(Effect.andThen(Scope.close(entry.scope, Exit.void))) diff --git a/repos/effect/packages/effect/src/ScopedCache.ts b/repos/effect/packages/effect/src/ScopedCache.ts index 61b4fb724..ebf8ddbdf 100644 --- a/repos/effect/packages/effect/src/ScopedCache.ts +++ b/repos/effect/packages/effect/src/ScopedCache.ts @@ -271,7 +271,7 @@ export const get: { // Move the entry to the end of the map to keep it fresh MutableHashMap.remove(state.map, key) MutableHashMap.set(state.map, key, oentry.value) - return awaitEntry(oentry.value, restore) + return awaitEntry(self, key, oentry.value, restore) } const scope = Scope.makeUnsafe() const deferred = Deferred.makeUnsafe() @@ -288,41 +288,46 @@ export const get: { entry.fiber = effect.forkUnsafe( fiber, effect.onExit(effect.suspend(() => Scope.provide(self.lookup(key), scope)), (exit) => { - Deferred.doneUnsafe(deferred, exit) if (effect.exitHasInterrupts(exit)) { - if (self.state._tag === "Open") { - const current = MutableHashMap.get(self.state.map, key) - if (Option.isSome(current) && current.value === entry) MutableHashMap.remove(self.state.map, key) - } + removeEntry(self, key, entry) + Deferred.doneUnsafe(deferred, exit) return Scope.close(scope, exit) } - const ttl = self.timeToLive(exit, key) - if (Duration.isFinite(ttl)) { - entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) + try { + const ttl = self.timeToLive(exit, key) + if (Duration.isFinite(ttl)) { + entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) + } + } finally { + Deferred.doneUnsafe(deferred, exit) } return effect.void }), true, true ) - return awaitEntry(entry, restore) + return awaitEntry(self, key, entry, restore) }) ) }) ) ) -const awaitEntry = ( +const awaitEntry = ( + self: ScopedCache, + key: Key, entry: Entry, restore: (effect: Effect.Effect) => Effect.Effect ): Effect.Effect => { - const fiber = entry.fiber - if (fiber === undefined || Deferred.isDoneUnsafe(entry.deferred)) return restore(Deferred.await(entry.deferred)) + if (Deferred.isDoneUnsafe(entry.deferred)) return restore(Deferred.await(entry.deferred)) entry.awaiters++ // Install cleanup before restore so a pending interrupt cannot skip the decrement. return effect.onExit(restore(Deferred.await(entry.deferred)), () => { entry.awaiters-- - if (entry.awaiters > 0 || Deferred.isDoneUnsafe(entry.deferred)) return effect.void + const fiber = entry.fiber + if (entry.awaiters > 0 || fiber === undefined || Deferred.isDoneUnsafe(entry.deferred)) return effect.void + // Detach before interruption so callers arriving during finalization start fresh. + removeEntry(self, key, entry) return effect.flatMap(effect.fiberInterrupt(fiber), () => { const exit = fiber.pollUnsafe()! return Exit.isFailure(exit) && Cause.hasDies(exit.cause) ? effect.failCause(exit.cause) : effect.void @@ -330,6 +335,12 @@ const awaitEntry = ( }) } +const removeEntry = (self: ScopedCache, key: Key, entry: Entry): void => { + if (self.state._tag !== "Open") return + const current = MutableHashMap.get(self.state.map, key) + if (Option.isSome(current) && current.value === entry) MutableHashMap.remove(self.state.map, key) +} + const hasExpired = (entry: Entry, fiber: Fiber.Fiber): boolean => { if (entry.expiresAt === undefined) { return false @@ -386,7 +397,7 @@ export const getOption: { core.withFiber((fiber) => effect.flatMap( getImpl(self, key, fiber), - (entry) => entry ? effect.asSome(awaitEntry(entry, restore)) : effect.succeedNone + (entry) => entry ? effect.asSome(awaitEntry(self, key, entry, restore)) : effect.succeedNone ) ) ) @@ -620,7 +631,7 @@ export const invalidateWhen: { if (entry === undefined) { return effect.succeed(false) } - return awaitEntry(entry, restore).pipe( + return awaitEntry(self, key, entry, restore).pipe( effect.flatMap((value) => { if (self.state._tag === "Closed") { return effect.succeed(false) diff --git a/repos/effect/packages/effect/src/Stream.ts b/repos/effect/packages/effect/src/Stream.ts index 3554bad47..96eda628b 100644 --- a/repos/effect/packages/effect/src/Stream.ts +++ b/repos/effect/packages/effect/src/Stream.ts @@ -7934,19 +7934,18 @@ export const scan: { f: (s: S, a: A) => S ): Stream => suspend(() => { - let isFirst = true - return fromChannel(Channel.mapAccum(self.channel, initial, (state, arr) => { - const states = Arr.empty() as Arr.NonEmptyArray - if (isFirst) { - isFirst = false - states.push(state) - } - for (let index = 0; index < arr.length; index++) { - state = f(state, arr[index]) - states.push(state) - } - return [state, Arr.of(states)] - })) + const seed = initial() + return concat( + succeed(seed), + fromChannel(Channel.mapAccum(self.channel, () => seed, (state, arr) => { + const states = Arr.empty() as Arr.NonEmptyArray + for (let index = 0; index < arr.length; index++) { + state = f(state, arr[index]) + states.push(state) + } + return [state, Arr.of(states)] + })) + ) })) /** @@ -8215,37 +8214,36 @@ const throttleShapeEffect = ( const durationMs = Duration.toMillis(Duration.fromInputUnsafe(duration)) const max = units + burst < 0 ? Number.POSITIVE_INFINITY : units + burst let tokens = units - let timestampMs = clock.currentTimeMillisUnsafe() + let timestampNanos = clock.monotonicTimeNanosUnsafe() return Effect.succeed(Effect.flatMap(pull, (arr) => Effect.flatMap(cost(arr), (weight) => { - const currentMs = clock.currentTimeMillisUnsafe() - const elapsed = currentMs - timestampMs + const currentNanos = clock.monotonicTimeNanosUnsafe() + const elapsed = Number(currentNanos - timestampNanos) / 1_000_000 const cycles = elapsed / durationMs const sum = tokens + (cycles * units) - const available = sum < 0 ? max : Math.min(sum, max) + const available = Math.min(sum, max) const remaining = available - weight if (remaining >= 0) { tokens = remaining - timestampMs = currentMs + timestampNanos = currentNanos return Effect.succeed(arr) } - // Calculate delay needed const waitCycles = -remaining / units const delayMs = Math.max(0, waitCycles * durationMs) if (delayMs > 0) { return Effect.flatMap(Effect.sleep(delayMs), () => { tokens = remaining - timestampMs = currentMs + timestampNanos = currentNanos return Effect.succeed(arr) }) } tokens = remaining - timestampMs = currentMs + timestampNanos = currentNanos return Effect.succeed(arr) }))) })) @@ -8371,8 +8369,9 @@ export const grouped: { * * **Details** * - * Finite fractional chunk sizes are rounded down. `NaN` and non-positive sizes - * are treated as `1`. + * The duration starts when the first element of a group arrives, so an idle + * stream does not wake up. Finite fractional chunk sizes are rounded down. + * `NaN` and non-positive sizes are treated as `1`. * * **Example** (Grouping elements by size or time) * @@ -8802,6 +8801,18 @@ export const aggregate: { * **Details** * * The schedule can flush the current aggregation even if the sink has not finished. + * It is stepped at most once per aggregation, when the aggregation receives its + * first element, with the previous aggregation's output as input. An aggregation + * the sink completes immediately, such as one built from leftovers, may finish + * without stepping the schedule. + * + * Time between aggregations is hidden from the schedule, so an idle stream does + * not run it. Time-based schedules such as `Schedule.fixed` and + * `Schedule.upTo({ duration })` only count time spent aggregating; use + * `Schedule.spaced` for a flush timer. + * + * When the schedule ends, the stream emits the current aggregation and any sink + * leftovers, then ends without pulling more from upstream. * * **Example** (Aggregating with a sink and schedule) * @@ -8848,73 +8859,70 @@ export const aggregateWithin: { capacity: 0 }) - // upstream -> buffer yield* pull.pipe( pullLatch.whenOpen, Effect.flatMap((arr) => { pullLatch.closeUnsafe() return Queue.offer(buffer, arr) }), - Effect.forever, // don't disable autoYield to prevent choking the schedule + Effect.forever, // Keep autoYield enabled so the schedule can run. Effect.catchCause((cause) => Queue.failCause(buffer, cause)), Effect.forkIn(scope) ) - // schedule -> buffer + // The schedule clock excludes time between aggregations. + const clock = yield* Clock + const step = yield* Schedule.toStep(schedule) + const hasInput = Latch.makeUnsafe(false) + let scheduleTime = clock.currentTimeMillisUnsafe() + let openedAt = 0 let lastOutput = Option.none() let leftover: Arr.NonEmptyReadonlyArray | undefined - let sinkHasInput = false - const step = yield* Schedule.toStepWithSleep(schedule) - const stepLoop = Effect.suspend(function loop(): Pull.Pull { - return Effect.flatMap(step(lastOutput), () => !sinkHasInput ? loop() : Queue.offer(buffer, scheduleStep)) - }) - const stepToBuffer: Pull.Pull = stepLoop.pipe( - Effect.flatMap(() => Effect.never), - Pull.catchDone(() => Cause.done()) - ) - - // buffer -> sink - const pullFromBuffer: Pull.Pull< - Arr.NonEmptyReadonlyArray, - E - > = Queue.take(buffer).pipe( - Effect.flatMap((arr) => { - if (arr === scheduleStep) { - return Cause.done() - } - sinkHasInput = true - return Effect.succeed(arr) - }) + const onInput = (chunk: X): Effect.Effect => { + if (hasInput.openUnsafe()) { + openedAt = clock.currentTimeMillisUnsafe() + } + return Effect.succeed(chunk) + } + const stepToBuffer: Pull.Pull = hasInput.await.pipe( + Effect.flatMap(() => step(scheduleTime, lastOutput)), + Effect.flatMap(([, delay]) => Effect.sleep(delay)), + Effect.flatMap(() => Queue.offer(buffer, scheduleStep)), + // End input so the sink drains leftovers without further upstream pulls. + Pull.catchDone(() => Queue.end(buffer)), + Effect.flatMap(() => Effect.never) ) const sinkUpstream = Effect.suspend((): Pull.Pull, E> => { if (leftover !== undefined) { const chunk = leftover leftover = undefined - sinkHasInput = true - return Effect.succeed(chunk) + return onInput(chunk) } - pullLatch.openUnsafe() - return pullFromBuffer + if (buffer.state._tag === "Open") { + pullLatch.openUnsafe() + } + return Effect.flatMap(Queue.take(buffer), (arr) => arr === scheduleStep ? Cause.done() : onInput(arr)) }) - const catchSinkHalt = Effect.flatMap(([value, leftover_]: Sink.End) => { - // ignore the last output if the upstream only pulled a halt - if (!sinkHasInput && buffer.state._tag === "Done") return Cause.done() + const emit = Effect.flatMap(([value, leftover_]: Sink.End) => { + if (hasInput.isOpen()) { + scheduleTime += clock.currentTimeMillisUnsafe() - openedAt + } else if (buffer.state._tag === "Done") { + return Cause.done() + } lastOutput = Option.some(value) leftover = leftover_ return Effect.succeed(Arr.of(value)) }) - return Effect.suspend(() => { - // if the buffer has exited and there is no more data to process + return Effect.suspend((): Pull.Pull, E | E2 | E3, void, R2 | R3> => { if (buffer.state._tag === "Done" && leftover === undefined) { return buffer.state.exit as Exit.Exit | E> } - sinkHasInput = leftover !== undefined - return Effect.succeed(Effect.suspend(() => sink.transform(sinkUpstream as any, scope))) - }).pipe( - Effect.flatMap((pull) => Effect.raceFirst(catchSinkHalt(pull), stepToBuffer)) - ) + hasInput.closeUnsafe() + const pull = emit(Effect.suspend(() => sink.transform(sinkUpstream as any, scope))) + return buffer.state._tag === "Open" ? Effect.raceFirst(pull, stepToBuffer) : pull + }) })))) /** diff --git a/repos/effect/packages/effect/src/ai/LanguageModel.ts b/repos/effect/packages/effect/src/ai/LanguageModel.ts index e20a9e694..6926ffc93 100644 --- a/repos/effect/packages/effect/src/ai/LanguageModel.ts +++ b/repos/effect/packages/effect/src/ai/LanguageModel.ts @@ -107,6 +107,13 @@ export const TypeId: TypeId = "~effect/ai/LanguageModel" export interface LanguageModel { readonly [TypeId]: TypeId + /** + * Whether the effective provider configuration supports system messages in + * history. Evaluate in the request's context to respect scoped overrides. + * Placement restrictions may apply; an absent capability means unknown support. + */ + readonly supportsSystemMessagesInHistory?: Effect.Effect | undefined + /** * Generate text using the language model. */ @@ -833,6 +840,9 @@ export const make: (params: { * for structured output generation. */ readonly codecTransformer?: CodecTransformer | undefined + + /** Whether the effective provider configuration supports system messages in history. */ + readonly supportsSystemMessagesInHistory?: Effect.Effect | undefined }) => Effect.Effect = Effect.fnUntraced(function*(params) { const codecTransformer = params.codecTransformer ?? defaultCodecTransformer @@ -1749,6 +1759,7 @@ export const make: (params: { return LanguageModel.of({ [TypeId]: TypeId, + supportsSystemMessagesInHistory: params.supportsSystemMessagesInHistory, generateText: generateText as LanguageModel["generateText"], generateObject: generateObject as LanguageModel["generateObject"], streamText: streamText as LanguageModel["streamText"] @@ -2417,18 +2428,32 @@ const resolveToolCalls = >( // Utilities // ============================================================================= +// Stable parameter-mode copies let Response reuse each tool's part schemas. +const parameterModeTools = new WeakMap() + +const withParameterMode = (tool: Tool.Any, mode: "encoded" | "opaque"): Tool.Any => { + let tools = parameterModeTools.get(tool) + if (tools === undefined) { + tools = {} + parameterModeTools.set(tool, tools) + } + return tools[mode] ??= tool.setParameters( + mode === "encoded" ? Schema.toEncoded(tool.parametersSchema) : Schema.Unknown + ) +} + const makeToolkitWithEncodedParameters = >( toolkit: Toolkit.WithHandler ): Toolkit.Any => Toolkit.make( - ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toEncoded(tool.parametersSchema))) + ...Object.values(toolkit.tools).map((tool) => withParameterMode(tool, "encoded")) ) const makeToolkitWithOpaqueParameters = >( toolkit: Toolkit.WithHandler ): Toolkit.Any => Toolkit.make( - ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.Unknown)) + ...Object.values(toolkit.tools).map((tool) => withParameterMode(tool, "opaque")) ) // Provider-executed tools bypass Toolkit, so validate their parameters here. diff --git a/repos/effect/packages/effect/src/ai/McpSchema.ts b/repos/effect/packages/effect/src/ai/McpSchema.ts index bd6934b9c..02fa72449 100644 --- a/repos/effect/packages/effect/src/ai/McpSchema.ts +++ b/repos/effect/packages/effect/src/ai/McpSchema.ts @@ -86,10 +86,10 @@ export const optionalWithDefault = ( schema: S -): Schema.decodeTo, Schema.optionalKey> => +): Schema.decodeTo>, Schema.optionalKey> => Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough() as any, + Schema.decodeTo(Schema.toType(Schema.optional(schema)), { + decode: SchemaGetter.passthrough(), encode: SchemaGetter.transformOptional(Option.flatMap(Option.fromUndefinedOr)) }) ) diff --git a/repos/effect/packages/effect/src/ai/McpServer.ts b/repos/effect/packages/effect/src/ai/McpServer.ts index f6ba80a70..f76029cfb 100644 --- a/repos/effect/packages/effect/src/ai/McpServer.ts +++ b/repos/effect/packages/effect/src/ai/McpServer.ts @@ -1620,7 +1620,7 @@ const layerMcpProtocolHttp = (options: { const hasMultipleMessages = response.body._tag === "Uint8Array" && response.body.body.subarray(0, -1).includes(10) return admission.isSubscription || response.body._tag === "Stream" || hasMultipleMessages - ? toServerSentEvents(response) + ? toServerSentEvents(response, admission.isSubscription) : response }) return yield* admission.acknowledge @@ -1713,7 +1713,7 @@ function mcpJsonRpcSerialization(options?: { }) } -const toServerSentEvents = (response: HttpServerResponse.HttpServerResponse) => { +const toServerSentEvents = (response: HttpServerResponse.HttpServerResponse, keepAlive: boolean) => { const encoder = new TextEncoder() const decoder = new TextDecoder() const frame = (data: Uint8Array) => @@ -1726,7 +1726,16 @@ const toServerSentEvents = (response: HttpServerResponse.HttpServerResponse) => contentType: "text/event-stream" } if (response.body._tag === "Stream") { - return HttpServerResponse.stream(response.body.stream.pipe(Stream.map(frame)), options) + const events = response.body.stream.pipe(Stream.map(frame)) + if (!keepAlive) { + return HttpServerResponse.stream(events, options) + } + // Keep a native timer pending so workerd does not treat idle subscriptions as hung requests. + const keepAliveComments = Stream.tick("15 seconds").pipe( + Stream.drop(1), + Stream.map(() => encoder.encode(": keepalive\n\n")) + ) + return HttpServerResponse.stream(Stream.merge(events, keepAliveComments, { haltStrategy: "left" }), options) } if (response.body._tag === "Uint8Array") { return HttpServerResponse.uint8Array(frame(response.body.body), options) diff --git a/repos/effect/packages/effect/src/ai/Response.ts b/repos/effect/packages/effect/src/ai/Response.ts index 3af65de55..5f19e99e2 100644 --- a/repos/effect/packages/effect/src/ai/Response.ts +++ b/repos/effect/packages/effect/src/ai/Response.ts @@ -152,6 +152,24 @@ export type AllPartsEncoded = | FinishPartEncoded | ErrorPartEncoded +// Tools are immutable, so response schemas can share their per-tool parts. +const toolPartSchemaCache = new WeakMap() + +const toolPartSchemas = (tool: Tool.Any) => { + let parts = toolPartSchemaCache.get(tool) + if (parts === undefined) { + parts = { + toolCall: ToolCallPart(tool.name, tool.parametersSchema), + toolResult: ToolResultPart(tool.name, tool.successSchema, Tool.failureResultSchema(tool)) + } + toolPartSchemaCache.set(tool, parts) + } + return parts +} + /** * Creates a Schema for all response parts based on a toolkit. * @@ -192,10 +210,9 @@ export const AllParts = >( const toolCalls: Array = [] const toolResults: Array = [] for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, Tool.failureResultSchema(tool)) - toolCalls.push(toolCall) - toolResults.push(toolResult) + const parts = toolPartSchemas(tool) + toolCalls.push(parts.toolCall) + toolResults.push(parts.toolResult) } return Schema.Union([ TextPart, @@ -287,10 +304,9 @@ export const Part = >( const toolCalls: Array = [] const toolResults: Array = [] for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, Tool.failureResultSchema(tool)) - toolCalls.push(toolCall) - toolResults.push(toolResult) + const parts = toolPartSchemas(tool) + toolCalls.push(parts.toolCall) + toolResults.push(parts.toolResult) } return Schema.Union([ TextPart, @@ -385,10 +401,9 @@ export const StreamPart = >( const toolCalls: Array = [] const toolResults: Array = [] for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, Tool.failureResultSchema(tool)) - toolCalls.push(toolCall) - toolResults.push(toolResult) + const parts = toolPartSchemas(tool) + toolCalls.push(parts.toolCall) + toolResults.push(parts.toolResult) } return Schema.Union([ TextStartPart, diff --git a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2024_11_05.ts b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2024_11_05.ts index 647d52246..6204a3396 100644 --- a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2024_11_05.ts +++ b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2024_11_05.ts @@ -53,6 +53,7 @@ const projectCapabilities = ( capabilities: McpCore.CanonicalServerCapabilities ): typeof McpSchema.ServerCapabilities.Type => ({ experimental: capabilities.experimental, + extensions: capabilities.extensions, logging: capabilities.logging ? {} : undefined, prompts: capabilities.prompts, resources: capabilities.resources, diff --git a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_03_26.ts b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_03_26.ts index 7e952aeec..156c817a4 100644 --- a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_03_26.ts +++ b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_03_26.ts @@ -134,6 +134,7 @@ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, capabilities: ({ experimental: result.capabilities.experimental, + extensions: result.capabilities.extensions, logging: result.capabilities.logging ? {} : undefined, completions: result.capabilities.completions ? {} : undefined, prompts: result.capabilities.prompts, diff --git a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_06_18.ts b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_06_18.ts index 2cb939620..2318e22de 100644 --- a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_06_18.ts +++ b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_06_18.ts @@ -150,6 +150,7 @@ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, capabilities: ({ experimental: result.capabilities.experimental, + extensions: result.capabilities.extensions, logging: result.capabilities.logging ? {} : undefined, completions: result.capabilities.completions ? {} : undefined, prompts: result.capabilities.prompts, diff --git a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_11_25.ts b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_11_25.ts index facfd55e5..e6e984675 100644 --- a/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_11_25.ts +++ b/repos/effect/packages/effect/src/ai/internal/mcpProtocol/v2025_11_25.ts @@ -145,6 +145,7 @@ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, capabilities: ({ experimental: result.capabilities.experimental, + extensions: result.capabilities.extensions, logging: result.capabilities.logging ? {} : undefined, completions: result.capabilities.completions ? {} : undefined, prompts: result.capabilities.prompts, diff --git a/repos/effect/packages/effect/src/ai/internal/mcpSchema/v2024_11_05.ts b/repos/effect/packages/effect/src/ai/internal/mcpSchema/v2024_11_05.ts index 62c1bf9b8..ed117834f 100644 --- a/repos/effect/packages/effect/src/ai/internal/mcpSchema/v2024_11_05.ts +++ b/repos/effect/packages/effect/src/ai/internal/mcpSchema/v2024_11_05.ts @@ -22,9 +22,9 @@ export const protocolVersion = "2024-11-05" */ export const optional = ( schema: S -): Schema.decodeTo, Schema.optionalKey> => +): Schema.decodeTo>, Schema.optionalKey> => Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { + Schema.decodeTo(Schema.toType(Schema.optional(schema)), { decode: SchemaGetter.passthrough(), encode: SchemaGetter.transformOptional(Option.flatMap(Option.fromUndefinedOr)) }) @@ -111,6 +111,7 @@ export const Implementation = Schema.Struct({ * @internal */ export const ClientCapabilities = Schema.Struct({ + extensions: optional(Schema.Record(Schema.TemplateLiteral([Schema.String, "/", Schema.String]), Schema.Json)), experimental: optional(Schema.Record(Schema.String, JsonObject)), roots: optional(Schema.Struct({ listChanged: optional(Schema.Boolean) @@ -122,6 +123,7 @@ export const ClientCapabilities = Schema.Struct({ * @internal */ export const ServerCapabilities = Schema.Struct({ + extensions: optional(Schema.Record(Schema.TemplateLiteral([Schema.String, "/", Schema.String]), Schema.Json)), experimental: optional(Schema.Record(Schema.String, JsonObject)), logging: optional(JsonObject), prompts: optional(Schema.Struct({ diff --git a/repos/effect/packages/effect/src/cluster/ClusterWorkflowEngine.ts b/repos/effect/packages/effect/src/cluster/ClusterWorkflowEngine.ts index 75e856e33..357d06184 100644 --- a/repos/effect/packages/effect/src/cluster/ClusterWorkflowEngine.ts +++ b/repos/effect/packages/effect/src/cluster/ClusterWorkflowEngine.ts @@ -411,7 +411,7 @@ export const make = Effect.gen(function*() { // Replays reuse the request id but track a fresh set of awaits. let currentRun: { readonly request: Entity.Request - readonly awaitedDeferreds: ReadonlySet + readonly instance: WorkflowEngine.WorkflowInstance["Service"] } | undefined // Concurrent wakes share one wait for the current run to publish its reply. const resumeGate = Semaphore.makeUnsafe(1) @@ -428,7 +428,7 @@ export const make = Effect.gen(function*() { return { run: (request: Entity.Request) => { const instance = WorkflowEngine.WorkflowInstance.initial(workflow, executionId) - currentRun = { request, awaitedDeferreds: instance.awaitedDeferreds } + currentRun = { request, instance } const parent = (request.payload as any)[payloadParentKey] as | { workflowName: string; executionId: string } | undefined @@ -522,9 +522,13 @@ export const make = Effect.gen(function*() { // registered yet, a later await will see the result just stored. // Check when the wake runs and skip preemption too: interrupting // a later await would require waiting for its reply before reset. + // A run completing its own deferred also reads it from the cache + // when it awaits later, so that completion never wakes the run. const wake = Effect.suspend(() => - currentRun && !currentRun.awaitedDeferreds.has(payload.name) - ? ensureSuccess(resume(workflow, executionId)) + currentRun && !currentRun.instance.awaitedDeferreds.has(payload.name) + ? currentRun.instance.completedDeferreds.has(payload.name) + ? Effect.void + : ensureSuccess(resume(workflow, executionId)) : deferredState.deferredDone(executionId, payload.name).pipe(Effect.andThen(resumeCurrentRun)) ) // An asynchronous reply releases the RPC concurrency permit while @@ -703,6 +707,16 @@ export const make = Effect.gen(function*() { deferredDone: Effect.fnUntraced( function*({ deferredName, executionId, exit, workflowName }) { + const instance = yield* Effect.serviceOption(WorkflowEngine.WorkflowInstance) + // Activities run with their own instance, so their completions still + // wake a run that suspended before reaching the await. An interrupt + // must wake the run even when it signals itself. + if ( + Option.isSome(instance) && instance.value.executionId === executionId && + deferredName !== InterruptSignal.name + ) { + instance.value.completedDeferreds.add(deferredName) + } const workflow = workflows.get(workflowName) if (workflow) { return yield* Effect.orDie(sendDiscard({ diff --git a/repos/effect/packages/effect/src/cluster/Sharding.ts b/repos/effect/packages/effect/src/cluster/Sharding.ts index 740f7fbdd..43b634c65 100644 --- a/repos/effect/packages/effect/src/cluster/Sharding.ts +++ b/repos/effect/packages/effect/src/cluster/Sharding.ts @@ -549,30 +549,27 @@ const make = Effect.gen(function*() { return Effect.logInfo("Shard lock storage has recovered") }) - const refreshShardLocks = Effect.suspend(() => - runnerStorage.refresh(selfAddress, [ - ...acquiredShards, - ...releasingShards - ]) - ).pipe( - Effect.flatMap((acquired) => { - for (const shardId of acquiredShards) { - if (!acquired.includes(shardId)) { - MutableHashSet.remove(acquiredShards, shardId) - MutableHashSet.add(releasingShards, shardId) - } + const refreshShardLocks = Effect.gen(function*() { + const refreshed = [...acquiredShards, ...releasingShards] + const acquired = yield* runnerStorage.refresh(selfAddress, refreshed) + // Shards acquired during the request are absent from its response. + for (const shardId of refreshed) { + if (MutableHashSet.has(acquiredShards, shardId) && !acquired.includes(shardId)) { + MutableHashSet.remove(acquiredShards, shardId) + MutableHashSet.add(releasingShards, shardId) } - for (let i = 0; i < acquired.length; i++) { - const shardId = acquired[i] - if (!MutableHashSet.has(selfShards, shardId)) { - MutableHashSet.remove(acquiredShards, shardId) - MutableHashSet.add(releasingShards, shardId) - } + } + for (let i = 0; i < acquired.length; i++) { + const shardId = acquired[i] + if (!MutableHashSet.has(selfShards, shardId)) { + MutableHashSet.remove(acquiredShards, shardId) + MutableHashSet.add(releasingShards, shardId) } - return MutableHashSet.size(releasingShards) > 0 - ? activeShardsLatch.open - : Effect.void - }), + } + if (MutableHashSet.size(releasingShards) > 0) { + yield* activeShardsLatch.open + } + }).pipe( Effect.retry({ times: 5, schedule: Schedule.spaced(50) @@ -1462,8 +1459,10 @@ const make = Effect.gen(function*() { // for durable messages, we ignore interrupts on shutdown or as a // result of a shard being resassigned const caller = Context.getOption(entry.context, CurrentAddress).valueOrUndefined + const singletonShard = Context.getOption(entry.context, SingletonShardTag).valueOrUndefined const isTransientInterrupt = MutableRef.get(isShutdown) || - (caller !== undefined && ActiveTeardown.isActive(caller)) + (caller !== undefined && ActiveTeardown.isActive(caller)) || + (singletonShard !== undefined && ActiveTeardown.isShardActive(singletonShard)) if (isTransientInterrupt && Context.get(entry.message.annotations, Persisted)) { return Effect.void } @@ -1592,7 +1591,7 @@ const make = Effect.gen(function*() { Effect.andThen(Effect.never), Effect.scoped, Effect.provideService(CurrentLogAnnotations, {}), - Effect.provideContext(services), + Effect.provideContext(Context.add(services, SingletonShardTag, address.shardId)), Effect.orDie, Effect.interruptible ) as Effect.Effect @@ -1855,3 +1854,4 @@ export const layer: Layer.Layer< // Utilities const ClientAddressTag = Context.Service("effect/cluster/Sharding/ClientAddress") +const SingletonShardTag = Context.Service("effect/cluster/Sharding/SingletonShard") diff --git a/repos/effect/packages/effect/src/cluster/SqlMessageStorage.ts b/repos/effect/packages/effect/src/cluster/SqlMessageStorage.ts index eb39d7fd7..8d7f9b218 100644 --- a/repos/effect/packages/effect/src/cluster/SqlMessageStorage.ts +++ b/repos/effect/packages/effect/src/cluster/SqlMessageStorage.ts @@ -29,6 +29,7 @@ import * as Migrator from "../sql/Migrator.ts" import * as SqlClient from "../sql/SqlClient.ts" import type { Row } from "../sql/SqlConnection.ts" import { isSqlError, type SqlError } from "../sql/SqlError.ts" +import type { Fragment } from "../sql/Statement.ts" import { PersistenceError } from "./ClusterError.ts" import type * as EntityAddress from "./EntityAddress.ts" import type * as Envelope from "./Envelope.ts" @@ -494,6 +495,28 @@ export const makeEncoded: (options?: { ? Effect.succeed([]) : getUnprocessedMessagesForDialect(shardIds, now, unprocessedFilters(options)) + const resetUnprocessed = sql.onDialectOrElse({ + // Match the claim query's lock order to avoid deadlocks during resets. + pg: () => (filter: Fragment) => + sql` + WITH messages AS ( + SELECT id FROM ${messagesTableSql} + WHERE processed = ${sqlFalse} AND ${filter} + ORDER BY rowid ASC + FOR UPDATE + ) + UPDATE ${messagesTableSql} + SET last_read = NULL + WHERE id IN (SELECT id FROM messages) + `, + orElse: () => (filter: Fragment) => + sql` + UPDATE ${messagesTableSql} + SET last_read = NULL + WHERE processed = ${sqlFalse} AND ${filter} + ` + }) + const encoded: MessageStorage.Encoded = { saveEnvelope: ({ deliverAt, envelope, primaryKey }) => Effect.suspend(() => { @@ -716,11 +739,7 @@ export const makeEncoded: (options?: { resetAddresses: (addresses) => addresses.length === 0 ? Effect.void - : sql` - UPDATE ${messagesTableSql} - SET last_read = NULL - WHERE processed = ${sqlFalse} - AND (${ + : resetUnprocessed(sql`(${ sql.or( groupAddresses(addresses).map( (group) => @@ -731,8 +750,7 @@ export const makeEncoded: (options?: { ]) ) ) - }) - `.pipe( + })`).pipe( Effect.asVoid, PersistenceError.refail, withTracerDisabled @@ -761,12 +779,7 @@ export const makeEncoded: (options?: { ), resetShards: (shardIds) => - sql` - UPDATE ${messagesTableSql} - SET last_read = NULL - WHERE processed = ${sqlFalse} - AND shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))}) - `.pipe( + resetUnprocessed(sql`shard_id IN (${sql.literal(shardIds.map(wrapString).join(","))})`).pipe( Effect.asVoid, PersistenceError.refail, withTracerDisabled diff --git a/repos/effect/packages/effect/src/cluster/SqlRunnerStorage.ts b/repos/effect/packages/effect/src/cluster/SqlRunnerStorage.ts index b82d24902..d0c278e3f 100644 --- a/repos/effect/packages/effect/src/cluster/SqlRunnerStorage.ts +++ b/repos/effect/packages/effect/src/cluster/SqlRunnerStorage.ts @@ -11,13 +11,14 @@ * @since 4.0.0 */ import * as Arr from "../Array.ts" +import * as Cause from "../Cause.ts" import * as Duration from "../Duration.ts" import * as Effect from "../Effect.ts" -import * as Exit from "../Exit.ts" import * as Fiber from "../Fiber.ts" import * as Layer from "../Layer.ts" import * as Scope from "../Scope.ts" import * as SqlClient from "../sql/SqlClient.ts" +import type { Connection } from "../sql/SqlConnection.ts" import type { SqlError } from "../sql/SqlError.ts" import type * as Statement from "../sql/Statement.ts" import { PersistenceError } from "./ClusterError.ts" @@ -156,47 +157,28 @@ export const make = Effect.fnUntraced(function*(options: { ) }) - let lockConnRebuilding = false - // Incremented every time the reserved connection is replaced, so failures - // from operations that ran on an already replaced connection do not trigger - // another rebuild. - let lockConnGeneration = 0 - const rebuildLockConn = (generation: number) => { - if ( - !lockConn || - lockConnRebuilding || - generation !== lockConnGeneration || - lockConn.state.current._tag === "Closed" - ) return Effect.void - lockConnRebuilding = true - // The rebuild starts by closing the previous scope, releasing the - // unresponsive connection back to the pool. Bound it with `withDeadline` - // so a release that never completes cannot leave `lockConnRebuilding` set - // forever, which would disable every subsequent rebuild. - return withDeadline(lockConn.rebuildUnsafe()).pipe( + const rebuildLockConn = (from?: readonly [Connection, number]) => { + const rebuild = lockConn?.rebuildUnsafe({ from }) + if (!rebuild) return Effect.void + // Bound release so a stalled connection cannot block subsequent rebuilds. + return withDeadline(rebuild).pipe( Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - if (Exit.isSuccess(exit) && lockConn.state.current._tag === "Acquired") { - lockConnGeneration++ - } - }) - ), - Effect.ensuring(Effect.sync(() => { - lockConnRebuilding = false - })), Effect.forkIn(layerScope, { startImmediately: true }), Effect.asVoid ) } - // Rebuild the reserved connection when `effect` fails on it. Failures keep - // scheduling rebuilds, so a rebuilt connection that is also unresponsive is - // replaced again. - const onErrorRebuildLockConn = (effect: Effect.Effect): Effect.Effect => - Effect.suspend(() => { - const generation = lockConnGeneration - return Effect.onError(effect, () => rebuildLockConn(generation)) - }) + // SQL failures replace the held connection; deadlines are handled by withLockOperationDeadline. + const useLockConn = ( + f: (conn: Connection, pid: number) => Effect.Effect + ): Effect.Effect => + Effect.flatMap( + lockConn!.await, + (held) => + Effect.onError( + f(held[0], held[1]), + (cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : rebuildLockConn(held) + ) + ) const runnersTable = table("runners") const runnersTableSql = sql(runnersTable) @@ -378,30 +360,21 @@ export const make = Effect.fnUntraced(function*(options: { const execWithLockConn = (effect: Statement.Statement): Effect.Effect => { if (!lockConn) return effect const [query, params] = effect.compile() - return lockConn.await.pipe( - Effect.flatMap(([conn]) => conn.executeRaw(query, params)), - onErrorRebuildLockConn - ) + return useLockConn((conn) => conn.executeRaw(query, params)) } const execWithLockConnUnprepared = ( effect: Statement.Statement ): Effect.Effect>, SqlError> => { if (!lockConn) return effect.values const [query, params] = effect.compile() - return lockConn.await.pipe( - Effect.flatMap(([conn]) => conn.executeUnprepared(query, params, undefined)), - onErrorRebuildLockConn - ) + return useLockConn((conn) => conn.executeUnprepared(query, params, undefined)) } const execWithLockConnValues = ( effect: Statement.Statement ): Effect.Effect>, SqlError> => { if (!lockConn) return effect.values const [query, params] = effect.compile() - return lockConn.await.pipe( - Effect.flatMap(([conn]) => conn.executeValues(query, params)), - onErrorRebuildLockConn - ) + return useLockConn((conn) => conn.executeValues(query, params)) } const acquireLock = sql.onDialectOrElse({ @@ -425,33 +398,33 @@ export const make = Effect.fnUntraced(function*(options: { ) } } - return Effect.fnUntraced(function*(_address: string, shardIds: ReadonlyArray) { - const [conn, pid] = yield* lockConn!.await - const acquiredShardIds: Array = [] - const toAcquire = new Map(shardIds.map((shardId) => [lockNumbers.get(shardId)!, shardId])) - const takenLocks = yield* conn.executeValues( - `SELECT objid FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objsubid = 2 AND pid = ${pid} ORDER BY objid`, - [] - ) - for (let i = 0; i < takenLocks.length; i++) { - const lockNum = takenLocks[i][0] as number - const shardId = toAcquire.get(lockNum) - if (shardId === undefined) continue - acquiredShardIds.push(shardId) - toAcquire.delete(lockNum) - } - if (toAcquire.size === 0) { - return acquiredShardIds - } - const rows = yield* conn.executeUnprepared(`SELECT ${pgLocks(toAcquire)}`, [], undefined) - const results = rows[0] as Record - for (const shardId in results) { - if (results[shardId]) { + return (_address: string, shardIds: ReadonlyArray) => + useLockConn(Effect.fnUntraced(function*(conn, pid) { + const acquiredShardIds: Array = [] + const toAcquire = new Map(shardIds.map((shardId) => [lockNumbers.get(shardId)!, shardId])) + const takenLocks = yield* conn.executeValues( + `SELECT objid FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objsubid = 2 AND pid = ${pid} ORDER BY objid`, + [] + ) + for (let i = 0; i < takenLocks.length; i++) { + const lockNum = takenLocks[i][0] as number + const shardId = toAcquire.get(lockNum) + if (shardId === undefined) continue acquiredShardIds.push(shardId) + toAcquire.delete(lockNum) } - } - return acquiredShardIds - }, onErrorRebuildLockConn) + if (toAcquire.size === 0) { + return acquiredShardIds + } + const rows = yield* conn.executeUnprepared(`SELECT ${pgLocks(toAcquire)}`, [], undefined) + const results = rows[0] as Record + for (const shardId in results) { + if (results[shardId]) { + acquiredShardIds.push(shardId) + } + } + return acquiredShardIds + })) }, mysql: () => { @@ -471,31 +444,31 @@ export const make = Effect.fnUntraced(function*(options: { ) } } - return Effect.fnUntraced(function*(_address: string, shardIds: ReadonlyArray) { - const [conn, pid] = yield* lockConn!.await - const takenLocks = (yield* conn.executeValues(`SELECT ${allMySqlTakenLocks}`, []))[0] as Array - const acquiredShardIds: Array = [] - const toAcquire: Array = [] - for (let i = 0; i < shardIds.length; i++) { - const shardId = shardIds[i] - const lockTakenBy = takenLocks[shardIdsIndex.get(shardId)!] - if (lockTakenBy === pid) { - acquiredShardIds.push(shardId) - } else if (shardIds.includes(shardId)) { - toAcquire.push(shardId) + return (_address: string, shardIds: ReadonlyArray) => + useLockConn(Effect.fnUntraced(function*(conn, pid) { + const takenLocks = (yield* conn.executeValues(`SELECT ${allMySqlTakenLocks}`, []))[0] as Array + const acquiredShardIds: Array = [] + const toAcquire: Array = [] + for (let i = 0; i < shardIds.length; i++) { + const shardId = shardIds[i] + const lockTakenBy = takenLocks[shardIdsIndex.get(shardId)!] + if (lockTakenBy === pid) { + acquiredShardIds.push(shardId) + } else if (shardIds.includes(shardId)) { + toAcquire.push(shardId) + } } - } - if (toAcquire.length === 0) { - return acquiredShardIds - } - const results = (yield* conn.executeValues(`SELECT ${mysqlLocks(toAcquire)}`, []))[0] as Array - for (let i = 0; i < results.length; i++) { - if (results[i] === 1) { - acquiredShardIds.push(toAcquire[i]) + if (toAcquire.length === 0) { + return acquiredShardIds } - } - return acquiredShardIds - }, onErrorRebuildLockConn) + const results = (yield* conn.executeValues(`SELECT ${mysqlLocks(toAcquire)}`, []))[0] as Array + for (let i = 0; i < results.length; i++) { + if (results[i] === 1) { + acquiredShardIds.push(toAcquire[i]) + } + } + return acquiredShardIds + })) }, mssql: () => (address: string, shardIds: ReadonlyArray) => { @@ -647,8 +620,16 @@ export const make = Effect.fnUntraced(function*(options: { `.pipe(execWithLockConnValues, Effect.map((rows) => rows.map((row) => row[0] as string))) }) + // On failure, replace the connection ready at entry. If none was ready, + // retry only a failed rebuild, leaving pending or newly ready connections alone. const withLockOperationDeadline = (operation: Effect.Effect) => - onErrorRebuildLockConn(withDeadline(operation)) + Effect.suspend(() => { + const ready = lockConn?.getUnsafe() + return Effect.onError(withDeadline(operation), () => { + if (ready !== undefined) return rebuildLockConn(ready) + return lockConn?.getUnsafe() === undefined ? rebuildLockConn() : Effect.void + }) + }) const releaseShard = sql.onDialectOrElse({ pg: () => { @@ -656,11 +637,10 @@ export const make = Effect.fnUntraced(function*(options: { return (address: string, shardId: string) => sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}`.pipe(execWithLockConn) } - return Effect.fnUntraced( - function*(_address, shardId) { + return (_address: string, shardId: string) => + useLockConn(Effect.fnUntraced(function*(conn) { const lockNum = lockNumbers.get(shardId)! for (let i = 0; i < 5; i++) { - const [conn] = yield* lockConn!.await yield* conn.executeRaw(`SELECT pg_advisory_unlock(${pgLockNamespace}, ${lockNum})`, []) const takenLocks = yield* conn.executeValues( `SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND granted = true AND classid = ${pgLockNamespaceOid} AND objid = ${lockNum} AND objsubid = 2 AND pid = pg_backend_pid()`, @@ -668,23 +648,18 @@ export const make = Effect.fnUntraced(function*(options: { ) if (takenLocks.length === 0) return } - const [conn] = yield* lockConn!.await yield* conn.executeRaw(`SELECT pg_advisory_unlock_all()`, []) - }, - onErrorRebuildLockConn, - Effect.asVoid - ) + })) }, mysql: () => { if (disableAdvisoryLocks) { return (address: string, shardId: string) => sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}`.pipe(execWithLockConn) } - return Effect.fnUntraced( - function*(_address, shardId) { + return (_address: string, shardId: string) => + useLockConn(Effect.fnUntraced(function*(conn, pid) { const lockName = lockNames.get(shardId)! while (true) { - const [conn, pid] = yield* lockConn!.await yield* conn.executeRaw(`SELECT RELEASE_LOCK('${lockName}')`, []) const takenLocks = yield* conn.executeValues( `SELECT IS_USED_LOCK('${lockName}')`, @@ -692,10 +667,7 @@ export const make = Effect.fnUntraced(function*(options: { ) if (takenLocks.length === 0 || takenLocks[0][0] !== pid) return } - }, - onErrorRebuildLockConn, - Effect.asVoid - ) + })) }, orElse: () => (address: string, shardId: string) => sql`DELETE FROM ${locksTableSql} WHERE address = ${address} AND shard_id = ${shardId}` diff --git a/repos/effect/packages/effect/src/cluster/internal/entityManager.ts b/repos/effect/packages/effect/src/cluster/internal/entityManager.ts index 12bc386ad..2fb7c18b1 100644 --- a/repos/effect/packages/effect/src/cluster/internal/entityManager.ts +++ b/repos/effect/packages/effect/src/cluster/internal/entityManager.ts @@ -97,6 +97,8 @@ export type EntityState = { readonly rpc: Rpc.AnyWithProps readonly message: Message.IncomingRequestLocal sentReply: boolean + /** Excludes requests awaiting their first dispatch from replay. */ + delivered: boolean sentExit: boolean lastSentChunk: Option.Option> sequence: number @@ -200,11 +202,19 @@ export const make = Effect.fnUntraced(function*< ) const activeRequests: EntityState["activeRequests"] = new Map() - let defectRequestIds = new Set() - let isRestartingDueToDefect = false + const retired = Latch.makeUnsafe() + const isActive = () => !retired.isOpen() + + // Replay previously dispatched requests before admitting new work. + const replay = Effect.fnUntraced(function*(write: EntityState["write"]) { + if (!isActive()) return + for (const request of activeRequests.values()) { + if (!request.delivered) continue + request.sentExit = false + yield* write(0, requestEnvelope(request), requestWriteOptions(request)) + } + }) - // the server is stored in a ref, so if there is a defect, we can - // swap the server without losing the active requests const writeRef = yield* ResourceRef.from( scope, Effect.fnUntraced(function*(handlerScope) { @@ -245,11 +255,8 @@ export const make = Effect.fnUntraced(function*< request.sentReply = true request.sentExit = true - if ( - isShuttingDown && - Exit.hasInterrupts(response.exit) && - defectRequestIds.has(Snowflake.Snowflake(response.requestId)) - ) { + // Rebuild interrupts are not replies; replacement handlers replay the request. + if (isShuttingDown && Exit.hasInterrupts(response.exit) && isActive()) { return Effect.void } @@ -265,19 +272,7 @@ export const make = Effect.fnUntraced(function*< ) { if (!isShuttingDown) { request.sentExit = false - return server.write( - 0, - { - ...request.message.envelope, - id: request.message.envelope.requestId as any, - tag: request.message.envelope.tag as any, - payload: new Request({ - ...request.message.envelope, - lastSentChunk: request.lastSentChunk - } as any) as any - }, - requestWriteOptions(request) - ).pipe( + return server.write(0, requestEnvelope(request), requestWriteOptions(request)).pipe( Effect.forkIn(handlerScope) ) } @@ -335,7 +330,10 @@ export const make = Effect.fnUntraced(function*< )) } case "Defect": { - return Effect.forkIn(onDefect(Cause.die(response.defect)), managerScope) + if (!isActive()) return endLatch.open + const rebuild = writeRef.rebuildUnsafe({ from: server.write, prepare: replay }) + if (!rebuild) return Effect.void + return Effect.forkIn(restart(Cause.die(response.defect), rebuild), managerScope) } case "ClientEnd": { return endLatch.open @@ -354,56 +352,25 @@ export const make = Effect.fnUntraced(function*< }) ) - if (defectRequestIds.size > 0) { - for (const id of defectRequestIds) { - const request = activeRequests.get(id) - if (!request) continue - const { lastSentChunk, message } = request - request.sentExit = false - yield* server.write( - 0, - { - ...message.envelope, - id: message.envelope.requestId as any, - tag: message.envelope.tag as any, - payload: new Request({ - ...message.envelope, - lastSentChunk - } as any) as any - }, - requestWriteOptions(request) - ) - } - defectRequestIds.clear() - } - return server.write }), address ) - function onDefect(cause: Cause.Cause): Effect.Effect { - if (!activeServers.has(address.entityId)) { - return endLatch.open - } - if (isRestartingDueToDefect) { - return Effect.void - } - defectRequestIds = new Set(activeRequests.keys()) - isRestartingDueToDefect = true - const effect = writeRef.rebuildUnsafe() + function restart(cause: Cause.Cause, rebuild: Effect.Effect): Effect.Effect { return Effect.logError("Defect in entity, restarting", cause).pipe( Effect.andThen(Effect.ignore(retryDriver(void 0))), - Effect.flatMap(() => activeServers.has(address.entityId) ? effect : endLatch.open), - Effect.ensuring(Effect.sync(() => { - isRestartingDueToDefect = false - })), + Effect.flatMap(() => isActive() ? rebuild : endLatch.open), Effect.annotateLogs({ module: "EntityManager", address, runner: options.runnerAddress }), - Effect.catchCause(onDefect) + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.void + const retry = isActive() ? writeRef.rebuildUnsafe({ prepare: replay }) : undefined + return retry ? restart(cause, retry) : Effect.void + }) ) } @@ -411,10 +378,27 @@ export const make = Effect.fnUntraced(function*< scope, address, write(clientId, message, writeOptions) { - if (writeRef.state.current._tag !== "Acquired") { - return Effect.flatMap(writeRef.await, (write) => write(clientId, message, writeOptions)) - } - return writeRef.state.current.value(clientId, message, writeOptions) + return Effect.suspend(() => { + if (message._tag !== "Request") { + // Interrupts, acks and EOF reach the handlers even during shutdown. + const write = writeRef.getUnsafe() + return write + ? write(clientId, message, writeOptions) + : Effect.flatMap(writeRef.await, (write) => write(clientId, message, writeOptions)) + } + if (!isActive()) return Effect.interrupt + const write = writeRef.getUnsafe() + if (write === undefined) { + return Effect.flatMap( + Effect.raceFirst(writeRef.await, retired.await), + () => state.write(clientId, message, writeOptions) + ) + } + const request = activeRequests.get(Snowflake.Snowflake(message.id)) + if (!request) return Effect.void + request.delivered = true + return write(clientId, message, writeOptions) + }) }, activeRequests, lastActiveCheck: clock.currentTimeMillisUnsafe(), @@ -430,6 +414,7 @@ export const make = Effect.fnUntraced(function*< scope, Effect.suspend(() => { activeServers.delete(address.entityId) + retired.openUnsafe() acquireEntity(address) return Effect.raceFirst( state.write(0, { _tag: "Eof" }).pipe( @@ -441,6 +426,9 @@ export const make = Effect.fnUntraced(function*< ) }) ) + if (!options.sharding.hasShardId(address.shardId)) { + return yield* new EntityNotAssignedToRunner({ address }) + } // Do not make shard interruption wait for an entity that is still building. serverCloseLatches.set(address, closeLatches) activeServers.set(address.entityId, state) @@ -544,6 +532,7 @@ export const make = Effect.fnUntraced(function*< rpc, message, sentReply: false, + delivered: false, sentExit: false, lastSentChunk: Option.filter( message.lastSentReply, @@ -565,21 +554,7 @@ export const make = Effect.fnUntraced(function*< } })) } - return server.write( - 0, - { - ...message.envelope, - id: message.envelope.requestId as any, - payload: new Request({ - ...message.envelope, - lastSentChunk: Option.filter( - message.lastSentReply, - (reply): reply is Reply.Chunk => reply._tag === "Chunk" - ) - }) - }, - requestWriteOptions(entry) - ) + return server.write(0, requestEnvelope(entry), requestWriteOptions(entry)) } case "IncomingEnvelope": { const entry = server.activeRequests.get(message.envelope.requestId) @@ -795,6 +770,19 @@ const makeMessageDecode = (entityRpcs: Map) } } +const requestEnvelope = (entry: { + readonly message: Message.IncomingRequestLocal + readonly lastSentChunk: Option.Option> +}): Parameters[1] => ({ + ...entry.message.envelope, + id: entry.message.envelope.requestId as any, + tag: entry.message.envelope.tag as any, + payload: new Request({ + ...entry.message.envelope, + lastSentChunk: entry.lastSentChunk + } as any) as any +}) + const retryRespond = (times: number, effect: Effect.Effect): Effect.Effect => times === 0 ? effect : diff --git a/repos/effect/packages/effect/src/cluster/internal/interruptors.ts b/repos/effect/packages/effect/src/cluster/internal/interruptors.ts index 8223b7a51..c0593259b 100644 --- a/repos/effect/packages/effect/src/cluster/internal/interruptors.ts +++ b/repos/effect/packages/effect/src/cluster/internal/interruptors.ts @@ -48,6 +48,14 @@ export const acquireEntityType = (entityType: string): void => acquire(entityTyp */ export const releaseEntityType = (entityType: string): void => release(entityTypeKey(entityType)) +/** + * @internal + */ +export const aroundEntity = ( + address: EntityAddress, + effect: Effect.Effect +): Effect.Effect => around(entityKey(address), effect) + /** * @internal */ @@ -71,3 +79,8 @@ export const isActive = (address: EntityAddress): boolean => counts.has(entityKey(address)) || counts.has(shardKey(address.shardId)) || counts.has(entityTypeKey(address.entityType)) + +/** + * @internal + */ +export const isShardActive = (shardId: ShardId): boolean => counts.has(shardKey(shardId)) diff --git a/repos/effect/packages/effect/src/cluster/internal/resourceMap.ts b/repos/effect/packages/effect/src/cluster/internal/resourceMap.ts index 74270a885..70fc4603a 100644 --- a/repos/effect/packages/effect/src/cluster/internal/resourceMap.ts +++ b/repos/effect/packages/effect/src/cluster/internal/resourceMap.ts @@ -2,6 +2,7 @@ import * as Context from "../../Context.ts" import * as Deferred from "../../Deferred.ts" import * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" +import * as Fiber from "../../Fiber.ts" import * as MutableHashMap from "../../MutableHashMap.ts" import * as MutableRef from "../../MutableRef.ts" import * as Scope from "../../Scope.ts" @@ -44,9 +45,9 @@ export class ResourceMap { scope, (exit) => { MutableRef.set(isClosed, true) - return Effect.forEach(entries.map, ([key, { scope }]) => { + return Effect.forEach(entries.map, ([key, { fiber, scope }]) => { backingDelete(entries, key) - return Effect.exit(Scope.close(scope, exit)) + return Effect.exit(Effect.andThen(Fiber.interrupt(fiber), Scope.close(scope, exit))) }, { concurrency: "unbounded", discard: true }) } ) @@ -67,30 +68,64 @@ export class ResourceMap { } get(key: K): Effect.Effect { - return Effect.suspend(() => { + return Effect.uninterruptibleMask((restore) => { if (MutableRef.get(this.isClosed)) { return Effect.interrupt } const existing = backingGet(this.entries, key) if (existing) { - return Deferred.await(existing.deferred) + return this.awaitEntry(key, existing, restore) + } + const entry: Entry = { + scope: Scope.makeUnsafe(), + deferred: Deferred.makeUnsafe(), + fiber: undefined as any, + awaiters: 0 + } + backingSet(this.entries, key, entry) + // Run the lookup on a detached fiber so it is shared by every caller and + // only interrupted once all of them have been interrupted. + entry.fiber = Effect.runForkWith(Fiber.getCurrent()!.context)( + Effect.onExit(this.lookup(key, entry.scope), (exit) => { + if (exit._tag === "Success") { + return Deferred.done(entry.deferred, exit) + } + this.deleteEntry(key, entry) + return Effect.andThen( + Deferred.done(entry.deferred, exit), + Scope.close(entry.scope, exit) + ) + }) + ) + return this.awaitEntry(key, entry, restore) + }) + } + + private awaitEntry( + key: K, + entry: Entry, + restore: (effect: Effect.Effect) => Effect.Effect + ): Effect.Effect { + if (Deferred.isDoneUnsafe(entry.deferred)) return Deferred.await(entry.deferred) + entry.awaiters++ + return Effect.onExit(restore(Deferred.await(entry.deferred)), () => { + if (--entry.awaiters > 0 || Deferred.isDoneUnsafe(entry.deferred)) { + return Effect.void } - const scope = Scope.makeUnsafe() - const deferred = Deferred.makeUnsafe() - backingSet(this.entries, key, { scope, deferred }) - return Effect.onExit(this.lookup(key, scope), (exit) => { - if (exit._tag === "Success") { - return Deferred.done(deferred, exit) - } - backingDelete(this.entries, key) - return Effect.andThen( - Deferred.done(deferred, exit), - Scope.close(scope, exit) - ) - }) + // Detach the abandoned entry first so new callers start a fresh lookup + // instead of joining one that is being interrupted. + this.deleteEntry(key, entry) + return Fiber.interrupt(entry.fiber) }) } + private deleteEntry(key: K, entry: Entry): void { + // Never delete a replacement entry created after this one was removed. + if (backingGet(this.entries, key) === entry) { + backingDelete(this.entries, key) + } + } + remove(key: K): Effect.Effect { return Effect.suspend(() => { const entry = backingGet(this.entries, key) @@ -123,6 +158,9 @@ type BackingMap = { type Entry = { readonly scope: Scope.Closeable readonly deferred: Deferred.Deferred + // Assigned as soon as the lookup is forked. + fiber: Fiber.Fiber + awaiters: number } const backingGet = (map: BackingMap, key: K): Entry | undefined => { diff --git a/repos/effect/packages/effect/src/cluster/internal/resourceRef.ts b/repos/effect/packages/effect/src/cluster/internal/resourceRef.ts index 3857ed16b..362d50568 100644 --- a/repos/effect/packages/effect/src/cluster/internal/resourceRef.ts +++ b/repos/effect/packages/effect/src/cluster/internal/resourceRef.ts @@ -1,133 +1,226 @@ -import type * as Cause from "../../Cause.ts" +import * as Deferred from "../../Deferred.ts" import * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" -import * as Latch from "../../Latch.ts" -import * as MutableRef from "../../MutableRef.ts" -import * as Option from "../../Option.ts" +import * as Fiber from "../../Fiber.ts" import * as Scope from "../../Scope.ts" import type { EntityAddress } from "../EntityAddress.ts" -import { acquireEntity, releaseEntity } from "./interruptors.ts" +import { aroundEntity } from "./interruptors.ts" -/** - * @internal - */ -export type State = { - readonly _tag: "Closed" -} | { - readonly _tag: "Acquiring" - readonly scope: Scope.Closeable -} | { - readonly _tag: "Acquired" - readonly scope: Scope.Closeable - readonly value: A -} | { - readonly _tag: "Failed" +type Pending = { + readonly _tag: "Pending" readonly scope: Scope.Closeable - readonly cause: Cause.Cause + /** Predecessor scope or ongoing release to await before acquisition. */ + readonly releases: Scope.Closeable | Fiber.Fiber | undefined + releasing: boolean + /** Shared with waiters across superseded rebuilds. */ + readonly ready: Deferred.Deferred + value: A | undefined + work: Fiber.Fiber | undefined } +type State = + | { readonly _tag: "Closed" } + | { readonly _tag: "Ready"; readonly scope: Scope.Closeable; readonly value: A } + | Pending + | { + readonly _tag: "Failed" + readonly ready: Deferred.Deferred + readonly releasing: Fiber.Fiber | undefined + } + /** + * A replaceable resource whose acquired value identifies its generation. + * * @internal */ -export class ResourceRef { - static from = Effect.fnUntraced(function*( +export class ResourceRef { + static from = Effect.fnUntraced(function*( parentScope: Scope.Scope, - acquire: (scope: Scope.Scope) => Effect.Effect, + acquire: (scope: Scope.Scope) => Effect.Effect, teardownAddress?: EntityAddress ) { - const state = MutableRef.make>({ _tag: "Closed" }) - - yield* Scope.addFinalizerExit(parentScope, (exit) => { - const s = MutableRef.get(state) - if (s._tag === "Closed") { - return Effect.void - } - const scope = s.scope - MutableRef.set(state, { _tag: "Closed" }) - return Scope.close(scope, exit) - }) - - const scope = yield* Scope.make() - MutableRef.set(state, { _tag: "Acquiring", scope }) - const value = yield* acquire(scope) - MutableRef.set(state, { _tag: "Acquired", scope, value }) - - return new ResourceRef(state, acquire, teardownAddress) + // Close the ref before joining releases, so waiters wake even if release stalls. + const releaseScope = Scope.forkUnsafe(parentScope) + const ref = new ResourceRef(releaseScope, acquire, teardownAddress) + yield* Scope.addFinalizerExit(parentScope, (exit) => ref.close(exit)) + const pending = ref.pending(undefined, Deferred.makeUnsafe()) + const value = yield* acquire(pending.scope) + if (ref.state === pending) ref.publish(pending, value) + return ref }) - readonly state: MutableRef.MutableRef> - readonly acquire: (scope: Scope.Scope) => Effect.Effect - readonly teardownAddress: EntityAddress | undefined - constructor( - state: MutableRef.MutableRef>, - acquire: (scope: Scope.Scope) => Effect.Effect, - teardownAddress?: EntityAddress + private state: State = { _tag: "Closed" } + private readonly releaseScope: Scope.Scope + private readonly acquire: (scope: Scope.Scope) => Effect.Effect + private readonly teardownAddress: EntityAddress | undefined + private constructor( + releaseScope: Scope.Scope, + acquire: (scope: Scope.Scope) => Effect.Effect, + teardownAddress: EntityAddress | undefined ) { - this.state = state + this.releaseScope = releaseScope this.acquire = acquire this.teardownAddress = teardownAddress } - latch = Latch.makeUnsafe(true) + /** Returns the value only after acquisition and preparation complete. */ + getUnsafe(): A | undefined { + return this.state._tag === "Ready" ? this.state.value : undefined + } + + /** Waits for a ready value; interrupts when the ref closes. */ + readonly await: Effect.Effect = Effect.suspend(() => { + const s = this.state + switch (s._tag) { + case "Closed": + return Effect.interrupt + case "Ready": + return Effect.succeed(s.value) + case "Pending": + case "Failed": + return Deferred.await(s.ready) + } + }) - getUnsafe(): Option.Option { - if (this.state.current._tag === "Acquired") { - return Option.some(this.state.current.value) + /** + * Replaces the generation, holding waiters until acquisition and `prepare` finish. + * Returns `undefined` if closed, if `from` differs from the ready or preparing + * value, or if `from` is omitted while a rebuild is pending. + * + * Acceptance immediately blocks waiters; run the returned effect to release + * the predecessor and build its replacement. Closing or superseding the ref + * interrupts the rebuild. Failure wakes waiters with its cause; the next + * rebuild must omit `from` and await cleanup. Interruption during predecessor + * release instead carries waiters forward without awaiting that release again. + */ + rebuildUnsafe(options?: { + readonly from?: A | undefined + readonly prepare?: ((value: A) => Effect.Effect) | undefined + }): Effect.Effect | undefined { + const s = this.state + const from = options?.from + let pending: Pending + switch (s._tag) { + case "Closed": + return undefined + case "Ready": { + if (from !== undefined && from !== s.value) return undefined + pending = this.pending(s.scope, Deferred.makeUnsafe()) + break + } + case "Failed": { + if (from !== undefined) return undefined + pending = this.pending(s.releasing, s.ready) + break + } + case "Pending": { + if (from === undefined || s.value !== from) return undefined + s.work?.interruptUnsafe() + pending = this.pending(s.scope, s.ready) + break + } } - return Option.none() + return this.run(pending, options?.prepare) } - rebuildUnsafe(): Effect.Effect { - const s = this.state.current - if (s._tag === "Closed") { - return Effect.interrupt + private pending( + releases: Scope.Closeable | Fiber.Fiber | undefined, + ready: Deferred.Deferred + ): Pending { + const pending: Pending = { + _tag: "Pending", + scope: Scope.makeUnsafe(), + releases, + releasing: false, + ready, + value: undefined, + work: undefined } - const prevScope = s.scope - const scope = Scope.makeUnsafe() - this.latch.closeUnsafe() - MutableRef.set(this.state, { _tag: "Acquiring", scope }) - const teardownAddress = this.teardownAddress - return Effect.suspend(() => { - const close = Scope.close(prevScope, Exit.void) - if (!teardownAddress) return close - acquireEntity(teardownAddress) - return Effect.ensuring(close, Effect.sync(() => releaseEntity(teardownAddress))) + this.state = pending + return pending + } + + private publish(pending: Pending, value: A): void { + this.state = { _tag: "Ready", scope: pending.scope, value } + Deferred.doneUnsafe(pending.ready, Exit.succeed(value)) + } + + private run(pending: Pending, prepare: ((value: A) => Effect.Effect) | undefined): Effect.Effect { + const work = Effect.suspend(() => { + if (this.state !== pending) return Effect.interrupt + const releases = pending.releases + if (!releases) return Effect.void + pending.releasing = true + return Effect.andThen( + Fiber.isFiber(releases) ? Fiber.join(releases) : Effect.flatMap(this.release(releases), Fiber.join), + Effect.sync(() => { + pending.releasing = false + }) + ) }).pipe( - Effect.andThen(this.acquire(scope)), + Effect.andThen(Effect.suspend(() => this.acquire(pending.scope))), + Effect.tap((value) => { + if (this.state !== pending) return Effect.interrupt + pending.value = value + return prepare ? prepare(value) : Effect.void + }), Effect.flatMap((value) => { - if (this.state.current._tag === "Closed") { - return Effect.interrupt - } - MutableRef.set(this.state, { _tag: "Acquired", scope, value }) - return this.latch.open - }) - ).pipe( + if (this.state !== pending) return Effect.interrupt + this.publish(pending, value) + return Effect.void + }), Effect.onExit((exit) => { - if (Exit.isSuccess(exit)) { + if (Exit.isSuccess(exit) || this.state !== pending) { + return Effect.void + } + if (pending.releasing) { + // No resource was acquired. Carry waiters forward without rejoining the stalled release. + this.state = { _tag: "Failed", ready: pending.ready, releasing: undefined } return Effect.void } - return Scope.close(scope, exit).pipe( - Effect.ensuring(Effect.sync(() => { - const state = this.state.current - if (state._tag === "Acquiring" && state.scope === scope) { - MutableRef.set(this.state, { _tag: "Failed", scope, cause: exit.cause }) - this.latch.openUnsafe() - } - })) - ) + return Effect.map(this.release(pending.scope), (releasing) => { + if (this.state !== pending) return + this.state = { _tag: "Failed", ready: Deferred.makeUnsafe(), releasing } + Deferred.doneUnsafe(pending.ready, Exit.failCause(exit.cause)) + }) }) ) + return Effect.flatMap(Effect.forkChild(work, { startImmediately: true }), (fiber) => { + pending.work = fiber + // Replaced or closed before the fiber handle existed. + if (this.state !== pending) fiber.interruptUnsafe() + return Fiber.join(fiber) + }) } - await: Effect.Effect = Effect.suspend(() => { - const s = this.state.current - if (s._tag === "Closed") { - return Effect.interrupt - } else if (s._tag === "Acquired") { - return Effect.succeed(s.value) - } else if (s._tag === "Failed") { - return Effect.failCause(s.cause) + // A stalled release must not prevent rebuild interruption. + private release(scope: Scope.Closeable): Effect.Effect> { + const close = Scope.close(scope, Exit.void) + return Effect.forkIn( + this.teardownAddress ? aroundEntity(this.teardownAddress, close) : close, + this.releaseScope, + { startImmediately: true } + ) + } + + private close(exit: Exit.Exit): Effect.Effect { + const s = this.state + this.state = { _tag: "Closed" } + switch (s._tag) { + case "Closed": + return Effect.void + case "Ready": + return Scope.close(s.scope, exit) + case "Failed": { + Deferred.doneUnsafe(s.ready, Effect.interrupt) + return Effect.void + } + case "Pending": { + Deferred.doneUnsafe(s.ready, Effect.interrupt) + s.work?.interruptUnsafe() + const close = Scope.close(s.scope, exit) + return s.releases && !Fiber.isFiber(s.releases) ? Effect.andThen(Scope.close(s.releases, exit), close) : close + } } - return Effect.flatMap(this.latch.await, () => this.await) - }) + } } diff --git a/repos/effect/packages/effect/src/http-api/HttpApiError.ts b/repos/effect/packages/effect/src/http-api/HttpApiError.ts index b01e145c2..87660b57b 100644 --- a/repos/effect/packages/effect/src/http-api/HttpApiError.ts +++ b/repos/effect/packages/effect/src/http-api/HttpApiError.ts @@ -479,6 +479,7 @@ export class HttpApiSchemaError extends Data.TaggedClass("HttpApiSchemaError")<{ readonly cause: Schema.SchemaError }> { readonly [HttpApiSchemaErrorTypeId]: HttpApiSchemaErrorTypeId = HttpApiSchemaErrorTypeId + readonly [ErrorReporter.ignore] = true static is(u: unknown): u is HttpApiSchemaError { return hasProperty(u, HttpApiSchemaErrorTypeId) diff --git a/repos/effect/packages/effect/src/http/HttpMiddleware.ts b/repos/effect/packages/effect/src/http/HttpMiddleware.ts index 4ae2e11d0..4c53b9b09 100644 --- a/repos/effect/packages/effect/src/http/HttpMiddleware.ts +++ b/repos/effect/packages/effect/src/http/HttpMiddleware.ts @@ -26,7 +26,7 @@ import { nativeTracer, ParentSpan, Tracer } from "../Tracer.ts" import * as Headers from "./Headers.ts" import type { CompressionAlgorithm } from "./HttpPlatform.ts" import { HttpPlatform } from "./HttpPlatform.ts" -import { causeResponseStripped } from "./HttpServerError.ts" +import { causeResponseStripped, ClientAbort } from "./HttpServerError.ts" import { HttpServerRequest } from "./HttpServerRequest.ts" import * as Request from "./HttpServerRequest.ts" import * as Response from "./HttpServerResponse.ts" @@ -158,8 +158,14 @@ export const logger: ( return exit } else if (exit._tag === "Failure") { const [response, cause] = causeResponseStripped(exit.cause) + const message = Option.isSome(cause) && + !cause.value.reasons.every((reason) => + reason._tag === "Interrupt" && reason.annotations.has(ClientAbort.key) + ) + ? cause.value + : "Sent HTTP response" return Effect.andThen( - Effect.annotateLogs(Effect.log(Option.getOrElse(cause, () => "Sent HTTP Response")), { + Effect.annotateLogs(Effect.log(message), { "http.method": request.method, "http.url": path, "http.status": response.status diff --git a/repos/effect/packages/effect/src/http/HttpStaticServer.ts b/repos/effect/packages/effect/src/http/HttpStaticServer.ts index e7797c448..b1be9c518 100644 --- a/repos/effect/packages/effect/src/http/HttpStaticServer.ts +++ b/repos/effect/packages/effect/src/http/HttpStaticServer.ts @@ -140,6 +140,14 @@ export const make: (options: { return yield* getFullResponse() } + const ifRange = request.headers["if-range"] + if (ifRange !== undefined) { + fullResponse = yield* getFullResponse() + if (!matchesIfRange(ifRange, fullResponse.headers["etag"])) { + return fullResponse + } + } + const resolvedFileSize = fileSize ?? (yield* handlePlatformError(request, fileSystem.stat(filePath))).size const parsedRange = parseRange(rangeHeader, resolvedFileSize) @@ -459,6 +467,14 @@ const isNotModifiedSince = (ifModifiedSince: string, lastModified: string | unde return lastModifiedMs <= ifModifiedSinceMs } +// Only a strong entity-tag can match (RFC 9110 §13.1.5). Weak tags start +// with `W/`, and Last-Modified dates never match: filesystem metadata cannot +// prove the file did not change twice within the same second. +const matchesIfRange = (ifRange: string, etag: string | undefined): boolean => { + const value = ifRange.trim() + return value.startsWith("\"") && value === etag +} + const notModifiedResponse = ( response: HttpServerResponse.HttpServerResponse ): HttpServerResponse.HttpServerResponse => { diff --git a/repos/effect/packages/effect/src/http/HttpTraceContext.ts b/repos/effect/packages/effect/src/http/HttpTraceContext.ts index eda822e62..0fd64e12f 100644 --- a/repos/effect/packages/effect/src/http/HttpTraceContext.ts +++ b/repos/effect/packages/effect/src/http/HttpTraceContext.ts @@ -99,7 +99,7 @@ export const b3: FromHeaders = (headers) => { return Option.some(Tracer.externalSpan({ traceId: parts[0], spanId: parts[1], - sampled: parts[2] ? parts[2] === "1" : true + sampled: parts[2] ? parts[2] === "1" || parts[2] === "d" : true })) } @@ -109,7 +109,7 @@ export const b3: FromHeaders = (headers) => { * **Details** * * The decoder reads `x-b3-traceid`, `x-b3-spanid`, and optional `x-b3-sampled` - * headers. + * and `x-b3-flags` headers. Debug flags override the sampling decision. * * @stability unstable * @category decoding @@ -122,7 +122,8 @@ export const xb3: FromHeaders = (headers) => { return Option.some(Tracer.externalSpan({ traceId: headers["x-b3-traceid"], spanId: headers["x-b3-spanid"], - sampled: headers["x-b3-sampled"] ? headers["x-b3-sampled"] === "1" : true + sampled: headers["x-b3-flags"] === "1" || + (headers["x-b3-sampled"] ? headers["x-b3-sampled"] === "1" || headers["x-b3-sampled"] === "true" : true) })) } @@ -151,7 +152,10 @@ export const w3c: FromHeaders = (headers) => { const [version, traceId, spanId, flags] = parts switch (version) { case "00": { - if (w3cTraceId.test(traceId) === false || w3cSpanId.test(spanId) === false) { + if ( + w3cTraceId.test(traceId) === false || w3cSpanId.test(spanId) === false || + traceId === "00000000000000000000000000000000" || spanId === "0000000000000000" + ) { return Option.none() } return Option.some(Tracer.externalSpan({ diff --git a/repos/effect/packages/effect/src/internal/core.ts b/repos/effect/packages/effect/src/internal/core.ts index 11f07efbe..04d6eddc4 100644 --- a/repos/effect/packages/effect/src/internal/core.ts +++ b/repos/effect/packages/effect/src/internal/core.ts @@ -279,6 +279,48 @@ export const causeFromReasons = ( reasons: ReadonlyArray> ): Cause.Cause => new CauseImpl(reasons) +const dedupeReasons = ( + self: ReadonlyArray>, + that: ReadonlyArray> +): Array> => { + // Avoid importing Array.ts into the core bundle. + // Snapshot both arrays before invoking user-defined hash or equality methods. + const buckets = new Map>>() + const out: Array> = [] + for (const reason of self.concat(that)) { + const hash = Hash.hash(reason) + const bucket = buckets.get(hash) + if (bucket === undefined) { + buckets.set(hash, [reason]) + } else if (bucket.some((previous) => Equal.equals(previous, reason))) { + continue + } else { + bucket.push(reason) + } + out.push(reason) + } + return out +} + +/** @internal */ +export const causeCombine: { + (that: Cause.Cause): (self: Cause.Cause) => Cause.Cause + (self: Cause.Cause, that: Cause.Cause): Cause.Cause +} = dual( + 2, + (self: Cause.Cause, that: Cause.Cause): Cause.Cause => { + if (self.reasons.length === 0) { + return that as Cause.Cause + } else if (that.reasons.length === 0) { + return self as Cause.Cause + } + const newCause = new CauseImpl( + dedupeReasons(self.reasons, that.reasons) + ) + return Equal.equals(self, newCause) ? self : newCause + } +) + /** @internal */ export const causeEmpty: Cause.Cause = new CauseImpl([]) @@ -546,8 +588,20 @@ export const exitFailCause: (cause: Cause.Cause) => Exit.Exit = annotated = true } let cont = fiber.getCont(contE) - while (fiber.interruptible && fiber._interruptedCause && cont) { - cont = fiber.getCont(contE) + const interruptedCause = fiber._interruptedCause + if (interruptedCause && fiber.interruptible) { + // Drop typed failures only when interruption skips a recovery handler. + // Interruptibility-restoration continuations have no identifier. + let skippedHandler = false + while (cont && fiber.interruptible) { + skippedHandler ||= identifier in cont + cont = fiber.getCont(contE) + } + if (skippedHandler) { + cause = causeFromReasons(cause.reasons.filter((reason) => reason._tag !== "Fail")) + } + cause = causeCombine(cause, interruptedCause) + annotated = true } return cont ? cont[contE](cause, fiber, annotated ? undefined : this) diff --git a/repos/effect/packages/effect/src/internal/dateTime.ts b/repos/effect/packages/effect/src/internal/dateTime.ts index 007343f4d..7ba567f98 100644 --- a/repos/effect/packages/effect/src/internal/dateTime.ts +++ b/repos/effect/packages/effect/src/internal/dateTime.ts @@ -575,7 +575,9 @@ export const toDate = (self: DateTime.DateTime): Date => { Number(parts[3].value), Number(parts[4].value), Number(parts[5].value), - Number(parts[6].value) + parts[6]?.type === "fractionalSecond" + ? Number(parts[6].value) + : ((self.epochMilliseconds % 1000) + 1000) % 1000 ) self.adjustedEpochMilliseconds = date.getTime() return date diff --git a/repos/effect/packages/effect/src/internal/effect.ts b/repos/effect/packages/effect/src/internal/effect.ts index 6a84e0775..874e545e1 100644 --- a/repos/effect/packages/effect/src/internal/effect.ts +++ b/repos/effect/packages/effect/src/internal/effect.ts @@ -50,7 +50,7 @@ import type { Primitive } from "./core.ts" import { args, causeAnnotate, - causeDie, + causeCombine, causeEmpty, causeFromReasons, CauseImpl, @@ -238,48 +238,6 @@ export const causeAnnotations = ( return Context.makeUnsafe(map) } -const dedupeReasons = ( - self: ReadonlyArray>, - that: ReadonlyArray> -): Array> => { - // Keep deduplication local so causeCombine does not retain Array.ts in the core bundle. - // Snapshot both arrays before invoking user-defined hash or equality methods. - const buckets = new Map>>() - const out: Array> = [] - for (const reason of self.concat(that)) { - const hash = Hash.hash(reason) - const bucket = buckets.get(hash) - if (bucket === undefined) { - buckets.set(hash, [reason]) - } else if (bucket.some((previous) => Equal.equals(previous, reason))) { - continue - } else { - bucket.push(reason) - } - out.push(reason) - } - return out -} - -/** @internal */ -export const causeCombine: { - (that: Cause.Cause): (self: Cause.Cause) => Cause.Cause - (self: Cause.Cause, that: Cause.Cause): Cause.Cause -} = dual( - 2, - (self: Cause.Cause, that: Cause.Cause): Cause.Cause => { - if (self.reasons.length === 0) { - return that as Cause.Cause - } else if (that.reasons.length === 0) { - return self as Cause.Cause - } - const newCause = new CauseImpl( - dedupeReasons(self.reasons, that.reasons) - ) - return Equal.equals(self, newCause) ? self : newCause - } -) - /** @internal */ export const causeMap: { (f: (error: NoInfer) => E2): (self: Cause.Cause) => Cause.Cause @@ -504,6 +462,21 @@ const fiberVariance = { const fiberIdStore = { id: 0 } +interface AsyncContext { + runInAsyncScope(fn: (this: This, arg: Arg) => R, thisArg: This, arg: Arg): R +} + +const AsyncResource: (new(type: string) => AsyncContext) | undefined = (() => { + try { + return (globalThis as any).process?.getBuiltinModule?.("node:async_hooks")?.AsyncResource + } catch { + return undefined + } +})() + +const captureAsyncContext = (): AsyncContext | undefined => + AsyncResource === undefined ? undefined : new AsyncResource("effect/Fiber") + /** @internal */ export const getCurrentFiber = (): Fiber.Fiber | undefined => (globalThis as any)[currentFiberTypeId] @@ -526,6 +499,7 @@ export class FiberImpl implements Fiber.Fiber { this._running = false this._deferredInterrupt = false this._parent = undefined + this._asyncContext = undefined this.cache.runtimeMetrics?.recordFiberStart(this.context) } @@ -545,6 +519,7 @@ export class FiberImpl implements Fiber.Fiber { declare _running: boolean declare _deferredInterrupt: boolean declare _parent: FiberImpl | undefined + declare _asyncContext: AsyncContext | undefined // set in setContext declare context: Context.Context @@ -605,6 +580,10 @@ export class FiberImpl implements Fiber.Fiber { evaluate(effect: Primitive): void { if (this._exit) { return + } else if (this._asyncContext !== undefined) { + const asyncContext = this._asyncContext + this._asyncContext = undefined + return asyncContext.runInAsyncScope(this.evaluate, this, effect) } else if (this._yielded !== undefined) { const yielded = this._yielded as () => void this._yielded = undefined @@ -612,6 +591,7 @@ export class FiberImpl implements Fiber.Fiber { } const exit = this.runLoop(effect) if (exit === Yield) { + this._asyncContext = captureAsyncContext() return } // the interruptChildren middleware is added in Effect.forkChild, so it can be @@ -1122,20 +1102,22 @@ export const tryPromise = ( ? ((cause: unknown) => new UnknownError(cause, "An error occurred in Effect.tryPromise")) : options.catch return callbackOptions(function(resume, signal) { - const failWithCatch = (cause: unknown) => { - try { - resume(fail(internalCall(() => catcher(cause)) as E)) - } catch (err) { - resume(die(err)) - } - } + // Defer the catcher until the fiber restores its async context. + const failWithCatch = (cause: unknown) => + suspend(() => { + try { + return fail(internalCall(() => catcher(cause)) as E) + } catch (err) { + return die(err) + } + }) try { f(signal!).then( (a) => resume(succeed(a)), - failWithCatch + (e) => resume(failWithCatch(e)) ) } catch (err) { - failWithCatch(err) + resume(failWithCatch(err)) } }, f.length !== 0) } @@ -4457,48 +4439,78 @@ export const cachedInvalidateWithTTL: { self: Effect.Effect, ttl: Duration.Input | ((exit: Exit.Exit) => Duration.Input) ): Effect.Effect<[Effect.Effect, Effect.Effect]> => - sync(() => { - const ttlMillis = typeof ttl === "function" - ? (exit: Exit.Exit) => Duration.toMillis(Duration.fromInputUnsafe(ttl(exit))) - : constant(Duration.toMillis(Duration.fromInputUnsafe(ttl))) - const latch = makeLatchUnsafe(false) - let expiresAt = 0 - let running = false - let exit: Exit.Exit | undefined - const wait = flatMap(latch.await, () => exit!) - return [ - withFiber((fiber) => { - const clock = fiber.getRef(ClockRef) - const now = expiresAt === Infinity ? 0 : clock.currentTimeMillisUnsafe() - if (running || now < expiresAt) return exit ?? wait - running = true - latch.closeUnsafe() - exit = undefined - onExitUnsafe(fiber, (exit_) => - sync(() => { - try { - const duration = ttlMillis(exit_) - expiresAt = clock.currentTimeMillisUnsafe() + duration - exit = exit_ - } catch (error) { - const cause = causeDie(error) - // Publish the same combined cause that onExit returns to the owner. - exit = exitFailCause(exitIsFailure(exit_) ? causeCombine(exit_.cause, cause) : cause) - throw error - } finally { - running = false - latch.openUnsafe() - } - })) - return self - }), - sync(() => { - expiresAt = 0 - latch.closeUnsafe() - exit = undefined - }) - ] - })) + sync(() => + makeCachedUnsafe( + self, + typeof ttl === "function" + ? (exit: Exit.Exit) => Duration.toMillis(Duration.fromInputUnsafe(ttl(exit))) + : constant(Duration.toMillis(Duration.fromInputUnsafe(ttl))) + ) + )) + +const infiniteTTL = constant(Infinity) + +interface CachedRun { + fiber: FiberImpl | undefined + awaiters: number +} + +const makeCachedUnsafe = ( + self: Effect.Effect, + ttlMillis: (exit: Exit.Exit) => number +): [Effect.Effect, Effect.Effect] => { + let expiresAt = 0 + let exit: Exit.Exit | undefined + let current: CachedRun | undefined + + const join = (fiber: Fiber.Fiber, run: CachedRun): Effect.Effect => { + run.awaiters++ + onExitUnsafe(fiber, () => { + // Abandon the run once every caller has left, unless it already finished. + if (--run.awaiters > 0 || current !== run) return + // Detach it first so new callers start a fresh run instead of joining + // one that is being interrupted. + current = undefined + return fiberInterrupt(run.fiber!) + }) + return fiberJoin(run.fiber!) + } + + return [ + withFiber((fiber) => { + if (current !== undefined) return join(fiber, current) + if ( + exit !== undefined && + (expiresAt === Infinity || fiber.getRef(ClockRef).currentTimeMillisUnsafe() < expiresAt) + ) { + return exit + } + exit = undefined + const clock = fiber.getRef(ClockRef) + const run: CachedRun = { fiber: undefined, awaiters: 0 } + current = run + run.fiber = forkUnsafe( + fiber, + onExitPrimitive(self, (exit_) => { + // An abandoned run must not overwrite or clear a replacement run. + if (current !== run) return + current = undefined + // Interruption is abandonment, so it is never cached. + if (exitHasInterrupts(exit_)) return + const duration = ttlMillis(exit_) + expiresAt = duration === Infinity ? Infinity : clock.currentTimeMillisUnsafe() + duration + exit = exit_ + }), + true, + true + ) + return run.fiber._exit ?? join(fiber, run) + }), + sync(() => { + exit = undefined + }) + ] +} /** @internal */ export const cachedWithTTL: { @@ -4525,23 +4537,7 @@ export const cachedWithTTL: { /** @internal */ export const cached = (self: Effect.Effect): Effect.Effect> => - sync(() => { - const latch = makeLatchUnsafe(false) - let started = false - let exit: Exit.Exit | undefined - const wait = flatMap(latch.await, () => exit!) - return withFiber((fiber) => { - if (exit !== undefined) return exit - if (started) return wait - started = true - onExitUnsafe(fiber, (result) => - sync(() => { - exit = result - latch.openUnsafe() - })) - return self - }) - }) + sync(() => makeCachedUnsafe(self, infiniteTTL)[0]) // ---------------------------------------------------------------------------- // interruption @@ -5531,6 +5527,8 @@ export const forkUnsafe = ( if (immediate) { child.evaluate(effect as any) } else { + // Preserve the fork context rather than the dispatcher's context. + child._asyncContext = captureAsyncContext() parentRuntime.currentDispatcher.scheduleTask(() => child.evaluate(effect as any), 0) } if (!daemon && !child._exit) { diff --git a/repos/effect/packages/effect/src/internal/rcRef.ts b/repos/effect/packages/effect/src/internal/rcRef.ts index f9e42e06b..d242ba10f 100644 --- a/repos/effect/packages/effect/src/internal/rcRef.ts +++ b/repos/effect/packages/effect/src/internal/rcRef.ts @@ -7,17 +7,26 @@ import { identity } from "../Function.ts" import { pipeArguments } from "../Pipeable.ts" import type * as RcRef from "../RcRef.ts" import * as Scope from "../Scope.ts" -import * as Semaphore from "../Semaphore.ts" const TypeId = "~effect/RcRef" -type State = State.Empty | State.Acquired | State.Closed +type State = State.Empty | State.Acquiring | State.Acquired | State.Closed declare namespace State { interface Empty { readonly _tag: "Empty" } + interface Acquiring { + readonly _tag: "Acquiring" + // Assigned as soon as the acquisition is forked. + fiber: Fiber.Fiber, unknown> + awaiters: number + // Set once references are allocated to the waiters, which can happen + // before the acquisition fiber exits. + acquired: Acquired | undefined + } + interface Acquired { readonly _tag: "Acquired" readonly value: A @@ -48,7 +57,6 @@ class RcRefImpl implements RcRef.RcRef { } state: State = stateEmpty - readonly semaphore = Semaphore.makeUnsafe(1) readonly acquire: Effect.Effect readonly context: Context.Context readonly scope: Scope.Scope @@ -85,11 +93,11 @@ export const make = (options: { ) return Effect.as( Scope.addFinalizerExit(scope, () => { - const close = ref.state._tag === "Acquired" - ? Scope.close(ref.state.scope, Exit.void) - : Effect.void + const state = ref.state ref.state = stateClosed - return close + if (state._tag === "Acquired") return Scope.close(state.scope, Exit.void) + if (state._tag === "Acquiring") return Fiber.interrupt(state.fiber) + return Effect.void }), ref ) @@ -110,40 +118,82 @@ const getState = ( ? Effect.as(Fiber.interrupt(state.fiber), state) : Effect.succeed(state) } + case "Acquiring": { + const state = self.state + state.awaiters++ + return awaitAcquiring(self, state, restore) + } case "Empty": { - return self.semaphore.withPermit( - Effect.suspend(() => { - if (self.state._tag !== "Empty") { - return getState(self, restore) + // The caller that starts the acquisition counts as its first awaiter. + const acquiring: State.Acquiring = { + _tag: "Acquiring", + fiber: undefined as any, + awaiters: 1, + acquired: undefined + } + self.state = acquiring + const scope = Scope.makeUnsafe() + // Acquire on a detached fiber so the acquisition is shared by every + // caller and only interrupted once all of them have been interrupted. + const acquire = Effect.provideContext( + self.acquire as Effect.Effect, + Context.add(self.context, Scope.Scope, scope) + ).pipe( + Effect.flatMap((value) => { + // The ref was closed, or the acquisition was abandoned. + if (self.state !== acquiring) { + return Effect.interrupt + } + const state: State.Acquired = { + _tag: "Acquired", + value, + scope, + fiber: undefined, + // Every caller still waiting holds a reference. + refCount: acquiring.awaiters, + invalidated: false } - const scope = Scope.makeUnsafe() - return restore(Effect.provideContext( - self.acquire as Effect.Effect, - Context.add(self.context, Scope.Scope, scope) - )).pipe( - Effect.flatMap((value) => { - if (self.state._tag === "Closed") { - return Effect.interrupt - } - const state: State.Acquired = { - _tag: "Acquired", - value, - scope, - fiber: undefined, - refCount: 1, - invalidated: false - } - self.state = state - return Effect.succeed(state) - }), - Effect.onExit((exit) => Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void) - ) + self.state = state + acquiring.acquired = state + return Effect.succeed(state) + }), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + if (self.state === acquiring) self.state = stateEmpty + return Scope.close(scope, exit) }) ) + acquiring.fiber = Effect.runForkWith(Fiber.getCurrent()!.context)(acquire) + return awaitAcquiring(self, acquiring, restore) } } } +const awaitAcquiring = ( + self: RcRefImpl, + acquiring: State.Acquiring, + restore: (effect: Effect.Effect) => Effect.Effect +): Effect.Effect, E> => + Effect.onExit( + restore(Fiber.join(acquiring.fiber)) as Effect.Effect, E>, + (exit) => { + const acquired = acquiring.acquired + if (acquired !== undefined) { + // The acquisition counted this caller as a reference, possibly before + // its fiber exited. Release it if the caller was interrupted before it + // could register its finalizer. + return Exit.isFailure(exit) ? release(self, acquired) : Effect.void + } + // Abandon the acquisition once every caller has left, unless it already + // failed (a failed acquisition allocated no references). + if (--acquiring.awaiters > 0 || self.state !== acquiring) return Effect.void + // Detach it first so new callers start a fresh acquisition instead of + // joining one that is being interrupted. + self.state = stateEmpty + return Fiber.interrupt(acquiring.fiber) + } + ) + /** @internal */ export const get = (self_: RcRef.RcRef): Effect.Effect => { const self = self_ as RcRefImpl diff --git a/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index fd4d040fa..39fa39567 100644 --- a/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/repos/effect/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -1067,13 +1067,15 @@ function translateJsonSchemaMultiDocument( ) case "ignore": return undefined - case "apply": + case "apply": { + let regex: RegExp try { - globalThis.RegExp(pattern, jsonSchemaPatternFlags) + regex = globalThis.RegExp(pattern, jsonSchemaPatternFlags) } catch { throw errorWithPath("Cannot import pattern using ECMAScript Unicode mode.", path) } - return jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: jsonSchemaPatternFlags }) + return jsonSchemaFilter("effect/schema/isPattern", { source: regex.source, flags: regex.flags }) + } } } diff --git a/repos/effect/packages/effect/src/rpc/RpcClient.ts b/repos/effect/packages/effect/src/rpc/RpcClient.ts index e5e5a214e..3272af43e 100644 --- a/repos/effect/packages/effect/src/rpc/RpcClient.ts +++ b/repos/effect/packages/effect/src/rpc/RpcClient.ts @@ -454,9 +454,11 @@ export const makeNoSerialization: (streamBufferSize) yield* Scope.addFinalizerExit( scope, (exit) => { + Queue.shutdownUnsafe(queue) if (!entries.has(id)) return Effect.void entries.delete(id) return sendInterrupt( @@ -469,7 +471,6 @@ export const makeNoSerialization: (streamBufferSize) entries.set(id, { _tag: "Queue", rpc, @@ -671,6 +672,12 @@ export const make: } const entries = new Map() + const interruptRequest = (requestId: RequestId): Effect.Effect => { + if (!entries.has(requestId)) return Effect.void + entries.delete(requestId) + return send(clientId, { _tag: "Interrupt", requestId }) as Effect.Effect + } + const { client, write } = yield* makeNoSerialization(group, { ...options, supportsAck, @@ -711,13 +718,7 @@ export const make: }) as Effect.Effect } case "Interrupt": { - const entry = entries.get(message.requestId) - if (!entry) return Effect.void - entries.delete(message.requestId) - return send(clientId, { - _tag: "Interrupt", - requestId: message.requestId - }) as Effect.Effect + return interruptRequest(message.requestId) } case "Eof": { return Effect.void @@ -732,19 +733,22 @@ export const make: const requestId = RequestId(message.requestId) const entry = entries.get(requestId) if (!entry || Option.isNone(entry.schemas.decodeChunk)) return Effect.void - return entry.schemas.decodeChunk.value(message.values).pipe( - Effect.provideContext(entry.context), - Effect.orDie, - Effect.flatMap((chunk) => - write({ _tag: "Chunk", clientId: 0, requestId: RequestId(message.requestId), values: chunk }) - ), - Effect.onError((cause) => - write({ - _tag: "Exit", - clientId: 0, - requestId: RequestId(message.requestId), - exit: Exit.failCause(cause) - }) + const decodeChunk = entry.schemas.decodeChunk.value + return Effect.uninterruptibleMask((restore) => + restore( + decodeChunk(message.values).pipe( + Effect.provideContext(entry.context), + Effect.orDie, + Effect.flatMap((chunk) => write({ _tag: "Chunk", clientId: 0, requestId, values: chunk })) + ) + ).pipe( + Effect.onError((cause) => + write({ _tag: "Exit", clientId: 0, requestId, exit: Exit.failCause(cause) }).pipe( + Effect.andThen(Cause.hasInterrupts(cause) ? Effect.void : Effect.ignore(interruptRequest(requestId))) + ) + ), + // a decode failure only ends its own request; interruption still propagates + Effect.catchCauseIf((cause) => !Cause.hasInterrupts(cause), () => Effect.void) ) ) as Effect.Effect } @@ -768,7 +772,7 @@ export const make: case "ClientProtocolError": { const exit = Exit.fail(message.error) return Effect.forEach( - entries.keys(), + Array.from(entries.keys()), (requestId) => write({ _tag: "Exit", clientId: 0, requestId, exit: exit as any }) ) } diff --git a/repos/effect/packages/effect/src/rpc/RpcServer.ts b/repos/effect/packages/effect/src/rpc/RpcServer.ts index 3f1987a50..a6d06f709 100644 --- a/repos/effect/packages/effect/src/rpc/RpcServer.ts +++ b/repos/effect/packages/effect/src/rpc/RpcServer.ts @@ -1392,13 +1392,14 @@ export const makeProtocolStdio = Effect.gen(function*() { Effect.sandbox, Effect.tapError(Effect.logError), Effect.retry(Schedule.spaced(500)), - Effect.ensuring(Effect.forkDetach(Fiber.interrupt(fiber), { startImmediately: true })), + Effect.andThen(() => writeRequest(0, constEof)), Effect.forkScoped ) yield* Stream.fromQueue(queue).pipe( Stream.run(stdio.stdout()), Effect.retry(Schedule.spaced(500)), + Effect.ensuring(Effect.forkDetach(Fiber.interrupt(fiber), { startImmediately: true })), Effect.forkScoped ) diff --git a/repos/effect/packages/effect/src/rpc/Utils.ts b/repos/effect/packages/effect/src/rpc/Utils.ts index 50d1701d2..e371ff3f8 100644 --- a/repos/effect/packages/effect/src/rpc/Utils.ts +++ b/repos/effect/packages/effect/src/rpc/Utils.ts @@ -1,10 +1,5 @@ /** - * Helps RPC protocol services buffer messages until their receive loop starts. - * - * Client and server protocol constructors use these helpers to expose a stable - * service before the active receiver is installed. Writes made before `run` - * starts are buffered with their current `Context`, then replayed once the - * receiver is ready. + * Buffers RPC protocol messages until their receive loop starts. * * @stability unstable * @since 4.0.0 @@ -16,9 +11,8 @@ import type { Protocol } from "./RpcClient.ts" import type { FromServerEncoded } from "./RpcMessage.ts" /** - * Builds a service with a `run` method that buffers writes until `run` installs - * a writer, replays buffered writes with their original contexts, and restores - * the previous writer when the run ends. + * Buffers writes until `run` replays them with their original contexts and + * installs the writer. Restores the previous writer when `run` ends. * * @stability unstable * @category services @@ -43,12 +37,12 @@ export const withRun = < run(f) { return semaphore.withPermits(1)(Effect.gen(function*() { const prev = write - write = f for (const [args, context] of buffer) { yield* Effect.provideContext(Effect.suspend(() => f(...args)), context) } buffer = [] + write = f return yield* Effect.onExit(Effect.never, () => { write = prev diff --git a/repos/effect/packages/effect/src/workflow/WorkflowEngine.ts b/repos/effect/packages/effect/src/workflow/WorkflowEngine.ts index 26c9cadef..288499f3c 100644 --- a/repos/effect/packages/effect/src/workflow/WorkflowEngine.ts +++ b/repos/effect/packages/effect/src/workflow/WorkflowEngine.ts @@ -274,6 +274,9 @@ export class WorkflowInstance extends Context.Service< /** Deferred names this run parked on; their completions preempt the run. */ readonly awaitedDeferreds: Set + /** Deferred names this run completed itself; their completions need not wake it. */ + readonly completedDeferreds: Set + readonly activityState: { count: number readonly latch: Latch.Latch @@ -294,6 +297,7 @@ export class WorkflowInstance extends Context.Service< abandoned: false, cause: undefined, awaitedDeferreds: new Set(), + completedDeferreds: new Set(), activityState: { count: 0, latch: Latch.makeUnsafe() diff --git a/repos/effect/packages/effect/test/Cache.test.ts b/repos/effect/packages/effect/test/Cache.test.ts index 2a4f43fbc..7776eedcb 100644 --- a/repos/effect/packages/effect/test/Cache.test.ts +++ b/repos/effect/packages/effect/test/Cache.test.ts @@ -536,6 +536,37 @@ describe("Cache", () => { assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99)) })) + + it.effect("concurrent access - a get made while an abandoned lookup is finalizing starts a new lookup", () => + Effect.gen(function*() { + let lookups = 0 + const started = yield* Latch.make() + const finalizing = yield* Latch.make() + const finishFinalizer = yield* Latch.make() + const cache = yield* Cache.make({ + capacity: 10, + lookup: () => + Effect.suspend(() => { + if (++lookups > 1) return Effect.succeed(lookups) + return started.open.pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => finalizing.open.pipe(Effect.andThen(finishFinalizer.await))) + ) + }) + }) + + const getter = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true })) + yield* started.await + const interruptGetter = yield* Fiber.interrupt(getter).pipe(Effect.forkChild({ startImmediately: true })) + yield* finalizing.await + const fresh = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true })) + yield* finishFinalizer.open + yield* Fiber.join(interruptGetter) + + assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2)) + assert.strictEqual(yield* Cache.get(cache, "key"), 2) + assert.strictEqual(lookups, 2) + })) }) describe("getOption", () => { diff --git a/repos/effect/packages/effect/test/DateTime.test.ts b/repos/effect/packages/effect/test/DateTime.test.ts index b81d917c8..30f975d8e 100644 --- a/repos/effect/packages/effect/test/DateTime.test.ts +++ b/repos/effect/packages/effect/test/DateTime.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "@effect/vitest" import { assertNone, assertSome, deepStrictEqual, strictEqual, throws } from "@effect/vitest/utils" import { DateTime, Duration, Effect, Option } from "effect" import { TestClock } from "effect/testing" +import { vi } from "vitest" const setTo2024NZ = TestClock.setTime(new Date("2023-12-31T11:00:00.000Z").getTime()) const assertSomeIso = (value: Option.Option, expected: string) => { @@ -11,6 +12,25 @@ const assertSomeIso = (value: Option.Option, expected: string const isDeno = "Deno" in globalThis describe("DateTime", () => { + it("preserves pre-epoch milliseconds when Intl omits fractionalSecond", () => { + const formatToParts = Intl.DateTimeFormat.prototype.formatToParts + const mock = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts").mockImplementation(function( + this: Intl.DateTimeFormat, + date + ) { + return formatToParts.call(this, date).filter((part) => part.type !== "fractionalSecond") + }) + try { + const dt = DateTime.makeZonedUnsafe("1969-12-31T23:59:59.999Z", { + timeZone: "Australia/Brisbane" + }) + strictEqual(DateTime.toDate(dt).toISOString(), "1970-01-01T09:59:59.999Z") + strictEqual(DateTime.formatIsoDate(dt), "1970-01-01") + } finally { + mock.mockRestore() + } + }) + describe("mutate", () => { it.effect("should mutate the date", () => Effect.gen(function*() { diff --git a/repos/effect/packages/effect/test/Effect.test.ts b/repos/effect/packages/effect/test/Effect.test.ts index 6f323b81a..609e1f06b 100644 --- a/repos/effect/packages/effect/test/Effect.test.ts +++ b/repos/effect/packages/effect/test/Effect.test.ts @@ -16,6 +16,7 @@ import { Logger, type LogLevel, Option, + Pool, Ref, References, Result, @@ -26,6 +27,7 @@ import { } from "effect" import { constFalse, constTrue, pipe } from "effect/Function" import { TestClock } from "effect/testing" +import { AsyncLocalStorage } from "node:async_hooks" import { assertCauseFail } from "./utils/assert.ts" class ATag extends Context.Service()("ATag") {} @@ -2098,6 +2100,69 @@ describe("Effect", () => { }) describe("interruption", () => { + it.effect("skipped handlers drop failures and keep defects when interrupted", () => + Effect.gen(function*() { + const latch = yield* Latch.make() + const fiber = yield* latch.await.pipe( + Effect.andThen(Effect.failCause(Cause.combine(Cause.fail("error"), Cause.die("defect")))), + Effect.uninterruptible, + Effect.catchCause(() => Effect.void), + Effect.forkChild({ startImmediately: true }) + ) + fiber.interruptUnsafe(123) + yield* latch.open + const exit = yield* Fiber.await(fiber) + assert.isTrue(Exit.isFailure(exit)) + if (Exit.isFailure(exit)) { + assert.isFalse(Cause.hasFails(exit.cause)) + assert.isTrue(Cause.hasDies(exit.cause)) + assert.deepStrictEqual(Cause.interruptors(exit.cause), new Set([123])) + } + })) + + it.effect("uninterruptible handlers observe the interruption after skipped handlers", () => + Effect.gen(function*() { + const latch = yield* Latch.make() + let result: Exit.Exit | undefined + const fiber = yield* latch.await.pipe( + Effect.andThen(Effect.fail("error")), + Effect.uninterruptible, + Effect.catch(() => Effect.void), + Effect.interruptible, + Effect.exit, + Effect.map((exit) => { + result = exit + }), + Effect.uninterruptible, + Effect.forkChild({ startImmediately: true }) + ) + fiber.interruptUnsafe(123) + yield* latch.open + yield* Fiber.await(fiber) + assert.isTrue(result !== undefined && Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)) + })) + + it.effect("handlers inside an uninterruptible region recover with interruption pending", () => + Effect.gen(function*() { + const latch = yield* Latch.make() + let recovered = false + const fiber = yield* latch.await.pipe( + Effect.andThen(Effect.fail("error")), + Effect.catch(() => + Effect.sync(() => { + recovered = true + }) + ), + Effect.uninterruptible, + Effect.forkChild({ startImmediately: true }) + ) + fiber.interruptUnsafe(123) + yield* latch.open + const exit = yield* Fiber.await(fiber) + assert.isTrue(recovered) + assert.isTrue(Exit.hasInterrupts(exit)) + })) + it("a map callback that interrupts its own fiber skips the next map", () => { let ran = false const exit = Effect.runSyncExit( @@ -4342,25 +4407,60 @@ describe("Effect", () => { assert.strictEqual(yield* cached, 42) })) - it.effect("replays the owner's interrupted exit", () => + it.effect("shares a run until every caller is interrupted, then starts fresh", () => Effect.gen(function*() { + let runs = 0 const started = yield* Deferred.make() + const finalizing = yield* Deferred.make() + const finishFinalizer = yield* Deferred.make() const cached = yield* Effect.cached( - Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never)) + Effect.suspend(() => { + if (++runs > 1) return Effect.succeed(runs) + return Deferred.succeed(started, void 0).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizing, void 0).pipe(Effect.andThen(Deferred.await(finishFinalizer))) + ) + ) + }) ) - const owner = yield* cached.pipe(Effect.forkChild({ startImmediately: true })) + const first = yield* cached.pipe(Effect.forkChild({ startImmediately: true })) yield* Deferred.await(started) - yield* Fiber.interrupt(owner) - const ownerExit = yield* Fiber.await(owner) - const replayedExit = yield* Effect.exit(cached) - - assert.isTrue(Exit.hasInterrupts(ownerExit)) - assert.deepStrictEqual(replayedExit, ownerExit) + const second = yield* cached.pipe(Effect.forkChild({ startImmediately: true })) + yield* Fiber.interrupt(first) + assert.isFalse(yield* Deferred.isDone(finalizing)) + + // A call made while the abandoned run finalizes starts a fresh run. + const interruptSecond = yield* Effect.forkChild(Fiber.interrupt(second), { startImmediately: true }) + yield* Deferred.await(finalizing) + const fresh = yield* cached.pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.succeed(finishFinalizer, void 0) + yield* Fiber.join(interruptSecond) + assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2)) })) }) describe("cachedWithTTL", () => { + it.effect("skips the ttl callback on interruption and invokes it once on the next successful run", () => + Effect.gen(function*() { + let runs = 0 + const exits: Array> = [] + const cached = yield* Effect.cachedWithTTL( + Effect.suspend(() => ++runs === 1 ? Effect.interrupt : Effect.succeed(runs)), + (exit: Exit.Exit) => { + exits.push(exit) + return "1 minute" + } + ) + + assert.isTrue(Exit.hasInterrupts(yield* Effect.exit(cached))) + assert.deepStrictEqual(exits, []) + assert.strictEqual(yield* cached, 2) + assert.strictEqual(yield* cached, 2) + assert.deepStrictEqual(exits, [Exit.succeed(2)]) + })) + it.effect("selects ttl from each fresh exit without evaluating on cache hits", () => Effect.gen(function*() { let count = 0 @@ -4437,6 +4537,28 @@ describe("Effect", () => { })) }) + describe("cachedInvalidateWithTTL", () => { + it.effect("supports a piped callback that skips failures and caches successes", () => + Effect.gen(function*() { + let count = 0 + const exits: Array> = [] + const [cached] = yield* Effect.suspend(() => ++count === 1 ? Effect.fail("boom") : Effect.succeed(count)).pipe( + Effect.cachedInvalidateWithTTL((exit: Exit.Exit) => { + exits.push(exit) + return Exit.isFailure(exit) ? 0 : "1 second" + }) + ) + + assert.deepStrictEqual(yield* Effect.exit(cached), Exit.fail("boom")) + assert.strictEqual(yield* cached, 2) + assert.strictEqual(yield* cached, 2) + assert.deepStrictEqual(exits, [Exit.fail("boom"), Exit.succeed(2)]) + yield* TestClock.adjust("1 second") + assert.strictEqual(yield* cached, 3) + assert.deepStrictEqual(exits, [Exit.fail("boom"), Exit.succeed(2), Exit.succeed(3)]) + })) + }) + describe("updateServiceScoped", () => { class CurrentNumber extends Context.Service()("CurrentNumber") {} @@ -4538,4 +4660,76 @@ describe("Effect", () => { assert.strictEqual(buildCount, 2) })) }) + + describe("AsyncLocalStorage", () => { + const storage = new AsyncLocalStorage() + const current = () => storage.getStore() ?? "none" + + const runWithStore = (store: string | undefined, effect: Effect.Effect): Promise => + store === undefined ? Effect.runPromise(effect) : storage.run(store, () => Effect.runPromise(effect)) + + const observe = Effect.gen(function*() { + const resumed = current() + const promise = yield* Effect.promise(async () => current()) + const timer = yield* Effect.callback((resume) => { + setTimeout(() => resume(Effect.succeed(current())), 1) + }) + yield* Effect.sleep(1) + const afterSleep = current() + return { resumed, promise, timer, afterSleep } + }) + + const expected = (store: string) => ({ resumed: store, promise: store, timer: store, afterSleep: store }) + + for (const waiterStore of [undefined, "waiter"]) { + const label = waiterStore === undefined ? "without a store" : "with its own store" + + describe(`waiter ${label}`, () => { + it("Deferred.await resumes in the awaiter's context", async () => { + const deferred = Deferred.makeUnsafe() + const waiter = runWithStore(waiterStore, Effect.andThen(Deferred.await(deferred), observe)) + const completer = await runWithStore( + "waker", + Effect.sleep(5).pipe( + Effect.andThen(Deferred.succeed(deferred, undefined)), + Effect.andThen(Effect.sync(current)) + ) + ) + assert.strictEqual(completer, "waker") + assert.deepStrictEqual(await waiter, expected(waiterStore ?? "none")) + }) + + it("Pool.get resumes in the waiter's context", async () => { + const scope = Scope.makeUnsafe() + try { + const pool = await Effect.runPromise( + Pool.make({ acquire: Effect.succeed("conn"), size: 1 }).pipe( + // Warm the pool before starting the holder. + Effect.tap((pool) => Effect.scoped(Pool.get(pool))), + Scope.provide(scope) + ) + ) + const holderAcquired = Deferred.makeUnsafe() + const holder = runWithStore( + "waker", + Effect.scoped( + Pool.get(pool).pipe( + Effect.andThen(Effect.sync(current)), + Effect.tap((store) => Deferred.succeed(holderAcquired, store)), + Effect.andThen(Effect.sleep(10)), + Effect.andThen(Effect.sync(current)) + ) + ) + ) + assert.strictEqual(await Effect.runPromise(Deferred.await(holderAcquired)), "waker") + const waiter = runWithStore(waiterStore, Effect.andThen(Effect.scoped(Pool.get(pool)), observe)) + assert.strictEqual(await holder, "waker") + assert.deepStrictEqual(await waiter, expected(waiterStore ?? "none")) + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + }) + }) + } + }) }) diff --git a/repos/effect/packages/effect/test/HashRing.test.ts b/repos/effect/packages/effect/test/HashRing.test.ts index b81534123..80067131c 100644 --- a/repos/effect/packages/effect/test/HashRing.test.ts +++ b/repos/effect/packages/effect/test/HashRing.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest" +import * as Hash from "effect/Hash" import * as HashRing from "effect/HashRing" import * as PrimaryKey from "effect/PrimaryKey" @@ -79,4 +80,57 @@ describe("HashRing", () => { assert.deepStrictEqual(HashRing.getShards(ring, 3)?.map(PrimaryKey.value), ["node-10", "node-29", "node-29"]) }) + + it("getShards is unchanged after adding and removing a fractional-weight node", () => { + const [a, b, c, d] = ["runner-a:34431", "runner-b:34431", "runner-c:34431", "runner-d:34431"].map(makeNode) + // Adding and removing `d` leaves a different floating-point weight total + // than adding only `a`, `b` and `c`. + const churned = HashRing.make() + HashRing.addMany(churned, [a, b, c], { weight: 0.1 }) + HashRing.add(churned, d, { weight: 0.3 }) + HashRing.remove(churned, d) + const fresh = HashRing.make() + HashRing.addMany(fresh, [a, b, c], { weight: 0.1 }) + + assert.deepStrictEqual(HashRing.getShards(churned, 300), HashRing.getShards(fresh, 300)) + }) + + it("getShards breaks ring hash ties independently of insertion order", () => { + const first = makeNode("runner-27:34431") + const second = makeNode("runner-1176:34431") + // These virtual points of different nodes have the same hash. + assert.strictEqual(Hash.string("runner-27:34431:13"), Hash.string("runner-1176:34431:71")) + const ring = HashRing.make() + HashRing.add(ring, first) + HashRing.add(ring, second) + const swapped = HashRing.make() + HashRing.add(swapped, second) + HashRing.add(swapped, first) + + assert.deepStrictEqual( + HashRing.getShards(swapped, 300), + HashRing.getShards(ring, 300) + ) + }) + + it("getShards keeps the assignment for integer weights", () => { + const ring = HashRing.make() + const nodes = Array.from({ length: 5 }, (_, i) => makeNode(`runner-${i}:34431`)) + const weights = [1, 1, 2, 1, 3] + weights.forEach((weight, i) => HashRing.add(ring, nodes[i], { weight })) + + // Assignment from the implementation before deterministic ordering. + assert.deepStrictEqual( + HashRing.getShards(ring, 16), + [4, 3, 0, 2, 2, 4, 3, 4, 1, 2, 2, 4, 1, 4, 4, 0].map((i) => nodes[i]) + ) + }) +}) + +interface Node extends PrimaryKey.PrimaryKey {} + +const makeNode = (key: string): Node => ({ + [PrimaryKey.symbol]() { + return key + } }) diff --git a/repos/effect/packages/effect/test/Pool.test.ts b/repos/effect/packages/effect/test/Pool.test.ts index ce78a81e0..b3faee601 100644 --- a/repos/effect/packages/effect/test/Pool.test.ts +++ b/repos/effect/packages/effect/test/Pool.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import { Deferred, Duration, Effect, Exit, Fiber, pipe, Pool, Ref, Schedule, Scheduler, Scope } from "effect" +import { Clock, Deferred, Duration, Effect, Exit, Fiber, pipe, Pool, Ref, Schedule, Scheduler, Scope } from "effect" import { TestClock } from "effect/testing" import { collectGarbage } from "./utils/gc.ts" @@ -210,6 +210,82 @@ describe("Pool", () => { strictEqual(result, 11) })) + describe("usage TTL idle timeout", () => { + it.effect("shrinks to min in idle order rather than acquisition order", () => + Effect.gen(function*() { + let acquired = 0 + const finalized: Array = [] + const pool = yield* Pool.makeWithTTL({ + acquire: Effect.acquireRelease( + Effect.sync(() => ++acquired), + (value) => Effect.sync(() => finalized.push(value)) + ), + min: 1, + max: 3, + timeToLive: 1000, + timeToLiveStrategy: "usage" + }) + const first = yield* Scope.fork(yield* Effect.scope) + const second = yield* Scope.fork(yield* Effect.scope) + const third = yield* Scope.fork(yield* Effect.scope) + strictEqual(yield* Pool.get(pool).pipe(Scope.provide(first)), 1) + strictEqual(yield* Pool.get(pool).pipe(Scope.provide(second)), 2) + strictEqual(yield* Pool.get(pool).pipe(Scope.provide(third)), 3) + + yield* TestClock.adjust(100) + yield* Scope.close(second, Exit.void) + yield* TestClock.adjust(100) + yield* Scope.close(first, Exit.void) + yield* TestClock.adjust(100) + yield* Scope.close(third, Exit.void) + yield* TestClock.adjust(700) + deepStrictEqual(finalized, []) + yield* TestClock.adjust(1000) + deepStrictEqual(finalized, [2, 1]) + strictEqual(pool.state.items.size, 1) + yield* TestClock.adjust(2000) + deepStrictEqual(finalized, [2, 1]) + strictEqual(yield* Pool.use(pool, Effect.succeed), 3) + strictEqual(acquired, 3) + })) + + it.effect("retains items used every 3 seconds across TTL sweeps", () => + Effect.gen(function*() { + let acquired = 0 + const pool = yield* Pool.makeWithTTL({ + acquire: Effect.sync(() => ++acquired), + min: 0, + max: 10, + timeToLive: "10 seconds", + timeToLiveStrategy: "usage" + }) + for (let i = 0; i < 20; i++) { + yield* Effect.scoped(Pool.get(pool)) + yield* TestClock.adjust("3 seconds") + } + strictEqual(acquired, 1) + })) + + it.effect("measures idle time with the pool clock when a release runs under another clock", () => + Effect.gen(function*() { + const pool = yield* Pool.makeWithTTL({ + acquire: Effect.succeed("resource"), + min: 0, + max: 1, + timeToLive: 1000, + timeToLiveStrategy: "usage" + }) + const clock = yield* Clock.Clock + yield* Pool.use(pool, Effect.succeed).pipe( + Effect.provideService(Clock.Clock, { ...clock, currentTimeMillisUnsafe: () => 1_000_000 }) + ) + yield* TestClock.adjust(999) + strictEqual(pool.state.items.size, 1) + yield* TestClock.adjust(1) + strictEqual(pool.state.items.size, 0) + })) + }) + it.effect("max pool size", () => Effect.gen(function*() { const deferred = yield* Deferred.make() @@ -273,31 +349,29 @@ describe("Pool", () => { strictEqual(max, 15) })) - it.effect("concurrency reclaim", () => + it.effect("usage TTL retains shared items with active borrowers", () => Effect.gen(function*() { - const count = yield* Ref.make(0) - const acquire = Effect.acquireRelease( - Ref.updateAndGet(count, (n) => n + 1), - () => Ref.update(count, (n) => n - 1) - ) + let acquired = 0 const pool = yield* Pool.makeWithTTL({ - acquire, + acquire: Effect.sync(() => ++acquired), min: 0, max: 2, concurrency: 2, timeToLive: Duration.seconds(60) }) - const scope1 = yield* Scope.make() - yield* Scope.provide(Pool.get(pool), scope1) - yield* Pool.get(pool) - yield* Effect.scoped(Pool.get(pool)) + const scope1 = yield* Scope.fork(yield* Effect.scope) + strictEqual(yield* Scope.provide(Pool.get(pool), scope1), 1) + strictEqual(yield* Pool.get(pool), 1) + strictEqual(yield* Effect.scoped(Pool.get(pool)), 2) yield* TestClock.adjust(Duration.seconds(60)) + strictEqual(pool.state.invalidated.size, 0) + strictEqual(pool.state.items.size, 1) yield* Scope.close(scope1, Exit.void) - yield* Pool.get(pool) - yield* Pool.get(pool) strictEqual(yield* Pool.get(pool), 1) - strictEqual(yield* Ref.get(count), 2) + strictEqual(yield* Pool.get(pool), 3) + strictEqual(yield* Pool.get(pool), 3) + strictEqual(acquired, 3) })) it.effect("reserve takes an item out of shared circulation", () => @@ -407,7 +481,7 @@ describe("Pool", () => { assert.strictEqual(pool.state.usage, 0) })) - it.effect("usage TTL reclaim skips reserved items", () => + it.effect("usage TTL preserves reservations and waits for capacity", () => Effect.gen(function*() { let acquired = 0 const pool = yield* Pool.makeWithTTL({ @@ -417,24 +491,25 @@ describe("Pool", () => { concurrency: 2, timeToLive: 1000 }) - const owner = yield* Scope.make() - const reservation = yield* Scope.make() + const owner = yield* Scope.fork(yield* Effect.scope) + const reservation = yield* Scope.fork(yield* Effect.scope) yield* Pool.get(pool).pipe(Scope.provide(owner)) yield* Pool.reserve(pool, 1).pipe(Scope.provide(reservation)) yield* Pool.use(pool, Effect.succeed) yield* TestClock.adjust(1000) + assert.strictEqual(pool.state.items.size, 1) + assert.strictEqual(pool.state.invalidated.size, 0) const first = yield* Pool.get(pool).pipe(Scope.provide(owner)) - yield* TestClock.adjust(0) const second = yield* Pool.get(pool).pipe(Scope.provide(owner)) - // The invalidated item 1 is still reserved, so reclaim must not - // un-invalidate it; the waiter is served by a fresh item instead. - const result = yield* Pool.use(pool, Effect.succeed) + const next = yield* Pool.use(pool, Effect.succeed).pipe(Effect.forkChild({ startImmediately: true })) + assert.isUndefined(next.pollUnsafe()) yield* Scope.close(reservation, Exit.void) + const result = yield* Fiber.join(next) yield* Scope.close(owner, Exit.void) - assert.deepStrictEqual([first, second], [2, 2]) - assert.strictEqual(result, 3) + assert.deepStrictEqual([first, second], [3, 3]) + assert.strictEqual(result, 1) assert.strictEqual(acquired, 3) assert.strictEqual(pool.state.usage, 0) })) diff --git a/repos/effect/packages/effect/test/RcMap.test.ts b/repos/effect/packages/effect/test/RcMap.test.ts index 86c26fc1c..adca80119 100644 --- a/repos/effect/packages/effect/test/RcMap.test.ts +++ b/repos/effect/packages/effect/test/RcMap.test.ts @@ -569,4 +569,32 @@ describe("RcMap", () => { yield* Fiber.join(close) assert.strictEqual(released, 2) })) + + it.effect("drops an interrupted lookup and closes its scope", () => + Effect.gen(function*() { + let lookups = 0 + const started = yield* Deferred.make() + const interrupt = yield* Deferred.make() + const finalized = yield* Deferred.make() + const map = yield* RcMap.make({ + lookup: (_key: string) => + Effect.gen(function*() { + if (++lookups > 1) return lookups + yield* Effect.addFinalizer(() => Deferred.succeed(finalized, void 0)) + yield* Deferred.succeed(started, void 0) + yield* Deferred.await(interrupt) + return yield* Effect.interrupt + }), + idleTimeToLive: "1 minute" + }) + + // The only borrower leaves, so nothing else releases the entry. + const borrower = yield* Effect.forkChild(Effect.scoped(RcMap.get(map, "key")), { startImmediately: true }) + yield* Deferred.await(started) + yield* Fiber.interrupt(borrower) + yield* Deferred.succeed(interrupt, void 0) + + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "key")), 2) + yield* Deferred.await(finalized) + })) }) diff --git a/repos/effect/packages/effect/test/RcRef.test.ts b/repos/effect/packages/effect/test/RcRef.test.ts index e60965103..58486b1f5 100644 --- a/repos/effect/packages/effect/test/RcRef.test.ts +++ b/repos/effect/packages/effect/test/RcRef.test.ts @@ -382,4 +382,105 @@ describe("RcRef", () => { yield* Fiber.join(close) assert.strictEqual(released, 2) })) + + describe("interruption", () => { + it.effect("shares an acquisition until every get is interrupted, then starts fresh", () => + Effect.gen(function*() { + let acquired = 0 + const started = yield* Deferred.make() + const finalizing = yield* Deferred.make() + const finishFinalizer = yield* Deferred.make() + const ref = yield* RcRef.make({ + acquire: Effect.suspend(() => { + if (++acquired > 1) return Effect.succeed(acquired) + return Deferred.succeed(started, void 0).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizing, void 0).pipe(Effect.andThen(Deferred.await(finishFinalizer))) + ) + ) + }) + }) + + const first = yield* Effect.forkChild(Effect.scoped(RcRef.get(ref)), { startImmediately: true }) + yield* Deferred.await(started) + const second = yield* Effect.forkChild(Effect.scoped(RcRef.get(ref)), { startImmediately: true }) + yield* Fiber.interrupt(first) + assert.isFalse(yield* Deferred.isDone(finalizing)) + + // A get made while the abandoned acquisition finalizes starts a fresh one. + const interruptSecond = yield* Effect.forkChild(Fiber.interrupt(second), { startImmediately: true }) + yield* Deferred.await(finalizing) + const fresh = yield* Effect.forkChild(Effect.scoped(RcRef.get(ref)), { startImmediately: true }) + yield* Deferred.succeed(finishFinalizer, void 0) + yield* Fiber.join(interruptSecond) + assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2)) + })) + + it.effect.each([false, true])( + "releases references of waiters interrupted after the acquisition publishes them (interrupt all: %s)", + (interruptAll) => + Effect.gen(function*() { + // Sweep the op budget so the acquisition yields at every step between + // publishing its references and its fiber completing. + for (let ops = 3; ops <= 64; ops++) { + let acquired = 0 + let released = 0 + const gate = yield* Deferred.make() + const ref = yield* RcRef.make({ + acquire: Effect.acquireRelease( + Deferred.await(gate).pipe(Effect.map(() => ++acquired)), + () => + Effect.sync(() => { + released++ + }) + ) + }) + const withBudget = Effect.provideService(Scheduler.MaxOpsBeforeYield, ops) + + const holderScope = yield* Scope.make() + const holder = yield* Effect.forkChild(withBudget(RcRef.get(ref).pipe(Scope.provide(holderScope))), { + startImmediately: true + }) + const waiter = yield* Effect.forkChild(withBudget(Effect.scoped(RcRef.get(ref))), { + startImmediately: true + }) + for (let i = 0; i < 200; i++) yield* Effect.yieldNow + yield* Deferred.succeed(gate, void 0) + yield* Fiber.interrupt(waiter) + if (interruptAll) yield* Fiber.interrupt(holder) + yield* Fiber.await(holder) + yield* Scope.close(holderScope, Exit.void) + + assert.deepStrictEqual([acquired, released], [1, 1], `${ops} ops`) + assert.strictEqual(yield* Effect.scoped(RcRef.get(ref)), 2, `${ops} ops`) + } + }) + ) + + it.effect("closing the ref interrupts a pending acquisition and closes its scope", () => + Effect.gen(function*() { + let released = 0 + const started = yield* Deferred.make() + const refScope = yield* Scope.make() + const ref = yield* RcRef.make({ + acquire: Effect.gen(function*() { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + released++ + }) + ) + yield* Deferred.succeed(started, void 0) + return yield* Effect.never + }) + }).pipe(Scope.provide(refScope)) + + const getter = yield* Effect.forkChild(Effect.scoped(RcRef.get(ref)), { startImmediately: true }) + yield* Deferred.await(started) + yield* Scope.close(refScope, Exit.void) + + assert.strictEqual(released, 1) + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(getter))) + })) + }) }) diff --git a/repos/effect/packages/effect/test/ScopedCache.test.ts b/repos/effect/packages/effect/test/ScopedCache.test.ts index a4de55f8b..961afbf93 100644 --- a/repos/effect/packages/effect/test/ScopedCache.test.ts +++ b/repos/effect/packages/effect/test/ScopedCache.test.ts @@ -1892,6 +1892,55 @@ describe("ScopedCache", () => { describe("error scenarios", () => { describe("Failed Lookups", () => { + it.effect("settles concurrent gets when TTL calculation throws after an asynchronous lookup", () => + Effect.gen(function*() { + const started = yield* Latch.make() + const finish = yield* Latch.make() + const cache = yield* ScopedCache.makeWith({ + capacity: 10, + lookup: (_key: string) => Effect.andThen(started.open, Effect.as(finish.await, 42)), + timeToLive: () => { + throw new Error("TTL defect") + } + }) + + const first = yield* ScopedCache.get(cache, "test").pipe( + Effect.exit, + Effect.timeoutOption("1 second"), + Effect.forkChild({ startImmediately: true }) + ) + yield* started.await + const second = yield* ScopedCache.get(cache, "test").pipe( + Effect.exit, + Effect.timeoutOption("1 second"), + Effect.forkChild({ startImmediately: true }) + ) + yield* Effect.yieldNow + yield* finish.open + yield* TestClock.adjust("1 second") + + assert.deepStrictEqual(yield* Fiber.join(first), Option.some(Exit.succeed(42))) + assert.deepStrictEqual(yield* Fiber.join(second), Option.some(Exit.succeed(42))) + })) + + it.effect("does not cache an asynchronous failure with zero TTL", () => + Effect.gen(function*() { + let lookupCount = 0 + const cache = yield* ScopedCache.makeWith({ + capacity: 10, + lookup: (_key: string) => + Effect.promise(() => Promise.resolve()).pipe( + Effect.andThen(Effect.sync(() => ++lookupCount)), + Effect.flatMap((count) => count === 1 ? Effect.fail("error") : Effect.succeed(count)) + ), + timeToLive: (exit) => Exit.isFailure(exit) ? Duration.zero : Duration.infinity + }) + + assert.deepStrictEqual(yield* Effect.exit(ScopedCache.get(cache, "test")), Exit.fail("error")) + assert.deepStrictEqual(yield* Effect.exit(ScopedCache.get(cache, "test")), Exit.succeed(2)) + assert.strictEqual(lookupCount, 2) + })) + it.effect("failed lookup caches the failure", () => Effect.gen(function*() { let lookupCount = 0 @@ -2412,6 +2461,71 @@ describe("ScopedCache", () => { }) describe("concurrency tests", () => { + it.effect("a get made while an abandoned lookup is finalizing starts a new lookup", () => + Effect.gen(function*() { + let lookups = 0 + const started = yield* Latch.make() + const finalizing = yield* Latch.make() + const finishFinalizer = yield* Latch.make() + const cache = yield* ScopedCache.make({ + capacity: 10, + lookup: (_key: string) => + Effect.suspend(() => { + if (++lookups > 1) return Effect.succeed(lookups) + return started.open.pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => finalizing.open.pipe(Effect.andThen(finishFinalizer.await))) + ) + }) + }) + + const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true })) + yield* started.await + const interruptFirst = yield* Fiber.interrupt(first).pipe(Effect.forkChild({ startImmediately: true })) + yield* finalizing.await + const fresh = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true })) + yield* finishFinalizer.open + yield* Fiber.join(interruptFirst) + + assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2)) + assert.strictEqual(yield* ScopedCache.get(cache, "key"), 2) + assert.strictEqual(lookups, 2) + })) + + it.effect("a getOption waiter arriving during eviction keeps the lookup alive when the first caller leaves", () => + Effect.gen(function*() { + const closing = yield* Latch.make() + const close = yield* Latch.make() + const started = yield* Latch.make() + const finish = yield* Latch.make() + const cache = yield* ScopedCache.make({ + capacity: 1, + lookup: (key: string) => + Effect.gen(function*() { + if (key === "a") { + yield* Effect.acquireRelease(Effect.void, () => closing.open.pipe(Effect.andThen(close.await))) + return 1 + } + yield* started.open + yield* finish.await + return 42 + }) + }) + + yield* ScopedCache.get(cache, "a") + const first = yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild({ startImmediately: true })) + yield* closing.await + const second = yield* ScopedCache.getOption(cache, "b").pipe(Effect.forkChild({ startImmediately: true })) + yield* close.open + yield* started.await + yield* Effect.yieldNow + yield* Fiber.interrupt(first) + yield* finish.open + + assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(Option.some(42))) + assert.strictEqual(yield* ScopedCache.get(cache, "b"), 42) + })) + it.effect("interrupting the only caller during eviction releases its lookup", () => Effect.gen(function*() { const closing = yield* Latch.make() diff --git a/repos/effect/packages/effect/test/Stream.test.ts b/repos/effect/packages/effect/test/Stream.test.ts index 3cd7cfa19..e6933ce98 100644 --- a/repos/effect/packages/effect/test/Stream.test.ts +++ b/repos/effect/packages/effect/test/Stream.test.ts @@ -1363,6 +1363,15 @@ describe("Stream", () => { }) describe("scanning", () => { + it.effect("scan emits the initial state for Stream.empty", () => + Effect.gen(function*() { + const result = yield* Stream.empty.pipe( + Stream.scan(() => 0, (acc, curr: number) => acc + curr), + Stream.runCollect + ) + assert.deepStrictEqual(result, [0]) + })) + it.effect("scan", () => Effect.gen(function*() { const stream = Stream.make(1, 2, 3, 4, 5) @@ -2578,19 +2587,11 @@ describe("Stream", () => { }) describe("aggregateWithin", () => { - it.effect("does not grow the fiber continuation stack while upstream is idle", () => + it.effect("does not step the schedule while upstream is idle", () => Effect.gen(function*() { - const continuationCounts: Array = [] + let steps = 0 const schedule = Schedule.spaced("10 millis").pipe( - Schedule.tap(() => - Effect.withFiber((fiber) => - Effect.sync(() => { - continuationCounts.push( - (fiber as unknown as { readonly _stack: ReadonlyArray })._stack.length - ) - }) - ) - ) + Schedule.tap(() => Effect.sync(() => steps++)) ) const fiber = yield* Stream.never.pipe( Stream.aggregateWithin(Sink.take(25), schedule), @@ -2598,11 +2599,49 @@ describe("Stream", () => { Effect.forkChild({ startImmediately: true }) ) yield* TestClock.adjust("1 second") - assert.isAbove(continuationCounts.length, 1) - assert.strictEqual(continuationCounts.at(-1), continuationCounts[0]) + assert.strictEqual(steps, 0) yield* Fiber.interrupt(fiber) })) + it.effect("schedule exhaustion drains sink leftovers without pulling more upstream", () => + Effect.gen(function*() { + let pulls = 0 + const result = yield* Stream.make(1, 2, 3, 4, 5).pipe( + Stream.concat(Stream.fromEffect(Effect.sync(() => ++pulls))), + Stream.aggregateWithin( + Sink.take(2).pipe(Sink.mapEffect((batch) => Effect.as(Effect.yieldNow, batch))), + Schedule.forever.pipe(Schedule.upTo({ times: 0 })) + ), + Stream.runCollect + ) + + assert.strictEqual(pulls, 0) + assert.deepStrictEqual(result, [[1, 2], [3, 4], [5]]) + })) + + it.effect("groupedWithin starts each window at its first element", () => + Effect.gen(function*() { + const queue = yield* Queue.unbounded() + const batches: Array<[number, ReadonlyArray]> = [] + yield* Stream.fromQueue(queue).pipe( + Stream.groupedWithin(10, "100 millis"), + Stream.runForEach((batch) => + Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.sync(() => batches.push([now, batch]))) + ), + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust("1030 millis") + yield* Queue.offer(queue, 1) + yield* TestClock.adjust("50 millis") + yield* Queue.offer(queue, 2) + yield* TestClock.adjust("1000 millis") + yield* Queue.offer(queue, 3) + yield* TestClock.adjust("100 millis") + + deepStrictEqual(batches, [[1130, [1, 2]], [2180, [3]]]) + })) + it.effect("groupedWithin does not emit empty arrays when upstream is idle", () => Effect.gen(function*() { const fiber = yield* Stream.never.pipe( @@ -3136,6 +3175,65 @@ describe("Stream", () => { ) deepStrictEqual(result, [1, 2, 3, 4]) })) + + it.effect("throttleShape - keeps the rate when sleeps end early", () => + Effect.gen(function*() { + const clock = yield* Clock.Clock + const earlyClock: Clock.Clock = { + ...clock, + monotonicTimeNanosUnsafe: () => (1n << 80n) + clock.monotonicTimeNanosUnsafe(), + sleep: (duration) => clock.sleep(Duration.millis(Duration.toMillis(duration) - 1)) + } + const fiber = yield* pipe( + Stream.range(1, 3), + Stream.rechunk(1), + Stream.throttle({ + strategy: "shape", + cost: (arr) => arr.length, + units: 1, + duration: Duration.seconds(1) + }), + Stream.mapEffect(() => clock.currentTimeMillis), + Stream.runCollect, + Effect.provideService(Clock.Clock, earlyClock), + Effect.forkScoped + ) + yield* TestClock.adjust(Duration.seconds(3)) + const timestamps = yield* Fiber.join(fiber) + deepStrictEqual(timestamps, [0, 999, 1999], "Early wake-ups must not accumulate rate drift") + })) + + it.effect("throttleShape - is not affected by wall clock changes", () => + Effect.gen(function*() { + const clock = yield* Clock.Clock + let offset = 0 + const shiftedClock: Clock.Clock = { + ...clock, + currentTimeMillisUnsafe: () => clock.currentTimeMillisUnsafe() + offset + } + const fiber = yield* pipe( + Stream.range(1, 2), + Stream.rechunk(1), + Stream.throttle({ + strategy: "shape", + cost: (arr) => arr.length, + units: 1, + duration: Duration.seconds(1) + }), + Stream.tap((n) => + Effect.sync(() => { + if (n === 1) offset = -5000 + }) + ), + Stream.mapEffect(() => clock.currentTimeMillis), + Stream.runCollect, + Effect.provideService(Clock.Clock, shiftedClock), + Effect.forkScoped + ) + yield* TestClock.adjust(Duration.seconds(6)) + const timestamps = yield* Fiber.join(fiber) + deepStrictEqual(timestamps, [0, 1000], "Wall clock changes must not affect the refill rate") + })) }) describe("zipping", () => { diff --git a/repos/effect/packages/effect/test/SubscriptionRef.test.ts b/repos/effect/packages/effect/test/SubscriptionRef.test.ts index 972634c48..61b83663e 100644 --- a/repos/effect/packages/effect/test/SubscriptionRef.test.ts +++ b/repos/effect/packages/effect/test/SubscriptionRef.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Array, Effect, Exit, Fiber, Latch, Number, Option, Pull, Random, Stream, SubscriptionRef } from "effect" +import { Array, Cause, Effect, Exit, Fiber, Latch, Number, Option, Random, Stream, SubscriptionRef } from "effect" describe("SubscriptionRef", () => { it.effect("isSubscriptionRef", () => @@ -76,7 +76,7 @@ describe("SubscriptionRef", () => { yield* Fiber.interrupt(fiber1) const result1 = yield* Fiber.await(fiber1) const result2 = yield* Fiber.join(fiber2) - assert.isTrue(Exit.isFailure(result1) && Pull.isDoneCause(result1.cause)) + assert.isTrue(Exit.isFailure(result1) && Cause.hasInterruptsOnly(result1.cause)) assert.deepStrictEqual(result2, [1, 2]) })) diff --git a/repos/effect/packages/effect/test/ai/LanguageModel.test.ts b/repos/effect/packages/effect/test/ai/LanguageModel.test.ts index 2d133dcdd..65892245c 100644 --- a/repos/effect/packages/effect/test/ai/LanguageModel.test.ts +++ b/repos/effect/packages/effect/test/ai/LanguageModel.test.ts @@ -251,6 +251,71 @@ describe("LanguageModel", () => { Effect.provide(TransformToolkitLayer) )) + it.effect("validates tool parameters against the schemas of each request", () => + Effect.gen(function*() { + const StructTransformToolkit = Toolkit.make(Tool.make("TransformTool", { + parameters: Schema.Struct({ value: Schema.Finite }), + success: Schema.Finite + })) + + yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe(Effect.provide(TransformToolkitLayer)) + + const error = yield* LanguageModel.generateText({ + prompt: [], + toolkit: StructTransformToolkit, + disableToolCallResolution: true + }).pipe( + Effect.provide(StructTransformToolkit.toLayer({ TransformTool: ({ value }) => Effect.succeed(value) })), + Effect.flip + ) + + strictEqual(error.reason._tag, "InvalidOutputError") + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-transform", + name: "TransformTool", + params: "21" + }] + }) + )) + + it.effect("validates encoded tool parameters after a request with tool call resolution enabled", () => { + // A tool unused by other tests, so the opaque request decodes it first + const toolkit = Toolkit.make(Tool.make("TransformTool", { + parameters: Schema.FiniteFromString, + success: Schema.Finite + })) + const responses: Array> = [ + [{ type: "tool-call", id: "tool-valid", name: "TransformTool", params: "21" }], + [{ type: "tool-call", id: "tool-invalid", name: "TransformTool", params: { invalid: true } }] + ] + return Effect.gen(function*() { + yield* LanguageModel.generateText({ + prompt: [], + toolkit + }) + + const error = yield* LanguageModel.generateText({ + prompt: [], + toolkit, + disableToolCallResolution: true + }).pipe(Effect.flip) + + strictEqual(error.reason._tag, "InvalidOutputError") + }).pipe( + TestUtils.withLanguageModel({ + generateText: () => responses.shift()! + }), + Effect.provide(toolkit.toLayer({ TransformTool: (value) => Effect.succeed(value * 2) })) + ) + }) + it.effect("validates provider-executed tool call parameters", () => Effect.gen(function*() { const calls = yield* Ref.make(0) diff --git a/repos/effect/packages/effect/test/ai/McpSchema.test.ts b/repos/effect/packages/effect/test/ai/McpSchema.test.ts index 9ef3a65af..d335a72b5 100644 --- a/repos/effect/packages/effect/test/ai/McpSchema.test.ts +++ b/repos/effect/packages/effect/test/ai/McpSchema.test.ts @@ -46,4 +46,12 @@ describe("McpSchema", () => { legacy: { type: "string", enum: ["one"], enumNames: ["One"] } }) }) + + it("round-trips an implementation with icons", () => { + const implementation = { name: "server", version: "1.0.0", icons: [{ src: "https://example.com/icon.png" }] } + assert.deepStrictEqual( + Schema.encodeSync(McpSchema.Implementation)(Schema.decodeUnknownSync(McpSchema.Implementation)(implementation)), + implementation + ) + }) }) diff --git a/repos/effect/packages/effect/test/ai/McpServer/McpConformance/SubscriptionsTest.ts b/repos/effect/packages/effect/test/ai/McpServer/McpConformance/SubscriptionsTest.ts index 8df50868e..984cb02af 100644 --- a/repos/effect/packages/effect/test/ai/McpServer/McpConformance/SubscriptionsTest.ts +++ b/repos/effect/packages/effect/test/ai/McpServer/McpConformance/SubscriptionsTest.ts @@ -7,6 +7,7 @@ import * as McpProtocol from "effect/ai/McpProtocol" import * as McpSchema from "effect/ai/McpSchema" import * as McpServer from "effect/ai/McpServer" import type * as Arr from "effect/Array" +import * as Clock from "effect/Clock" import * as Context from "effect/Context" import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" @@ -21,6 +22,7 @@ import { RequestId } from "effect/rpc/RpcMessage" import type * as RpcSerialization from "effect/rpc/RpcSerialization" import * as RpcServer from "effect/rpc/RpcServer" import * as Schema from "effect/Schema" +import * as TestClock from "effect/testing/TestClock" import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts" import { makeMcpSseReader } from "../TestUtils/McpHttpResponse.ts" import { makeServerLayer } from "../TestUtils/McpServerLayer.ts" @@ -127,7 +129,10 @@ const httpHeaders = (protocol: McpProtocol.ProtocolAdapter): HeadersInit => ({ "Mcp-Method": "subscriptions/listen" }) -const makeHttpSubscriptionHarness = Effect.fnUntraced(function*(protocol: McpProtocol.ProtocolAdapter) { +const makeHttpSubscriptionHarness = Effect.fnUntraced(function*( + protocol: McpProtocol.ProtocolAdapter, + clockLayer: Layer.Layer = Layer.empty +) { const serverReady = yield* Deferred.make() const registrations = Layer.effectDiscard( Effect.gen(function*() { @@ -137,14 +142,37 @@ const makeHttpSubscriptionHarness = Effect.fnUntraced(function*(protocol: McpPro }) ) const harness = yield* makeHttpHarness( - registrations.pipe(Layer.provideMerge(makeServerLayer({ - name: "SubscriptionConformance", - protocols: [protocol] - }))) + registrations.pipe( + Layer.provideMerge(makeServerLayer({ + name: "SubscriptionConformance", + protocols: [protocol] + })), + Layer.provideMerge(clockLayer) + ) ) return { harness, serverReady } }) +const makeTrackingClock = Effect.gen(function*() { + const clock = yield* Clock.Clock + let activeSleeps = 0 + const trackingClock: Clock.Clock = { + ...clock, + sleep: (duration) => + Effect.acquireUseRelease( + Effect.sync(() => { + activeSleeps++ + }), + () => clock.sleep(duration), + () => + Effect.sync(() => { + activeSleeps-- + }) + ) + } + return { layer: Layer.succeed(Clock.Clock, trackingClock), activeSleeps: () => activeSleeps } +}) + export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Subscriptions", () => { @@ -564,6 +592,69 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.notProperty(paramsOf(notification), "_meta") })) + it.effect("should keep idle HTTP subscriptions alive with periodic SSE comments", () => + Effect.gen(function*() { + const clock = yield* makeTrackingClock + const { harness, serverReady } = yield* makeHttpSubscriptionHarness( + protocol, + clock.layer + ) + yield* harness.post({ + jsonrpc: "2.0", + id: "ready", + method: "server/discover", + params: { _meta: httpMetadata(protocol) } + }, { "MCP-Protocol-Version": protocol.protocolVersion, "Mcp-Method": "server/discover" }) + yield* TestClock.adjust("1 second") + const response = yield* harness.post(httpListenRequest(protocol, "idle-http"), httpHeaders(protocol)) + assert.match(response.headers.get("content-type") ?? "", /^text\/event-stream/) + assert.isNotNull(response.body) + const reader = response.body.getReader() + yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel())) + const read = Effect.promise(() => reader.read()) + const decoder = new TextDecoder() + const acknowledgment = decoder.decode((yield* read).value) + assert.isTrue(acknowledgment.startsWith("data: ")) + assertAcknowledged(JSON.parse(acknowledgment.slice(6)), "idle-http", { toolsListChanged: true }) + + const next = yield* read.pipe(Effect.forkChild) + yield* TestClock.adjust("14 seconds") + assert.isUndefined(next.pollUnsafe()) + yield* TestClock.adjust("1 second") + assert.isDefined(next.pollUnsafe()) + assert.strictEqual(decoder.decode((yield* Fiber.join(next)).value), ": keepalive\n\n") + const heartbeat = yield* read.pipe(Effect.forkChild) + yield* TestClock.adjust("15 seconds") + assert.strictEqual(decoder.decode((yield* Fiber.join(heartbeat)).value), ": keepalive\n\n") + + const tools = yield* harness.post({ + jsonrpc: "2.0", + id: "tools-during-subscription", + method: "tools/list", + params: { _meta: httpMetadata(protocol) } + }, { "MCP-Protocol-Version": protocol.protocolVersion, "Mcp-Method": "tools/list" }) + assert.match(tools.headers.get("content-type") ?? "", /^application\/json/) + const listed = yield* Effect.promise(() => tools.json()) + assert.strictEqual(listed.id, "tools-during-subscription") + assert.deepStrictEqual(listed.result.tools, [ + { name: "http-subscription-baseline", inputSchema: { type: "object", properties: {} } } + ]) + assert.strictEqual(clock.activeSleeps(), 1) + + const server = yield* Deferred.await(serverReady) + yield* server.notifications["notifications/tools/list_changed"]({}) + const notification = decoder.decode((yield* read).value) + assert.isTrue(notification.startsWith("data: ")) + const message = JSON.parse(notification.slice(6)) + assert.strictEqual(message.method, "notifications/tools/list_changed") + assert.strictEqual(subscriptionIdOf(message), "idle-http") + yield* Effect.promise(() => reader.cancel()) + assert.strictEqual(clock.activeSleeps(), 0) + yield* TestClock.adjust("30 seconds") + assert.deepStrictEqual(yield* read, { done: true, value: undefined }) + assert.strictEqual(clock.activeSleeps(), 0) + })) + it.effect("should stream acknowledgment before matching events when using HTTP", () => Effect.gen(function*() { const { harness, serverReady } = yield* makeHttpSubscriptionHarness(protocol) @@ -582,7 +673,8 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman it.effect("should close only the HTTP subscription stream when its pending backlog overflows", () => Effect.gen(function*() { - const { harness, serverReady } = yield* makeHttpSubscriptionHarness(protocol) + const clock = yield* makeTrackingClock + const { harness, serverReady } = yield* makeHttpSubscriptionHarness(protocol, clock.layer) const response = yield* harness.post( httpListenRequest(protocol, "overflowing-http-subscription"), httpHeaders(protocol) @@ -602,6 +694,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman const remaining = yield* subscription.drain() assert.notInclude(remaining.map((message) => message.method), "notifications/cancelled") + assert.strictEqual(clock.activeSleeps(), 0) const discovery = yield* harness.post({ jsonrpc: "2.0", diff --git a/repos/effect/packages/effect/test/ai/McpServer/McpProtocol.test.ts b/repos/effect/packages/effect/test/ai/McpServer/McpProtocol.test.ts index 72486d4a6..26fa6b516 100644 --- a/repos/effect/packages/effect/test/ai/McpServer/McpProtocol.test.ts +++ b/repos/effect/packages/effect/test/ai/McpServer/McpProtocol.test.ts @@ -185,18 +185,12 @@ describe("MCP v2025-06-18 schema", () => { assert.strictEqual(message.content.type, "resource_link") }) - it("does not expose future annotations or named extension capabilities", () => { + it("omits later core annotation fields", () => { const annotations = Schema.decodeUnknownSync(McpSchema2025_06_18.Annotations)({ audience: ["user"], lastModified: "2026-07-26" }) - const capabilities = Schema.decodeUnknownSync(McpSchema2025_06_18.ServerCapabilities)({ - completions: {}, - extensions: { "example/extension": { enabled: true } } - }) - assert.notProperty(annotations, "lastModified") - assert.notProperty(capabilities, "extensions") }) }) diff --git a/repos/effect/packages/effect/test/ai/McpServer/ProtocolAdapters.test.ts b/repos/effect/packages/effect/test/ai/McpServer/ProtocolAdapters.test.ts index 0d6a9da50..585b19140 100644 --- a/repos/effect/packages/effect/test/ai/McpServer/ProtocolAdapters.test.ts +++ b/repos/effect/packages/effect/test/ai/McpServer/ProtocolAdapters.test.ts @@ -179,7 +179,9 @@ interface TestState { capabilityInvocations: number } -const makeFixture = Effect.fnUntraced(function*() { +const makeFixture = Effect.fnUntraced(function*( + extensions?: NonNullable +) { const state: TestState = { sharedInvocations: 0, structuredInvocations: 0, @@ -250,6 +252,7 @@ const makeFixture = Effect.fnUntraced(function*() { sizes: ["any"] })], path: "/mcp", + extensions, protocols: [ McpProtocol.v2026_07_28, McpProtocol.v2025_11_25, @@ -1239,6 +1242,18 @@ describe("McpServer protocol adapters", () => { assert.isUndefined(yield* protocol.projectNotification(elicitationComplete)) } })) + + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) { + it.effect(`should preserve server extensions when initializing ${protocolVersion} requests`, () => + Effect.gen(function*() { + const extensions = { "example/extension": { enabled: true } } + const fixture = yield* makeFixture(extensions) + const client = yield* initialize(fixture.post, protocolVersion) + + assert.deepNestedPropertyVal(client.initializeResult, "capabilities.extensions", extensions) + })) + } + it.effect("should omit elicitation when the negotiated protocol predates v2025-06-18", () => Effect.gen(function*() { const fixture = yield* makeFixture() @@ -1456,11 +1471,12 @@ describe("McpServer protocol adapters", () => { assert.strictEqual(currentShared.title, "Shared tool title") })) - for (const protocolVersion of ["2025-06-18", "2024-11-05"] as const) { + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const) { it.effect(`should expose the negotiated client profile when serving ${protocolVersion} requests`, () => Effect.gen(function*() { const fixture = yield* makeFixture() const advertisedCapabilities = { + extensions: { "example/extension": { enabled: true } }, roots: { listChanged: true }, sampling: {} } diff --git a/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts b/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts index 1e3f874ff..69dd94828 100644 --- a/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts +++ b/repos/effect/packages/effect/test/cluster/ClusterWorkflowEngine.test.ts @@ -40,6 +40,45 @@ import { } from "effect/workflow/WorkflowEngine" describe.concurrent("ClusterWorkflowEngine", () => { + it.effect("does not replay for an unawaited deferred checkpoint", () => + Effect.gen(function*() { + const checkpoint = DurableDeferred.make("UnawaitedCheckpoint/Checkpoint") + const gate = DurableDeferred.make("UnawaitedCheckpoint/Gate", { success: Schema.String }) + const workflow = Workflow.make("UnawaitedCheckpoint", { + payload: {}, + success: Schema.String, + idempotencyKey: () => "one" + }) + let activations = 0 + const context = yield* Layer.build( + workflow.toLayer(Effect.fnUntraced(function*(_payload, executionId) { + activations++ + const engine = yield* WorkflowEngine + if (Option.isNone(yield* engine.deferredResult(checkpoint))) { + yield* DurableDeferred.succeed(checkpoint, { + token: DurableDeferred.tokenFromExecutionId(checkpoint, { workflow, executionId }), + value: undefined + }) + } + return yield* DurableDeferred.await(gate) + })).pipe(Layer.provideMerge(makeTestWorkflowEngine())) + ) + yield* Effect.gen(function*() { + const executionId = yield* workflow.execute({}, { discard: true }) + yield* pollUntil(workflow, executionId, "Suspended") + const beforeRelease = activations + yield* DurableDeferred.succeed(gate, { + token: DurableDeferred.tokenFromExecutionId(gate, { workflow, executionId }), + value: "done" + }) + assert.deepStrictEqual( + yield* pollUntil(workflow, executionId, "Complete"), + new Workflow.Complete({ exit: Exit.succeed("done") }) + ) + assert.deepStrictEqual({ beforeRelease, afterRelease: activations }, { beforeRelease: 1, afterRelease: 2 }) + }).pipe(Effect.provide(context)) + })) + it.effect("retries a deferred wake after a transient run reset failure", () => Effect.gen(function*() { const gate = DurableDeferred.make("ResetRetry/Gate", { success: Schema.String }) diff --git a/repos/effect/packages/effect/test/cluster/ResourceMap.test.ts b/repos/effect/packages/effect/test/cluster/ResourceMap.test.ts index 728f12a37..87890c5b3 100644 --- a/repos/effect/packages/effect/test/cluster/ResourceMap.test.ts +++ b/repos/effect/packages/effect/test/cluster/ResourceMap.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect } from "effect" +import { Deferred, Effect, Exit, Fiber, Scope } from "effect" import { ResourceMap } from "effect/cluster/internal/resourceMap" describe("ResourceMap", () => { @@ -15,4 +15,87 @@ describe("ResourceMap", () => { yield* Effect.exit(map.get("key")) assert.strictEqual(finalized, 1) }))) + + it.effect("shares a lookup until every get is interrupted, then starts fresh", () => + Effect.scoped(Effect.gen(function*() { + let lookups = 0 + const started = yield* Deferred.make() + const finalizing = yield* Deferred.make() + const finishFinalizer = yield* Deferred.make() + const map = yield* ResourceMap.make((_key: string) => + Effect.suspend(() => { + if (++lookups > 1) return Effect.succeed(lookups) + return Deferred.succeed(started, void 0).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizing, void 0).pipe(Effect.andThen(Deferred.await(finishFinalizer))) + ) + ) + }) + ) + + const first = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Deferred.await(started) + const second = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Fiber.interrupt(first) + assert.isFalse(yield* Deferred.isDone(finalizing)) + + // A get made while the abandoned lookup finalizes starts a fresh one. + const interruptSecond = yield* Effect.forkChild(Fiber.interrupt(second), { startImmediately: true }) + yield* Deferred.await(finalizing) + const fresh = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Deferred.succeed(finishFinalizer, void 0) + yield* Fiber.join(interruptSecond) + assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2)) + }))) + + it.effect("an abandoned lookup does not remove a replacement entry", () => + Effect.scoped(Effect.gen(function*() { + let lookups = 0 + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const map = yield* ResourceMap.make((_key: string) => + Effect.suspend(() => { + const lookup = ++lookups + if (lookup === 1) return Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never)) + return Deferred.await(release).pipe(Effect.as(lookup)) + }) + ) + + const abandoned = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Deferred.await(started) + yield* map.remove("key") + const replacement = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Fiber.interrupt(abandoned) + const joiner = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + + yield* Deferred.succeed(release, void 0) + assert.deepStrictEqual(yield* Fiber.await(replacement), Exit.succeed(2)) + assert.deepStrictEqual(yield* Fiber.await(joiner), Exit.succeed(2)) + }))) + + it.effect("closing the map interrupts a pending lookup and closes its scope", () => + Effect.gen(function*() { + let finalized = 0 + const started = yield* Deferred.make() + const mapScope = yield* Scope.make() + const map = yield* ResourceMap.make((_key: string) => + Effect.gen(function*() { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + finalized++ + }) + ) + yield* Deferred.succeed(started, void 0) + return yield* Effect.never + }) + ).pipe(Scope.provide(mapScope)) + + const getter = yield* Effect.forkChild(map.get("key"), { startImmediately: true }) + yield* Deferred.await(started) + yield* Scope.close(mapScope, Exit.void) + + assert.strictEqual(finalized, 1) + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(getter))) + })) }) diff --git a/repos/effect/packages/effect/test/cluster/ResourceRef.test.ts b/repos/effect/packages/effect/test/cluster/ResourceRef.test.ts index 4e35d6e3a..3d0cee4b1 100644 --- a/repos/effect/packages/effect/test/cluster/ResourceRef.test.ts +++ b/repos/effect/packages/effect/test/cluster/ResourceRef.test.ts @@ -1,87 +1,178 @@ import { assert, it } from "@effect/vitest" -import { Deferred, Effect, Exit, Fiber, Option, Scope } from "effect" +import { Deferred, Effect, Exit, Fiber, Scope } from "effect" import { EntityAddress, EntityId, EntityType, ShardId } from "effect/cluster" import { isActive } from "effect/cluster/internal/interruptors" import { ResourceRef } from "effect/cluster/internal/resourceRef" -it.live("does not wedge await after a failed rebuild", () => - Effect.scoped(Effect.gen(function*() { - const parentScope = yield* Effect.scope - let fail = false - let releases = 0 - const ref = yield* ResourceRef.from(parentScope, (scope) => - Scope.addFinalizer( +// A gate signals `reached` and then waits for `open`. +const makeGate = Effect.fnUntraced(function*() { + const gate = { reached: yield* Deferred.make(), open: yield* Deferred.make() } + return gate +}) +type Gate = Effect.Success> +const pass = (gate: Gate) => Effect.andThen(Deferred.succeed(gate.reached, void 0), Deferred.await(gate.open)) + +// Acquires 1, 2, 3, ... An acquisition listed in `gated` waits at its gate +// before completing; the release of one listed in `gatedRelease` waits at its +// gate too. +const makeAcquire = Effect.fnUntraced(function*(options?: { + readonly gated?: ReadonlyArray + readonly gatedRelease?: ReadonlyArray +}) { + let acquisitions = 0 + let completed = 0 + let releases = 0 + const gates = new Map() + const releaseGates = new Map() + for (const acquisition of options?.gated ?? []) gates.set(acquisition, yield* makeGate()) + for (const acquisition of options?.gatedRelease ?? []) releaseGates.set(acquisition, yield* makeGate()) + const acquire = (scope: Scope.Scope) => + Effect.gen(function*() { + const acquisition = ++acquisitions + const releaseGate = releaseGates.get(acquisition) + yield* Scope.addFinalizer( scope, - Effect.sync(() => releases++) - ).pipe( - Effect.andThen(fail ? Effect.fail("failed") : Effect.succeed(1)) - )) - fail = true - assert.deepStrictEqual(yield* Effect.exit(ref.rebuildUnsafe()), Exit.fail("failed")) - assert.strictEqual(releases, 2) - const completed = yield* Effect.exit(ref.await).pipe(Effect.timeoutOption(10)) - assert.deepStrictEqual(completed, Option.some(Exit.fail("failed"))) - fail = false - yield* ref.rebuildUnsafe() - assert.strictEqual(yield* ref.await, 1) - }))) + Effect.andThen(releaseGate ? pass(releaseGate) : Effect.void, Effect.sync(() => releases++)) + ) + const gate = gates.get(acquisition) + if (gate) yield* pass(gate) + completed++ + return acquisition + }) + return { + acquire, + gate: (acquisition: number) => gates.get(acquisition)!, + releaseGate: (acquisition: number) => releaseGates.get(acquisition)!, + acquisitions: () => acquisitions, + /** Acquisitions that made it past their gate. */ + completed: () => completed, + releases: () => releases + } as const +}) + +// Lets forked fibers run until they block. +const settle = Effect.gen(function*() { + for (let i = 0; i < 10; i++) yield* Effect.yieldNow +}) + +it.effect("rebuilds only the current generation", () => + Effect.gen(function*() { + const parentScope = yield* Scope.make() + const { acquire } = yield* makeAcquire() + const ref = yield* ResourceRef.from(parentScope, acquire) + assert.strictEqual(ref.getUnsafe(), 1) + + yield* ref.rebuildUnsafe({ from: 1 })! + assert.strictEqual(ref.getUnsafe(), 2) + // A replaced value can never replace its successor. + assert.isUndefined(ref.rebuildUnsafe({ from: 1 })) + + // Without `from`, an idle ref is rebuilt. + yield* ref.rebuildUnsafe()! + assert.strictEqual(ref.getUnsafe(), 3) + assert.isUndefined(ref.rebuildUnsafe({ from: 2 })) -it.effect("does not let a stale rebuild overwrite a newer acquisition", () => + yield* Scope.close(parentScope, Exit.void) + assert.isUndefined(ref.getUnsafe()) + assert.isUndefined(ref.rebuildUnsafe({ from: 3 })) + assert.isUndefined(ref.rebuildUnsafe()) + assert.isTrue(Exit.hasInterrupts(yield* Effect.exit(ref.await))) + })) + +it.effect("refuses a second rebuild of a generation without disturbing the accepted one", () => Effect.scoped(Effect.gen(function*() { const parentScope = yield* Effect.scope - const releasing = yield* Deferred.make() - const release = yield* Deferred.make() - const newerAcquiring = yield* Deferred.make() - const newerAcquire = yield* Deferred.make() - let acquisitions = 0 - const ref = yield* ResourceRef.from(parentScope, (scope) => - Effect.gen(function*() { - const acquisition = ++acquisitions - if (acquisition === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(releasing, void 0).pipe(Effect.andThen(Deferred.await(release))) - ) - } else if (acquisition === 2) { - yield* Deferred.succeed(newerAcquiring, void 0) - yield* Deferred.await(newerAcquire) - } else { - return yield* Effect.fail("stale") - } - return acquisition - })) - - const staleRebuild = yield* Effect.forkChild(ref.rebuildUnsafe()) - yield* Deferred.await(releasing) - const newerRebuild = yield* Effect.forkChild(ref.rebuildUnsafe()) - yield* Deferred.await(newerAcquiring) - - yield* Deferred.succeed(release, void 0) - assert.deepStrictEqual(yield* Fiber.await(staleRebuild), Exit.fail("stale")) - assert.strictEqual(ref.state.current._tag, "Acquiring") - assert.isFalse(ref.latch.isOpen()) - - yield* Deferred.succeed(newerAcquire, void 0) - yield* Fiber.join(newerRebuild) + const { acquire, gate } = yield* makeAcquire({ gated: [2] }) + const ref = yield* ResourceRef.from(parentScope, acquire) + + const accepted = ref.rebuildUnsafe({ from: 1 }) + assert.isDefined(accepted) + // Admission closes as soon as the rebuild is accepted, before it runs. + assert.isUndefined(ref.getUnsafe()) + assert.isUndefined(ref.rebuildUnsafe({ from: 1 })) + assert.isUndefined(ref.rebuildUnsafe()) + + const rebuild = yield* Effect.forkChild(accepted!) + yield* Deferred.await(gate(2).reached) + assert.isUndefined(ref.rebuildUnsafe({ from: 1 })) + assert.isUndefined(ref.rebuildUnsafe()) + + yield* Deferred.succeed(gate(2).open, void 0) + assert.deepStrictEqual(yield* Fiber.await(rebuild), Exit.void) + assert.strictEqual(ref.getUnsafe(), 2) assert.strictEqual(yield* ref.await, 2) }))) -it.effect("does not leak teardown membership when a rebuild is discarded", () => +it.effect("interrupts prepare when its generation is replaced", () => Effect.scoped(Effect.gen(function*() { const parentScope = yield* Effect.scope + const { acquire } = yield* makeAcquire() + const ref = yield* ResourceRef.from(parentScope, acquire) + let newer: Effect.Effect | undefined + let finishedPreparing = false + + const superseded = yield* Effect.forkChild( + ref.rebuildUnsafe({ + from: 1, + // Replaces the value being prepared, as a replayed request that defects does. + prepare: (value) => + Effect.gen(function*() { + newer = ref.rebuildUnsafe({ from: value }) + yield* Effect.yieldNow + finishedPreparing = true + }) + })! + ) + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(superseded))) + assert.isFalse(finishedPreparing, "prepare must be interrupted") + assert.isDefined(newer) + + // The superseded value is never admitted. + const waiter = yield* Effect.forkChild(ref.await) + yield* Effect.yieldNow + assert.isUndefined(ref.getUnsafe()) + assert.isUndefined(waiter.pollUnsafe()) + + yield* newer! + assert.strictEqual(yield* Fiber.join(waiter), 3) + assert.strictEqual(ref.getUnsafe(), 3) + }))) + +it.effect("closes a replaced generation whose rebuild never ran when the ref closes", () => + Effect.gen(function*() { + const parentScope = yield* Scope.make() + const { acquire, acquisitions, releases } = yield* makeAcquire() + const ref = yield* ResourceRef.from(parentScope, acquire) + + const rebuild = ref.rebuildUnsafe({ from: 1 }) + assert.isDefined(rebuild) + const waiter = yield* Effect.forkChild(ref.await) + yield* Effect.yieldNow + + yield* Scope.close(parentScope, Exit.void) + assert.strictEqual(releases(), 1, "the replaced generation must be closed") + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(waiter))) + + // Running the abandoned rebuild afterwards acquires nothing. + assert.isTrue(Exit.hasInterrupts(yield* Effect.exit(rebuild!))) + assert.strictEqual(acquisitions(), 1) + })) + +it.effect("does not leak teardown membership when a rebuild is discarded", () => + Effect.gen(function*() { + const parentScope = yield* Scope.make() const address = EntityAddress.make({ shardId: ShardId.make("resource-ref-discard", 1), entityType: EntityType.make("ResourceRefDiscard"), entityId: EntityId.make("1") }) - const ref = yield* ResourceRef.from(parentScope, () => Effect.succeed(1), address) - const discarded = ref.rebuildUnsafe() - assert.isDefined(discarded) + const { acquire } = yield* makeAcquire() + const ref = yield* ResourceRef.from(parentScope, acquire, address) + assert.isDefined(ref.rebuildUnsafe({ from: 1 })) assert.isFalse(isActive(address)) - yield* ref.rebuildUnsafe() + yield* Scope.close(parentScope, Exit.void) assert.isFalse(isActive(address)) - assert.strictEqual(yield* ref.await, 1) - }))) + })) it.effect("does not leak teardown membership when a rebuild is interrupted", () => Effect.scoped(Effect.gen(function*() { @@ -91,17 +182,48 @@ it.effect("does not leak teardown membership when a rebuild is interrupted", () entityType: EntityType.make("ResourceRefInterrupt"), entityId: EntityId.make("1") }) - const release = yield* Deferred.make() - const ref = yield* ResourceRef.from( - parentScope, - (scope) => - Scope.addFinalizer(scope, Deferred.await(release)).pipe( - Effect.andThen(Effect.succeed(1)) - ), - address - ) - const fiber = yield* Effect.forkChild(ref.rebuildUnsafe()) + const { acquire, releaseGate } = yield* makeAcquire({ gatedRelease: [1] }) + const ref = yield* ResourceRef.from(parentScope, acquire, address) + const fiber = yield* Effect.forkChild(ref.rebuildUnsafe({ from: 1 })!) yield* Fiber.interrupt(fiber) - yield* Deferred.succeed(release, void 0) + yield* Deferred.succeed(releaseGate(1).open, void 0) assert.isFalse(isActive(address)) }))) + +it.effect("stays rebuildable when a failed replacement cannot be released", () => + Effect.scoped(Effect.gen(function*() { + const parentScope = yield* Effect.scope + // Acquisition 2 holds something whose release hangs, then never completes. + const { acquire, gate, releaseGate } = yield* makeAcquire({ gated: [2], gatedRelease: [2] }) + const ref = yield* ResourceRef.from(parentScope, acquire) + + yield* Effect.gen(function*() { + const rebuild = yield* Effect.forkChild(ref.rebuildUnsafe({ from: 1 })!) + yield* Deferred.await(gate(2).reached) + // A deadline abandons the rebuild, whose replacement then cannot be released. + rebuild.interruptUnsafe() + yield* settle + assert.isDefined(ref.rebuildUnsafe(), "a hung release must not block the next rebuild") + }).pipe(Effect.ensuring(Deferred.succeed(releaseGate(2).open, void 0))) + }))) + +it.effect("interrupts a rebuild that is acquiring when the ref closes", () => + Effect.gen(function*() { + const parentScope = yield* Scope.make() + const { acquire, completed, gate } = yield* makeAcquire({ gated: [2] }) + const ref = yield* ResourceRef.from(parentScope, acquire) + + const rebuild = yield* Effect.forkChild(ref.rebuildUnsafe({ from: 1 })!) + yield* Deferred.await(gate(2).reached) + const waiter = yield* Effect.forkChild(ref.await) + yield* Scope.close(parentScope, Exit.void) + yield* settle + const exit = rebuild.pollUnsafe() + assert.isDefined(exit, "closing the ref must interrupt acquisition") + assert.isTrue(Exit.hasInterrupts(exit)) + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(waiter))) + + yield* Deferred.succeed(gate(2).open, void 0) + yield* settle + assert.strictEqual(completed(), 1, "acquisition must not continue after the close") + })) diff --git a/repos/effect/packages/effect/test/cluster/Sharding.test.ts b/repos/effect/packages/effect/test/cluster/Sharding.test.ts index 3bf19c25f..c2f63a21b 100644 --- a/repos/effect/packages/effect/test/cluster/Sharding.test.ts +++ b/repos/effect/packages/effect/test/cluster/Sharding.test.ts @@ -14,6 +14,7 @@ import { MutableRef, Option, Queue, + Ref, Result, Schedule, Schema, @@ -41,6 +42,8 @@ import { ShardingConfig, Snowflake } from "effect/cluster" +import * as EntityManager from "effect/cluster/internal/entityManager" +import { EntityReaper } from "effect/cluster/internal/entityReaper" import * as ActiveTeardown from "effect/cluster/internal/interruptors" import { Headers } from "effect/http" import { Rpc, type RpcGroup } from "effect/rpc" @@ -405,7 +408,7 @@ describe.concurrent("Sharding", () => { 1, "reset request was claimed but not redelivered before claim expiry" ) - }).pipe(Effect.provide(CappedSharding({}, (storage) => ({ + }).pipe(Effect.provide(CappedSharding({ refreshAssignmentsInterval: 1000 }, (storage) => ({ ...storage, unprocessedMessages: (shardIds, options) => Effect.gen(function*() { @@ -1710,6 +1713,216 @@ describe.concurrent("Sharding", () => { })) }) +const DefectRecoveryRun = Rpc.make("run", { + payload: { id: Schema.String }, + success: Schema.String +}) + +const DefectRecoveryEntity = Entity.make("DefectRecovery", [DefectRecoveryRun.annotate(ClusterSchema.Persisted, true)]) + +const DefectRecoverySharding = (entityLayer: Layer.Layer) => + entityLayer.pipe(Layer.provideMerge(UnregisteredSharding({}))) + +// Counts handler builds. Building the `blocked` generation waits until +// `acquired` completes, holding the entity in replacement acquisition. +const makeGenerations = Effect.fnUntraced(function*(blocked?: number) { + const count = yield* Ref.make(0) + const acquiring = yield* Deferred.make() + const acquired = yield* Deferred.make() + const next = Effect.gen(function*() { + const generation = yield* Ref.updateAndGet(count, (n) => n + 1) + if (generation === blocked) { + yield* Deferred.succeed(acquiring, undefined) + yield* Deferred.await(acquired) + } + return generation + }) + return { count, acquiring, acquired, next } as const +}) + +// Drives an EntityManager directly: its first request defects, and the +// replacement handlers stay in acquisition until `generations.acquired`. +const startBlockedRebuild = Effect.fnUntraced(function*(entityId: string) { + const generations = yield* makeGenerations(2) + const attempts = yield* Ref.make(0) + const run = Rpc.make("run") + const entity = Entity.make("DefectRecoveryShutdown", [run]) + const sharding = yield* Sharding.Sharding + const manager = yield* EntityManager.make( + entity, + Effect.as( + generations.next, + entity.of({ + run: () => Ref.update(attempts, (n) => n + 1).pipe(Effect.andThen(Effect.die("restart"))) + }) + ), + { + sharding, + storage: MessageStorage.noop, + runnerAddress: RunnerAddress.make("localhost", 1234), + residency: { admitUnsafe: () => true, releaseUnsafe: () => {} }, + maxIdleTime: Infinity, + defectRetryPolicy: Schedule.spaced(1) + } + ) + const id = EntityId.make(entityId) + const shardId = sharding.getShardId(id, "default") + const address = EntityAddress.make({ shardId, entityType: EntityType.make(entity.type), entityId: id }) + const send = Effect.gen(function*() { + return yield* manager.sendLocal( + new Message.IncomingRequestLocal({ + envelope: Envelope.makeRequest({ + requestId: yield* sharding.getSnowflake, + address, + tag: "run", + payload: undefined, + headers: Headers.empty + }), + lastSentReply: Option.none(), + annotations: Context.empty(), + respond: () => Effect.void + }) + ) + }) + yield* TestClock.adjust(1) + yield* send + yield* TestClock.adjust(10) + yield* Deferred.await(generations.acquiring) + return { manager, shardId, send, attempts, acquired: generations.acquired } as const +}) + +describe.concurrent("Sharding defect recovery", () => { + it.effect("restarts again when a replayed request defects synchronously", () => + Effect.gen(function*() { + const entity = DefectRecoveryEntity + const generations = yield* makeGenerations() + const attempts = yield* Ref.make(0) + const entityLayer = entity.toLayer(Effect.as( + generations.next, + entity.of({ + run: Effect.fnUntraced(function*({ payload }) { + if ((yield* Ref.updateAndGet(attempts, (n) => n + 1)) <= 3) { + return yield* Effect.die("repeated defect") + } + return payload.id + }) + }) + )) + + yield* Effect.gen(function*() { + const client = (yield* entity.client)("repeated") + const work = yield* client.run({ id: "request" }).pipe(Effect.forkChild) + yield* TestClock.adjust("30 seconds") + assert.strictEqual(yield* Ref.get(attempts), 4) + assert.strictEqual(yield* Ref.get(generations.count), 4) + assert.strictEqual(yield* Fiber.join(work), "request") + }).pipe(Effect.provide(DefectRecoverySharding(entityLayer))) + })) + + it.effect("replays unfinished requests before arrivals during acquisition", () => + Effect.gen(function*() { + const entity = DefectRecoveryEntity + const generations = yield* makeGenerations(2) + const fail = yield* Deferred.make() + const bothStarted = yield* Deferred.make() + const started = yield* Ref.make(0) + const calls = yield* Ref.make>([]) + const completed = yield* Ref.make>([]) + const thirdRequestId = yield* Deferred.make() + const entityLayer = entity.toLayer( + Effect.map(generations.next, (generation) => + entity.of({ + run: Effect.fnUntraced(function*({ payload, requestId }) { + if (payload.id === "third") { + yield* Deferred.succeed(thirdRequestId, requestId) + } + yield* Ref.update(calls, (calls) => [...calls, [generation, payload.id] as const]) + if (generation === 1) { + if ((yield* Ref.updateAndGet(started, (n) => n + 1)) === 2) { + yield* Deferred.succeed(bothStarted, undefined) + } + yield* Deferred.await(fail) + return yield* Effect.die("initial defect") + } + if (generation === 2 && payload.id === "first") { + return yield* Effect.die("replay defect") + } + yield* Ref.update(completed, (ids) => [...ids, payload.id]) + return payload.id + }) + })), + { concurrency: "unbounded" } + ) + + yield* Effect.gen(function*() { + const client = (yield* entity.client)("arrivals") + const first = yield* client.run({ id: "first" }).pipe(Effect.forkChild) + const second = yield* client.run({ id: "second" }).pipe(Effect.forkChild) + yield* Deferred.await(bothStarted) + yield* Deferred.succeed(fail, undefined) + yield* TestClock.adjust("10 seconds") + yield* Deferred.await(generations.acquiring) + const third = yield* client.run({ id: "third" }).pipe(Effect.forkChild) + yield* TestClock.adjust(1) + yield* Deferred.succeed(generations.acquired, undefined) + yield* TestClock.adjust("30 seconds") + assert.strictEqual(yield* Fiber.join(first), "first") + assert.strictEqual(yield* Fiber.join(second), "second") + assert.strictEqual(yield* Fiber.join(third), "third") + const recorded = yield* Ref.get(calls) + assert.deepStrictEqual( + Array.findFirst(recorded, ([generation]) => generation === 2), + Option.some([2, "first"] as const) + ) + // A request still waiting for its first dispatch is not replayed as well + assert.strictEqual(Array.filter(recorded, ([, id]) => id === "third").length, 1) + const requestId = yield* Deferred.await(thirdRequestId) + const driver = yield* MessageStorage.MemoryDriver + const replies = yield* driver.encoded.repliesFor([String(requestId)]).pipe(Effect.orDie) + assert.isTrue(Array.some( + replies, + (reply) => reply._tag === "WithExit" && reply.exit._tag === "Success" && reply.exit.value === "third" + )) + assert.sameMembers(yield* Ref.get(completed), ["first", "second", "third"]) + assert.strictEqual(yield* Ref.get(generations.count), 3) + }).pipe(Effect.provide(DefectRecoverySharding(entityLayer))) + })) + + it.effect("finishes shutdown when replacement acquisition completes", () => + Effect.gen(function*() { + const { acquired, attempts, manager, shardId } = yield* startBlockedRebuild("shutdown") + const shutdown = yield* manager.interruptShard(shardId).pipe(Effect.forkChild) + yield* TestClock.adjust(1) + assert.strictEqual(yield* manager.activeEntityCount, 0) + yield* Deferred.succeed(acquired, undefined) + yield* TestClock.adjust(1) + const completed = shutdown.pollUnsafe() + // Let a broken implementation's termination timeout finish before asserting. + yield* TestClock.adjust(1000) + assert.deepStrictEqual(completed, Exit.void) + assert.strictEqual(yield* Ref.get(attempts), 1, "shutdown must not replay application requests") + }).pipe(Effect.provide(BlockedRebuildSharding))) + + it.effect("interrupts a queued arrival when shutdown starts during replacement acquisition", () => + Effect.gen(function*() { + const { acquired, manager, send, shardId } = yield* startBlockedRebuild("shutdown-queued") + // Arrives while the replacement handlers are still being built, so it + // queues behind the replay of unfinished requests. + const queued = yield* send.pipe(Effect.forkChild) + yield* TestClock.adjust(1) + assert.strictEqual(queued.pollUnsafe(), undefined) + yield* manager.interruptShard(shardId).pipe(Effect.forkChild) + yield* TestClock.adjust(1) + assert.strictEqual(yield* manager.activeEntityCount, 0) + // The queued request must be refused as soon as the activation is retired, + // without waiting for the replacement handlers. + const exit = queued.pollUnsafe() + assert.isDefined(exit, "queued arrival must not wait for replacement acquisition") + assert.isTrue(Exit.hasInterrupts(exit), "queued arrival must be interrupted") + yield* Deferred.succeed(acquired, undefined) + }).pipe(Effect.provide(BlockedRebuildSharding))) +}) + const ActiveTeardownCaller = Entity.make("ActiveTeardownCaller", [ Rpc.make("Arm").annotate(ClusterSchema.Persisted, false) ]) @@ -2124,7 +2337,7 @@ describe.concurrent("Sharding residency cap", () => { }).pipe(Effect.provide(CappedSharding({ maxResidentEntities: "unbounded" })))) }) -describe("Sharding shard lock failover", () => { +describe("Sharding shard lock failover", { concurrent: false }, () => { it.effect("interrupts entities and reacquires shards after lock storage recovers", () => Effect.gen(function*() { const warnings: Array = [] @@ -2220,6 +2433,215 @@ describe("Sharding shard lock failover", () => { }).pipe(Effect.provide(layer), Effect.withLogger(logger), Effect.scoped) })) + it.effect("keeps shards acquired while an earlier lock refresh is in flight", () => + Effect.gen(function*() { + const storageState = makeFailoverStorageState() + const acquireStarted = yield* Deferred.make() + const refreshStarted = yield* Deferred.make() + const acquireDone = yield* Deferred.make() + const runnerStorage = Layer.effect( + RunnerStorage.RunnerStorage, + Effect.map(Clock.Clock, (clock) => { + const storage = makeFailoverStorage(storageState, clock) + return RunnerStorage.RunnerStorage.of({ + ...storage, + acquire: Effect.fnUntraced(function*(address, shardIds) { + if (yield* Deferred.isDone(acquireDone)) { + return yield* storage.acquire(address, shardIds) + } + yield* Deferred.succeed(acquireStarted, void 0) + yield* Deferred.await(refreshStarted) + const acquired = yield* storage.acquire(address, shardIds) + yield* Deferred.succeed(acquireDone, void 0) + return acquired + }), + refresh: Effect.fnUntraced(function*(address, shardIds) { + const shards = globalThis.Array.from(shardIds) + if ((yield* Deferred.isDone(acquireStarted)) && !(yield* Deferred.isDone(acquireDone))) { + assert.deepStrictEqual(shards, []) + yield* Deferred.succeed(refreshStarted, void 0) + yield* Deferred.await(acquireDone) + } + return yield* storage.refresh(address, shards) + }) + }) + }) + ) + const config = ShardingConfig.layer({ + runnerAddress: Option.some(RunnerAddress.make("localhost", 1234)), + shardsPerGroup: 1, + shardLockExpiration: 3000, + shardLockRefreshInterval: 100, + entityTerminationTimeout: 0, + refreshAssignmentsInterval: 10 + }) + const layer = Sharding.layer.pipe( + Layer.provide(runnerStorage), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(Runners.layerNoop), + Layer.provide([MessageStorage.layerMemory, Snowflake.layerGenerator]), + Layer.provide(config) + ) + + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const shardId = ShardId.make("default", 1) + + yield* TestClock.adjust(100) + assert.isTrue(yield* Deferred.isDone(acquireDone)) + assert.isTrue(sharding.hasShardId(shardId)) + + yield* TestClock.adjust(500) + + assert.isTrue(sharding.hasShardId(shardId), "shard acquired during the refresh was dropped") + assert.deepStrictEqual(storageState.releaseCalls, []) + assert.strictEqual(storageState.acquireCalls.length, 1) + }).pipe(Effect.provide(layer)) + })) + + it.effect("releases a lost requested shard without dropping a shard acquired during refresh", () => + Effect.gen(function*() { + const storageState = makeFailoverStorageState() + const firstShard = ShardId.make("default", 1) + const secondShard = ShardId.make("default", 2) + const refreshStarted = yield* Deferred.make() + const refreshResponse = yield* Deferred.make() + let acquireCount = 0 + let lostShard = false + const runnerStorage = Layer.effect( + RunnerStorage.RunnerStorage, + Effect.map(Clock.Clock, (clock) => { + const storage = makeFailoverStorage(storageState, clock) + return RunnerStorage.RunnerStorage.of({ + ...storage, + acquire: Effect.fnUntraced(function*(address, shardIds) { + acquireCount++ + if (acquireCount === 1) { + return yield* storage.acquire(address, [firstShard]) + } + if (acquireCount === 2) { + yield* Deferred.await(refreshStarted) + } + return yield* storage.acquire( + address, + globalThis.Array.from(shardIds).filter((shard) => shard.id !== firstShard.id) + ) + }), + refresh: Effect.fnUntraced(function*(address, shardIds) { + const shards = globalThis.Array.from(shardIds) + if (!lostShard && shards.length > 0) { + lostShard = true + assert.deepStrictEqual(shards, [firstShard]) + yield* Deferred.succeed(refreshStarted, void 0) + yield* Deferred.await(refreshResponse) + return [] + } + return yield* storage.refresh(address, shards) + }) + }) + }) + ) + const layer = Sharding.layer.pipe( + Layer.provide(runnerStorage), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(Runners.layerNoop), + Layer.provide([MessageStorage.layerMemory, Snowflake.layerGenerator]), + Layer.provide(ShardingConfig.layer({ + runnerAddress: Option.some(RunnerAddress.make("localhost", 1234)), + shardsPerGroup: 2, + shardLockExpiration: 30_000, + shardLockRefreshInterval: 2000, + entityTerminationTimeout: 0, + refreshAssignmentsInterval: 10 + })) + ) + + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + + yield* TestClock.adjust(2000) + assert.isTrue(yield* Deferred.isDone(refreshStarted)) + assert.isTrue(sharding.hasShardId(firstShard)) + assert.isTrue(sharding.hasShardId(secondShard)) + + yield* Deferred.succeed(refreshResponse, void 0) + yield* TestClock.adjust(1) + + assert.isFalse(sharding.hasShardId(firstShard)) + assert.isTrue(sharding.hasShardId(secondShard)) + + yield* TestClock.adjust(1000) + + assert.isFalse(sharding.hasShardId(firstShard)) + assert.isTrue(sharding.hasShardId(secondShard)) + assert.deepStrictEqual(storageState.releaseCalls, [firstShard]) + }).pipe(Effect.provide(layer)) + })) + + it.effect("refreshes a newly acquired shard when retrying a failed lock refresh", () => + Effect.gen(function*() { + const storageState = makeFailoverStorageState() + const acquireStarted = yield* Deferred.make() + const refreshFailed = yield* Deferred.make() + const retryShards = yield* Deferred.make>() + const runnerStorage = Layer.effect( + RunnerStorage.RunnerStorage, + Effect.map(Clock.Clock, (clock) => { + const storage = makeFailoverStorage(storageState, clock) + return RunnerStorage.RunnerStorage.of({ + ...storage, + acquire: Effect.fnUntraced(function*(address, shardIds) { + yield* Deferred.succeed(acquireStarted, void 0) + yield* Deferred.await(refreshFailed) + return yield* storage.acquire(address, shardIds) + }), + refresh: Effect.fnUntraced(function*(address, shardIds) { + const shards = globalThis.Array.from(shardIds) + if (yield* Deferred.isDone(refreshFailed)) { + yield* Deferred.succeed(retryShards, shards) + } else if (yield* Deferred.isDone(acquireStarted)) { + assert.deepStrictEqual(shards, []) + yield* Deferred.succeed(refreshFailed, void 0) + return yield* Effect.fail(new ClusterError.PersistenceError({ cause: "refresh failed" })) + } + return yield* storage.refresh(address, shards) + }) + }) + }) + ) + const layer = Sharding.layer.pipe( + Layer.provide(runnerStorage), + Layer.provide(RunnerHealth.layerNoop), + Layer.provide(Runners.layerNoop), + Layer.provide([MessageStorage.layerMemory, Snowflake.layerGenerator]), + Layer.provide(ShardingConfig.layer({ + runnerAddress: Option.some(RunnerAddress.make("localhost", 1234)), + shardsPerGroup: 1, + shardLockExpiration: 30_000, + shardLockRefreshInterval: 1000, + entityTerminationTimeout: 0, + refreshAssignmentsInterval: 10 + })) + ) + + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const shardId = ShardId.make("default", 1) + + yield* TestClock.adjust(1000) + assert.isTrue(yield* Deferred.isDone(refreshFailed)) + assert.isTrue(sharding.hasShardId(shardId)) + assert.isFalse(yield* Deferred.isDone(retryShards)) + + yield* TestClock.adjust(50) + + assert.isTrue(yield* Deferred.isDone(retryShards)) + assert.deepStrictEqual(yield* Deferred.await(retryShards), [shardId]) + assert.isTrue(sharding.hasShardId(shardId)) + assert.deepStrictEqual(storageState.releaseCalls, []) + }).pipe(Effect.provide(layer)) + })) + it.effect("reacquires shards when the liveness probe succeeds while lock refresh is hung", () => Effect.gen(function*() { const storageState = makeFailoverStorageState() @@ -2419,6 +2841,72 @@ describe("Sharding shard lock failover", () => { ) })) + it.effect("does not register an entity built after shard release", () => + Effect.gen(function*() { + const storageState = makeFailoverStorageState() + const runnerStorage = Layer.effect( + RunnerStorage.RunnerStorage, + Effect.map(Clock.Clock, (clock) => makeFailoverStorage(storageState, clock)) + ) + const config = ShardingConfig.layer({ + runnerAddress: Option.some(RunnerAddress.make("localhost", 1234)), + shardsPerGroup: 1, + shardLockExpiration: 3000, + shardLockRefreshInterval: 100, + entityTerminationTimeout: 0, + entityMessagePollInterval: 10, + refreshAssignmentsInterval: 10, + sendRetryInterval: 10 + }) + const layer = TestEntityNoState.pipe( + Layer.provideMerge(Sharding.layer), + Layer.provide(runnerStorage), + Layer.provide(RunnerHealth.layerNoop), + Layer.provideMerge(TestEntityState.layer), + Layer.provide(Runners.layerNoop), + Layer.provide([MessageStorage.layerMemory, Snowflake.layerGenerator]), + Layer.provide(config) + ) + + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const entityState = yield* TestEntityState + const makeClient = yield* TestEntity.client + const client = makeClient("1") + const shardId = sharding.getShardId(EntityId.make("1"), "default") + + while (!sharding.hasShardId(shardId)) { + yield* TestClock.adjust(10) + } + + yield* Effect.gen(function*() { + entityState.buildLatch.closeUnsafe() + const entityFiber = yield* client.GetUserVolatile({ id: 1 }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + while (entityState.layerBuilds.current === 0) { + yield* TestClock.adjust(1) + } + + storageState.assignSelf = false + while (storageState.releaseCalls.length === 0) { + yield* TestClock.adjust(10) + } + assert.isFalse(sharding.hasShardId(shardId)) + + entityState.buildLatch.openUnsafe() + yield* TestClock.adjust(10) + + assert.strictEqual(yield* sharding.activeEntityCount, 0) + assert.strictEqual(Queue.sizeUnsafe(entityState.envelopes), 0) + assert.isUndefined(entityFiber.pollUnsafe()) + }).pipe(Effect.ensuring(entityState.buildLatch.open)) + }).pipe( + Effect.provide(layer), + Effect.scoped + ) + })) + it.effect("does not acquire shards while a forced release is pending", () => Effect.gen(function*() { const shardsPerGroup = 4 @@ -2629,14 +3117,17 @@ const testConfigDefaults: Partial = { const TestShardingConfig = ShardingConfig.layer(testConfigDefaults) -const TestShardingWithoutState = TestEntityNoState.pipe( +const TestShardingWithoutRunnerStorage = TestEntityNoState.pipe( Layer.provideMerge(Sharding.layer), - Layer.provide(RunnerStorage.layerMemory), Layer.provide(RunnerHealth.layerNoop) // Layer.provide(Logger.minimumLogLevel(LogLevel.All)), // Layer.provideMerge(Logger.pretty) ) +const TestShardingWithoutState = TestShardingWithoutRunnerStorage.pipe( + Layer.provide(RunnerStorage.layerMemory) +) + const TestShardingWithoutRunners = TestShardingWithoutState.pipe( Layer.provideMerge(TestEntityState.layer) ) @@ -2684,6 +3175,13 @@ const UnregisteredSharding = ( ) } +// The directly driven EntityManager waits up to 1000ms for its entities to end. +const BlockedRebuildSharding = Layer.mergeAll( + EntityReaper.layer, + Snowflake.layerGenerator, + ShardingConfig.layer({ ...testConfigDefaults, entityTerminationTimeout: 1000 }) +).pipe(Layer.provideMerge(UnregisteredSharding({}))) + const ContextBleedSharding = ContextBleedLayer.pipe(Layer.provideMerge(TestSharding)) const ActiveTeardownSharding = ( @@ -2741,3 +3239,198 @@ const saveGetUserRequest = Effect.fnUntraced(function*(entityId: string, id: num }) ) }) + +interface SingletonStorageState { + assignSelf: boolean + runner: Runner.Runner | undefined +} + +const makeSingletonStorageState = (): SingletonStorageState => ({ assignSelf: true, runner: undefined }) + +const singletonOtherRunner = Runner.make({ + address: RunnerAddress.make("localhost", 5678), + groups: ["singleton"], + // With these fixed addresses, the weighted ring moves singleton:1 to this + // runner. The ownership assertions below guard that fixture assumption. + weight: 1000 +}) + +// Keep the destination shard local while only the singleton shard moves away. +const SingletonReassignmentSharding = (state: SingletonStorageState) => { + const runnerStorage = Layer.effect( + RunnerStorage.RunnerStorage, + Effect.succeed(RunnerStorage.RunnerStorage.of({ + getRunners: Effect.sync(() => { + if (!state.runner) return [] + if (state.assignSelf) return [[state.runner, true]] as const + return [ + [state.runner, true], + [singletonOtherRunner, true] + ] as const + }), + register: (runner) => + Effect.sync(() => { + state.runner = runner + return MachineId.make(1) + }), + unregister: () => Effect.void, + setRunnerHealth: () => Effect.void, + acquire: (_address, shards) => Effect.succeed(globalThis.Array.from(shards)), + refresh: (_address, shards) => Effect.succeed(globalThis.Array.from(shards)), + release: () => Effect.void, + releaseAll: () => Effect.void + })) + ) + return TestShardingWithoutRunnerStorage.pipe( + Layer.provide(runnerStorage), + Layer.provideMerge(TestEntityState.layer), + Layer.provide(Runners.layerNoop), + Layer.provideMerge(MessageStorage.layerMemory), + Layer.provide(ShardingConfig.layer({ + ...testConfigDefaults, + runnerAddress: Option.some(RunnerAddress.make("localhost", 1234)), + availableShardGroups: ["default", "singleton"], + assignedShardGroups: ["default", "singleton"], + shardsPerGroup: 1, + entityTerminationTimeout: 0, + entityMessagePollInterval: 10, + refreshAssignmentsInterval: 10, + sendRetryInterval: 10 + })) + ) +} + +const singletonShard = ShardId.make("singleton", 1) +const destinationShard = ShardId.make("default", 1) + +const waitForSingletonOwnership = Effect.fnUntraced(function*( + sharding: Sharding.Sharding["Service"], + owned: boolean +) { + for (let i = 0; i < 100; i++) { + if (sharding.hasShardId(singletonShard) === owned) return + yield* TestClock.adjust(10) + } + assert.strictEqual(sharding.hasShardId(singletonShard), owned) +}) + +// These tests share the internal teardown registry, so do not run concurrently. +describe("Sharding singleton cancellation", { concurrent: false }, () => { + it.effect("cancels explicitly but abandons a singleton child RPC on reassignment", () => + Effect.gen(function*() { + const storageState = makeSingletonStorageState() + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const driver = yield* MessageStorage.MemoryDriver + const state = yield* TestEntityState + // Acquire clients outside the singleton context; children must use caller identity. + const makeClient = yield* TestEntity.client + const explicitClient = makeClient("explicit-singleton-target") + const reassignmentClient = makeClient("singleton-target") + const childReady = yield* Deferred.make>() + const runReassignment = yield* Deferred.make() + const stopped = yield* Deferred.make() + yield* waitForSingletonOwnership(sharding, true) + yield* sharding.registerSingleton( + "rpc-caller", + Effect.gen(function*() { + const child = yield* explicitClient.Never().pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.succeed(childReady, child) + yield* Deferred.await(runReassignment) + yield* reassignmentClient.Never().pipe(Effect.forkChild({ startImmediately: true })) + return yield* Effect.never + }).pipe(Effect.ensuring(Deferred.succeed(stopped, void 0))), + { shardGroup: "singleton" } + ) + yield* Queue.take(state.envelopes) + yield* Fiber.interrupt(yield* Deferred.await(childReady)) + yield* TestClock.adjust(1) + assert.isTrue(sharding.hasShardId(singletonShard)) + assert.strictEqual(journalInterrupts(driver), 1, "explicit child cancellation must persist") + yield* Queue.take(state.interrupts) + + yield* Deferred.succeed(runReassignment, void 0) + yield* Queue.take(state.envelopes) + storageState.assignSelf = false + yield* waitForSingletonOwnership(sharding, false) + yield* Deferred.await(stopped) + yield* TestClock.adjust(1) + assert.isTrue(sharding.hasShardId(destinationShard), "Sharding and the destination stay alive") + assert.strictEqual(journalInterrupts(driver), 1, "reassignment must not add a durable cancellation") + assert.strictEqual(Queue.sizeUnsafe(state.interrupts), 0) + }).pipe(Effect.provide(SingletonReassignmentSharding(storageState)), Effect.scoped) + })) + + it.effect("bounds singleton teardown to interruption finalization, not inherited context", () => + Effect.gen(function*() { + const storageState = makeSingletonStorageState() + yield* Effect.gen(function*() { + const sharding = yield* Sharding.Sharding + const driver = yield* MessageStorage.MemoryDriver + const state = yield* TestEntityState + const client = (yield* TestEntity.client)("finalizer-target") + // Both clients are acquired before singleton execution. Keep the survivor + // on a separate local entity so the preserved finalizer RPC cannot block it. + const survivorClient = (yield* TestEntity.client)("survivor-target") + const outerScope = yield* Effect.scope + const runLater = yield* Deferred.make() + const survivorReady = yield* Deferred.make>() + const finalizing = yield* Deferred.make() + const finish = yield* Deferred.make() + const stopped = yield* Deferred.make() + yield* waitForSingletonOwnership(sharding, true) + yield* sharding.registerSingleton( + "finalizer-caller", + Effect.gen(function*() { + // This fiber inherits singleton identity but outlives its local run. + const survivor = yield* Deferred.await(runLater).pipe( + Effect.andThen(survivorClient.Never()), + Effect.forkIn(outerScope, { startImmediately: true }) + ) + yield* Deferred.succeed(survivorReady, survivor) + return yield* Effect.never + }).pipe( + Effect.ensuring(Effect.gen(function*() { + // Cancellation during finalization is still shard teardown. + const child = yield* client.Never().pipe( + Effect.interruptible, + Effect.forkChild({ startImmediately: true }) + ) + yield* Queue.take(state.envelopes) + yield* Fiber.interrupt(child) + yield* Deferred.succeed(finalizing, void 0) + yield* Deferred.await(finish) + })), + Effect.ensuring(Deferred.succeed(stopped, void 0)) + ), + { shardGroup: "singleton" } + ) + const survivor = yield* Deferred.await(survivorReady) + // Release the finalizer barrier even if a setup assertion fails. + yield* Effect.addFinalizer(() => Deferred.succeed(finish, void 0)) + storageState.assignSelf = false + yield* waitForSingletonOwnership(sharding, false) + yield* Deferred.await(finalizing) + const duringFinalization = journalInterrupts(driver) + const probe = EntityAddress.make({ + shardId: singletonShard, + entityType: EntityType.make("singleton-teardown-probe"), + entityId: EntityId.make("probe") + }) + assert.isTrue(ActiveTeardown.isActive(probe), "tracking must remain active through finalization") + yield* Deferred.succeed(finish, void 0) + yield* Deferred.await(stopped) + yield* TestClock.adjust(10) + assert.isFalse(ActiveTeardown.isActive(probe), "teardown must release its shard tracking") + + // Do not let inherited identity suppress a later explicit cancellation. + yield* Deferred.succeed(runLater, void 0) + yield* Queue.take(state.envelopes) + yield* Fiber.interrupt(survivor) + yield* TestClock.adjust(1) + assert.strictEqual(journalInterrupts(driver) - duringFinalization, 1) + assert.isTrue(sharding.hasShardId(destinationShard)) + assert.strictEqual(duringFinalization, 0, "teardown must include singleton finalizers") + }).pipe(Effect.provide(SingletonReassignmentSharding(storageState)), Effect.scoped) + })) +}) diff --git a/repos/effect/packages/effect/test/http/HttpMiddleware.test.ts b/repos/effect/packages/effect/test/http/HttpMiddleware.test.ts index f9414eab6..bfe91c667 100644 --- a/repos/effect/packages/effect/test/http/HttpMiddleware.test.ts +++ b/repos/effect/packages/effect/test/http/HttpMiddleware.test.ts @@ -7,6 +7,7 @@ import * as Fiber from "effect/Fiber" import * as Headers from "effect/http/Headers" import * as HttpEffect from "effect/http/HttpEffect" import * as HttpMiddleware from "effect/http/HttpMiddleware" +import * as HttpServerError from "effect/http/HttpServerError" import * as HttpServerRequest from "effect/http/HttpServerRequest" import * as HttpServerResponse from "effect/http/HttpServerResponse" import * as Logger from "effect/Logger" @@ -116,6 +117,74 @@ describe("HttpMiddleware", () => { }) describe("logger", () => { + it.effect("preserves failures mixed with client aborts even with status 499", () => + Effect.gen(function*() { + const cause = Cause.combine( + Cause.annotate(Cause.interrupt(2), Context.make(HttpServerError.ClientAbort, true)), + Cause.fail("handler failed") + ) + const logs: Array<{ message: unknown; cause: Cause.Cause; status: unknown }> = [] + const logger = Logger.make((options) => { + logs.push({ + message: options.message, + cause: options.cause, + status: options.fiber.getRef(References.CurrentLogAnnotations)["http.status"] + }) + }) + // The server attaches the response to the failed handler cause. + const handlerCause = Cause.combine(cause, Cause.die(HttpServerResponse.empty({ status: 499 }))) + const exit = yield* HttpMiddleware.logger(Effect.failCause(handlerCause)).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost/slow")) + ), + Effect.provide(Logger.layer([logger])), + Effect.exit + ) + + assert.deepStrictEqual(exit, Exit.failCause(handlerCause)) + assert.deepStrictEqual(logs, [{ message: [], cause, status: 499 }]) + })) + + it.effect("logs client aborts as sent responses with status 499", () => + Effect.gen(function*() { + const logs: Array<{ + message: unknown + cause: Cause.Cause + level: string + annotations: Record + }> = [] + const logger = Logger.make((options) => { + logs.push({ + message: options.message, + cause: options.cause, + level: options.logLevel, + annotations: { ...options.fiber.getRef(References.CurrentLogAnnotations) } + }) + }) + const started = Promise.withResolvers() + const handler = HttpEffect.toWebHandler( + Effect.interruptible(Effect.andThen(Effect.sync(() => started.resolve()), Effect.never)), + (app) => HttpMiddleware.logger(app).pipe(Effect.provide(Logger.layer([logger]))) + ) + const controller = new AbortController() + const pending = handler(new Request("http://localhost/slow", { signal: controller.signal })) + yield* Effect.promise(() => started.promise) + controller.abort() + const response = yield* Effect.promise(() => pending) + + assert.strictEqual(response.status, 499) + assert.strictEqual(logs.length, 1) + assert.deepStrictEqual(logs[0].annotations, { + "http.method": "GET", + "http.url": "/slow", + "http.status": 499 + }) + assert.strictEqual(logs[0].level, "Info") + assert.deepStrictEqual(logs[0].message, ["Sent HTTP response"]) + assert.deepStrictEqual(logs[0].cause, Cause.empty) + })) + it.effect("annotates method, path, and status without query or hash", () => Effect.gen(function*() { const annotations: Array> = [] diff --git a/repos/effect/packages/effect/test/http/HttpTraceContext.test.ts b/repos/effect/packages/effect/test/http/HttpTraceContext.test.ts new file mode 100644 index 000000000..918a96a9e --- /dev/null +++ b/repos/effect/packages/effect/test/http/HttpTraceContext.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "@effect/vitest" +import { strictEqual } from "@effect/vitest/utils" +import { Option } from "effect" +import { Headers, HttpTraceContext } from "effect/http" + +const traceId = "80f198ee56343ba864fe8b2a57d3eff7" +const spanId = "e457b5a2e4d86bd1" + +const assertSampled = (headers: Record, sampled: boolean) => { + const span = Option.getOrThrow(HttpTraceContext.fromHeaders(Headers.fromInput(headers))) + strictEqual(span.traceId, traceId) + strictEqual(span.spanId, spanId) + strictEqual(span.sampled, sampled) +} + +describe("HttpTraceContext", () => { + describe("fromHeaders", () => { + it("accepts single-header B3 debug sampling", () => { + assertSampled({ b3: `${traceId}-${spanId}-d` }, true) + }) + + it("accepts legacy X-B3-Sampled: true", () => { + assertSampled({ "X-B3-TraceId": traceId, "X-B3-SpanId": spanId, "X-B3-Sampled": "true" }, true) + }) + + it("lets X-B3-Flags debug override X-B3-Sampled: 0", () => { + assertSampled({ + "X-B3-TraceId": traceId, + "X-B3-SpanId": spanId, + "X-B3-Sampled": "0", + "X-B3-Flags": "1" + }, true) + }) + + it("rejects an all-zero W3C trace-id", () => { + strictEqual( + Option.isNone(HttpTraceContext.fromHeaders(Headers.fromInput({ + traceparent: `00-${"0".repeat(32)}-${spanId}-01` + }))), + true + ) + }) + + it("rejects an all-zero W3C parent-id", () => { + strictEqual( + Option.isNone(HttpTraceContext.fromHeaders(Headers.fromInput({ + traceparent: `00-${traceId}-${"0".repeat(16)}-01` + }))), + true + ) + }) + }) +}) diff --git a/repos/effect/packages/effect/test/httpapi/HttpApiError.test.ts b/repos/effect/packages/effect/test/httpapi/HttpApiError.test.ts new file mode 100644 index 000000000..6517ec960 --- /dev/null +++ b/repos/effect/packages/effect/test/httpapi/HttpApiError.test.ts @@ -0,0 +1,29 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Effect, ErrorReporter, Exit, Schema } from "effect" +import { HttpApiError } from "effect/http-api" + +describe("HttpApiError", () => { + it.effect("does not report payload schema-validation failures", () => + Effect.gen(function*() { + const schemaError = yield* HttpApiError.HttpApiSchemaError.wrap( + "Payload", + Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String }))({ name: 123 }) + ).pipe(Effect.flip) + const reports: Array> = [] + const reporter = ErrorReporter.make(({ cause }) => reports.push(cause)) + + const exit = yield* Effect.fail(schemaError).pipe( + Effect.withErrorReporting, + Effect.provide(ErrorReporter.layer([reporter])), + Effect.exit + ) + + assert.instanceOf(schemaError, HttpApiError.HttpApiSchemaError) + assert.strictEqual(schemaError.kind, "Payload") + assert.isTrue(Exit.isFailure(exit)) + if (Exit.isFailure(exit)) { + assert.strictEqual(Cause.squash(exit.cause), schemaError) + } + assert.deepStrictEqual(reports, []) + })) +}) diff --git a/repos/effect/packages/effect/test/rpc/RpcClient.test.ts b/repos/effect/packages/effect/test/rpc/RpcClient.test.ts index fc33227a8..1ca9e1fa1 100644 --- a/repos/effect/packages/effect/test/rpc/RpcClient.test.ts +++ b/repos/effect/packages/effect/test/rpc/RpcClient.test.ts @@ -3,7 +3,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schedule, Schema, S import * as HttpClient from "effect/http/HttpClient" import * as HttpClientResponse from "effect/http/HttpClientResponse" import { Rpc, RpcClient, RpcGroup, RpcMessage, RpcSchema, RpcSerialization } from "effect/rpc" -import { RpcClientError } from "effect/rpc/RpcClientError" +import { RpcClientDefect, RpcClientError } from "effect/rpc/RpcClientError" import * as Socket from "effect/socket/Socket" import { TestClock } from "effect/testing" import * as Worker from "effect/workers/Worker" @@ -16,6 +16,37 @@ const TestGroup = RpcGroup.make( Rpc.make("Events", { success: RpcSchema.Stream(Schema.String, Schema.Never) }) ) +const ChunkGroup = RpcGroup.make( + Rpc.make("Bad", { success: RpcSchema.Stream(Schema.Number, Schema.Never) }), + Rpc.make("Good", { success: RpcSchema.Stream(Schema.String, Schema.Never) }), + Rpc.make("Unary", { success: Schema.String }) +) + +const makeChunkProtocol = (beforeInterrupt: Effect.Effect = Effect.void) => + Effect.gen(function*() { + const received = yield* Deferred.make[1]>() + const sent = yield* Queue.unbounded[1]>() + const protocol = RpcClient.Protocol.of({ + codecFor: RpcSerialization.json.codecFor, + supportsAck: false, + supportsTransferables: false, + run: (_clientId, handle) => Deferred.succeed(received, handle).pipe(Effect.andThen(Effect.never)), + send: (_clientId, message) => + Effect.andThen( + message._tag === "Interrupt" ? beforeInterrupt : Effect.void, + Effect.asVoid(Queue.offer(sent, message)) + ) + }) + const client = yield* RpcClient.make(ChunkGroup).pipe(Effect.provideService(RpcClient.Protocol, protocol)) + const handle = yield* Deferred.await(received) + return { client, handle, sent } + }) + +const takeRequestId = (message: Parameters[1]) => { + assert(message._tag === "Request") + return message.id +} + const makeHttpClient = (body: string): HttpClient.HttpClient => HttpClient.make((request) => Effect.succeed( @@ -62,6 +93,101 @@ const assertEmptyResponseFailsRequest = ( }) describe("RpcClient", () => { + it.effect("does not fail a new request started synchronously during protocol error delivery", () => + Effect.gen(function*() { + const { client, handle, sent } = yield* makeChunkProtocol() + const error = new RpcClientError({ + reason: new RpcClientDefect({ message: "connection dropped", cause: undefined }) + }) + const caller = yield* client.Unary().pipe( + Effect.catch((received) => { + assert.strictEqual(received, error) + return client.Unary() + }), + Effect.exit, + Effect.forkChild + ) + takeRequestId(yield* Queue.take(sent)) + // Let the first call suspend before delivering the error. + yield* Effect.yieldNow + yield* handle({ _tag: "ClientProtocolError", error }) + + const retryRequestId = takeRequestId(yield* Queue.take(sent)) + yield* handle({ _tag: "Exit", requestId: retryRequestId, exit: { _tag: "Success", value: "ok" } }) + assert.deepStrictEqual(yield* Fiber.join(caller), Exit.succeed("ok")) + })) + + it.effect("isolates a malformed stream chunk and interrupts its server request", () => + Effect.gen(function*() { + const { client, handle, sent } = yield* makeChunkProtocol() + const bad = yield* client.Bad().pipe(Stream.runDrain, Effect.exit, Effect.forkChild) + const badRequestId = takeRequestId(yield* Queue.take(sent)) + + const good = yield* client.Good().pipe(Stream.runCollect, Effect.forkChild) + const goodRequestId = takeRequestId(yield* Queue.take(sent)) + + const unary = yield* client.Unary().pipe(Effect.forkChild) + const unaryRequestId = takeRequestId(yield* Queue.take(sent)) + + // A decode defect must not escape into the shared protocol receive loop. + yield* handle({ _tag: "Chunk", requestId: badRequestId, values: ["not a number"] }) + const badExit = yield* Fiber.join(bad) + assert(Exit.isFailure(badExit)) + assert.isFalse(Cause.hasInterruptsOnly(badExit.cause)) + assert.deepStrictEqual(yield* Queue.take(sent), { _tag: "Interrupt", requestId: badRequestId }) + + yield* handle({ _tag: "Chunk", requestId: goodRequestId, values: ["alive"] }) + yield* handle({ _tag: "Exit", requestId: goodRequestId, exit: { _tag: "Success", value: null } }) + yield* handle({ _tag: "Exit", requestId: unaryRequestId, exit: { _tag: "Success", value: "ok" } }) + assert.deepStrictEqual(Array.from(yield* Fiber.join(good)), ["alive"]) + assert.strictEqual(yield* Fiber.join(unary), "ok") + })) + + it.effect("preserves cancellation and the server Interrupt during chunk failure cleanup", () => + Effect.gen(function*() { + const sendingInterrupt = yield* Deferred.make() + const releaseInterrupt = yield* Deferred.make() + const { client, handle, sent } = yield* makeChunkProtocol( + Deferred.succeed(sendingInterrupt, void 0).pipe(Effect.andThen(Deferred.await(releaseInterrupt))) + ) + const reader = yield* client.Bad().pipe(Stream.runDrain, Effect.forkChild) + const requestId = takeRequestId(yield* Queue.take(sent)) + let observedInterrupt = false + const receiver = yield* handle({ _tag: "Chunk", requestId, values: ["not a number"] }).pipe( + Effect.onInterrupt(() => Effect.sync(() => void (observedInterrupt = true))), + Effect.forkChild + ) + yield* Deferred.await(sendingInterrupt) + assert(Exit.isFailure(yield* Fiber.await(reader))) + + // Request cancellation while the Interrupt send is suspended, without waiting for it. + yield* Effect.withFiber((fiber) => Effect.sync(() => receiver.interruptUnsafe(fiber.id))) + yield* Deferred.succeed(releaseInterrupt, void 0) + const receiveExit = yield* Fiber.await(receiver) + assert.deepStrictEqual(yield* Queue.clear(sent), [{ _tag: "Interrupt", requestId }]) + // The cancelled handler must end interrupted, not with the decode defect it already handled. + assert(Exit.isFailure(receiveExit) && Cause.hasInterruptsOnly(receiveExit.cause)) + assert.isTrue(observedInterrupt) + })) + + it.effect("releases a chunk blocked on a full buffer when its stream consumer is interrupted", () => + Effect.gen(function*() { + const consuming = yield* Deferred.make() + const { client, handle, sent } = yield* makeChunkProtocol() + const reader = yield* client.Good(undefined, { streamBufferSize: 1 }).pipe( + Stream.runForEach(() => Deferred.succeed(consuming, void 0).pipe(Effect.andThen(Effect.never))), + Effect.forkChild + ) + const requestId = takeRequestId(yield* Queue.take(sent)) + const receiver = yield* handle({ _tag: "Chunk", requestId, values: ["a", "b", "c"] }).pipe(Effect.forkChild) + yield* Deferred.await(consuming) + assert.isUndefined(receiver.pollUnsafe()) + yield* Fiber.interrupt(reader) + + assert(Exit.isSuccess(yield* Fiber.await(receiver))) + assert.deepStrictEqual(yield* Queue.take(sent), { _tag: "Interrupt", requestId }) + })) + for (const consumer of ["queue", "stream"] as const) { it.effect(`releases the ${consumer} consumer when the request write is interrupted`, () => Effect.gen(function*() { diff --git a/repos/effect/packages/effect/test/rpc/RpcServer.test.ts b/repos/effect/packages/effect/test/rpc/RpcServer.test.ts index f84e5875e..0322986ec 100644 --- a/repos/effect/packages/effect/test/rpc/RpcServer.test.ts +++ b/repos/effect/packages/effect/test/rpc/RpcServer.test.ts @@ -48,6 +48,102 @@ const producedWithoutReadingFramedBody = Effect.fnUntraced(function*( }) describe("RpcServer", () => { + it.effect("should drain the response when stdin ends during request startup", () => + Effect.gen(function*() { + const started = yield* Deferred.make() + const finishRequest = yield* Deferred.make() + const output: Array = [] + const group = RpcGroup.make(Rpc.make("wait", { payload: Schema.Struct({}), success: Schema.String })) + const server = yield* Layer.launch( + RpcServer.layer(group).pipe( + Layer.provide(group.toLayerHandler("wait", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(finishRequest)), + Effect.as("finished") + ))), + Layer.provide(RpcServer.layerProtocolStdio), + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide(Stdio.layerTest({ + stdin: Stream.make( + new TextEncoder().encode( + `{"_tag":"Request","id":1,"tag":"wait","payload":{},"headers":[]}\n` + ) + ).pipe(Stream.concat(Stream.fromEffect(Deferred.await(started)).pipe(Stream.drain))), + stdout: () => Sink.forEach((data) => Effect.sync(() => output.push(String(data)))) + })) + ) + ).pipe(Effect.forkScoped) + yield* Deferred.await(started) + yield* Deferred.succeed(finishRequest, undefined) + yield* Fiber.await(server) + assert.deepStrictEqual(output.join("").trim().split("\n").map((line) => JSON.parse(line)), [ + { _tag: "Exit", requestId: 1, exit: { _tag: "Success", value: "finished" } } + ]) + })) + + it.effect("should shut down when stdin ends after the STDIO response is written", () => + Effect.gen(function*() { + const responseWritten = yield* Deferred.make() + const closeStdin = yield* Deferred.make() + const output: Array = [] + const group = RpcGroup.make(Rpc.make("hello", { payload: Schema.Struct({}), success: Schema.String })) + const server = yield* Layer.launch( + RpcServer.layer(group).pipe( + Layer.provide(group.toLayerHandler("hello", () => Effect.succeed("world"))), + Layer.provide(RpcServer.layerProtocolStdio), + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide(Stdio.layerTest({ + stdin: Stream.make( + new TextEncoder().encode(`{"_tag":"Request","id":1,"tag":"hello","payload":{},"headers":[]}\n`) + ).pipe(Stream.concat(Stream.fromEffect(Deferred.await(closeStdin)).pipe(Stream.drain))), + stdout: () => + Sink.forEach((data) => + Effect.sync(() => output.push(String(data))).pipe( + Effect.andThen(Deferred.succeed(responseWritten, undefined)) + ) + ) + })) + ) + ).pipe(Effect.forkScoped) + yield* Deferred.await(responseWritten) + assert.deepStrictEqual(output.join("").trim().split("\n").map((line) => JSON.parse(line)), [ + { _tag: "Exit", requestId: 1, exit: { _tag: "Success", value: "world" } } + ]) + yield* Deferred.succeed(closeStdin, undefined) + yield* Fiber.await(server) + })) + + it.effect("should wait for a blocked STDIO write before shutting down", () => + Effect.gen(function*() { + const writeStarted = yield* Deferred.make() + const finishWrite = yield* Deferred.make() + const output: Array = [] + const server = yield* Layer.launch( + RpcServer.layer(RpcGroup.make()).pipe( + Layer.provide(RpcServer.layerProtocolStdio), + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide(Stdio.layerTest({ + stdin: Stream.make(new TextEncoder().encode(`{"_tag":"Ping"}\n`)), + stdout: () => + Sink.forEach((data) => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishWrite)), + Effect.andThen(Effect.sync(() => output.push(String(data)))) + ) + ) + })) + ) + ).pipe(Effect.forkScoped) + yield* Deferred.await(writeStarted) + yield* Effect.yieldNow + assert.isUndefined(server.pollUnsafe()) + yield* Deferred.succeed(finishWrite, undefined) + yield* Fiber.await(server) + assert.deepStrictEqual(output.join("").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)), [{ + _tag: "Pong" + }]) + })) + it.effect("should accept only cancellation of an active request when client input has ended", () => Effect.gen(function*() { const entered = yield* Deferred.make() diff --git a/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index 0479437a5..20d64922f 100644 --- a/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/repos/effect/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -3126,6 +3126,33 @@ describe("fromJsonSchemaDocument", () => { }) }) + it("applies patterns whose source is canonicalized by RegExp", () => { + for ( + const [pattern, source, matching, nonMatching] of [ + ["a/b", "a\\/b", "a/b", "ab"], + ["", "(?:)", "anything", undefined], + ["\n", "\\n", "a\nb", "ab"], + ["\r", "\\r", "a\rb", "ab"], + ["\u2028", "\\u2028", "a\u2028b", "ab"], + ["\u2029", "\\u2029", "a\u2029b", "ab"] + ] as const + ) { + const schema = toSchemaFromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern }) + ) + const representation = SchemaRepresentation.toRepresentation(schema.ast).representation + assert.strictEqual(representation._tag, "String") + if (representation._tag === "String") { + assert.deepStrictEqual(representation.checks[0]?.representation?.payload, { source, flags: "u" }) + } + assert.isTrue(Schema.is(schema)(matching)) + if (nonMatching !== undefined) { + assert.isFalse(Schema.is(schema)(nonMatching)) + } + assert.isFalse(Schema.is(schema)(123)) + } + }) + it("ignores patterns explicitly", () => { assertFromJsonSchema({ schema: { type: "string", pattern: "^a+$" }, options: { patterns: "ignore" } }, { codes: makeCode(`Schema.String`, `string`) diff --git a/repos/effect/packages/effect/typetest/Effect.tst.ts b/repos/effect/packages/effect/typetest/Effect.tst.ts index a47016ff5..7efb01d51 100644 --- a/repos/effect/packages/effect/typetest/Effect.tst.ts +++ b/repos/effect/packages/effect/typetest/Effect.tst.ts @@ -1461,6 +1461,26 @@ describe("Effect.cachedWithTTL", () => { }) }) +describe("Effect.cachedInvalidateWithTTL", () => { + it("data-first", () => { + const cached = Effect.cachedInvalidateWithTTL(number, (exit) => { + expect(exit).type.toBe>() + return Exit.isSuccess(exit) ? Duration.seconds(exit.value) : 0 + }) + + expect(cached).type.toBe, Effect.Effect]>>() + }) + + it("data-last", () => { + const cached = number.pipe(Effect.cachedInvalidateWithTTL((exit) => { + expect(exit).type.toBe>() + return Exit.isSuccess(exit) ? "1 second" : 0 + })) + + expect(cached).type.toBe, Effect.Effect]>>() + }) +}) + describe("Effect.track", () => { const observe = Effect.track( Metric.gauge("track"), diff --git a/repos/effect/packages/opentelemetry/CHANGELOG.md b/repos/effect/packages/opentelemetry/CHANGELOG.md index fcc9bf3a6..c51e721bd 100644 --- a/repos/effect/packages/opentelemetry/CHANGELOG.md +++ b/repos/effect/packages/opentelemetry/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/opentelemetry +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/opentelemetry/package.json b/repos/effect/packages/opentelemetry/package.json index c9b629185..d9ae509e7 100644 --- a/repos/effect/packages/opentelemetry/package.json +++ b/repos/effect/packages/opentelemetry/package.json @@ -1,7 +1,7 @@ { "name": "@effect/opentelemetry", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "OpenTelemetry integration for Effect", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/browser/CHANGELOG.md b/repos/effect/packages/platform/browser/CHANGELOG.md index cdd1fda8c..3bbe10f1b 100644 --- a/repos/effect/packages/platform/browser/CHANGELOG.md +++ b/repos/effect/packages/platform/browser/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/platform-browser +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/platform/browser/package.json b/repos/effect/packages/platform/browser/package.json index 2f621ef0a..d0c922fd4 100644 --- a/repos/effect/packages/platform/browser/package.json +++ b/repos/effect/packages/platform/browser/package.json @@ -1,7 +1,7 @@ { "name": "@effect/platform-browser", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Platform specific implementations for the browser", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/bun/CHANGELOG.md b/repos/effect/packages/platform/bun/CHANGELOG.md index 637ece3e4..8deaf5e6e 100644 --- a/repos/effect/packages/platform/bun/CHANGELOG.md +++ b/repos/effect/packages/platform/bun/CHANGELOG.md @@ -1,5 +1,14 @@ # @effect/platform-bun +## 4.0.1 + +### Patch Changes + +- [#8678](https://github.com/Effect-TS/effect/pull/8678) [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216) Thanks @tim-smart! - Fix duplex socket readers throwing when a pull is interrupted or its scope closes during a read. Preserve consumed bytes in order across pull interruption and normal EOF, while discarding them when the reader scope closes. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`f1aedb0`](https://github.com/Effect-TS/effect/commit/f1aedb0a674db20613515c0794253ea56f780986), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + - @effect/platform-node-shared@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/platform/bun/package.json b/repos/effect/packages/platform/bun/package.json index 01d101a2b..6183072ee 100644 --- a/repos/effect/packages/platform/bun/package.json +++ b/repos/effect/packages/platform/bun/package.json @@ -1,7 +1,7 @@ { "name": "@effect/platform-bun", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Platform specific implementations for the Bun runtime", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/deno/CHANGELOG.md b/repos/effect/packages/platform/deno/CHANGELOG.md index 869e03440..4e4741082 100644 --- a/repos/effect/packages/platform/deno/CHANGELOG.md +++ b/repos/effect/packages/platform/deno/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/platform-deno +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`f1aedb0`](https://github.com/Effect-TS/effect/commit/f1aedb0a674db20613515c0794253ea56f780986), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + - @effect/platform-node-shared@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/platform/deno/package.json b/repos/effect/packages/platform/deno/package.json index 9f002b922..1fe008bbc 100644 --- a/repos/effect/packages/platform/deno/package.json +++ b/repos/effect/packages/platform/deno/package.json @@ -1,7 +1,7 @@ { "name": "@effect/platform-deno", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Platform specific implementations for the Deno runtime", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/node-shared/CHANGELOG.md b/repos/effect/packages/platform/node-shared/CHANGELOG.md index 367b17a99..bc1e7838f 100644 --- a/repos/effect/packages/platform/node-shared/CHANGELOG.md +++ b/repos/effect/packages/platform/node-shared/CHANGELOG.md @@ -1,5 +1,15 @@ # @effect/platform-node-shared +## 4.0.1 + +### Patch Changes + +- [#8678](https://github.com/Effect-TS/effect/pull/8678) [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216) Thanks @tim-smart! - Fix duplex socket readers throwing when a pull is interrupted or its scope closes during a read. Preserve consumed bytes in order across pull interruption and normal EOF, while discarding them when the reader scope closes. + +- [#8736](https://github.com/Effect-TS/effect/pull/8736) [`f1aedb0`](https://github.com/Effect-TS/effect/commit/f1aedb0a674db20613515c0794253ea56f780986) Thanks @tim-smart! - Honor `forceKillAfter` during scope release after a command leader exits with a non-zero code, so surviving descendants that ignore the termination signal are forcefully killed. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/platform/node-shared/package.json b/repos/effect/packages/platform/node-shared/package.json index c7d1d54d9..0efd24fcb 100644 --- a/repos/effect/packages/platform/node-shared/package.json +++ b/repos/effect/packages/platform/node-shared/package.json @@ -1,7 +1,7 @@ { "name": "@effect/platform-node-shared", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Unified interfaces for common platform-specific services", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/node-shared/src/NodeChildProcessSpawner.ts b/repos/effect/packages/platform/node-shared/src/NodeChildProcessSpawner.ts index e5f7a0dd7..26143d53d 100644 --- a/repos/effect/packages/platform/node-shared/src/NodeChildProcessSpawner.ts +++ b/repos/effect/packages/platform/node-shared/src/NodeChildProcessSpawner.ts @@ -547,7 +547,7 @@ const make = Effect.gen(function*() { if (exited) { const [code] = yield* Deferred.await(exitSignal) if (code !== 0 && Predicate.isNotNull(code)) { - yield* Effect.ignore(killProcessGroup(cmd, childProcess, cmd.options.killSignal ?? "SIGTERM")) + yield* Effect.ignore(terminateProcessGroup(cmd, childProcess, exitSignal, cmd.options)) } else if (isReferenced && process.platform !== "win32" && cmd.options.detached !== false) { yield* Effect.ignore(terminateProcessGroup(cmd, childProcess, exitSignal, cmd.options)) } diff --git a/repos/effect/packages/platform/node-shared/src/NodeSocket.ts b/repos/effect/packages/platform/node-shared/src/NodeSocket.ts index 3d43a7e11..897f1de1f 100644 --- a/repos/effect/packages/platform/node-shared/src/NodeSocket.ts +++ b/repos/effect/packages/platform/node-shared/src/NodeSocket.ts @@ -47,9 +47,9 @@ export class NetSocket extends Context.Service()( const readAvailable = ( conn: Duplex -): Arr.NonEmptyReadonlyArray | null => { +): Arr.NonEmptyReadonlyArray | undefined => { const first = conn.read() as Uint8Array | string | null - if (first === null) return null + if (first === null) return undefined const second = conn.read() as Uint8Array | string | null if (second === null) return [first] const out: [Uint8Array | string, ...Array] = [first, second] @@ -181,51 +181,83 @@ export const fromDuplex = ( let error: Socket.SocketError | undefined let waiter: ReadResume | undefined + // Bytes consumed by read() while the parked pull was interrupted. + let pending: Arr.NonEmptyReadonlyArray | undefined + let reading = false + let closed = false let upgradeAvailable = true - function fail(err: Socket.SocketError) { - if (error === undefined) error = err - if (waiter !== undefined) { - const resume = waiter - waiter = undefined - resume(Effect.fail(error!)) + // The only place a waiter is resumed, so delivery order has one owner: + // retained bytes first, then freshly read bytes, then the error. + function drain() { + if (waiter === undefined || reading) return + let chunk = pending + pending = undefined + if (chunk === undefined && !closed) { + // A data listener can interrupt and replace the pull synchronously. + // The replacement must wait for this read to preserve byte order. + reading = true + try { + chunk = readAvailable(conn) + } finally { + reading = false + } } - } - function onReadable() { - if (waiter === undefined) return - const chunk = readAvailable(conn) - if (chunk === null) return + // A close during read() discards the bytes it consumed. + if (closed) chunk = undefined + if (waiter === undefined) { + pending = chunk + return + } + const result = chunk !== undefined + ? Effect.succeed(chunk) + : error !== undefined + ? Effect.fail(error) + : undefined + if (result === undefined) return const resume = waiter waiter = undefined - resume(Effect.succeed(chunk)) + resume(result) + } + // Normal EOF: already consumed bytes are still delivered before the error. + function end(err: Socket.SocketError) { + error ??= err + drain() + } + // Teardown or failure: consumed bytes are discarded. + function close(err: Socket.SocketError) { + error ??= err + closed = true + pending = undefined + drain() } function onEnd() { - fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) })) + end(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 }) })) } function onError(cause: Error) { - fail( + close( new Socket.SocketError({ reason: new Socket.SocketReadError({ cause }) }) ) } function onClose(hadError: boolean) { - fail( - new Socket.SocketError({ - reason: new Socket.SocketCloseError({ code: hadError ? 1006 : 1000 }) - }) - ) + const err = new Socket.SocketError({ + reason: new Socket.SocketCloseError({ code: hadError ? 1006 : 1000 }) + }) + if (hadError) close(err) + else end(err) } function attachReadListeners(conn: Duplex) { - conn.on("readable", onReadable) + conn.on("readable", drain) conn.on("end", onEnd) conn.on("error", onError) conn.on("close", onClose) } function detachReadListeners(conn: Duplex) { - conn.off("readable", onReadable) + conn.off("readable", drain) conn.off("end", onEnd) conn.off("error", onError) conn.off("close", onClose) @@ -239,7 +271,7 @@ export const fromDuplex = ( scope, Effect.sync(() => { // resume a pull blocked in another fiber before detaching - fail( + close( new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1006 }) }) @@ -254,15 +286,13 @@ export const fromDuplex = ( currentSocket = conn latch.openUnsafe() - const pull = Effect.suspend(() => { - const chunk = readAvailable(conn) - if (chunk !== null) return Effect.succeed(chunk) - if (error !== undefined) return Effect.fail(error) - return Effect.callback, Socket.SocketError>((resume) => { - waiter = resume - return Effect.sync(() => { - if (waiter === resume) waiter = undefined - }) + const pull = Effect.callback, Socket.SocketError>((resume) => { + waiter = resume + drain() + // Resumed synchronously: nothing to cancel. + if (waiter !== resume) return + return Effect.sync(() => { + if (waiter === resume) waiter = undefined }) }) @@ -352,7 +382,7 @@ export const fromDuplex = ( const upgradeError = new Socket.SocketError({ reason: new Socket.SocketUpgradeError({ cause }) }) - fail(upgradeError) + close(upgradeError) resume(Effect.fail(upgradeError)) } function onUpgradeClose() { @@ -365,7 +395,7 @@ export const fromDuplex = ( return Effect.sync(() => { cleanup() - fail( + close( new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1006 }) }) diff --git a/repos/effect/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts b/repos/effect/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts index e0dc3d52b..19787f6b9 100644 --- a/repos/effect/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts +++ b/repos/effect/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts @@ -189,6 +189,22 @@ describe.skipIf(process.platform === "win32")("process group cleanup", () => { assert.strictEqual(yield* fs.readFileString(marker), "exited") }).pipe(Effect.scoped, Effect.provide(NodeServices))) + it.live("scope release force kills descendants after the leader exits with a non-zero code", () => + Effect.gen(function*() { + const { descendantPid, handle, marker, scope } = yield* startProcessGroup("ignore-signal", { + stdin: "pipe", + forceKillAfter: "200 millis" + }) + yield* Effect.addFinalizer(() => killDescendant(descendantPid)) + + yield* Stream.run(Stream.make(new TextEncoder().encode("exit 1\n")), handle.stdin) + assert.strictEqual(yield* handle.exitCode, 1) + + yield* Scope.close(scope, Exit.void).pipe(liveTimeout(2_000)) + + yield* assertHeartbeatStopped(marker) + }).pipe(Effect.scoped, Effect.provide(NodeServices))) + it.live("scope release cleans descendants after the leader is killed externally", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem diff --git a/repos/effect/packages/platform/node-shared/test/fixtures/process-group.ts b/repos/effect/packages/platform/node-shared/test/fixtures/process-group.ts index 7c35be675..3f7c66ba7 100644 --- a/repos/effect/packages/platform/node-shared/test/fixtures/process-group.ts +++ b/repos/effect/packages/platform/node-shared/test/fixtures/process-group.ts @@ -10,7 +10,7 @@ if (role === "leader") { spawn(process.execPath, [process.argv[1], "descendant", mode, marker], { stdio: ["ignore", "inherit", "inherit"] }) - process.stdin.once("data", () => process.exit(0)) + process.stdin.once("data", (data) => process.exit(data.toString().trim() === "exit 1" ? 1 : 0)) setInterval(() => {}, 1_000) } else { if (mode === "exit-on-signal") { diff --git a/repos/effect/packages/platform/node/CHANGELOG.md b/repos/effect/packages/platform/node/CHANGELOG.md index 4def94b7b..2b5c58795 100644 --- a/repos/effect/packages/platform/node/CHANGELOG.md +++ b/repos/effect/packages/platform/node/CHANGELOG.md @@ -1,5 +1,18 @@ # @effect/platform-node +## 4.0.1 + +### Patch Changes + +- [#8678](https://github.com/Effect-TS/effect/pull/8678) [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216) Thanks @tim-smart! - Fix duplex socket readers throwing when a pull is interrupted or its scope closes during a read. Preserve consumed bytes in order across pull interruption and normal EOF, while discarding them when the reader scope closes. + +- [#8705](https://github.com/Effect-TS/effect/pull/8705) [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a) Thanks @tim-smart! - Create Node HTTP file response streams lazily and destroy them when body consumption ends, avoiding unused open streams when conditional or range handling discards a response. + +- [#8738](https://github.com/Effect-TS/effect/pull/8738) [`0f64715`](https://github.com/Effect-TS/effect/commit/0f647153eda5696d79e68f0f5762aaf6d98f1ba9) Thanks @tim-smart! - Fail refused WebSocket upgrades with `SocketError` instead of hanging the request fiber, and skip HTTP response writes on destroyed sockets. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`f1aedb0`](https://github.com/Effect-TS/effect/commit/f1aedb0a674db20613515c0794253ea56f780986), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + - @effect/platform-node-shared@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/platform/node/package.json b/repos/effect/packages/platform/node/package.json index e6ed6c639..1403c7954 100644 --- a/repos/effect/packages/platform/node/package.json +++ b/repos/effect/packages/platform/node/package.json @@ -1,7 +1,7 @@ { "name": "@effect/platform-node", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Platform specific implementations for the Node.js runtime", "homepage": "https://effect.website", diff --git a/repos/effect/packages/platform/node/src/NodeHttpPlatform.ts b/repos/effect/packages/platform/node/src/NodeHttpPlatform.ts index d0a74b18a..b200031a4 100644 --- a/repos/effect/packages/platform/node/src/NodeHttpPlatform.ts +++ b/repos/effect/packages/platform/node/src/NodeHttpPlatform.ts @@ -20,10 +20,41 @@ import * as Mime from "effect/http/Mime" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Fs from "node:fs" -import { Readable } from "node:stream" +import { pipeline, Readable } from "node:stream" import * as NodeFileSystem from "./NodeFileSystem.ts" import * as NodeStream from "./NodeStream.ts" +// Keep the native Raw/pipeline path without opening discarded response bodies. +const lazyReadable = (evaluate: () => Readable, onDestroy?: () => void): Readable => { + let source: Readable | undefined + return new Readable({ + read() { + if (source === undefined) { + try { + source = evaluate() + } catch (cause) { + this.destroy(cause instanceof Error ? cause : new Error(String(cause))) + return + } + source.on("data", (chunk) => { + if (!this.push(chunk)) source!.pause() + }) + source.once("end", () => this.push(null)) + source.once("error", (cause) => this.destroy(cause)) + source.once("close", () => { + if (!source!.readableEnded) this.destroy(new Error("Response stream closed prematurely")) + }) + } + source.resume() + }, + destroy(error, callback) { + source?.destroy(error ?? undefined) + onDestroy?.() + callback(error) + } + }) +} + // replaces the response body while keeping every other field, dropping the // now-stale Content-Length header const compressedBody = ( @@ -49,17 +80,18 @@ const compression = NodeHttpCompression.make({ )) } case "Raw": { - const readable = body.body instanceof Readable - ? body.body - : Readable.fromWeb(new Response(body.body as BodyInit).body as any) - const transform = NodeHttpCompression.compressTransform(algorithm, options) - readable.on("error", (cause) => transform.destroy(cause)) - transform.on("error", (cause) => readable.destroy(cause)) - transform.on("close", () => readable.destroy()) + let readable = body.body instanceof Readable ? body.body : undefined + const compressed = lazyReadable(() => { + const transform = NodeHttpCompression.compressTransform(algorithm, options) + readable ??= Readable.fromWeb(new Response(body.body as BodyInit).body as any) + return pipeline(readable, transform, (cause) => { + if (cause) compressed.destroy(cause) + }) + }, () => readable?.destroy()) return Effect.succeed( compressedBody( response, - HttpBody.raw(readable.pipe(transform), { + HttpBody.raw(compressed, { contentType: response.headers["content-type"] ?? body.contentType }) ) @@ -83,9 +115,11 @@ export const make = Platform.make({ platform: "node", compression, fileResponse(path, status, statusText, headers, start, end, contentLength) { - const stream = contentLength === BigInt(0) - ? Readable.from([]) - : Fs.createReadStream(path, { start, end: end === undefined ? undefined : end - 1 }) + const stream = lazyReadable(() => + contentLength === BigInt(0) + ? Readable.from([]) + : Fs.createReadStream(path, { start, end: end === undefined ? undefined : end - 1 }) + ) return ServerResponse.raw(stream, { headers: { ...headers, @@ -98,20 +132,23 @@ export const make = Platform.make({ }) }, fileWebResponse(file, status, statusText, headers, _options) { - return ServerResponse.raw(Readable.fromWeb(file.stream() as any), { - headers: Headers.merge( - headers, - Headers.fromRecordUnsafe({ - "content-type": headers["content-type"] ?? - (file.type === "" - ? Option.getOrElse(Mime.getType(file.name), () => "application/octet-stream") - : file.type), - "content-length": file.size.toString() - }) - ), - status, - statusText - }) + return ServerResponse.raw( + lazyReadable(() => Readable.fromWeb(file.stream() as any)), + { + headers: Headers.merge( + headers, + Headers.fromRecordUnsafe({ + "content-type": headers["content-type"] ?? + (file.type === "" + ? Option.getOrElse(Mime.getType(file.name), () => "application/octet-stream") + : file.type), + "content-length": file.size.toString() + }) + ), + status, + statusText + } + ) } }) diff --git a/repos/effect/packages/platform/node/src/NodeHttpServer.ts b/repos/effect/packages/platform/node/src/NodeHttpServer.ts index 9de808d4e..1c4996f65 100644 --- a/repos/effect/packages/platform/node/src/NodeHttpServer.ts +++ b/repos/effect/packages/platform/node/src/NodeHttpServer.ts @@ -258,10 +258,8 @@ export const makeUpgradeHandler = < const nodeResponse = () => { if (nodeResponse_ === undefined) { nodeResponse_ = new Http.ServerResponse(nodeRequest) - if (upgraded) { - // the connection now carries WebSocket frames, so end the response - // before a socket is assigned to it to make handleResponse skip the - // write (writableEnded check) + if (upgraded || socket.destroyed) { + // End without assigning the socket so handleResponse skips HTTP writes. nodeResponse_.end() } else { nodeResponse_.assignSocket(socket as any) @@ -276,12 +274,25 @@ export const makeUpgradeHandler = < lazyWss, (wss) => Effect.acquireRelease( - Effect.callback((resume) => + Effect.callback((resume) => { + // A refused handshake never invokes the callback, so fail on close instead. + const onClose = () => + resume(Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketOpenError({ + kind: "Unknown", + cause: new Error("The socket closed before the upgrade") + }) + }) + )) + if (socket.destroyed) return onClose() + socket.once("close", onClose) wss.handleUpgrade(nodeRequest, socket, head, (ws) => { + socket.off("close", onClose) upgraded = true resume(Effect.succeed(ws)) }) - ), + }), (ws, exit) => Effect.sync(() => ws.close(closeCode(exit))) ) )) diff --git a/repos/effect/packages/platform/node/test/HttpStaticServer.test.ts b/repos/effect/packages/platform/node/test/HttpStaticServer.test.ts index 3ac574337..e77aa0487 100644 --- a/repos/effect/packages/platform/node/test/HttpStaticServer.test.ts +++ b/repos/effect/packages/platform/node/test/HttpStaticServer.test.ts @@ -4,10 +4,17 @@ import * as NodePathLayer from "@effect/platform-node/NodePath" import { assert, describe, it } from "@effect/vitest" import { HttpRouter, HttpStaticServer } from "effect/http" import * as Layer from "effect/Layer" +import * as Fs from "node:fs" import { copyFile, cp, mkdtemp, rm, utimes, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import * as NodePath from "node:path" import { fileURLToPath } from "node:url" +import { vi } from "vitest" + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal() + return { ...original, createReadStream: vi.fn(original.createReadStream) } +}) const fixturesRoot = fileURLToPath(new URL("./fixtures/http-static-server", import.meta.url)) const fixturesOutsideFile = fileURLToPath(new URL("./fixtures/http-static-server-outside.txt", import.meta.url)) @@ -56,7 +63,7 @@ const withStaticFiles = async ( } } -describe("HttpStaticServer", () => { +describe("HttpStaticServer", { concurrent: false }, () => { it("serves files with expected content type and body", async () => { await withStaticFiles(async ({ handler }) => { const response = await handler(new Request("http://localhost/hello.txt")) @@ -149,9 +156,71 @@ describe("HttpStaticServer", () => { }) }) + it("honors Range only for a matching strong If-Range ETag", async () => { + await withStaticFiles(async ({ handler, root }) => { + const first = await handler(new Request("http://localhost/range.txt")) + const etag = first.headers.get("etag")! + assert.strictEqual(etag.startsWith("W/"), false) + const fullBody = await first.text() + vi.mocked(Fs.createReadStream).mockClear() + + const matching = await handler( + new Request("http://localhost/range.txt", { + headers: { Range: "bytes=0-10", "If-Range": etag } + }) + ) + assert.strictEqual(matching.status, 206) + assert.strictEqual(matching.headers.get("content-range"), `bytes 0-10/${fullBody.length}`) + assert.strictEqual(await matching.text(), "0123456789a") + // Only the selected range acquires a source, not the discarded full response. + assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, [[NodePath.join(root, "range.txt"), { + start: 0, + end: 10 + }]]) + + // Weak tags and Last-Modified dates never match: filesystem metadata + // cannot prove a date is a strong validator. + for (const ifRange of [`W/${etag}`, first.headers.get("last-modified")!]) { + const response = await handler( + new Request("http://localhost/range.txt", { + headers: { Range: "bytes=0-10", "If-Range": ifRange } + }) + ) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers.get("content-range"), null) + assert.strictEqual(await response.text(), fullBody) + } + }) + }) + + it.each(["bytes=10-", "bytes=100-200"])( + "returns the full changed file for a stale If-Range ETag with Range %s", + async (range) => { + await withStaticFiles(async ({ handler, root }) => { + const first = await handler(new Request("http://localhost/range.txt")) + const etag = first.headers.get("etag")! + await first.text() + const updatedBody = "NEW-NEW-NEW-NEW-NEW-NEW-" + await writeFile(NodePath.join(root, "range.txt"), updatedBody) + const resumed = await handler( + new Request("http://localhost/range.txt", { + headers: { Range: range, "If-Range": etag } + }) + ) + assert.strictEqual(resumed.status, 200) + assert.strictEqual(resumed.headers.get("content-range"), null) + assert.strictEqual(resumed.headers.get("content-length"), String(updatedBody.length)) + assert.notStrictEqual(resumed.headers.get("etag"), etag) + assert.strictEqual(await resumed.text(), updatedBody) + }) + } + ) + it("handles range requests for valid, invalid, and malformed headers", async () => { await withStaticFiles(async ({ handler }) => { - const fullBody = await handler(new Request("http://localhost/range.txt")).then((response) => response.text()) + const full = await handler(new Request("http://localhost/range.txt")) + const etag = full.headers.get("etag")! + const fullBody = await full.text() const fileSize = fullBody.length const first = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=0-10" } })) @@ -169,9 +238,16 @@ describe("HttpStaticServer", () => { assert.strictEqual(suffix.headers.get("content-range"), `bytes ${fileSize - 10}-${fileSize - 1}/${fileSize}`) assert.strictEqual(await suffix.text(), fullBody.slice(-10)) - const invalid = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=100-200" } })) + vi.mocked(Fs.createReadStream).mockClear() + const invalid = await handler( + new Request("http://localhost/range.txt", { + headers: { Range: "bytes=100-200", "If-Range": etag } + }) + ) assert.strictEqual(invalid.status, 416) assert.strictEqual(invalid.headers.get("content-range"), `bytes */${fileSize}`) + assert.strictEqual(await invalid.text(), "") + assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, []) const malformed = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=abc" } })) assert.strictEqual(malformed.status, 200) diff --git a/repos/effect/packages/platform/node/test/HttpStaticServerConditional.test.ts b/repos/effect/packages/platform/node/test/HttpStaticServerConditional.test.ts index d3293ea56..27a82fc51 100644 --- a/repos/effect/packages/platform/node/test/HttpStaticServerConditional.test.ts +++ b/repos/effect/packages/platform/node/test/HttpStaticServerConditional.test.ts @@ -245,6 +245,7 @@ describe("HttpStaticServer", () => { new Request("http://localhost/file.txt", { headers: { "If-None-Match": "\"etag-value\"", + "If-Range": "\"stale\"", Range: "bytes=1000-1001" } }) diff --git a/repos/effect/packages/platform/node/test/NodeHttpCompression.test.ts b/repos/effect/packages/platform/node/test/NodeHttpCompression.test.ts index 7de35bb1b..9ee00f112 100644 --- a/repos/effect/packages/platform/node/test/NodeHttpCompression.test.ts +++ b/repos/effect/packages/platform/node/test/NodeHttpCompression.test.ts @@ -12,7 +12,6 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse" import * as Latch from "effect/Latch" import * as Layer from "effect/Layer" import * as Stream from "effect/Stream" -import * as Crypto from "node:crypto" import * as Fs from "node:fs" import * as Os from "node:os" import * as Path from "node:path" @@ -184,36 +183,27 @@ describe("NodeHttpCompression", () => { assert.strictEqual(compressed.headers["content-type"], uncompressed.headers["content-type"]) }).pipe(Effect.provide(NodeHttpServer.layerTest))) - it.effect("closes compressed file bodies for HEAD requests", () => - Effect.gen(function*() { - const directory = Fs.mkdtempSync(Path.join(Os.tmpdir(), "effect-http-compression-")) - yield* Effect.addFinalizer(() => Effect.sync(() => Fs.rmSync(directory, { recursive: true }))) - const path = Path.join(directory, "random.bin") - Fs.writeFileSync(path, Crypto.randomBytes(1024 * 1024)) - - const closed = yield* Latch.make(false) - const response = yield* HttpServerResponse.file(path, { headers: { "content-type": "text/plain" } }) - .pipe( - Effect.tap((response) => - Effect.sync(() => { - if (response.body._tag !== "Raw") { - throw new Error(`Expected a Raw body, received ${response.body._tag}`) - } - const readable = response.body.body as Fs.ReadStream - readable.once("close", () => closed.openUnsafe()) - }) - ) - ) - - yield* HttpRouter.add("GET", "/file", Effect.succeed(response)).pipe( - (self) => HttpRouter.serve(self, { middleware: HttpMiddleware.compression({ minSize: 0 }) }), - Layer.build + it("does not acquire file bodies for compressed HEAD requests", async () => { + let acquired = 0 + class CountingFile extends File { + override stream() { + acquired++ + return super.stream() + } + } + const file = new CountingFile(["abc"], "test.txt") + await withHandler(HttpServerResponse.fileWeb(file), { minSize: 0 }, async (handler) => { + const head = await handler( + new Request("http://localhost/", { + method: "HEAD", + headers: { "accept-encoding": "gzip" } + }) ) - const head = yield* HttpClient.head("/file", { headers: { "accept-encoding": "gzip" } }) assert.strictEqual(head.status, 200) - const result = yield* closed.await.pipe(Effect.timeoutOption("1 second")) - assert.strictEqual(result._tag, "Some") - }).pipe(Effect.provide(NodeHttpServer.layerTest))) + assert.strictEqual(await head.text(), "") + assert.strictEqual(acquired, 0) + }) + }) it.effect("flushes compressed chunks incrementally over the wire", () => Effect.gen(function*() { diff --git a/repos/effect/packages/platform/node/test/NodeHttpPlatform.test.ts b/repos/effect/packages/platform/node/test/NodeHttpPlatform.test.ts index d28978f3f..89bc67953 100644 --- a/repos/effect/packages/platform/node/test/NodeHttpPlatform.test.ts +++ b/repos/effect/packages/platform/node/test/NodeHttpPlatform.test.ts @@ -3,22 +3,16 @@ import { assert, describe, it } from "@effect/vitest" import * as ByteSize from "effect/ByteSize" import * as Effect from "effect/Effect" import * as Etag from "effect/http/Etag" -import type * as HttpBody from "effect/http/HttpBody" import * as HttpPlatform from "effect/http/HttpPlatform" +import * as HttpServerResponse from "effect/http/HttpServerResponse" import * as Layer from "effect/Layer" import * as Fs from "node:fs" import { Readable } from "node:stream" import { afterEach, beforeEach, vi } from "vitest" import { fileSystemLayer } from "../../node-shared/test/HttpPlatform.test-utils.ts" -const readStream = (stream: Readable) => - Effect.promise(async () => { - let text = "" - for await (const chunk of stream) { - text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8") - } - return text - }) +const readResponse = (response: HttpServerResponse.HttpServerResponse) => + Effect.promise(() => HttpServerResponse.toWeb(response).text()) describe("NodeHttpPlatform", { concurrent: false }, () => { it.effect("fileWebResponse prefers File.type over the extension", () => @@ -38,11 +32,7 @@ describe("NodeHttpPlatform", { concurrent: false }, () => { assert.strictEqual(response.headers["content-length"], "5") assert.strictEqual(response.headers["content-type"], "text/plain") - assert.strictEqual(response.body._tag, "Raw") - const body = (response.body as HttpBody.Raw).body - assert(body instanceof Readable) - - const text = yield* readStream(body) + const text = yield* readResponse(response) assert.strictEqual(text, "ipsum") }).pipe(Effect.provide(NodeHttpPlatform.layer))) @@ -63,8 +53,7 @@ describe("NodeHttpPlatform", { concurrent: false }, () => { bytesToRead: bytesToRead === undefined ? undefined : ByteSize.bytes(bytesToRead) }) - assert.strictEqual(response.body._tag, "Raw") - const text = yield* readStream((response.body as HttpBody.Raw).body as Readable) + const text = yield* readResponse(response) assert.deepStrictEqual( { contentLength: response.headers["content-length"], body: text }, { contentLength: String(expected.length), body: expected } @@ -81,11 +70,7 @@ describe("NodeHttpPlatform", { concurrent: false }, () => { }) assert.strictEqual(response.headers["content-length"], "0") - assert.strictEqual(response.body._tag, "Raw") - const body = (response.body as HttpBody.Raw).body - assert(body instanceof Readable) - - const text = yield* readStream(body) + const text = yield* readResponse(response) assert.strictEqual(text, "") }).pipe(Effect.provide(NodeHttpPlatform.layer))) @@ -120,7 +105,8 @@ describe("NodeHttpPlatform precision", { concurrent: false }, () => { Effect.gen(function*() { const maxSafe = Number.MAX_SAFE_INTEGER const platform = yield* HttpPlatform.HttpPlatform - yield* platform.fileResponse("precision.bin", { offset: maxSafe - 1, bytesToRead: 1 }) + const response = yield* platform.fileResponse("precision.bin", { offset: maxSafe - 1, bytesToRead: 1 }) + yield* readResponse(response) assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, [["precision.bin", { start: maxSafe - 1, end: maxSafe - 1 @@ -133,6 +119,7 @@ describe("NodeHttpPlatform precision", { concurrent: false }, () => { const response = yield* platform.fileResponse("precision.bin") assert.strictEqual(response.status, 200) assert.strictEqual(response.headers["content-length"], "9007199254740993") + yield* readResponse(response) assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, [["precision.bin", { start: 0, end: undefined diff --git a/repos/effect/packages/platform/node/test/NodeHttpServer.test.ts b/repos/effect/packages/platform/node/test/NodeHttpServer.test.ts index 8d382386f..5ff81a718 100644 --- a/repos/effect/packages/platform/node/test/NodeHttpServer.test.ts +++ b/repos/effect/packages/platform/node/test/NodeHttpServer.test.ts @@ -1043,6 +1043,54 @@ describe("HttpServer", () => { expect(response.status).toEqual(200) }).pipe(Effect.provide(layerTestWebsocket))) + it.effect("fails refused websocket handshakes without hanging shutdown", () => + Effect.gen(function*() { + const outcome = yield* Deferred.make() + yield* HttpRouter.add( + "GET", + "/ws", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const socket = yield* request.upgrade + const error = yield* Effect.scoped(socket.reader).pipe( + Effect.match({ onFailure: (error) => error, onSuccess: () => undefined }) + ) + yield* Deferred.succeed(outcome, error) + return HttpServerResponse.empty() + }) + ).pipe( + HttpRouter.serve, + Layer.build + ) + const server = yield* HttpServer.HttpServer + const port = (server.address as NetAddress.InetAddress).port + + const status = yield* Effect.callback((resume) => { + const req = Http.request({ + hostname: "127.0.0.1", + port, + agent: false, + path: "/ws", + method: "GET", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version": "12" // Unsupported version forces handshake rejection. + } + }) + req.on("response", (res) => { + res.resume() + res.on("end", () => resume(Effect.succeed(res.statusCode))) + }) + req.on("error", (error) => resume(Effect.fail(error))) + req.end() + return Effect.sync(() => req.destroy()) + }) + assert.strictEqual(status, 400) + assert.isTrue(Socket.SocketError.is(yield* Deferred.await(outcome))) + }).pipe(Effect.provide(layerTestWebsocket), Effect.scoped)) + it.effect("does not write the HTTP response to an upgraded connection", () => Effect.gen(function*() { yield* HttpRouter.add( diff --git a/repos/effect/packages/platform/node/test/NodeSocket.test.ts b/repos/effect/packages/platform/node/test/NodeSocket.test.ts index 3bd8b8878..670088cb1 100644 --- a/repos/effect/packages/platform/node/test/NodeSocket.test.ts +++ b/repos/effect/packages/platform/node/test/NodeSocket.test.ts @@ -9,7 +9,7 @@ import { Socket } from "effect/socket" import * as Stream from "effect/Stream" import * as Fs from "node:fs" import * as Net from "node:net" -import { Duplex } from "node:stream" +import { Duplex, PassThrough } from "node:stream" import * as Tls from "node:tls" import { fileURLToPath } from "node:url" import { vi } from "vitest" @@ -46,6 +46,22 @@ const echoHandler = (socket: Socket.Socket, upgradeOptions?: Socket.TlsUpgradeOp Effect.catchTag("SocketError", () => Effect.void) ) +const makePassThrough = Effect.acquireRelease( + Effect.sync(() => new PassThrough()), + (duplex) => Effect.sync(() => duplex.destroy()) +) + +// A duplex-backed reader with one pull parked, waiting for bytes. +const parkedPull = Effect.gen(function*() { + const duplex = yield* makePassThrough + const { pull } = yield* (yield* NodeSocket.fromDuplex(Effect.succeed(duplex))).reader + const parked = yield* Effect.forkChild(pull, { startImmediately: true }) + return { duplex, pull, parked } +}) + +const decode = (batch: ReadonlyArray) => + Buffer.concat(batch.map((chunk) => Buffer.from(chunk))).toString() + const sendHelloWorld = (socket: Socket.Socket, upgradeOptions?: Socket.TlsUpgradeOptions) => Effect.gen(function*() { const writer = yield* socket.writer @@ -197,6 +213,84 @@ describe("Socket", () => { } })) + it.live("handles reader scope closure during a parked pull read", () => + Effect.gen(function*() { + const duplex = yield* makePassThrough + const socket = yield* NodeSocket.fromDuplex(Effect.succeed(duplex)) + const readScope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(readScope, Exit.void)) + const { pull } = yield* Scope.provide(socket.reader, readScope) + const parked = yield* Effect.forkChild(pull, { startImmediately: true }) + let observed = "" + duplex.once("data", (chunk) => { + observed = chunk.toString() + Effect.runSync(Scope.close(readScope, Exit.void)) + }) + + // Deliver synchronously so an exception stays in the test, rather than + // escaping from Node's next-tick readable notification. + duplex.write("hello") + duplex.emit("readable") + const error = yield* Fiber.join(parked).pipe(Effect.flip, Effect.timeout("1 second")) + assert.strictEqual(observed, "hello") + assert.strictEqual(error.reason._tag, "SocketCloseError") + if (error.reason._tag === "SocketCloseError") { + assert.strictEqual(error.reason.code, 1006) + } + })) + + it.live("preserves a chunk when a parked pull is interrupted during read", () => + Effect.gen(function*() { + const { duplex, parked, pull } = yield* parkedPull + duplex.once("data", () => Effect.runSync(Fiber.interrupt(parked))) + duplex.write("hello") + duplex.emit("readable") + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(parked).pipe(Effect.timeout("1 second")))) + const next = yield* pull.pipe(Effect.timeout("1 second")) + assert.strictEqual(decode(next), "hello") + })) + + it.live("drains a retained chunk when EOF follows parked pull interruption", () => + Effect.gen(function*() { + const { duplex, parked, pull } = yield* parkedPull + duplex.once("data", () => Effect.runSync(Fiber.interrupt(parked))) + const ended = new Promise((resolve) => duplex.once("end", resolve)) + duplex.end("hello") + duplex.emit("readable") + yield* Effect.promise(() => ended).pipe(Effect.timeout("1 second")) + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(parked))) + const next = yield* pull.pipe(Effect.timeout("1 second")) + assert.strictEqual(decode(next), "hello") + const error = yield* Effect.flip(pull).pipe(Effect.timeout("1 second")) + assert.strictEqual(error.reason._tag, "SocketCloseError") + if (error.reason._tag === "SocketCloseError") { + assert.strictEqual(error.reason.code, 1000) + } + })) + + it.live("preserves order when a parked pull is replaced synchronously during read", () => + Effect.gen(function*() { + const { duplex, parked, pull } = yield* parkedPull + const scope = yield* Effect.scope + let replacement: Fiber.Fiber, Socket.SocketError> | undefined + duplex.once("data", () => { + Effect.runSync(Fiber.interrupt(parked)) + // Make the second chunk available only after read() consumes the first, + // so this also exercises separate reads on Node versions that coalesce. + duplex.write("second") + replacement = Effect.runSync(Effect.forkIn(pull, scope, { startImmediately: true })) + }) + duplex.write("first") + duplex.emit("readable") + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(parked))) + assert.isDefined(replacement) + let received = decode(yield* Fiber.join(replacement).pipe(Effect.timeout("1 second"))) + while (received.length < "firstsecond".length) { + received += decode(yield* pull.pipe(Effect.timeout("1 second"))) + } + assert.strictEqual(received, "firstsecond") + })) + it.live("respects a zero open timeout", () => Effect.gen(function*() { const socket = yield* NodeSocket.fromDuplex(Effect.never, { openTimeout: 0 }) diff --git a/repos/effect/packages/platform/node/test/cluster/SqlMessageStorage.integration.test.ts b/repos/effect/packages/platform/node/test/cluster/SqlMessageStorage.integration.test.ts index 5ceb1e2b5..6630affcf 100644 --- a/repos/effect/packages/platform/node/test/cluster/SqlMessageStorage.integration.test.ts +++ b/repos/effect/packages/platform/node/test/cluster/SqlMessageStorage.integration.test.ts @@ -1,7 +1,7 @@ import { NodeCrypto, NodeFileSystem } from "@effect/platform-node" import { SqliteClient } from "@effect/sql-sqlite-node" import { assert, describe, expect, it } from "@effect/vitest" -import { Effect, Fiber, FileSystem, Latch, Layer, Option } from "effect" +import { Effect, Exit, Fiber, FileSystem, Latch, Layer, Option } from "effect" import { Entity, EntityAddress, @@ -62,6 +62,74 @@ describe("SqlMessageStorage", () => { timeout: 120000 })(label, (it) => { if (label === "pg") { + for (const reset of ["resetShards", "resetAddresses"] as const) { + it.effect(`${reset} does not deadlock with a concurrent message claim`, () => + Effect.gen(function*() { + yield* truncate + const sql = yield* SqlClient.SqlClient + const storage = yield* SqlMessageStorage.makeEncoded().pipe(Effect.provide(NodeCrypto.layer)) + // Heap order is the reverse of the claim query's rowid order. + yield* sql` + INSERT INTO cluster_messages + (id, rowid, shard_id, entity_type, entity_id, kind, tag, payload, headers, processed, request_id) + VALUES + (2, 2, 'default:1', 'test', '1', 0, 'GetUser', '{}', '{}', FALSE, 2), + (1, 1, 'default:1', 'test', '1', 0, 'GetUser', '{}', '{}', FALSE, 1) + ` + // Pause after the reset locks row 2, so the claim can lock row 1. + yield* sql` + CREATE FUNCTION cluster_test_slow_reset() RETURNS trigger AS $$ + BEGIN + IF NEW.last_read IS NULL AND NEW.rowid = 2 THEN + PERFORM pg_sleep(2); + END IF; + RETURN NEW; + END $$ LANGUAGE plpgsql + ` + yield* sql` + CREATE TRIGGER cluster_test_slow_reset BEFORE UPDATE ON cluster_messages + FOR EACH ROW EXECUTE FUNCTION cluster_test_slow_reset() + ` + yield* Effect.gen(function*() { + const resetFiber = yield* Effect.gen(function*() { + // Make the unordered UPDATE use heap order regardless of indexes. + yield* sql`SET LOCAL enable_indexscan = off` + yield* sql`SET LOCAL enable_bitmapscan = off` + yield* reset === "resetShards" + ? storage.resetShards(["default:1"]) + : storage.resetAddresses([EntityAddress.make({ + shardId: ShardId.make("default", 1), + entityType: EntityType.make("test"), + entityId: EntityId.make("1") + })]) + }).pipe(sql.withTransaction, Effect.exit, Effect.forkChild) + yield* Effect.gen(function*() { + while (true) { + const waiting = yield* sql` + SELECT 1 FROM pg_stat_activity + WHERE pid <> pg_backend_pid() AND wait_event = 'PgSleep' + AND query LIKE '%cluster_messages%' + ` + if (waiting.length > 0) return + yield* Effect.sleep("10 millis") + } + }).pipe(Effect.timeout("5 seconds")) + const claim = yield* Effect.exit(storage.unprocessedMessages(["default:1"], Date.now())) + const resetResult = yield* Fiber.join(resetFiber) + expect(Exit.isSuccess(resetResult)).toBe(true) + assert(Exit.isSuccess(claim), String(claim)) + expect(claim.value.map((message) => message.envelope.requestId)).toEqual(["1", "2"]) + expect(yield* storage.unprocessedMessages(["default:1"], Date.now())).toHaveLength(0) + }).pipe(Effect.ensuring( + Effect.gen(function*() { + yield* sql`DROP TRIGGER cluster_test_slow_reset ON cluster_messages` + yield* sql`DROP FUNCTION cluster_test_slow_reset()` + yield* truncate + }).pipe(Effect.orDie) + )) + }).pipe(TestClock.withLive)) + } + it.effect("creates an index for insertion-ordered message reads", () => Effect.gen(function*() { const sql = yield* SqlClient.SqlClient diff --git a/repos/effect/packages/platform/node/test/cluster/SqlRunnerStorage.integration.test.ts b/repos/effect/packages/platform/node/test/cluster/SqlRunnerStorage.integration.test.ts index 4faa7cbd6..bf3e7674b 100644 --- a/repos/effect/packages/platform/node/test/cluster/SqlRunnerStorage.integration.test.ts +++ b/repos/effect/packages/platform/node/test/cluster/SqlRunnerStorage.integration.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { SqliteClient } from "@effect/sql-sqlite-node" import { assert, describe, expect, it } from "@effect/vitest" -import { Cause, Duration, Effect, Exit, FileSystem, Latch, Layer, Schedule } from "effect" +import { Cause, Duration, Effect, Exit, Fiber, FileSystem, Latch, Layer, Schedule } from "effect" import { ClusterError, Runner, @@ -236,56 +236,84 @@ describe("SqlRunnerStorage", () => { ) }, 60_000) - it.effect("rebuilds the reserved connection when releasing the previous one hangs", () => { - const partitioned = makePartitionState() - const layer = StorageLayer.pipe( - Layer.provideMerge(blackholeReservedConnection(partitioned, false)), - Layer.provide(ShardingConfig.layer({ - shardLockDisableAdvisory: true, - ...partitionConfig - })) - ) - - return Effect.gen(function*() { - const storage = yield* RunnerStorage.RunnerStorage - const runner = Runner.make({ - address: runnerAddress1, - groups: ["default"], - weight: 1 - }) - const shards = [ShardId.make("default", 1)] + it.effect( + "rebuilds the reserved connection when releasing the previous one hangs", + () => + withStalledRelease((storage, shards, partitioned) => + Effect.gen(function*() { + yield* Effect.sleep(lockOperationInterval * 1.5) - yield* storage.register(runner, true) - yield* storage.acquire(runnerAddress1, shards) + // the stalled release must not disable further rebuilds + restoreConnection(partitioned) + const reserved = partitioned.reservedConnections + yield* storage.refresh(runnerAddress1, shards).pipe(Effect.exit) + yield* waitUntil(() => partitioned.reservedConnections > reserved) - // the connection wedges and the driver never releases it back to the - // pool, so the rebuild stalls closing the previous scope - partitionConnection(partitioned) - partitioned.blockRelease = true - yield* storage.refresh(runnerAddress1, shards).pipe(Effect.exit) - yield* Effect.sleep(lockOperationInterval * 1.5) + expect( + yield* storage.refresh(runnerAddress1, shards).pipe( + Effect.retry({ times: 20, schedule: Schedule.spaced(20) }) + ) + ).toEqual(shards) + }) + ), + 60_000 + ) - // the stalled release must not disable further rebuilds - restoreConnection(partitioned) - const reserved = partitioned.reservedConnections - yield* storage.refresh(runnerAddress1, shards).pipe(Effect.exit) - yield* waitUntil(() => partitioned.reservedConnections > reserved) + it.effect( + "replaces a pending reserved connection once when operations waiting on it fail", + () => + withStalledRelease((storage, shards, partitioned) => + Effect.gen(function*() { + restoreConnection(partitioned) + const reserved = partitioned.reservedConnections + + // both operations wait on the pending connection and time out together, + // but only one of them may replace it + const exits = yield* Effect.all([ + storage.refresh(runnerAddress1, shards).pipe(Effect.exit), + storage.refresh(runnerAddress1, shards).pipe(Effect.exit) + ], { concurrency: "unbounded" }) + assert(exits.every(Exit.isFailure)) + yield* waitUntil(() => partitioned.reservedConnections > reserved) + expect( + yield* storage.refresh(runnerAddress1, shards).pipe( + Effect.retry({ times: 20, schedule: Schedule.spaced(20) }) + ) + ).toEqual(shards) + yield* Effect.sleep(lockOperationInterval / 2) + expect(partitioned.reservedConnections, partitionDiagnostics(partitioned)).toEqual(reserved + 1) + }) + ), + 60_000 + ) - expect( - yield* storage.refresh(runnerAddress1, shards).pipe( - Effect.retry({ times: 20, schedule: Schedule.spaced(20) }) - ) - ).toEqual(shards) - }).pipe( - // let the stalled release finish so the layer can be torn down - Effect.ensuring(Effect.sync(() => { - restoreConnection(partitioned) - partitioned.blockRelease = false - })), - Effect.provide(layer), - TestClock.withLive - ) - }, 60_000) + it.effect( + "replaces a connection that fails an operation which waited for it", + () => + withStalledRelease((storage, shards, partitioned) => + Effect.gen(function*() { + restoreConnection(partitioned) + const waiting = yield* storage.refresh(runnerAddress1, shards).pipe(Effect.exit, Effect.forkChild) + yield* Effect.sleep(lockOperationInterval / 10) + + // the waiting operation runs on the replacement, which then fails it + const reserved = partitioned.reservedConnections + partitioned.failNextQueries = 1 + partitioned.blockRelease = false + yield* waitUntil(() => partitioned.reservedConnections > reserved) + assert(Exit.isFailure(yield* Fiber.join(waiting))) + + // the failing replacement is replaced without another operation failing on it + yield* waitUntil(() => partitioned.reservedConnections > reserved + 1) + expect( + yield* storage.refresh(runnerAddress1, shards).pipe( + Effect.retry({ times: 20, schedule: Schedule.spaced(20) }) + ) + ).toEqual(shards) + }) + ), + 60_000 + ) it.effect("isolates advisory shard locks by prefix", () => Effect.gen(function*() { @@ -458,6 +486,49 @@ interface PartitionState { usableConnections: number } +// Registers a runner holding one shard, then wedges the reserved connection +// and blocks its release, so the first rebuild stalls closing the previous +// scope. `body` runs with the stall in place and may lift it through +// `partitioned`; it is lifted afterwards so the layer can be torn down. +const withStalledRelease = ( + body: ( + storage: RunnerStorage.RunnerStorage["Service"], + shards: ReadonlyArray, + partitioned: PartitionState + ) => Effect.Effect +) => { + const partitioned = makePartitionState() + const layer = StorageLayer.pipe( + Layer.provideMerge(blackholeReservedConnection(partitioned, false)), + Layer.provide(ShardingConfig.layer({ + shardLockDisableAdvisory: true, + ...partitionConfig + })) + ) + return Effect.gen(function*() { + const storage = yield* RunnerStorage.RunnerStorage + const runner = Runner.make({ + address: runnerAddress1, + groups: ["default"], + weight: 1 + }) + const shards = [ShardId.make("default", 1)] + yield* storage.register(runner, true) + yield* storage.acquire(runnerAddress1, shards) + partitionConnection(partitioned) + partitioned.blockRelease = true + yield* storage.refresh(runnerAddress1, shards).pipe(Effect.exit) + yield* body(storage, shards, partitioned) + }).pipe( + Effect.ensuring(Effect.sync(() => { + restoreConnection(partitioned) + partitioned.blockRelease = false + })), + Effect.provide(layer), + TestClock.withLive + ) +} + const makePartitionState = (): PartitionState => ({ connectionAvailable: Latch.makeUnsafe(true), blockRelease: false, diff --git a/repos/effect/packages/sql/clickhouse/CHANGELOG.md b/repos/effect/packages/sql/clickhouse/CHANGELOG.md index b5dd4a09d..d6c40dc96 100644 --- a/repos/effect/packages/sql/clickhouse/CHANGELOG.md +++ b/repos/effect/packages/sql/clickhouse/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/sql-clickhouse +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8), [`0f64715`](https://github.com/Effect-TS/effect/commit/0f647153eda5696d79e68f0f5762aaf6d98f1ba9)]: + - effect@4.0.1 + - @effect/platform-node@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/clickhouse/package.json b/repos/effect/packages/sql/clickhouse/package.json index 1b2e4be2e..c6bd64205 100644 --- a/repos/effect/packages/sql/clickhouse/package.json +++ b/repos/effect/packages/sql/clickhouse/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-clickhouse", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A Clickhouse toolkit for Effect", diff --git a/repos/effect/packages/sql/d1/CHANGELOG.md b/repos/effect/packages/sql/d1/CHANGELOG.md index a969b8171..39a33dc7a 100644 --- a/repos/effect/packages/sql/d1/CHANGELOG.md +++ b/repos/effect/packages/sql/d1/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-d1 +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/d1/package.json b/repos/effect/packages/sql/d1/package.json index b6491526b..10b50a59e 100644 --- a/repos/effect/packages/sql/d1/package.json +++ b/repos/effect/packages/sql/d1/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-d1", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A Cloudflare D1 integration for Effect", diff --git a/repos/effect/packages/sql/libsql/CHANGELOG.md b/repos/effect/packages/sql/libsql/CHANGELOG.md index af227b55b..f1125b05f 100644 --- a/repos/effect/packages/sql/libsql/CHANGELOG.md +++ b/repos/effect/packages/sql/libsql/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-libsql +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/libsql/package.json b/repos/effect/packages/sql/libsql/package.json index 9127c6537..2368086c5 100644 --- a/repos/effect/packages/sql/libsql/package.json +++ b/repos/effect/packages/sql/libsql/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-libsql", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A libSQL toolkit for Effect", diff --git a/repos/effect/packages/sql/mssql/CHANGELOG.md b/repos/effect/packages/sql/mssql/CHANGELOG.md index 69250d374..0b6d1149c 100644 --- a/repos/effect/packages/sql/mssql/CHANGELOG.md +++ b/repos/effect/packages/sql/mssql/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/sql-mssql +## 4.0.1 + +### Patch Changes + +- [#8709](https://github.com/Effect-TS/effect/pull/8709) [`81d965e`](https://github.com/Effect-TS/effect/commit/81d965eeb1e832409130dbfaecfe0dd05b025317) Thanks @tim-smart! - Replace pooled MSSQL connections on driver `end` as well as `error`, and install error handling before connecting so early driver errors cannot crash the process. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/mssql/package.json b/repos/effect/packages/sql/mssql/package.json index 1d8cdb8ea..ee057d98b 100644 --- a/repos/effect/packages/sql/mssql/package.json +++ b/repos/effect/packages/sql/mssql/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-mssql", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A Microsoft SQL Server toolkit for Effect", diff --git a/repos/effect/packages/sql/mssql/src/MssqlClient.ts b/repos/effect/packages/sql/mssql/src/MssqlClient.ts index 424d781f6..8249e3ae6 100644 --- a/repos/effect/packages/sql/mssql/src/MssqlClient.ts +++ b/repos/effect/packages/sql/mssql/src/MssqlClient.ts @@ -14,6 +14,7 @@ */ import * as Config from "effect/Config" import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import { identity } from "effect/Function" @@ -295,6 +296,15 @@ export const make = ( // oxlint-disable-next-line prefer-const let pool: Pool.Pool + const connectionEnded = new WeakMap>() + // Invalidation can run before acquisition publishes the pool item. Check + // the latch on every lease so an early event cannot leave it reusable. + const acquireConnection: Effect.Effect = Effect.suspend(() => + Effect.flatMap(Pool.get(pool), (connection) => + Deferred.isDoneUnsafe(connectionEnded.get(connection)!) + ? Effect.andThen(Pool.invalidate(pool, connection), acquireConnection) + : Effect.succeed(connection)) + ) const makeConnection = Effect.gen(function*() { const conn = new Tedious.Connection({ @@ -331,7 +341,33 @@ export const make = ( } }) - yield* Effect.addFinalizer(() => Effect.sync(() => conn.close())) + const ended = Deferred.makeUnsafe() + let closing = false + let endEmitted = false + const onError = () => { + Deferred.doneUnsafe(ended, Effect.void) + } + const removeListeners = () => { + conn.removeListener("error", onError) + conn.removeListener("end", onEnd) + } + const onEnd = () => { + endEmitted = true + Deferred.doneUnsafe(ended, Effect.void) + if (closing) removeListeners() + } + + // Install before connect and keep the error handler through asynchronous + // close. The latch also records events before the pool item exists. + conn.on("error", onError) + conn.on("end", onEnd) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closing = true + conn.close() + if (endEmitted) removeListeners() + }) + ) yield* Effect.callback((resume) => { conn.connect((cause) => { @@ -523,10 +559,9 @@ export const make = ( }) }) - yield* Effect.callback((resume) => { - conn.on("error", (_) => resume(Effect.fail(_))) - }).pipe( - Effect.catch(() => Pool.invalidate(pool, connection)), + connectionEnded.set(connection, ended) + yield* Deferred.await(ended).pipe( + Effect.andThen(Effect.suspend(() => pool ? Pool.invalidate(pool, connection) : Effect.void)), Effect.interruptible, Effect.forkScoped ) @@ -542,7 +577,7 @@ export const make = ( timeToLiveStrategy: "creation" }) - yield* Pool.get(pool).pipe( + yield* acquireConnection.pipe( Effect.tap((connection) => connection.executeUnprepared("SELECT 1", [], undefined)), Effect.mapError((cause) => new SqlError({ reason: classifyError(cause, "MssqlClient: Failed to connect", "connect", "connection") }) @@ -570,7 +605,7 @@ export const make = ( spanAttributes, acquireConnection: Effect.gen(function*() { const scope = Scope.makeUnsafe() - const conn = yield* Scope.provide(Pool.get(pool), scope) + const conn = yield* Scope.provide(acquireConnection, scope) return [scope, conn] as const }), begin: (conn) => conn.begin, @@ -582,7 +617,7 @@ export const make = ( return identity(Object.assign( yield* Client.make({ - acquirer: Pool.get(pool), + acquirer: acquireConnection, compiler, transactionService: transactionService as any, spanAttributes, @@ -603,9 +638,9 @@ export const make = ( A >( procedure: Procedure.ProcedureWithValues - ) => Effect.scoped(Effect.flatMap(Pool.get(pool), (_) => _.call(procedure, transformRows))), + ) => Effect.scoped(Effect.flatMap(acquireConnection, (_) => _.call(procedure, transformRows))), withoutTransforms() { - const statement = Statement.make(Pool.get(pool), compiler.withoutTransform, spanAttributes, undefined) + const statement = Statement.make(acquireConnection, compiler.withoutTransform, spanAttributes, undefined) const client = Object.assign( statement, this, @@ -617,7 +652,7 @@ export const make = ( A >( procedure: Procedure.ProcedureWithValues - ) => Effect.scoped(Effect.flatMap(Pool.get(pool), (_) => _.call(procedure, undefined))) + ) => Effect.scoped(Effect.flatMap(acquireConnection, (_) => _.call(procedure, undefined))) } ) ;(client as any).safe = client diff --git a/repos/effect/packages/sql/mssql/test/Client.test.ts b/repos/effect/packages/sql/mssql/test/Client.test.ts index c42ab95cb..143f76d12 100644 --- a/repos/effect/packages/sql/mssql/test/Client.test.ts +++ b/repos/effect/packages/sql/mssql/test/Client.test.ts @@ -1,15 +1,33 @@ import { MssqlClient, Procedure } from "@effect/sql-mssql" import { assert, describe, expect, it } from "@effect/vitest" -import { Effect, Fiber } from "effect" +import { Deferred, Effect, Fiber } from "effect" import * as Reactivity from "effect/reactivity/Reactivity" import * as Statement from "effect/sql/Statement" +import type { EventEmitter } from "node:events" import type * as Tedious from "tedious" import { vi } from "vitest" -const state = vi.hoisted(() => ({ cancelCalls: 0, completeRequests: true, type: {} })) +interface MockConnection extends EventEmitter { + closed: boolean + closedSignal: Deferred.Deferred + cancellations: number + queries: Array + close(): void +} + +const state = vi.hoisted(() => ({ + cancelCalls: 0, + completeRequests: true, + type: {}, + connections: [] as Array, + pendingStarted: undefined as Deferred.Deferred | undefined, + errorAfterConnect: false, + emissionError: undefined as unknown +})) vi.mock("tedious", async (importOriginal) => { const original = await importOriginal() + const { EventEmitter } = await import("node:events") class MockRequest { readonly listeners: Record) => void> = {} @@ -26,33 +44,83 @@ vi.mock("tedious", async (importOriginal) => { } } - class MockConnection { + class MockConnection extends EventEmitter { + closed = false + readonly closedSignal = Deferred.makeUnsafe() + pending: MockRequest | undefined + cancellations = 0 + readonly queries: Array = [] + + constructor() { + super() + state.connections.push(this) + } + connect(callback: (cause: unknown) => void) { - callback(null) + // Resume acquisition, then emit in the same driver turn, before a forked + // pool listener can run. Do not install a test-only error listener. + Fiber.getCurrent()!.currentDispatcher.scheduleTask(() => { + callback(null) + if (state.errorAfterConnect) { + state.errorAfterConnect = false + this.closed = true + try { + this.emit("error", new Error("Connection lost - read ETIMEDOUT")) + } catch (error) { + state.emissionError = error + } + } + }, 0) + } + + close() { + if (!this.closed) { + this.closed = true + this.emit("end") + } + Effect.runSync(Deferred.succeed(this.closedSignal, undefined)) } - close() {} - on() {} + cancel() { + // MssqlClient also calls cancel before each request. Count only an actual + // in-flight cancellation, so that cannot satisfy the interruption assertion. state.cancelCalls++ + if (this.pending) { + this.cancellations++ + this.pending = undefined + } } + execSql(request: MockRequest) { - if (state.completeRequests) { - request.callback(null, 0, []) + this.queries.push(request.sql) + if (this.closed) { + request.callback(new Error("Requests can only be made in the LoggedIn state, not the Final state"), 0, []) + } else if (request.sql === "SELECT pending" || !state.completeRequests) { + this.pending = request + if (state.pendingStarted) { + Effect.runSync(Deferred.succeed(state.pendingStarted, undefined)) + } + } else { + request.callback(null, 1, [[{ metadata: { colName: "value" }, value: 42 }]]) } } callProcedure(request: MockRequest) { request.listeners.returnValue("answer", 42) request.callback(null, 0, []) } + beginTransaction(callback: (cause: unknown) => void) { callback(null) } + commitTransaction(callback: (cause: unknown) => void) { callback(null) } + saveTransaction(callback: (cause: unknown) => void) { callback(null) } + rollbackTransaction(callback: (cause: unknown) => void) { callback(null) } @@ -229,3 +297,66 @@ describe("mssql", () => { Effect.provide(Reactivity.layer) )) }) + +const reset = () => { + state.cancelCalls = 0 + state.completeRequests = true + state.connections = [] + state.pendingStarted = Deferred.makeUnsafe() + state.errorAfterConnect = false + state.emissionError = undefined +} + +const makeClient = MssqlClient.make({ + server: "localhost", + minConnections: 0, + maxConnections: 1, + connectionTTL: "1 hour" +}) + +const bounded = (effect: Effect.Effect) => effect.pipe(Effect.timeout("2 seconds")) + +describe("MssqlClient connection lifecycle", { concurrent: false }, () => { + it.live("replaces a connection that ends without error after query cancellation", () => + Effect.gen(function*() { + reset() + const client = yield* bounded(makeClient) + const connection = state.connections[0] + const query = yield* Effect.forkScoped(client.unsafe("SELECT pending")) + yield* bounded(Deferred.await(state.pendingStarted!)) + yield* bounded(Fiber.interrupt(query)) + assert.strictEqual(connection.cancellations, 1) + + // Model tedious' cancel timeout: Final state and end, with no error event. + connection.close() + yield* Effect.yieldNow + const rows = yield* bounded(client.unsafe("SELECT recovered")) + assert.deepStrictEqual(rows, [{ value: 42 }]) + assert.strictEqual(state.connections.length, 2) + assert.isFalse(connection.queries.includes("SELECT recovered")) + assert.isTrue(state.connections[1].queries.includes("SELECT recovered")) + }).pipe(Effect.scoped, Effect.provide(Reactivity.layer))) + + it.live("handles an error immediately after the connect callback and does not reuse the failed connection", () => + Effect.gen(function*() { + reset() + const client = yield* bounded(makeClient) + const original = state.connections[0] + yield* Effect.yieldNow + + // Retire the healthy connection through a normal driver error, then + // inject the callback race on the next acquisition of this same pool. + state.errorAfterConnect = true + original.emit("error", new Error("Connection lost")) + yield* bounded(Deferred.await(original.closedSignal)) + yield* bounded(Effect.result(client.unsafe("SELECT during_race"))) + assert.isUndefined(state.emissionError, "the driver's error emission must not throw") + + const failed = state.connections[1] + assert.isTrue(failed.closed) + const rows = yield* bounded(client.unsafe("SELECT recovered")) + assert.deepStrictEqual(rows, [{ value: 42 }]) + assert.isFalse(failed.queries.includes("SELECT recovered")) + assert.isTrue(state.connections.slice(2).some((connection) => connection.queries.includes("SELECT recovered"))) + }).pipe(Effect.scoped, Effect.provide(Reactivity.layer))) +}) diff --git a/repos/effect/packages/sql/mysql2/CHANGELOG.md b/repos/effect/packages/sql/mysql2/CHANGELOG.md index 0c57e3c73..d9403f57f 100644 --- a/repos/effect/packages/sql/mysql2/CHANGELOG.md +++ b/repos/effect/packages/sql/mysql2/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-mysql2 +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/mysql2/package.json b/repos/effect/packages/sql/mysql2/package.json index 95ae8b467..f0588b592 100644 --- a/repos/effect/packages/sql/mysql2/package.json +++ b/repos/effect/packages/sql/mysql2/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-mysql2", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A MySQL toolkit for Effect", diff --git a/repos/effect/packages/sql/pg/CHANGELOG.md b/repos/effect/packages/sql/pg/CHANGELOG.md index d2a1a594f..81510231e 100644 --- a/repos/effect/packages/sql/pg/CHANGELOG.md +++ b/repos/effect/packages/sql/pg/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/sql-pg +## 4.0.1 + +### Patch Changes + +- [#8679](https://github.com/Effect-TS/effect/pull/8679) [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e) Thanks @tim-smart! - Increase the default idle timeout from 10 to 60 seconds to reduce reconnections between bursts. Idle connections hold PostgreSQL backends and `max_connections` slots longer. Set `idleTimeout: "10 seconds"` to keep the old timeout. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/pg/package.json b/repos/effect/packages/sql/pg/package.json index 449e8f67e..b7eaaed4d 100644 --- a/repos/effect/packages/sql/pg/package.json +++ b/repos/effect/packages/sql/pg/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-pg", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A PostgreSQL toolkit for Effect", diff --git a/repos/effect/packages/sql/pg/src/PgPool.ts b/repos/effect/packages/sql/pg/src/PgPool.ts index 31db9e50e..c3d75942c 100644 --- a/repos/effect/packages/sql/pg/src/PgPool.ts +++ b/repos/effect/packages/sql/pg/src/PgPool.ts @@ -36,7 +36,7 @@ export type TypeId = "~@effect/sql-pg/PgPool" * * **Details** * - * The defaults are 0 to 10 connections and a 10-second idle timeout. + * The defaults are 0 to 10 connections and a 60-second idle timeout. * `connectionTTL` replaces connections that exceed the configured lifetime. * Every connection is used at least once, so a TTL of zero disables reuse. * @@ -160,7 +160,7 @@ export const make = Effect.fnUntraced(function*(options: Config): Effect.fn.Retu concurrency: multiplex ? Math.max(1, options.multiplexConcurrency ?? defaultMultiplexConcurrency) : 1, - timeToLive: options.idleTimeout ?? Duration.seconds(10), + timeToLive: options.idleTimeout ?? Duration.seconds(60), timeToLiveStrategy: "usage" }) diff --git a/repos/effect/packages/sql/pglite/CHANGELOG.md b/repos/effect/packages/sql/pglite/CHANGELOG.md index 053566736..c9af5e1ec 100644 --- a/repos/effect/packages/sql/pglite/CHANGELOG.md +++ b/repos/effect/packages/sql/pglite/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-pglite +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/pglite/package.json b/repos/effect/packages/sql/pglite/package.json index 59e3e011e..e2c851b31 100644 --- a/repos/effect/packages/sql/pglite/package.json +++ b/repos/effect/packages/sql/pglite/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-pglite", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A PGlite toolkit for Effect", diff --git a/repos/effect/packages/sql/sqlite-bun/CHANGELOG.md b/repos/effect/packages/sql/sqlite-bun/CHANGELOG.md index 129efdb2d..4cf5018ab 100644 --- a/repos/effect/packages/sql/sqlite-bun/CHANGELOG.md +++ b/repos/effect/packages/sql/sqlite-bun/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/sql-sqlite-bun +## 4.0.1 + +### Patch Changes + +- [#8708](https://github.com/Effect-TS/effect/pull/8708) [`e5930bd`](https://github.com/Effect-TS/effect/commit/e5930bd1f8c8e30862767cb8f4d50211b52178d1) Thanks @tim-smart! - Retry WAL initialization contention within busyTimeout and report database setup failures as typed SqlError values. Skip WAL initialization for read-only Node connections. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/sqlite-bun/package.json b/repos/effect/packages/sql/sqlite-bun/package.json index c31cf8f27..bab05044a 100644 --- a/repos/effect/packages/sql/sqlite-bun/package.json +++ b/repos/effect/packages/sql/sqlite-bun/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-sqlite-bun", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A SQLite toolkit for Effect", diff --git a/repos/effect/packages/sql/sqlite-bun/src/SqliteClient.ts b/repos/effect/packages/sql/sqlite-bun/src/SqliteClient.ts index 44129edfc..e243dc3f6 100644 --- a/repos/effect/packages/sql/sqlite-bun/src/SqliteClient.ts +++ b/repos/effect/packages/sql/sqlite-bun/src/SqliteClient.ts @@ -14,6 +14,7 @@ * @since 4.0.0 */ import { constants, Database } from "bun:sqlite" +import * as Clock from "effect/Clock" import * as Config from "effect/Config" import * as Context from "effect/Context" import * as Duration from "effect/Duration" @@ -21,6 +22,7 @@ import * as Effect from "effect/Effect" import { identity } from "effect/Function" import * as Layer from "effect/Layer" import * as Reactivity from "effect/reactivity/Reactivity" +import * as Schedule from "effect/Schedule" import type * as Scope from "effect/Scope" import * as Semaphore from "effect/Semaphore" import * as Client from "effect/sql/SqlClient" @@ -31,6 +33,7 @@ import * as Stream from "effect/Stream" const ATTR_DB_SYSTEM_NAME = "db.system.name" const MAX_BUSY_TIMEOUT = 2_147_483_647 +const NANOS_PER_MILLI = BigInt(1_000_000) const classifyError = (cause: unknown, message: string, operation: string) => classifySqliteError(cause, { message, operation }) @@ -118,7 +121,7 @@ interface SqliteConnection extends Connection { */ export const make = ( options: SqliteClientConfig -): Effect.Effect => +): Effect.Effect => Effect.gen(function*() { const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) const transformRows = options.transformResultNames ? @@ -134,24 +137,52 @@ export const make = ( readwrite: readonly ? false : options.readwrite ?? true, create: readonly ? false : options.create ?? true } - const db = new Database( - options.filename, - options.filename.startsWith("file:") - ? constants.SQLITE_OPEN_URI | - (openOptions.readonly ? constants.SQLITE_OPEN_READONLY : 0) | - (openOptions.readwrite || openOptions.create ? constants.SQLITE_OPEN_READWRITE : 0) | - (openOptions.create ? constants.SQLITE_OPEN_CREATE : 0) - : openOptions + const db = yield* Effect.acquireRelease( + Effect.try({ + try: () => + new Database( + options.filename, + options.filename.startsWith("file:") + ? constants.SQLITE_OPEN_URI | + (openOptions.readonly ? constants.SQLITE_OPEN_READONLY : 0) | + (openOptions.readwrite || openOptions.create ? constants.SQLITE_OPEN_READWRITE : 0) | + (openOptions.create ? constants.SQLITE_OPEN_CREATE : 0) + : openOptions + ), + catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to open database", "connect") }) + }), + (db) => Effect.sync(() => db.close()) ) - yield* Effect.addFinalizer(() => Effect.sync(() => db.close())) const busyTimeout = Math.min( MAX_BUSY_TIMEOUT, Math.max(0, Math.round(Duration.toMillis(options.busyTimeout ?? Duration.seconds(5)))) ) - db.run(`PRAGMA busy_timeout = ${busyTimeout};`) + const exec = (sql: string) => + Effect.try({ + try: () => db.run(sql), + catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to configure database", "connect") }) + }) + yield* exec(`PRAGMA busy_timeout = ${busyTimeout};`) if (options.disableWAL !== true && !readonly) { - db.run("PRAGMA journal_mode = WAL;") + // Switching to WAL can fail with SQLITE_BUSY without invoking the busy + // handler, so retry it until busyTimeout has elapsed. Each attempt's + // native busy wait is limited to the remaining time. + const deadline = (yield* Clock.monotonicTimeNanos) + BigInt(busyTimeout) * NANOS_PER_MILLI + const remainingMillis = Effect.map( + Clock.monotonicTimeNanos, + (now) => Math.max(0, Number((deadline - now) / NANOS_PER_MILLI)) + ) + yield* remainingMillis.pipe( + Effect.flatMap((remaining) => exec(`PRAGMA busy_timeout = ${remaining}`)), + Effect.andThen(exec("PRAGMA journal_mode = WAL;")), + Effect.retry({ + while: (error) => + error.reason._tag === "LockTimeoutError" && Effect.map(remainingMillis, (remaining) => remaining > 0), + schedule: Schedule.spaced("10 millis") + }) + ) + yield* exec(`PRAGMA busy_timeout = ${busyTimeout}`) } const prepare = (sql: string, useSafeIntegers: boolean) => { @@ -267,7 +298,7 @@ export const make = ( */ export const layerConfig = ( config: Config.Wrap -): Layer.Layer => +): Layer.Layer => Layer.effectContext( Config.unwrap(config).pipe( Effect.flatMap(make), @@ -287,7 +318,7 @@ export const layerConfig = ( */ export const layer = ( config: SqliteClientConfig -): Layer.Layer => +): Layer.Layer => Layer.effectContext( Effect.map(make(config), (client) => Context.make(SqliteClient, client).pipe( diff --git a/repos/effect/packages/sql/sqlite-bun/test/Client.test.ts b/repos/effect/packages/sql/sqlite-bun/test/Client.test.ts index 33714c370..b39295268 100644 --- a/repos/effect/packages/sql/sqlite-bun/test/Client.test.ts +++ b/repos/effect/packages/sql/sqlite-bun/test/Client.test.ts @@ -1,12 +1,30 @@ import { assert, describe, it } from "@effect/vitest" -import { Cause, Duration, Effect, Exit } from "effect" +import { Cause, Duration, Effect, Exit, Fiber } from "effect" import { Reactivity } from "effect/reactivity" +import { TestClock } from "effect/testing" import { rejects } from "node:assert/strict" -import { rm, stat } from "node:fs/promises" +import { mkdtemp, rm, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { pathToFileURL } from "node:url" const isBun = "bun" in process.versions +const makeLockedDatabase = Effect.gen(function*() { + const { Database } = yield* Effect.promise(() => import("bun:sqlite")) + const dir = yield* Effect.acquireRelease( + Effect.promise(() => mkdtemp(join(tmpdir(), "effect-sqlite-bun-"))), + (dir) => Effect.promise(() => rm(dir, { recursive: true, force: true })) + ) + const filename = join(dir, "test.db") + const lock = yield* Effect.acquireRelease( + Effect.sync(() => new Database(filename)), + (db) => Effect.sync(() => db.close()) + ) + lock.run("BEGIN IMMEDIATE") + return { filename, unlock: () => lock.run("ROLLBACK") } +}) + describe("Client", () => { it.effect("should work", () => Effect.void) @@ -162,17 +180,11 @@ describe("Client", () => { const filename = `/tmp/effect-sqlite-bun-uri-missing-${crypto.randomUUID()}.db` yield* Effect.acquireRelease(Effect.void, () => Effect.promise(() => rm(filename, { force: true }))) - yield* Effect.promise(async () => { - await rejects( - Effect.runPromise( - Effect.scoped(SqliteClient.make({ filename: pathToFileURL(filename).href, create: false })).pipe( - Effect.provide(Reactivity.layer) - ) - ), - /unable to open database file/i - ) - await rejects(stat(filename), { code: "ENOENT" }) - }) + const error = yield* Effect.flip( + Effect.scoped(SqliteClient.make({ filename: pathToFileURL(filename).href, create: false })) + ) + assert.strictEqual(error.reason._tag, "ConnectionError") + yield* Effect.promise(() => rejects(stat(filename), { code: "ENOENT" })) }).pipe(Effect.provide(Reactivity.layer))) it.effect.skipIf(!isBun)("create implies readwrite for file: URIs", () => @@ -185,4 +197,27 @@ describe("Client", () => { yield* sql`INSERT INTO test (id) VALUES (1)` assert.deepStrictEqual(yield* sql`SELECT * FROM test`, [{ id: 1 }]) }).pipe(Effect.provide(Reactivity.layer))) + + it.effect.skipIf(!isBun)("retries enabling WAL while the database is locked", () => + Effect.gen(function*() { + const { SqliteClient } = yield* Effect.promise(() => import("@effect/sql-sqlite-bun")) + const { filename, unlock } = yield* makeLockedDatabase + const fiber = yield* SqliteClient.make({ filename }).pipe(Effect.forkChild({ startImmediately: true })) + unlock() + yield* TestClock.adjust("10 millis") + const sql = yield* Fiber.join(fiber) + assert.deepStrictEqual(yield* sql`PRAGMA journal_mode`, [{ journal_mode: "wal" }]) + }).pipe(Effect.provide(Reactivity.layer))) + + it.effect.skipIf(!isBun)("fails to enable WAL with a typed error after busyTimeout", () => + Effect.gen(function*() { + const { SqliteClient } = yield* Effect.promise(() => import("@effect/sql-sqlite-bun")) + const { filename } = yield* makeLockedDatabase + const fiber = yield* SqliteClient.make({ filename, busyTimeout: "1 second" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + yield* TestClock.adjust("2 seconds") + const error = yield* Effect.flip(Fiber.join(fiber)) + assert.strictEqual(error.reason._tag, "LockTimeoutError") + }).pipe(Effect.provide(Reactivity.layer))) }) diff --git a/repos/effect/packages/sql/sqlite-do/CHANGELOG.md b/repos/effect/packages/sql/sqlite-do/CHANGELOG.md index ba136f660..73f09f536 100644 --- a/repos/effect/packages/sql/sqlite-do/CHANGELOG.md +++ b/repos/effect/packages/sql/sqlite-do/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-sqlite-do +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/sqlite-do/package.json b/repos/effect/packages/sql/sqlite-do/package.json index c2617b840..96a45f7c6 100644 --- a/repos/effect/packages/sql/sqlite-do/package.json +++ b/repos/effect/packages/sql/sqlite-do/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-sqlite-do", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A SQLite toolkit for Effect", diff --git a/repos/effect/packages/sql/sqlite-node/CHANGELOG.md b/repos/effect/packages/sql/sqlite-node/CHANGELOG.md index 96473164d..1fb855a1b 100644 --- a/repos/effect/packages/sql/sqlite-node/CHANGELOG.md +++ b/repos/effect/packages/sql/sqlite-node/CHANGELOG.md @@ -1,5 +1,17 @@ # @effect/sql-sqlite-node +## 4.0.1 + +### Patch Changes + +- [#8688](https://github.com/Effect-TS/effect/pull/8688) [`7336154`](https://github.com/Effect-TS/effect/commit/7336154e2c60a22e3433cb2bb69aca2a04e6253d) Thanks @danieljvdm! - Fix missing rows from cached SQLite statements after changing `PRAGMA count_changes`. + +- [#8716](https://github.com/Effect-TS/effect/pull/8716) [`234672b`](https://github.com/Effect-TS/effect/commit/234672ba8650196369dcdc74c317104540c54dc3) Thanks @juliusmarminge! - Do not cache failed statement preparations, allowing queries to retry after transient errors. + +- [#8708](https://github.com/Effect-TS/effect/pull/8708) [`e5930bd`](https://github.com/Effect-TS/effect/commit/e5930bd1f8c8e30862767cb8f4d50211b52178d1) Thanks @tim-smart! - Retry WAL initialization contention within busyTimeout and report database setup failures as typed SqlError values. Skip WAL initialization for read-only Node connections. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/sqlite-node/package.json b/repos/effect/packages/sql/sqlite-node/package.json index 7c41bc515..306125d4d 100644 --- a/repos/effect/packages/sql/sqlite-node/package.json +++ b/repos/effect/packages/sql/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-sqlite-node", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A SQLite toolkit for Effect", diff --git a/repos/effect/packages/sql/sqlite-node/src/SqliteClient.ts b/repos/effect/packages/sql/sqlite-node/src/SqliteClient.ts index f35fd5535..5192b94f1 100644 --- a/repos/effect/packages/sql/sqlite-node/src/SqliteClient.ts +++ b/repos/effect/packages/sql/sqlite-node/src/SqliteClient.ts @@ -15,14 +15,17 @@ * @since 4.0.0 */ import * as Cache from "effect/Cache" +import * as Clock from "effect/Clock" import * as Config from "effect/Config" import * as Context from "effect/Context" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" import { identity } from "effect/Function" import * as Layer from "effect/Layer" import * as Reactivity from "effect/reactivity/Reactivity" -import * as Scope from "effect/Scope" +import * as Schedule from "effect/Schedule" +import type * as Scope from "effect/Scope" import * as Semaphore from "effect/Semaphore" import * as Client from "effect/sql/SqlClient" import type { Connection } from "effect/sql/SqlConnection" @@ -34,6 +37,7 @@ import type { StatementSync } from "node:sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const MAX_BUSY_TIMEOUT = 2_147_483_647 +const NANOS_PER_MILLI = BigInt(1_000_000) /** * Runtime type identifier used to mark Node `SqliteClient` values. @@ -124,7 +128,7 @@ interface SqliteConnection extends Connection { */ export const make = ( options: SqliteClientConfig -): Effect.Effect => +): Effect.Effect => Effect.gen(function*() { const compiler = Statement.makeCompilerSqlite(options.transformQueryNames) const transformRows = options.transformResultNames ? @@ -134,21 +138,48 @@ export const make = ( undefined const makeConnection = Effect.gen(function*() { - const scope = yield* Effect.scope - const db = new DatabaseSync(options.filename, { - readOnly: options.readonly ?? false, - allowExtension: true - }) - yield* Scope.addFinalizer(scope, Effect.sync(() => db.close())) + const db = yield* Effect.acquireRelease( + Effect.try({ + try: () => + new DatabaseSync(options.filename, { + readOnly: options.readonly ?? false, + allowExtension: true + }), + catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to open database", "connect") }) + }), + (db) => Effect.sync(() => db.close()) + ) db.enableLoadExtension(false) const busyTimeout = Math.min( MAX_BUSY_TIMEOUT, Math.max(0, Math.round(Duration.toMillis(options.busyTimeout ?? Duration.seconds(5)))) ) - db.exec(`PRAGMA busy_timeout = ${busyTimeout}`) + const exec = (sql: string) => + Effect.try({ + try: () => db.exec(sql), + catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to configure database", "connect") }) + }) + yield* exec(`PRAGMA busy_timeout = ${busyTimeout}`) - if (options.disableWAL !== true) { - db.exec("PRAGMA journal_mode = WAL") + if (options.disableWAL !== true && !options.readonly) { + // Switching to WAL can fail with SQLITE_BUSY without invoking the busy + // handler, so retry it until busyTimeout has elapsed. Each attempt's + // native busy wait is limited to the remaining time. + const deadline = (yield* Clock.monotonicTimeNanos) + BigInt(busyTimeout) * NANOS_PER_MILLI + const remainingMillis = Effect.map( + Clock.monotonicTimeNanos, + (now) => Math.max(0, Number((deadline - now) / NANOS_PER_MILLI)) + ) + yield* remainingMillis.pipe( + Effect.flatMap((remaining) => exec(`PRAGMA busy_timeout = ${remaining}`)), + Effect.andThen(exec("PRAGMA journal_mode = WAL")), + Effect.retry({ + while: (error) => + error.reason._tag === "LockTimeoutError" && Effect.map(remainingMillis, (remaining) => remaining > 0), + schedule: Schedule.spaced("10 millis") + }) + ) + yield* exec(`PRAGMA busy_timeout = ${busyTimeout}`) } const prepare = (sql: string) => @@ -157,10 +188,10 @@ export const make = ( catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to prepare statement", "prepare") }) }) - const prepareCache = yield* Cache.make({ + const prepareCacheTTL = options.prepareCacheTTL ?? Duration.minutes(10) + const prepareCache = yield* Cache.makeWith(prepare, { capacity: options.prepareCacheSize ?? 200, - timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), - lookup: prepare + timeToLive: (exit) => Exit.isSuccess(exit) ? prepareCacheTTL : Duration.zero }) const runStatement = ( @@ -173,11 +204,11 @@ export const make = ( return Effect.try({ try: () => { statement.setReadBigInts(useSafeIntegers) - if (statement.columns().length > 0) { + if (!raw || statement.columns().length > 0) { return statement.all(...(params as Array)) as ReadonlyArray } const result = statement.run(...(params as Array)) - return raw ? { changes: result.changes, lastInsertRowid: result.lastInsertRowid } as any : [] + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } as any }, catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") }) }) @@ -192,11 +223,7 @@ export const make = ( return Effect.try({ try: () => { statement.setReadBigInts(useSafeIntegers) - if (statement.columns().length > 0) { - return statement.all(...(params as Array)) as unknown as ReadonlyArray> - } - statement.run(...(params as Array)) - return [] + return statement.all(...(params as Array)) as unknown as ReadonlyArray> }, catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") }) }) @@ -212,11 +239,7 @@ export const make = ( try: () => { statement.setReadBigInts(useSafeIntegers) statement.setReturnArrays(true) - if (statement.columns().length > 0) { - return statement.all(...(params as Array)) as unknown as ReadonlyArray> - } - statement.run(...(params as Array)) - return [] + return statement.all(...(params as Array)) as unknown as ReadonlyArray> }, catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") }) }) @@ -340,7 +363,7 @@ export const make = ( */ export const layerConfig = ( config: Config.Wrap -): Layer.Layer => +): Layer.Layer => Layer.effectContext( Config.unwrap(config).pipe( Effect.flatMap(make), @@ -360,7 +383,7 @@ export const layerConfig = ( */ export const layer = ( config: SqliteClientConfig -): Layer.Layer => +): Layer.Layer => Layer.effectContext( Effect.map(make(config), (client) => Context.make(SqliteClient, client).pipe( diff --git a/repos/effect/packages/sql/sqlite-node/test/Client.test.ts b/repos/effect/packages/sql/sqlite-node/test/Client.test.ts index 7755f0c6e..2f71bb806 100644 --- a/repos/effect/packages/sql/sqlite-node/test/Client.test.ts +++ b/repos/effect/packages/sql/sqlite-node/test/Client.test.ts @@ -1,9 +1,11 @@ import { NodeFileSystem } from "@effect/platform-node" import { SqliteClient } from "@effect/sql-sqlite-node" import { assert, describe, it } from "@effect/vitest" -import { Cause, Duration, Effect, Exit, FileSystem, Option } from "effect" +import { Cause, Duration, Effect, Exit, Fiber, FileSystem, Option } from "effect" import { Reactivity } from "effect/reactivity" import { ConnectionError, SqlError } from "effect/sql/SqlError" +import { TestClock } from "effect/testing" +import { DatabaseSync } from "node:sqlite" const makeClient = Effect.gen(function*() { const fs = yield* FileSystem.FileSystem @@ -23,6 +25,18 @@ const makeClients = Effect.gen(function*() { } }).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer])) +const makeLockedDatabase = Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + const filename = dir + "/test.db" + const lock = yield* Effect.acquireRelease( + Effect.sync(() => new DatabaseSync(filename)), + (db) => Effect.sync(() => db.close()) + ) + lock.exec("BEGIN IMMEDIATE") + return { filename, unlock: () => lock.exec("ROLLBACK") } +}).pipe(Effect.provide(NodeFileSystem.layer)) + describe("Client", () => { it.effect("releases completed nested savepoints", () => Effect.gen(function*() { @@ -68,8 +82,48 @@ describe("Client", () => { { id: 1, name: "hello" }, { id: 2, name: "world" } ]) + response = yield* sql`INSERT INTO test (name) VALUES ('unprepared')`.valuesUnprepared + assert.deepStrictEqual(response, []) + assert.deepStrictEqual(yield* sql`SELECT * FROM test WHERE id = 3`, [{ id: 3, name: "unprepared" }]) })) + it.effect.each(["rows", "values"] as const)( + "returns cached INSERT %s across count_changes OFF → ON → OFF", + (mode) => + Effect.gen(function*() { + const sql = yield* makeClient + yield* sql`CREATE TABLE count_changes (value INTEGER)` + yield* sql`PRAGMA count_changes = OFF` + const insert = sql`INSERT INTO count_changes VALUES (1)` + const execute = mode === "values" ? insert.values : insert + + assert.deepStrictEqual(yield* execute, []) + yield* sql`PRAGMA count_changes = ON` + // Node may omit fields after recompilation; check only the row count. + assert.lengthOf(yield* execute, 1) + yield* sql`PRAGMA count_changes = OFF` + assert.deepStrictEqual(yield* execute, []) + assert.deepStrictEqual(yield* sql`SELECT COUNT(*) AS count FROM count_changes`, [{ count: 3 }]) + }) + ) + + it.effect.each(["rows", "values"] as const)( + "retries %s queries after a missing table is created", + (mode) => + Effect.gen(function*() { + const sql = yield* makeClient + const select = sql`SELECT value FROM created_later` + const execute: Effect.Effect, SqlError> = mode === "values" ? select.values : select + + const error = yield* Effect.flip(execute) + assert.strictEqual(error._tag, "SqlError") + yield* sql`CREATE TABLE created_later (value INTEGER)` + yield* sql`INSERT INTO created_later VALUES (1)` + + assert.deepStrictEqual(yield* execute, mode === "values" ? [[1]] : [{ value: 1 }]) + }) + ) + it.effect("should work with raw", () => Effect.gen(function*() { const sql = yield* makeClient @@ -302,4 +356,42 @@ describe("Client", () => { assert(metadata.totalPages > 0) assert.strictEqual(metadata.remainingPages, 0) })) + + it.effect("retries enabling WAL while the database is locked", () => + Effect.gen(function*() { + const { filename, unlock } = yield* makeLockedDatabase + const fiber = yield* SqliteClient.make({ filename }).pipe(Effect.forkChild({ startImmediately: true })) + unlock() + yield* TestClock.adjust("10 millis") + const sql = yield* Fiber.join(fiber) + assert.deepStrictEqual(yield* sql`PRAGMA journal_mode`, [{ journal_mode: "wal" }]) + }).pipe(Effect.provide(Reactivity.layer))) + + it.effect("fails to enable WAL with a typed error after busyTimeout", () => + Effect.gen(function*() { + const { filename } = yield* makeLockedDatabase + const fiber = yield* SqliteClient.make({ filename, busyTimeout: "1 second" }).pipe( + Effect.forkChild({ startImmediately: true }) + ) + yield* TestClock.adjust("2 seconds") + const error = yield* Effect.flip(Fiber.join(fiber)) + assert.strictEqual(error.reason._tag, "LockTimeoutError") + }).pipe(Effect.provide(Reactivity.layer))) + + it.effect("does not enable WAL on readonly clients", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + const filename = dir + "/test.db" + + yield* Effect.scoped( + Effect.gen(function*() { + const sql = yield* SqliteClient.make({ filename, disableWAL: true }) + yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY)` + }) + ) + + const sql = yield* SqliteClient.make({ filename, readonly: true }) + assert.deepStrictEqual(yield* sql`PRAGMA journal_mode`, [{ journal_mode: "delete" }]) + }).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer]))) }) diff --git a/repos/effect/packages/sql/sqlite-react-native/CHANGELOG.md b/repos/effect/packages/sql/sqlite-react-native/CHANGELOG.md index 7682ed40c..ebf09e852 100644 --- a/repos/effect/packages/sql/sqlite-react-native/CHANGELOG.md +++ b/repos/effect/packages/sql/sqlite-react-native/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-sqlite-react-native +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/sqlite-react-native/package.json b/repos/effect/packages/sql/sqlite-react-native/package.json index ec92d21af..002db5b12 100644 --- a/repos/effect/packages/sql/sqlite-react-native/package.json +++ b/repos/effect/packages/sql/sqlite-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-sqlite-react-native", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A SQLite toolkit for Effect", diff --git a/repos/effect/packages/sql/sqlite-wasm/CHANGELOG.md b/repos/effect/packages/sql/sqlite-wasm/CHANGELOG.md index 9dda93897..188d11024 100644 --- a/repos/effect/packages/sql/sqlite-wasm/CHANGELOG.md +++ b/repos/effect/packages/sql/sqlite-wasm/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/sql-sqlite-wasm +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/sql/sqlite-wasm/package.json b/repos/effect/packages/sql/sqlite-wasm/package.json index 3f4db9e2c..4e7742996 100644 --- a/repos/effect/packages/sql/sqlite-wasm/package.json +++ b/repos/effect/packages/sql/sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@effect/sql-sqlite-wasm", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A SQLite toolkit for Effect", diff --git a/repos/effect/packages/tools/ai-codegen/test/OpenRouterTranscriptions.test.ts b/repos/effect/packages/tools/ai-codegen/test/OpenRouterTranscriptions.test.ts new file mode 100644 index 000000000..bf91255d7 --- /dev/null +++ b/repos/effect/packages/tools/ai-codegen/test/OpenRouterTranscriptions.test.ts @@ -0,0 +1,62 @@ +import * as Config from "@effect/ai-codegen/Config" +import * as Generator from "@effect/ai-codegen/Generator" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Yaml from "effect/encoding/Yaml" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Schema from "effect/Schema" + +const operation = (operationId: string) => ({ + operationId, + requestBody: { + required: true, + content: { + "application/json": { + schema: { type: "object", properties: { input_audio: { type: "string" } }, required: ["input_audio"] } + }, + "multipart/form-data": { + schema: { type: "object", properties: { file: { type: "string", format: "binary" } }, required: ["file"] } + } + } + }, + responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "string" } } } } } +}) + +describe("OpenRouter transcription spec patch", () => { + it.effect("selects JSON only for transcriptions while preserving multipart elsewhere", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const generator = yield* Generator.CodeGenerator + const config = Schema.decodeUnknownSync(Config.CodegenConfig)(Yaml.parse( + yield* fs.readFileString(new URL("../../../ai/openrouter/codegen.yml", import.meta.url).pathname) + )) + const patches = config.patchList.filter((patch) => patch.includes("/paths/~1audio~1transcriptions/")) + assert.strictEqual(patches.length, 1) + const generate = (patches: ReadonlyArray) => + generator.generate({ + name: "openrouter", + packagePath: ".", + outputPath: "Generated.ts", + specSource: Config.SpecSource.File("fixture.json"), + config: new Config.CodegenConfig({ ...config, patches }) + }, { + openapi: "3.1.0", + info: { title: "Transcriptions", version: "1.0.0" }, + paths: { + "/audio/transcriptions": { post: operation("createAudioTranscriptions") }, + "/uploads": { post: operation("upload") } + } + }) + const before = yield* generate([]) + const after = yield* generate(patches) + assert.include(before, "CreateAudioTranscriptionsRequestFormData.Encoded") + assert.notInclude(after, "CreateAudioTranscriptionsRequestFormData") + assert.include(after, "CreateAudioTranscriptionsRequestJson.Encoded") + assert.include(after, "UploadRequestFormData.Encoded") + assert.strictEqual(before.match(/HttpClientRequest.bodyFormDataRecord/g)?.length, 2) + assert.strictEqual(after.match(/HttpClientRequest.bodyFormDataRecord/g)?.length, 1) + assert.strictEqual(after.match(/HttpClientRequest.bodyJsonUnsafe/g)?.length, 1) + }).pipe(Effect.provide(Generator.layerSchema.pipe(Layer.provideMerge(NodeServices.layer))))) +}) diff --git a/repos/effect/packages/tools/docgen/CHANGELOG.md b/repos/effect/packages/tools/docgen/CHANGELOG.md index 2de38fe28..030c2a9d9 100644 --- a/repos/effect/packages/tools/docgen/CHANGELOG.md +++ b/repos/effect/packages/tools/docgen/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/docgen +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8), [`0f64715`](https://github.com/Effect-TS/effect/commit/0f647153eda5696d79e68f0f5762aaf6d98f1ba9)]: + - effect@4.0.1 + - @effect/platform-node@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/tools/docgen/package.json b/repos/effect/packages/tools/docgen/package.json index f7bd6b01a..bfcf4d530 100644 --- a/repos/effect/packages/tools/docgen/package.json +++ b/repos/effect/packages/tools/docgen/package.json @@ -1,6 +1,6 @@ { "name": "@effect/docgen", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "An opinionated documentation generator for Effect projects", diff --git a/repos/effect/packages/tools/doctest/CHANGELOG.md b/repos/effect/packages/tools/doctest/CHANGELOG.md index 29a4f69e8..ef551409c 100644 --- a/repos/effect/packages/tools/doctest/CHANGELOG.md +++ b/repos/effect/packages/tools/doctest/CHANGELOG.md @@ -1,5 +1,12 @@ # @effect/doctest +## 4.0.1 + +### Patch Changes + +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/tools/doctest/package.json b/repos/effect/packages/tools/doctest/package.json index b7aed0cb3..4f4579cbf 100644 --- a/repos/effect/packages/tools/doctest/package.json +++ b/repos/effect/packages/tools/doctest/package.json @@ -1,6 +1,6 @@ { "name": "@effect/doctest", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "Runs TypeScript documentation examples as Vitest tests", diff --git a/repos/effect/packages/tools/openapi-generator/CHANGELOG.md b/repos/effect/packages/tools/openapi-generator/CHANGELOG.md index ed3cb4f7f..9ec684fcd 100644 --- a/repos/effect/packages/tools/openapi-generator/CHANGELOG.md +++ b/repos/effect/packages/tools/openapi-generator/CHANGELOG.md @@ -1,5 +1,16 @@ # @effect/openapi-generator +## 4.0.1 + +### Patch Changes + +- [#8669](https://github.com/Effect-TS/effect/pull/8669) [`b9188c6`](https://github.com/Effect-TS/effect/commit/b9188c67ae90ca4cd50e442b3b228371628aac06) Thanks @tim-smart! - Fix generated HTTP client success types for dynamic and optional `includeResponse` options. Both `httpclient` formats now return a union of the body and response tuple when the flag may be true, while omitted configuration still returns only the body type. + +- [#8672](https://github.com/Effect-TS/effect/pull/8672) [`6389d9a`](https://github.com/Effect-TS/effect/commit/6389d9ac64c0f62ccc8b575fb9afc65fc104e814) Thanks @tim-smart! - Preserve body-only success results in generated HTTP clients when response configuration may be undefined. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`f79b231`](https://github.com/Effect-TS/effect/commit/f79b23118bce44696c044bf7b1141ec55fece216), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8), [`0f64715`](https://github.com/Effect-TS/effect/commit/0f647153eda5696d79e68f0f5762aaf6d98f1ba9)]: + - effect@4.0.1 + - @effect/platform-node@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/tools/openapi-generator/package.json b/repos/effect/packages/tools/openapi-generator/package.json index 235c5d114..7d8cc598b 100644 --- a/repos/effect/packages/tools/openapi-generator/package.json +++ b/repos/effect/packages/tools/openapi-generator/package.json @@ -1,7 +1,7 @@ { "name": "@effect/openapi-generator", "type": "module", - "version": "4.0.0", + "version": "4.0.1", "license": "MIT", "description": "Generate Effect Schema types, HTTP clients, and HttpApi modules from OpenAPI specifications", "homepage": "https://effect.website", diff --git a/repos/effect/packages/tools/openapi-generator/src/OpenApiTransformer.ts b/repos/effect/packages/tools/openapi-generator/src/OpenApiTransformer.ts index 136072298..f1fdff775 100644 --- a/repos/effect/packages/tools/openapi-generator/src/OpenApiTransformer.ts +++ b/repos/effect/packages/tools/openapi-generator/src/OpenApiTransformer.ts @@ -153,15 +153,7 @@ ${clientErrorSource(name)}` const type = `typeof ${operation.payload}.Encoded` options.push(`${key}: ${type}`) } - options.push("readonly config?: Config | undefined") - - // If all options are optional, the argument itself should be optional const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload - if (hasOptions) { - args.push(`options: { ${options.join("; ")} }`) - } else { - args.push(`options: { ${options.join("; ")} } | undefined`) - } const successTypes = new Set(Array.from(responses.successSchemas.values(), (schema) => `typeof ${schema}.Type`)) if (responses.binarySuccessStatuses.size > 0) { @@ -186,10 +178,7 @@ ${clientErrorSource(name)}` const jsdoc = Utils.toComment(operation.description) const methodKey = `readonly "${operation.id}"` - const generic = `` - const parameters = args.join(", ") - const returnType = `Effect.Effect, ${errors.join(" | ")}>` - return `${jsdoc}${methodKey}: ${generic}(${parameters}) => ${returnType}` + return `${jsdoc}${methodKey}: ${operationSignatures(args, options, Boolean(hasOptions), success, errors)}` } const operationToSseMethod = (_name: string, operation: ParsedOperation) => { @@ -265,7 +254,7 @@ ${clientErrorSource(name)}` const requirements = computeImportRequirements(operations) const implMethods: Array = [] for (const op of operations) { - implMethods.push(operationToImpl(op)) + implMethods.push(operationToImpl(name, op)) if (op.httpClientResponses.sseSchema) { implMethods.push(operationToSseImpl(importName, op)) } @@ -312,9 +301,11 @@ ${clientErrorSource(name)}` * of an operation based upon the value of the \`includeResponse\` configuration * option. */ -export type WithOptionalResponse = Config extends { +export type WithOptionalResponse = Config extends { readonly includeResponse: true -} ? [A, HttpClientResponse.HttpClientResponse] : A +} ? [A, HttpClientResponse.HttpClientResponse] + : Config extends { readonly includeResponse?: false | undefined } | undefined ? A + : A | [A, HttpClientResponse.HttpClientResponse] export const make = ( httpClient: HttpClient.HttpClient, @@ -341,10 +332,12 @@ export const make = ( }` } - const operationToImpl = (operation: ParsedOperation) => { + const operationToImpl = (name: string, operation: ParsedOperation) => { const responses = operation.httpClientResponses - const args: Array = [...operation.pathIds, "options"] - const params = `${args.join(", ")}` + const params = [ + ...operation.pathIds, + `options: Parameters<${name}["${operation.id}"]>[${operation.pathIds.length}]` + ].join(", ") const pipeline: Array = [] @@ -591,15 +584,7 @@ ${clientErrorSource(name)}` if (operation.payload) { options.push(`readonly payload: ${operation.payload}`) } - options.push("readonly config?: Config | undefined") - - // If all options are optional, the argument itself should be optional const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload - if (hasOptions) { - args.push(`options: { ${options.join("; ")} }`) - } else { - args.push(`options: { ${options.join("; ")} } | undefined`) - } const successTypes = new Set(responses.successSchemas.values()) if (responses.binarySuccessStatuses.size > 0) { @@ -622,10 +607,7 @@ ${clientErrorSource(name)}` const jsdoc = Utils.toComment(operation.description) const methodKey = `readonly "${operation.id}"` - const generic = `` - const parameters = args.join(", ") - const returnType = `Effect.Effect, ${errors.join(" | ")}>` - return `${jsdoc}${methodKey}: ${generic}(${parameters}) => ${returnType}` + return `${jsdoc}${methodKey}: ${operationSignatures(args, options, Boolean(hasOptions), success, errors)}` } const operationToSseMethod = (operation: ParsedOperation) => { @@ -697,7 +679,7 @@ ${clientErrorSource(name)}` const requirements = computeImportRequirements(operations) const implMethods: Array = [] for (const op of operations) { - implMethods.push(operationToImpl(op)) + implMethods.push(operationToImpl(name, op)) if (op.httpClientResponses.sseSchema) { implMethods.push(operationToSseImpl(op)) } @@ -743,9 +725,11 @@ ${clientErrorSource(name)}` * of an operation based upon the value of the \`includeResponse\` configuration * option. */ -export type WithOptionalResponse = Config extends { +export type WithOptionalResponse = Config extends { readonly includeResponse: true -} ? [A, HttpClientResponse.HttpClientResponse] : A +} ? [A, HttpClientResponse.HttpClientResponse] + : Config extends { readonly includeResponse?: false | undefined } | undefined ? A + : A | [A, HttpClientResponse.HttpClientResponse] export const make = ( httpClient: HttpClient.HttpClient, @@ -778,10 +762,12 @@ export const make = ( }` } - const operationToImpl = (operation: ParsedOperation) => { + const operationToImpl = (name: string, operation: ParsedOperation) => { const responses = operation.httpClientResponses - const args: Array = [...operation.pathIds, "options"] - const params = `${args.join(", ")}` + const params = [ + ...operation.pathIds, + `options: Parameters<${name}["${operation.id}"]>[${operation.pathIds.length}]` + ].join(", ") const pipeline: Array = [] @@ -1180,6 +1166,29 @@ export const ${name}Error = ( request: response.request, }) as any` +const operationSignatures = ( + args: ReadonlyArray, + options: ReadonlyArray, + optionsRequired: boolean, + success: string, + errors: ReadonlyArray +): string => { + const signature = (configOptional: boolean) => { + const config = configOptional ? "Config | undefined" : "Config" + const fields = [...options, `readonly config${configOptional ? "?" : ""}: ${config}`].join("; ") + const parameters = [...args, `options: { ${fields} }${configOptional && !optionsRequired ? " | undefined" : ""}`] + return ` (${ + parameters.join(", ") + }): Effect.Effect, ${errors.join(" | ")}>;` + } + // Infer the whole config from a required property before allowing omitted config. + // An optional property would otherwise discard undefined during inference. + return `{ +${signature(false)} +${signature(true)} + }` +} + const resolveConfigAccessor = (operation: ParsedOperation, rootKey: string, configKey: string): string => { // If an operation payload is defined, then the root object must exist if (Predicate.isNotUndefined(operation.payload)) { diff --git a/repos/effect/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/repos/effect/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index 36d82b9de..18ad0e3ee 100644 --- a/repos/effect/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/repos/effect/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -730,9 +730,11 @@ export interface OperationConfig { * of an operation based upon the value of the \`includeResponse\` configuration * option. */ -export type WithOptionalResponse = Config extends { +export type WithOptionalResponse = Config extends { readonly includeResponse: true -} ? [A, HttpClientResponse.HttpClientResponse] : A +} ? [A, HttpClientResponse.HttpClientResponse] + : Config extends { readonly includeResponse?: false | undefined } | undefined ? A + : A | [A, HttpClientResponse.HttpClientResponse] export const make = ( httpClient: HttpClient.HttpClient, @@ -812,7 +814,7 @@ export const make = ( ) return { httpClient, - "getUser": (id, options) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe( + "getUser": (id, options: Parameters[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe( Effect.flatMap((request) => request.pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(GetUser200), @@ -825,7 +827,10 @@ export const make = ( export interface TestClient { readonly httpClient: HttpClient.HttpClient - readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError> + readonly "getUser": { + (id: string, options: { readonly config: Config }): Effect.Effect, HttpClientError.HttpClientError | SchemaError>; + (id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect, HttpClientError.HttpClientError | SchemaError>; + } } export interface TestClientError { @@ -1099,15 +1104,19 @@ export const TestClientError = ( `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`, `Effect.map(response.arrayBuffer, (buffer) => new Uint8Array(buffer))`, `"2xx": decodeBinary`, - `readonly "downloadArchive": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect`, + `readonly "downloadArchive": {`, + `(options: { readonly config: Config }): Effect.Effect`, + `(options: { readonly config?: Config | undefined } | undefined): Effect.Effect`, `readonly "downloadArchiveStream": () => Stream.Stream`, `readonly "downloadAvatarStream": () => Stream.Stream`, - `"downloadAvatar": (options) => HttpClientRequest.get("/avatar").pipe( + `"downloadAvatar": (options: Parameters[0]) => HttpClientRequest.get("/avatar").pipe( withResponse(options?.config)(HttpClientResponse.matchStatus({ "2xx": decodeBinary`, `readonly "downloadCustomBinaryStream": () => Stream.Stream`, `"400": decodeError("DownloadMixedContent400", DownloadMixedContent400)`, - `readonly "downloadMixedContent": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect`, + `readonly "downloadMixedContent": {`, + `(options: { readonly config: Config }): Effect.Effect`, + `(options: { readonly config?: Config | undefined } | undefined): Effect.Effect`, `readonly "downloadMixedContentStream": () => Stream.Stream`, `"200": () => Effect.void`, `"404": decodeVoidError("404")`, @@ -1212,9 +1221,11 @@ export interface OperationConfig { * of an operation based upon the value of the \`includeResponse\` configuration * option. */ -export type WithOptionalResponse = Config extends { +export type WithOptionalResponse = Config extends { readonly includeResponse: true -} ? [A, HttpClientResponse.HttpClientResponse] : A +} ? [A, HttpClientResponse.HttpClientResponse] + : Config extends { readonly includeResponse?: false | undefined } | undefined ? A + : A | [A, HttpClientResponse.HttpClientResponse] export const make = ( httpClient: HttpClient.HttpClient, @@ -1317,7 +1328,7 @@ export const make = ( } return { httpClient, - "getUser": (id, options) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe( + "getUser": (id, options: Parameters[1]) => __makePathRequest(HttpClientRequest.get, [id], () => "/users/" + __encodePathParam(id) + "").pipe( Effect.flatMap((request) => request.pipe( onRequest(options?.config)(["2xx"]) )) @@ -1327,7 +1338,10 @@ export const make = ( export interface TestClient { readonly httpClient: HttpClient.HttpClient - readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError> + readonly "getUser": { + (id: string, options: { readonly config: Config }): Effect.Effect, HttpClientError.HttpClientError>; + (id: string, options: { readonly config?: Config | undefined } | undefined): Effect.Effect, HttpClientError.HttpClientError>; + } } export interface TestClientError { @@ -1365,14 +1379,18 @@ export const TestClientError = ( `onRequest(options?.config)([], {"400":"PollDeviceToken400"})`, `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>`, `onRequest(options?.config)([], {"404":"DownloadArchive404"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`, - `readonly "downloadArchive": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect`, + `readonly "downloadArchive": {`, + `(options: { readonly config: Config }): Effect.Effect`, + `(options: { readonly config?: Config | undefined } | undefined): Effect.Effect`, `readonly "downloadAvatarStream": () => Stream.Stream`, - `"downloadAvatar": (options) => HttpClientRequest.get("/avatar").pipe( + `"downloadAvatar": (options: Parameters[0]) => HttpClientRequest.get("/avatar").pipe( onRequest(options?.config)([], undefined, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`, `readonly "downloadCustomBinaryStream": () => Stream.Stream`, `import * as HttpClient from "effect/http/HttpClient"`, `onRequest(options?.config)([], {"400":"DownloadMixedContent400"}, {"binary":["2xx"],"voidSuccess":[],"voidError":[]})`, - `readonly "downloadMixedContent": (options: { readonly config?: Config | undefined } | undefined) => Effect.Effect`, + `readonly "downloadMixedContent": {`, + `(options: { readonly config: Config }): Effect.Effect`, + `(options: { readonly config?: Config | undefined } | undefined): Effect.Effect`, `readonly "downloadMixedContentStream": () => Stream.Stream`, `onRequest(options?.config)([], undefined, {"binary":[],"voidSuccess":["200"],"voidError":["404","500"]})`, `cases[code] = decodeVoidError(code)`, @@ -2948,6 +2966,75 @@ export const __HttpApiMultipartFiles = Multipart.FilesSchema`, }) describe("regression", () => { + for (const format of ["httpclient", "httpclient-type-only"] as const) { + for (const exactOptionalPropertyTypes of [true, false]) { + it.effect( + `preserves includeResponse success types (${format}, exactOptionalPropertyTypes=${exactOptionalPropertyTypes})`, + () => + assertGeneratedClientsCompile({ + openapi: "3.1.0", + info: { title: "Value", version: "1.0.0" }, + components: { schemas: {}, securitySchemes: {} }, + security: [], + tags: [], + paths: { + "/value": { + get: { + operationId: "getValue", + parameters: [], + security: [], + tags: ["Value"], + responses: { + "200": { + description: "Value", + content: { "application/json": { schema: { type: "string" } } } + } + } + } + } + } + }, { + formats: [format], + exactOptionalPropertyTypes, + usage: `type Equal = (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false +type Assert = T +type WithResponse = [string, HttpClientResponse.HttpClientResponse] +declare const client: TestClient +declare const dynamic: boolean +declare const optionalTrue: { readonly includeResponse?: true } +declare const optionalBoolean: { readonly includeResponse?: boolean } +declare const nullableConfig: { readonly includeResponse: true } | undefined + +const enabled = client.getValue({ config: { includeResponse: true } }) +const disabled = client.getValue({ config: { includeResponse: false } }) +const omitted = client.getValue(undefined) +const emptyOptions = client.getValue({}) +const undefinedConfig = client.getValue({ config: undefined }) +const dynamicResult = client.getValue({ config: { includeResponse: dynamic } }) +const optionalTrueResult = client.getValue({ config: optionalTrue }) +const optionalBooleanResult = client.getValue({ config: optionalBoolean }) +const nullableConfigResult = client.getValue({ config: nullableConfig }) +const conditionalConfig = Math.random() > 0.5 ? { includeResponse: true as const } : undefined +const conditionalConfigResult = client.getValue({ config: conditionalConfig }) + +export type Enabled = Assert, WithResponse>> +export type Disabled = Assert, string>> +export type Omitted = Assert, string>> +export type EmptyOptions = Assert, string>> +export type UndefinedConfig = Assert, string>> +export type Dynamic = Assert, string | WithResponse>> +export type OptionalTrue = Assert, string | WithResponse>> +export type OptionalBoolean = Assert, string | WithResponse>> +export type NullableConfig = Assert, string | WithResponse>> +export type ConditionalConfig = Assert, string | WithResponse>> +` + }), + compilationTimeout + ) + } + } + it.effect("quotes static path text with and without parameters", () => { const prefix = "/files/\"`\\\n/" const suffix = "/content\"`" diff --git a/repos/effect/packages/tools/openapi-generator/test/OpenApiTransformer.test.ts b/repos/effect/packages/tools/openapi-generator/test/OpenApiTransformer.test.ts index cc4076953..16765f500 100644 --- a/repos/effect/packages/tools/openapi-generator/test/OpenApiTransformer.test.ts +++ b/repos/effect/packages/tools/openapi-generator/test/OpenApiTransformer.test.ts @@ -70,6 +70,61 @@ const paths = [ ] as const describe("OpenApiTransformer", () => { + it.effect("returns only the body when a nullable response config is undefined", () => + Effect.gen(function*() { + const spec: OpenAPISpec = { + openapi: "3.1.0", + info: { title: "Nullable include response", version: "1.0.0" }, + components: { schemas: {}, securitySchemes: {} }, + security: [], + tags: [], + paths: { + "/value": { + get: { + operationId: "getValue", + parameters: [], + tags: ["Value"], + security: [], + responses: { + "200": { + description: "Value", + content: { "application/json": { schema: { type: "string" } } } + } + } + } + } + } + } + for (const format of ["httpclient", "httpclient-type-only"] as const) { + const source = yield* Effect.gen(function*() { + const generator = yield* OpenApiGenerator.OpenApiGenerator + return yield* generator.generate(spec, { name: "TestClient", format }) + }).pipe(Effect.provide( + format === "httpclient" + ? OpenApiGenerator.layerTransformerSchema + : OpenApiGenerator.layerTransformerTs + )) + const make = yield* Effect.promise(() => + loadClient<{ + getValue: (options?: { config?: { includeResponse?: boolean } | undefined }) => Effect.Effect + }>(source) + ) + const client = make( + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(JSON.stringify("hello")))) + ).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://example.com"))) + ) + + const body = yield* client.getValue({ config: undefined }) + assert.strictEqual(body, "hello") + + const withResponse = yield* client.getValue({ config: { includeResponse: true } }) + if (!Array.isArray(withResponse)) throw new Error("Expected response tuple") + assert.strictEqual(withResponse[0], "hello") + assert.strictEqual((withResponse[1] as HttpClientResponse.HttpClientResponse).status, 200) + } + })) + describe("path parameters", () => { const generate = Effect.gen(function*() { const generator = yield* OpenApiGenerator.OpenApiGenerator diff --git a/repos/effect/packages/vitest/CHANGELOG.md b/repos/effect/packages/vitest/CHANGELOG.md index 010107dba..4bb7d27e7 100644 --- a/repos/effect/packages/vitest/CHANGELOG.md +++ b/repos/effect/packages/vitest/CHANGELOG.md @@ -1,5 +1,13 @@ # @effect/vitest +## 4.0.1 + +### Patch Changes + +- [#8722](https://github.com/Effect-TS/effect/pull/8722) [`92e656c`](https://github.com/Effect-TS/effect/commit/92e656c43a92059d13fa9c96e6056ba96e216f05) Thanks @serhii-indyrct! - Fix `deepStrictEqual` and `notDeepStrictEqual` in `@effect/vitest/utils` throwing a `TypeError` instead of the assertion diff when no message is passed. +- Updated dependencies [[`ed2cc2f`](https://github.com/Effect-TS/effect/commit/ed2cc2f322dfd24da550c8d0ac811c3130b79d74), [`58a2ee8`](https://github.com/Effect-TS/effect/commit/58a2ee8e0db8e0a90bae83c6cceeff6916772fee), [`bc44526`](https://github.com/Effect-TS/effect/commit/bc445262eeaaba6f7f28a154023a590e193e547d), [`073bb47`](https://github.com/Effect-TS/effect/commit/073bb475d1e84e09cdcd8303a4a558f2a73d8396), [`2ec021e`](https://github.com/Effect-TS/effect/commit/2ec021eff7fbd2206adc3e8bf4e803ed4bf07d6a), [`cad1118`](https://github.com/Effect-TS/effect/commit/cad111867a666cebe8ff9bf2237a997fa6e4cb14), [`1672d9c`](https://github.com/Effect-TS/effect/commit/1672d9ca8da3e5b536bf7afb73365cccc6c01f53), [`a81e4e6`](https://github.com/Effect-TS/effect/commit/a81e4e6cb3dfbf01b1c53c1c877fddeaced8a278), [`f5c2a91`](https://github.com/Effect-TS/effect/commit/f5c2a91621401a018b770446d35ef99806c5561f), [`498e72b`](https://github.com/Effect-TS/effect/commit/498e72ba14d0191d6bf66cc0e931c229a1ff442f), [`cabf123`](https://github.com/Effect-TS/effect/commit/cabf1230723e92684a4b0b836ccfde25c657f9f1), [`a5b1d4e`](https://github.com/Effect-TS/effect/commit/a5b1d4e4cae01dbe30890d3796f846bda00ad2ae), [`878cb33`](https://github.com/Effect-TS/effect/commit/878cb33f4f5f5a31d2c80a9ddb7d62a611230e1c), [`5e6f756`](https://github.com/Effect-TS/effect/commit/5e6f7568a4c895eab3087249b191c5dcba5e73ef), [`18dfb98`](https://github.com/Effect-TS/effect/commit/18dfb98cf6d2aeedbab8ef5734f9bc273a82d56e), [`04706ef`](https://github.com/Effect-TS/effect/commit/04706eff5da94b4d475b99eab8b0256bed97b080), [`f157482`](https://github.com/Effect-TS/effect/commit/f15748273a89afff4b05d5e6bec22c567c2a3cda), [`b5a2d4c`](https://github.com/Effect-TS/effect/commit/b5a2d4c1d62c9620a68d72b7f20248c69ef7663b), [`1c18c68`](https://github.com/Effect-TS/effect/commit/1c18c688df10b697c10283160dba143e049f3d50), [`43416c4`](https://github.com/Effect-TS/effect/commit/43416c4142f973f4988440716eb54a498659cd3a), [`d6ac8f9`](https://github.com/Effect-TS/effect/commit/d6ac8f923a33ff298a87f31230735e71d2d082e9), [`1d9fb8c`](https://github.com/Effect-TS/effect/commit/1d9fb8c540a11326650baa0866a17951665189e7), [`20dcd50`](https://github.com/Effect-TS/effect/commit/20dcd5060581077b46325a433f787ba10a56963d), [`42e5750`](https://github.com/Effect-TS/effect/commit/42e5750f7e2fbf349cfe45452d7cc9dce98d6fe9), [`888e326`](https://github.com/Effect-TS/effect/commit/888e326a3180aea9b117fe7c97aace5dd527596e), [`0b52bb4`](https://github.com/Effect-TS/effect/commit/0b52bb42fac5aecb8bcb8d899293c641b62c3f66), [`e823e69`](https://github.com/Effect-TS/effect/commit/e823e693773aa163c0081af00a5236118446c6ae), [`962647b`](https://github.com/Effect-TS/effect/commit/962647b6a16000f882e6999f001c513a8cccc711), [`43fdcdc`](https://github.com/Effect-TS/effect/commit/43fdcdc6c81b6ad22d54061d6c6c45d9243d7c33), [`1f89b8e`](https://github.com/Effect-TS/effect/commit/1f89b8ec64e6362da3eaaff8cbb235df88d3441c), [`e1a521a`](https://github.com/Effect-TS/effect/commit/e1a521a6886a281abee016ff9aa57c3b172057c8)]: + - effect@4.0.1 + ## 4.0.0 ### Patch Changes diff --git a/repos/effect/packages/vitest/package.json b/repos/effect/packages/vitest/package.json index 0d117fbbe..5eacde243 100644 --- a/repos/effect/packages/vitest/package.json +++ b/repos/effect/packages/vitest/package.json @@ -1,6 +1,6 @@ { "name": "@effect/vitest", - "version": "4.0.0", + "version": "4.0.1", "type": "module", "license": "MIT", "description": "A set of helpers for testing Effects with vitest", diff --git a/repos/effect/packages/vitest/src/utils.ts b/repos/effect/packages/vitest/src/utils.ts index 4a4612cd2..8ce34094b 100644 --- a/repos/effect/packages/vitest/src/utils.ts +++ b/repos/effect/packages/vitest/src/utils.ts @@ -39,7 +39,11 @@ export function fail(message: string) { * @since 4.0.0 */ export function deepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { - assert.deepStrictEqual(actual, expected, message as string) + if (message !== undefined) { + assert.deepStrictEqual(actual, expected, message) + } else { + assert.deepStrictEqual(actual, expected) + } } /** @@ -49,7 +53,11 @@ export function deepStrictEqual(actual: A, expected: A, message?: string, ... * @since 4.0.0 */ export function notDeepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { - assert.notDeepStrictEqual(actual, expected, message as string) + if (message !== undefined) { + assert.notDeepStrictEqual(actual, expected, message) + } else { + assert.notDeepStrictEqual(actual, expected) + } } /** diff --git a/repos/effect/packages/vitest/test/index.test.ts b/repos/effect/packages/vitest/test/index.test.ts index 9514eb3fb..bc1c93f80 100644 --- a/repos/effect/packages/vitest/test/index.test.ts +++ b/repos/effect/packages/vitest/test/index.test.ts @@ -21,6 +21,14 @@ it("throwsAsync fails when the promise resolves", async () => { await expect(testAssert.throwsAsync(() => Promise.resolve())).rejects.toThrow() }) +it("deepStrictEqual fails with the assertion diff when no message is given", () => { + expect(() => testAssert.deepStrictEqual({ a: 1 }, { a: 2 })).toThrow("Expected values to be strictly deep-equal") +}) + +it("notDeepStrictEqual fails with the assertion message when no message is given", () => { + expect(() => testAssert.notDeepStrictEqual({ a: 1 }, { a: 1 })).toThrow("not to be strictly deep-equal") +}) + // each it.effect.each([1, 2, 3])( diff --git a/repos/effect/pnpm-lock.yaml b/repos/effect/pnpm-lock.yaml index a1a5a065b..21e30ff11 100644 --- a/repos/effect/pnpm-lock.yaml +++ b/repos/effect/pnpm-lock.yaml @@ -241,6 +241,12 @@ importers: specifier: workspace:^ version: link:../../effect + packages/ai/cloudflare: + devDependencies: + effect: + specifier: workspace:^ + version: link:../../effect + packages/ai/openai: devDependencies: effect: diff --git a/repos/effect/tsconfig.packages.json b/repos/effect/tsconfig.packages.json index 49066fdd7..c0dad077d 100644 --- a/repos/effect/tsconfig.packages.json +++ b/repos/effect/tsconfig.packages.json @@ -6,6 +6,7 @@ { "path": "ai-docs" }, { "path": "packages/effect" }, { "path": "packages/ai/anthropic" }, + { "path": "packages/ai/cloudflare" }, { "path": "packages/ai/openai-compat" }, { "path": "packages/ai/openai" }, { "path": "packages/ai/typesafe" }, diff --git a/repos/effect/tsconfig.tests.json b/repos/effect/tsconfig.tests.json index 453e80628..1224e3355 100644 --- a/repos/effect/tsconfig.tests.json +++ b/repos/effect/tsconfig.tests.json @@ -30,6 +30,8 @@ "effect/*": ["./packages/effect/src/*.ts"], "@effect/ai-anthropic": ["./packages/ai/anthropic/src/index.ts"], "@effect/ai-anthropic/*": ["./packages/ai/anthropic/src/*.ts"], + "@effect/ai-cloudflare": ["./packages/ai/cloudflare/src/index.ts"], + "@effect/ai-cloudflare/*": ["./packages/ai/cloudflare/src/*.ts"], "@effect/ai-openai-compat": ["./packages/ai/openai-compat/src/index.ts"], "@effect/ai-openai-compat/*": ["./packages/ai/openai-compat/src/*.ts"], "@effect/ai-openai": ["./packages/ai/openai/src/index.ts"], diff --git a/repos/effect/vitest.config.ts b/repos/effect/vitest.config.ts index 8315eb300..e5b9f1b3c 100644 --- a/repos/effect/vitest.config.ts +++ b/repos/effect/vitest.config.ts @@ -107,6 +107,7 @@ export default defineConfig({ : undefined ), ...project("@effect/ai-anthropic", "packages/ai/anthropic"), + ...project("@effect/ai-cloudflare", "packages/ai/cloudflare"), ...project("@effect/ai-openai", "packages/ai/openai"), ...project("@effect/ai-typesafe", "packages/ai/typesafe"), ...project("@effect/ai-openai-compat", "packages/ai/openai-compat"),